Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion app/api/projects/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { discoverProjects } from '@/lib/claude-fs'
import { discoverOpenCodeProjects } from '@/lib/opencode-fs'
import type { ProjectInfo } from '@/lib/types'

export async function GET(req: NextRequest) {
const authErr = await requireAuth(req)
if (authErr) return authErr
return NextResponse.json(discoverProjects())

const claude: ProjectInfo[] = discoverProjects()
const opencode: ProjectInfo[] = discoverOpenCodeProjects()

// Merge and sort by most recently active, Claude projects first when tied
const all = [
...claude.map(p => ({ ...p, source: 'claude' as const })),
...opencode,
].sort((a, b) => b.latestMtime - a.latestMtime)

return NextResponse.json(all)
}
12 changes: 11 additions & 1 deletion app/api/recent-sessions/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { getRecentSessions } from '@/lib/claude-fs'
import { getOpenCodeRecentSessions } from '@/lib/opencode-fs'

export async function GET(req: NextRequest) {
const authErr = await requireAuth(req)
if (authErr) return authErr

const limit = Math.min(parseInt(req.nextUrl.searchParams.get('limit') ?? '20', 10), 50)
return NextResponse.json(getRecentSessions(limit))

const claude = getRecentSessions(limit)
const opencode = getOpenCodeRecentSessions(limit)

// Merge and return the `limit` most recent across both sources
const merged = [...claude, ...opencode]
.sort((a, b) => b.mtime - a.mtime)
.slice(0, limit)

return NextResponse.json(merged)
}
22 changes: 20 additions & 2 deletions app/api/session/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { parseJsonlFilePaginated, decodeB64, safePath, getClaudeDir } from '@/lib/claude-fs'
import { parseOpenCodeSession, isOpenCodePath, openCodeSessionId } from '@/lib/opencode-fs'
import type { PaginatedSession } from '@/lib/types'

