diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index b651b56..8d13e44 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -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) } diff --git a/app/api/recent-sessions/route.ts b/app/api/recent-sessions/route.ts index 0a225ce..29f260c 100644 --- a/app/api/recent-sessions/route.ts +++ b/app/api/recent-sessions/route.ts @@ -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) } diff --git a/app/api/session/route.ts b/app/api/session/route.ts index a46cba0..f52e9ca 100644 --- a/app/api/session/route.ts +++ b/app/api/session/route.ts @@ -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) @@ -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)) } diff --git a/app/api/sessions/route.ts b/app/api/sessions/route.ts index d2025e5..19c90a9 100644 --- a/app/api/sessions/route.ts +++ b/app/api/sessions/route.ts @@ -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) @@ -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)) } diff --git a/app/api/tail/route.ts b/app/api/tail/route.ts index 2256229..bf4da07 100644 --- a/app/api/tail/route.ts +++ b/app/api/tail/route.ts @@ -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 }) } diff --git a/app/project/page.tsx b/app/project/page.tsx index cda25f5..556935b 100644 --- a/app/project/page.tsx +++ b/app/project/page.tsx @@ -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' @@ -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 ( + <> + + + + + ← Projects + + + + + {title} + + + opencode + + + + {directory} + + + + + + + Sessions · {sessions.length} + + {sessions.length === 0 ? ( + No sessions found. + ) : ( + + {sessions.map(s => ( + + ))} + + )} + + + > + ) + } + + // ── 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() 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 ( @@ -60,7 +111,6 @@ export default async function ProjectPage({ searchParams }: Props) { - {/* New session — full-width block below header */} @@ -77,7 +127,7 @@ export default async function ProjectPage({ searchParams }: Props) { {active.map(s => ( - {history.map(s => ( - + + + {s.firstPrompt} + + + Finished + {s.estimatedCostUsd != null && s.estimatedCostUsd > 0 && ( + {formatCost(s.estimatedCostUsd)} + )} + {formatRelative(s.mtime)} + {s.sessionId.slice(0, 8)} + + + + Open → + + + ) +} + +// ── Claude session row (full-featured) ──────────────────────────────────── + function activityLabel(processState: string, currentActivity?: string | null): string { if (processState === 'paused') return 'Paused' if (processState !== 'running') return 'Finished' @@ -134,7 +221,7 @@ function formatCost(usd: number): string { return `$${usd.toFixed(2)}` } -function SessionRow({ +function ClaudeSessionRow({ session: s, parentId, childId, @@ -158,26 +245,15 @@ function SessionRow({ return ( {s.firstPrompt} @@ -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` } diff --git a/app/projects/page.tsx b/app/projects/page.tsx index e48870f..36404df 100644 --- a/app/projects/page.tsx +++ b/app/projects/page.tsx @@ -1,8 +1,10 @@ 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' @@ -10,7 +12,10 @@ 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 ( <> diff --git a/app/session/page.tsx b/app/session/page.tsx index 1a0cb43..daefb27 100644 --- a/app/session/page.tsx +++ b/app/session/page.tsx @@ -2,9 +2,11 @@ import { redirect } from 'next/navigation' import path from 'path' import { getSessionToken, validateSession } from '@/lib/auth' import { parseJsonlFilePaginated, decodeB64, safePath, getClaudeDir, getSessionId, resolveProjectPath } from '@/lib/claude-fs' +import { parseOpenCodeSession, getOpenCodeSessionMeta, isOpenCodePath, openCodeSessionId } from '@/lib/opencode-fs' import { scanClaudeSessions, getProcessState } from '@/lib/process' import Nav from '@/components/Nav' import LiveSession from '@/components/LiveSession' +import type { PaginatedSession } from '@/lib/types' export const dynamic = 'force-dynamic' @@ -16,23 +18,57 @@ export default async function SessionPage({ searchParams }: Props) { const token = await getSessionToken() if (!validateSession(token)) redirect('/login') - const params = await searchParams + const params = await searchParams const encoded = params.f ?? '' if (!encoded) redirect('/projects') - const filepath = decodeB64(encoded) + const filepath = decodeB64(encoded) + const scrollTarget = params.msg ?? undefined + + // ── OpenCode session ────────────────────────────────────────────────────── + if (isOpenCodePath(filepath)) { + const sessionId = openCodeSessionId(filepath) + const messages = parseOpenCodeSession(sessionId) + const ocMeta = getOpenCodeSessionMeta(sessionId) + + const sessionData: PaginatedSession = { + firstMessage: messages[0] ?? null, + messages, + total: messages.length, + hiddenCount: 0, + hasMore: false, + } + + return ( + <> + + + > + ) + } + + // ── Claude session ──────────────────────────────────────────────────────── if (!safePath(filepath, getClaudeDir())) redirect('/projects') - const scrollTarget = params.msg ?? undefined - const sessionData = parseJsonlFilePaginated(filepath, 50, undefined, scrollTarget) - const sessionId = getSessionId(filepath) - const running = scanClaudeSessions(getClaudeDir()) - const proc = running[sessionId] + const sessionData = parseJsonlFilePaginated(filepath, 50, undefined, scrollTarget) + const sessionId = getSessionId(filepath) + const running = scanClaudeSessions(getClaudeDir()) + const proc = running[sessionId] const processState = proc ? getProcessState(proc.pid) : 'dead' - const pid = proc?.pid ?? null + const pid = proc?.pid ?? null const projectDirName = path.basename(path.dirname(filepath)) - const projectPath = proc?.cwd ?? resolveProjectPath(projectDirName) + const projectPath = proc?.cwd ?? resolveProjectPath(projectDirName) return ( <> @@ -46,6 +82,7 @@ export default async function SessionPage({ searchParams }: Props) { pid={pid} processState={processState} scrollTarget={scrollTarget} + source="claude" /> > ) diff --git a/components/LiveSession.tsx b/components/LiveSession.tsx index 399363c..f018c43 100644 --- a/components/LiveSession.tsx +++ b/components/LiveSession.tsx @@ -17,6 +17,7 @@ interface Props { pid: number | null processState: ProcState scrollTarget?: string + source?: 'claude' | 'opencode' } function lastRole(messages: ParsedMessage[]): 'user' | 'assistant' | null { @@ -34,7 +35,9 @@ export default function LiveSession({ pid: initialPid, processState: initialProcState, scrollTarget, + source = 'claude', }: Props) { + const isOpenCode = source === 'opencode' // ── message state ───────────────────────────────────────────────────────── const [firstMessage, setFirstMessage] = useState(initialData.firstMessage) const [messages, setMessages] = useState(initialData.messages) @@ -118,6 +121,7 @@ export default function LiveSession({ // ── SSE tail ────────────────────────────────────────────────────────────── useEffect(() => { + if (isOpenCode) return // OpenCode sessions are static; no live tail const es = new EventSource(`/api/tail?f=${encodedFilepath}`) es.onopen = () => setConnected(true) es.onerror = () => setConnected(false) @@ -172,10 +176,10 @@ export default function LiveSession({ }, [pid, messages]) useEffect(() => { - if (!pid) return + if (!pid || isOpenCode) return const id = setInterval(pollProcessState, 3000) return () => clearInterval(id) - }, [pid, pollProcessState]) + }, [pid, isOpenCode, pollProcessState]) // ── Load earlier messages ───────────────────────────────────────────────── async function loadMore() { @@ -550,15 +554,21 @@ export default function LiveSession({ }} /> - {/* Live dot */} - - - - {connected ? 'Live' : '…'} + {/* Source badge / live dot */} + {isOpenCode ? ( + + opencode - + ) : ( + + + + {connected ? 'Live' : '…'} + + + )} {/* Status + controls — pushed right */} @@ -593,7 +603,7 @@ export default function LiveSession({ {isPaused && Paused} {isDead && {wasInterrupted ? 'Interrupted' : 'Done'}} - {isRunning && pid && ( + {!isOpenCode && isRunning && pid && ( fetch('/api/pause', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pid }) }).then(() => setProcState('paused'))}> @@ -602,7 +612,7 @@ export default function LiveSession({ { setProcState('dead'); setWasInterrupted(true) }} /> )} - {isPaused && pid && ( + {!isOpenCode && isPaused && pid && ( Resume { setProcState('dead'); setWasInterrupted(true) }} /> @@ -625,14 +635,16 @@ export default function LiveSession({ - handleFork(firstMessage.uuid)} - disabled={!!forking} - title="Fork session from this message" - > - {forking === firstMessage.uuid ? '…' : '⑃ fork here'} - + {!isOpenCode && ( + handleFork(firstMessage.uuid)} + disabled={!!forking} + title="Fork session from this message" + > + {forking === firstMessage.uuid ? '…' : '⑃ fork here'} + + )} {/* Load more / hidden count divider */} @@ -698,7 +710,7 @@ export default function LiveSession({ displayMessages.map(msg => ( - {!msg.uuid.startsWith('__optimistic__') && ( + {!isOpenCode && !msg.uuid.startsWith('__optimistic__') && ( handleFork(msg.uuid)} @@ -774,16 +786,28 @@ export default function LiveSession({ {/* ── Bottom bar ────────────────────────────────────────────────── */} - + {isOpenCode ? ( + + + Read-only — OpenCode sessions cannot be continued from AgentTower + + + ) : ( + + )}
+ {directory} +
No sessions found.