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 ( + <> +