export async function GET(req: NextRequest) {
const authErr = await requireAuth(req)
Expand All @@ -10,13 +12,29 @@ export async function GET(req: NextRequest) {
if (!encoded) return NextResponse.json({ error: 'Missing f param' }, { status: 400 })

const filepath = decodeB64(encoded)

// ── OpenCode session ───────────────────────────────────────────────────────
if (isOpenCodePath(filepath)) {
const sessionId = openCodeSessionId(filepath)
const messages = parseOpenCodeSession(sessionId)
const response: PaginatedSession = {
firstMessage: messages[0] ?? null,
messages,
total: messages.length,
hiddenCount: 0,
hasMore: false,
}
return NextResponse.json(response)
}

// ── Claude session ─────────────────────────────────────────────────────────
if (!safePath(filepath, getClaudeDir())) {
return NextResponse.json({ error: 'Invalid path' }, { status: 403 })
}

const limit = Math.min(parseInt(req.nextUrl.searchParams.get('limit') ?? '50', 10), 200)
const limit = Math.min(parseInt(req.nextUrl.searchParams.get('limit') ?? '50', 10), 200)
const olderThan = req.nextUrl.searchParams.get('before') ?? undefined
const around = req.nextUrl.searchParams.get('around') ?? undefined
const around = req.nextUrl.searchParams.get('around') ?? undefined

return NextResponse.json(parseJsonlFilePaginated(filepath, limit, olderThan, around))
}
6 changes: 6 additions & 0 deletions app/api/sessions/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth'
import { listSessions, decodeB64 } from '@/lib/claude-fs'
import { listOpenCodeSessions, openCodeDirectory, isOpenCodePath } from '@/lib/opencode-fs'

export async function GET(req: NextRequest) {
const authErr = await requireAuth(req)
Expand All @@ -10,5 +11,10 @@ export async function GET(req: NextRequest) {
if (!encoded) return NextResponse.json({ error: 'Missing p param' }, { status: 400 })

const dirName = decodeB64(encoded)

if (isOpenCodePath(dirName)) {
return NextResponse.json(listOpenCodeSessions(openCodeDirectory(dirName)))
}

return NextResponse.json(listSessions(dirName))
}
11 changes: 11 additions & 0 deletions app/api/tail/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ export async function GET(req: NextRequest) {

const encoded = req.nextUrl.searchParams.get('f') ?? ''
const filepath = decodeB64(encoded)

// OpenCode sessions are stored in SQLite and have no live updates
if (filepath.startsWith('opencode:')) {
return new Response(': opencode session — no live tail\n\n', {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
},
})
}

if (!safePath(filepath, getClaudeDir())) {
return new Response('Forbidden', { status: 403 })
}
Expand Down
132 changes: 104 additions & 28 deletions app/project/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { redirect } from 'next/navigation'
import Link from 'next/link'
import { getSessionToken, validateSession } from '@/lib/auth'
import { listSessions, decodeB64, encodeB64, resolveProjectPath, detectContinuationChains } from '@/lib/claude-fs'
import { listOpenCodeSessions, openCodeDirectory, isOpenCodePath } from '@/lib/opencode-fs'
import { getProjectMeta } from '@/lib/project-meta'
import { loadSessionTags } from '@/lib/session-tags'
import Nav from '@/components/Nav'
import ProcessControls from '@/components/ProcessControls'
import NewSessionForm from '@/components/NewSessionForm'
import SessionTagsButton from '@/components/SessionTagsButton'
import type { SessionInfo } from '@/lib/types'

export const dynamic = 'force-dynamic'

Expand All @@ -24,20 +26,69 @@ export default async function ProjectPage({ searchParams }: Props) {
if (!encoded) redirect('/projects')

const dirName = decodeB64(encoded)
const sessions = listSessions(dirName)

// ── OpenCode project ────────────────────────────────────────────────────
if (isOpenCodePath(dirName)) {
const directory = openCodeDirectory(dirName)
const sessions = listOpenCodeSessions(directory)
const title = directory.split('/').pop() || directory

return (
<>
<Nav />
<main style={{ padding: '32px 28px', maxWidth: 1100, margin: '0 auto', width: '100%' }}>
<div style={{ marginBottom: 28 }}>
<Link href="/projects" style={{ color: 'var(--text2)', fontSize: 13, textDecoration: 'none' }}>
← Projects
</Link>
<div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<h1 style={{ margin: '0 0 3px', fontSize: 22, fontWeight: 700, letterSpacing: '-0.02em' }}>
{title}
</h1>
<span className="chip" style={{ fontSize: 11, background: 'rgba(99,102,241,0.15)', color: 'var(--text2)' }}>
opencode
</span>
</div>
<p style={{ margin: 0, color: 'var(--text3)', fontSize: 12, fontFamily: 'ui-monospace, monospace' }}>
{directory}
</p>
</div>
</div>

<section>
<h2 style={{ fontSize: 12, fontWeight: 600, color: 'var(--text2)', margin: '0 0 12px', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Sessions · {sessions.length}
</h2>
{sessions.length === 0 ? (
<p style={{ color: 'var(--text2)', fontSize: 14 }}>No sessions found.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{sessions.map(s => (
<OpenCodeSessionRow key={s.sessionId} session={s} />
))}
</div>
)}
</section>
</main>
</>
)
}

// ── Claude project ───────────────────────────────────────────────────────
const sessions = listSessions(dirName)
const projectPath = resolveProjectPath(dirName)
const meta = getProjectMeta(projectPath)
const title = meta?.displayName || projectPath.split('/').pop() || projectPath
const chains = detectContinuationChains(dirName)
const tagStore = loadSessionTags()
const meta = getProjectMeta(projectPath)
const title = meta?.displayName || projectPath.split('/').pop() || projectPath
const chains = detectContinuationChains(dirName)
const tagStore = loadSessionTags()

// Build reverse map: parentId → childId
const childOf = new Map<string, string>()
for (const [child, parent] of chains.entries()) {
childOf.set(parent, child)
}

const active = sessions.filter(s => s.processState === 'running' || s.processState === 'paused')
const active = sessions.filter(s => s.processState === 'running' || s.processState === 'paused')
const history = sessions.filter(s => s.processState !== 'running' && s.processState !== 'paused')

return (
Expand All @@ -60,7 +111,6 @@ export default async function ProjectPage({ searchParams }: Props) {
</div>
</div>

{/* New session — full-width block below header */}
<div style={{ marginTop: 16 }}>
<NewSessionForm projectPath={projectPath} />
</div>
Expand All @@ -77,7 +127,7 @@ export default async function ProjectPage({ searchParams }: Props) {
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{active.map(s => (
<SessionRow
<ClaudeSessionRow
key={s.sessionId}
session={s}
parentId={chains.get(s.sessionId)}
Expand All @@ -101,7 +151,7 @@ export default async function ProjectPage({ searchParams }: Props) {
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{history.map(s => (
<SessionRow
<ClaudeSessionRow
key={s.sessionId}
session={s}
parentId={chains.get(s.sessionId)}
Expand All @@ -119,6 +169,43 @@ export default async function ProjectPage({ searchParams }: Props) {
)
}

// ── OpenCode session row (read-only, no process controls) ─────────────────

function OpenCodeSessionRow({ session: s }: { session: SessionInfo }) {
return (
<div className="glass" style={{ borderRadius: 12, padding: '14px 18px', display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<Link
href={`/session?f=${encodeB64(s.filepath)}`}
style={{
color: 'var(--text)', fontWeight: 500, fontSize: 14, textDecoration: 'none',
display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginBottom: 7,
}}
>
{s.firstPrompt}
</Link>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<span className="chip">Finished</span>
{s.estimatedCostUsd != null && s.estimatedCostUsd > 0 && (
<span className="chip" title="Cost">{formatCost(s.estimatedCostUsd)}</span>
)}
<span className="chip">{formatRelative(s.mtime)}</span>
<span className="chip" style={{ fontFamily: 'ui-monospace, monospace' }}>{s.sessionId.slice(0, 8)}</span>
</div>
</div>
<Link
href={`/session?f=${encodeB64(s.filepath)}`}
className="glass-btn"
style={{ fontSize: 13, padding: '6px 14px', flexShrink: 0 }}
>
Open →
</Link>
</div>
)
}

// ── Claude session row (full-featured) ────────────────────────────────────

function activityLabel(processState: string, currentActivity?: string | null): string {
if (processState === 'paused') return 'Paused'
if (processState !== 'running') return 'Finished'
Expand All @@ -134,7 +221,7 @@ function formatCost(usd: number): string {
return `$${usd.toFixed(2)}`
}

function SessionRow({
function ClaudeSessionRow({
session: s,
parentId,
childId,
Expand All @@ -158,26 +245,15 @@ function SessionRow({

return (
<div className="glass" style={{
borderRadius: 12,
padding: '14px 18px',
display: 'flex',
alignItems: 'center',
gap: 14,
borderRadius: 12, padding: '14px 18px', display: 'flex', alignItems: 'center', gap: 14,
borderColor: s.processState === 'running' ? 'rgba(61,214,140,0.20)' : undefined,
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<Link
href={`/session?f=${encodeB64(s.filepath)}`}
style={{
color: 'var(--text)',
fontWeight: 500,
fontSize: 14,
textDecoration: 'none',
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
marginBottom: 7,
color: 'var(--text)', fontWeight: 500, fontSize: 14, textDecoration: 'none',
display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginBottom: 7,
}}
>
{s.firstPrompt}
Expand Down Expand Up @@ -231,8 +307,8 @@ function SessionRow({

function formatRelative(ms: number): string {
const diff = Date.now() - ms
if (diff < 60_000) return 'just now'
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
if (diff < 60_000) return 'just now'
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
return `${Math.floor(diff / 86_400_000)}d ago`
}
7 changes: 6 additions & 1 deletion app/projects/page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { redirect } from 'next/navigation'
import { getSessionToken, validateSession } from '@/lib/auth'
import { discoverProjects } from '@/lib/claude-fs'
import { discoverOpenCodeProjects } from '@/lib/opencode-fs'
import Nav from '@/components/Nav'
import ProjectsView from '@/components/ProjectsView'
import type { ProjectInfo } from '@/lib/types'

export const dynamic = 'force-dynamic'

export default async function ProjectsPage() {
const token = await getSessionToken()
if (!validateSession(token)) redirect('/login')

const projects = discoverProjects()
const claude: ProjectInfo[] = discoverProjects().map(p => ({ ...p, source: 'claude' as const }))
const opencode: ProjectInfo[] = discoverOpenCodeProjects()

const projects = [...claude, ...opencode].sort((a, b) => b.latestMtime - a.latestMtime)

return (
<>
Expand Down
Loading