diff --git a/src/__tests__/unit/claude-session-parser.test.ts b/src/__tests__/unit/claude-session-parser.test.ts new file mode 100644 index 00000000..b3d94b00 --- /dev/null +++ b/src/__tests__/unit/claude-session-parser.test.ts @@ -0,0 +1,519 @@ +/** + * Unit tests for claude-session-parser.ts + * + * Tests the JSONL parsing logic for Claude Code CLI session files. + * Uses Node's built-in test runner (zero dependencies). + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +// We test the parser functions by creating temporary JSONL files +// that mimic Claude Code's session storage format. + +const TEST_DIR = path.join(os.tmpdir(), `codepilot-test-sessions-${Date.now()}`); +const PROJECTS_DIR = path.join(TEST_DIR, '.claude', 'projects'); + +// Helper to create a JSONL session file +function createSessionFile( + projectDirName: string, + sessionId: string, + lines: object[], +): string { + const dir = path.join(PROJECTS_DIR, projectDirName); + fs.mkdirSync(dir, { recursive: true }); + const filePath = path.join(dir, `${sessionId}.jsonl`); + const content = lines.map(l => JSON.stringify(l)).join('\n') + '\n'; + fs.writeFileSync(filePath, content); + return filePath; +} + +// ========================================== +// Test data factories +// ========================================== + +function makeQueueEntry(sessionId: string, operation: string = 'dequeue') { + return { + type: 'queue-operation', + operation, + timestamp: '2026-01-15T10:00:00.000Z', + sessionId, + }; +} + +function makeUserEntry(opts: { + sessionId: string; + content: string; + parentUuid?: string | null; + cwd?: string; + gitBranch?: string; + version?: string; + timestamp?: string; +}) { + return { + parentUuid: opts.parentUuid ?? null, + isSidechain: false, + userType: 'external', + cwd: opts.cwd || '/home/user/myproject', + sessionId: opts.sessionId, + version: opts.version || '2.1.34', + gitBranch: opts.gitBranch || 'main', + type: 'user', + message: { + role: 'user', + content: opts.content, + }, + uuid: `user-${Date.now()}-${Math.random().toString(36).slice(2)}`, + timestamp: opts.timestamp || '2026-01-15T10:00:01.000Z', + permissionMode: 'default', + }; +} + +function makeAssistantEntry(opts: { + sessionId: string; + content: Array<{ type: string; text?: string; id?: string; name?: string; input?: unknown; tool_use_id?: string; content?: string; is_error?: boolean }>; + parentUuid: string; + timestamp?: string; + model?: string; +}) { + return { + parentUuid: opts.parentUuid, + isSidechain: false, + userType: 'external', + cwd: '/home/user/myproject', + sessionId: opts.sessionId, + version: '2.1.34', + gitBranch: 'main', + message: { + content: opts.content, + id: `req-${Date.now()}`, + model: opts.model || 'claude-sonnet-4-20250514', + role: 'assistant', + stop_reason: 'end_turn', + usage: { + input_tokens: 100, + output_tokens: 50, + }, + }, + type: 'assistant', + uuid: `asst-${Date.now()}-${Math.random().toString(36).slice(2)}`, + timestamp: opts.timestamp || '2026-01-15T10:00:02.000Z', + }; +} + +// ========================================== +// Tests +// ========================================== + +// We need to dynamically import the parser because it uses @/lib path aliases. +// Instead, we'll test the core logic by requiring the compiled output or +// using tsx to run these tests. + +// Since the project uses path aliases (@/), we import via a relative path +// that tsx can resolve with the project's tsconfig. +const parserPath = path.resolve(__dirname, '../../lib/claude-session-parser.ts'); + +describe('claude-session-parser', () => { + // We'll dynamically import the parser module + let parser: typeof import('../../lib/claude-session-parser'); + + before(async () => { + // Set HOME to our test directory so the parser looks for sessions there + process.env.HOME = TEST_DIR; + + // Dynamic import - tsx handles the TypeScript + path alias resolution + parser = await import(parserPath); + }); + + after(() => { + // Clean up test directory + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + // Restore HOME + process.env.HOME = os.homedir(); + }); + + describe('decodeProjectPath', () => { + it('should decode a simple project path', () => { + assert.equal(parser.decodeProjectPath('-root-myproject'), '/root/myproject'); + }); + + it('should decode a deeper project path', () => { + assert.equal( + parser.decodeProjectPath('-Users-john-projects-myapp'), + '/Users/john/projects/myapp', + ); + }); + + it('should return as-is if no leading dash', () => { + assert.equal(parser.decodeProjectPath('some-dir'), 'some-dir'); + }); + }); + + describe('getClaudeProjectsDir', () => { + it('should return path under HOME/.claude/projects', () => { + const dir = parser.getClaudeProjectsDir(); + assert.ok(dir.endsWith(path.join('.claude', 'projects'))); + }); + }); + + describe('listClaudeSessions', () => { + it('should return empty array when no sessions exist', () => { + // Projects dir exists but is empty + fs.mkdirSync(PROJECTS_DIR, { recursive: true }); + const sessions = parser.listClaudeSessions(); + assert.equal(sessions.length, 0); + }); + + it('should skip sessions with only queue-operation entries', () => { + const sessionId = 'empty-session-001'; + createSessionFile('-home-user-emptyproject', sessionId, [ + makeQueueEntry(sessionId), + ]); + + const sessions = parser.listClaudeSessions(); + const found = sessions.find(s => s.sessionId === sessionId); + assert.equal(found, undefined, 'Should skip session with no messages'); + }); + + it('should list a session with user and assistant messages', () => { + const sessionId = 'test-session-001'; + const userEntry = makeUserEntry({ + sessionId, + content: 'Hello, can you help me?', + cwd: '/home/user/myproject', + gitBranch: 'feature-branch', + version: '2.1.34', + }); + + createSessionFile('-home-user-myproject', sessionId, [ + makeQueueEntry(sessionId), + userEntry, + makeAssistantEntry({ + sessionId, + content: [{ type: 'text', text: 'Of course! How can I help you?' }], + parentUuid: userEntry.uuid, + }), + ]); + + const sessions = parser.listClaudeSessions(); + const found = sessions.find(s => s.sessionId === sessionId); + assert.ok(found, 'Session should be listed'); + assert.equal(found!.projectName, 'myproject'); + assert.equal(found!.cwd, '/home/user/myproject'); + assert.equal(found!.gitBranch, 'feature-branch'); + assert.equal(found!.version, '2.1.34'); + assert.equal(found!.preview, 'Hello, can you help me?'); + assert.equal(found!.userMessageCount, 1); + assert.equal(found!.assistantMessageCount, 1); + }); + + it('should sort sessions by most recent first', () => { + const oldSessionId = 'old-session-001'; + const newSessionId = 'new-session-001'; + + createSessionFile('-home-user-oldproject', oldSessionId, [ + makeQueueEntry(oldSessionId), + makeUserEntry({ + sessionId: oldSessionId, + content: 'Old message', + timestamp: '2025-01-01T00:00:00.000Z', + }), + ]); + + createSessionFile('-home-user-newproject', newSessionId, [ + makeQueueEntry(newSessionId), + makeUserEntry({ + sessionId: newSessionId, + content: 'New message', + timestamp: '2026-06-01T00:00:00.000Z', + }), + ]); + + const sessions = parser.listClaudeSessions(); + const oldIdx = sessions.findIndex(s => s.sessionId === oldSessionId); + const newIdx = sessions.findIndex(s => s.sessionId === newSessionId); + assert.ok(newIdx < oldIdx, 'Newer session should come first'); + }); + }); + + describe('parseClaudeSession', () => { + it('should return null for non-existent session', () => { + const result = parser.parseClaudeSession('non-existent-session-id'); + assert.equal(result, null); + }); + + it('should parse a simple text conversation', () => { + const sessionId = 'parse-text-001'; + const userEntry = makeUserEntry({ + sessionId, + content: 'What is TypeScript?', + cwd: '/home/user/tsproject', + }); + + createSessionFile('-home-user-tsproject', sessionId, [ + makeQueueEntry(sessionId), + userEntry, + makeAssistantEntry({ + sessionId, + content: [{ type: 'text', text: 'TypeScript is a typed superset of JavaScript.' }], + parentUuid: userEntry.uuid, + }), + ]); + + const result = parser.parseClaudeSession(sessionId); + assert.ok(result, 'Should return parsed session'); + assert.equal(result!.messages.length, 2); + + // Check user message + const userMsg = result!.messages[0]; + assert.equal(userMsg.role, 'user'); + assert.equal(userMsg.content, 'What is TypeScript?'); + assert.equal(userMsg.hasToolBlocks, false); + + // Check assistant message + const assistantMsg = result!.messages[1]; + assert.equal(assistantMsg.role, 'assistant'); + assert.equal(assistantMsg.content, 'TypeScript is a typed superset of JavaScript.'); + assert.equal(assistantMsg.hasToolBlocks, false); + }); + + it('should parse assistant messages with tool_use blocks', () => { + const sessionId = 'parse-tools-001'; + const userEntry = makeUserEntry({ + sessionId, + content: 'Read the package.json file', + cwd: '/home/user/toolproject', + }); + + createSessionFile('-home-user-toolproject', sessionId, [ + makeQueueEntry(sessionId), + userEntry, + makeAssistantEntry({ + sessionId, + content: [ + { type: 'text', text: "I'll read the file for you." }, + { + type: 'tool_use', + id: 'tool-001', + name: 'Read', + input: { file_path: '/home/user/toolproject/package.json' }, + }, + ], + parentUuid: userEntry.uuid, + }), + ]); + + const result = parser.parseClaudeSession(sessionId); + assert.ok(result); + assert.equal(result!.messages.length, 2); + + const assistantMsg = result!.messages[1]; + assert.equal(assistantMsg.role, 'assistant'); + assert.equal(assistantMsg.hasToolBlocks, true); + assert.equal(assistantMsg.contentBlocks.length, 2); + assert.equal(assistantMsg.contentBlocks[0].type, 'text'); + assert.equal(assistantMsg.contentBlocks[1].type, 'tool_use'); + if (assistantMsg.contentBlocks[1].type === 'tool_use') { + assert.equal(assistantMsg.contentBlocks[1].name, 'Read'); + assert.equal(assistantMsg.contentBlocks[1].id, 'tool-001'); + } + }); + + it('should parse assistant messages with tool_result blocks', () => { + const sessionId = 'parse-results-001'; + const userEntry = makeUserEntry({ + sessionId, + content: 'Show me the file', + cwd: '/home/user/resultproject', + }); + + createSessionFile('-home-user-resultproject', sessionId, [ + makeQueueEntry(sessionId), + userEntry, + makeAssistantEntry({ + sessionId, + content: [ + { type: 'text', text: 'Here is the content.' }, + { + type: 'tool_use', + id: 'tool-002', + name: 'Read', + input: { file_path: 'test.txt' }, + }, + { + type: 'tool_result', + tool_use_id: 'tool-002', + content: 'file content here', + is_error: false, + }, + ], + parentUuid: userEntry.uuid, + }), + ]); + + const result = parser.parseClaudeSession(sessionId); + assert.ok(result); + + const assistantMsg = result!.messages[1]; + assert.equal(assistantMsg.hasToolBlocks, true); + assert.equal(assistantMsg.contentBlocks.length, 3); + assert.equal(assistantMsg.contentBlocks[2].type, 'tool_result'); + if (assistantMsg.contentBlocks[2].type === 'tool_result') { + assert.equal(assistantMsg.contentBlocks[2].tool_use_id, 'tool-002'); + assert.equal(assistantMsg.contentBlocks[2].content, 'file content here'); + assert.equal(assistantMsg.contentBlocks[2].is_error, false); + } + }); + + it('should parse multi-turn conversations', () => { + const sessionId = 'parse-multi-001'; + const user1 = makeUserEntry({ + sessionId, + content: 'First question', + timestamp: '2026-01-15T10:00:01.000Z', + cwd: '/home/user/multiproject', + }); + const asst1 = makeAssistantEntry({ + sessionId, + content: [{ type: 'text', text: 'First answer' }], + parentUuid: user1.uuid, + timestamp: '2026-01-15T10:00:02.000Z', + }); + const user2 = makeUserEntry({ + sessionId, + content: 'Follow-up question', + parentUuid: asst1.uuid, + timestamp: '2026-01-15T10:00:03.000Z', + cwd: '/home/user/multiproject', + }); + const asst2 = makeAssistantEntry({ + sessionId, + content: [{ type: 'text', text: 'Follow-up answer' }], + parentUuid: user2.uuid, + timestamp: '2026-01-15T10:00:04.000Z', + }); + + createSessionFile('-home-user-multiproject', sessionId, [ + makeQueueEntry(sessionId), + user1, + asst1, + user2, + asst2, + ]); + + const result = parser.parseClaudeSession(sessionId); + assert.ok(result); + assert.equal(result!.messages.length, 4); + assert.equal(result!.messages[0].role, 'user'); + assert.equal(result!.messages[0].content, 'First question'); + assert.equal(result!.messages[1].role, 'assistant'); + assert.equal(result!.messages[1].content, 'First answer'); + assert.equal(result!.messages[2].role, 'user'); + assert.equal(result!.messages[2].content, 'Follow-up question'); + assert.equal(result!.messages[3].role, 'assistant'); + assert.equal(result!.messages[3].content, 'Follow-up answer'); + }); + + it('should handle empty assistant content gracefully', () => { + const sessionId = 'parse-empty-asst-001'; + const userEntry = makeUserEntry({ + sessionId, + content: 'Test message', + cwd: '/home/user/emptyasstproject', + }); + + createSessionFile('-home-user-emptyasstproject', sessionId, [ + makeQueueEntry(sessionId), + userEntry, + makeAssistantEntry({ + sessionId, + content: [], // Empty content + parentUuid: userEntry.uuid, + }), + ]); + + const result = parser.parseClaudeSession(sessionId); + assert.ok(result); + // Empty assistant message should be skipped + assert.equal(result!.messages.length, 1); + assert.equal(result!.messages[0].role, 'user'); + }); + + it('should truncate preview to 120 characters', () => { + const sessionId = 'parse-long-preview-001'; + const longMessage = 'A'.repeat(200); + createSessionFile('-home-user-longproject', sessionId, [ + makeQueueEntry(sessionId), + makeUserEntry({ + sessionId, + content: longMessage, + cwd: '/home/user/longproject', + }), + ]); + + const sessions = parser.listClaudeSessions(); + const found = sessions.find(s => s.sessionId === sessionId); + assert.ok(found); + assert.equal(found!.preview.length, 120); + }); + + it('should extract session info correctly', () => { + const sessionId = 'parse-info-001'; + createSessionFile('-home-user-infoproject', sessionId, [ + makeQueueEntry(sessionId), + makeUserEntry({ + sessionId, + content: 'Test', + cwd: '/home/user/infoproject', + gitBranch: 'develop', + version: '3.0.0', + timestamp: '2026-03-15T14:30:00.000Z', + }), + makeAssistantEntry({ + sessionId, + content: [{ type: 'text', text: 'Reply' }], + parentUuid: 'some-uuid', + timestamp: '2026-03-15T14:30:05.000Z', + }), + ]); + + const result = parser.parseClaudeSession(sessionId); + assert.ok(result); + assert.equal(result!.info.sessionId, sessionId); + assert.equal(result!.info.cwd, '/home/user/infoproject'); + assert.equal(result!.info.gitBranch, 'develop'); + assert.equal(result!.info.version, '3.0.0'); + assert.equal(result!.info.userMessageCount, 1); + assert.equal(result!.info.assistantMessageCount, 1); + assert.equal(result!.info.createdAt, '2026-01-15T10:00:00.000Z'); // queue-operation timestamp + assert.equal(result!.info.updatedAt, '2026-03-15T14:30:05.000Z'); + }); + + it('should handle malformed JSONL lines gracefully', () => { + const sessionId = 'parse-malformed-001'; + const dir = path.join(PROJECTS_DIR, '-home-user-malformedproject'); + fs.mkdirSync(dir, { recursive: true }); + const filePath = path.join(dir, `${sessionId}.jsonl`); + + const lines = [ + JSON.stringify(makeQueueEntry(sessionId)), + 'this is not valid json', + JSON.stringify(makeUserEntry({ + sessionId, + content: 'Valid message after bad line', + cwd: '/home/user/malformedproject', + })), + '{"incomplete": true', + ]; + fs.writeFileSync(filePath, lines.join('\n') + '\n'); + + const result = parser.parseClaudeSession(sessionId); + assert.ok(result, 'Should handle malformed lines gracefully'); + assert.equal(result!.messages.length, 1); + assert.equal(result!.messages[0].content, 'Valid message after bad line'); + }); + }); +}); diff --git a/src/app/api/claude-sessions/import/route.ts b/src/app/api/claude-sessions/import/route.ts new file mode 100644 index 00000000..f69e0fbb --- /dev/null +++ b/src/app/api/claude-sessions/import/route.ts @@ -0,0 +1,92 @@ +import { NextRequest } from 'next/server'; +import { parseClaudeSession } from '@/lib/claude-session-parser'; +import { createSession, addMessage, updateSdkSessionId, getAllSessions } from '@/lib/db'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { sessionId } = body; + + if (!sessionId) { + return Response.json( + { error: 'sessionId is required' }, + { status: 400 }, + ); + } + + // Check for duplicate import: reject if a session with this sdk_session_id already exists + const existingSessions = getAllSessions(); + const alreadyImported = existingSessions.find(s => s.sdk_session_id === sessionId); + if (alreadyImported) { + return Response.json( + { + error: 'This session has already been imported', + existingSessionId: alreadyImported.id, + }, + { status: 409 }, + ); + } + + const parsed = parseClaudeSession(sessionId); + if (!parsed) { + return Response.json( + { error: `Session "${sessionId}" not found or could not be parsed` }, + { status: 404 }, + ); + } + + const { info, messages } = parsed; + + if (messages.length === 0) { + return Response.json( + { error: 'Session has no messages to import' }, + { status: 400 }, + ); + } + + // Generate title from the first user message + const firstUserMsg = messages.find(m => m.role === 'user'); + const title = firstUserMsg + ? firstUserMsg.content.slice(0, 50) + (firstUserMsg.content.length > 50 ? '...' : '') + : `Imported: ${info.projectName}`; + + // Create a new CodePilot session + const session = createSession( + title, + undefined, // model — will use default + undefined, // system prompt + info.cwd || info.projectPath, + 'code', + ); + + // Store the original Claude Code SDK session ID so the conversation can be resumed + updateSdkSessionId(session.id, sessionId); + + // Import all messages + for (const msg of messages) { + // For assistant messages with tool blocks, store as structured JSON + // For text-only messages, store as plain text (consistent with CodePilot's convention) + const content = msg.hasToolBlocks + ? JSON.stringify(msg.contentBlocks) + : msg.content; + + if (content.trim()) { + addMessage(session.id, msg.role, content); + } + } + + return Response.json({ + session: { + id: session.id, + title, + messageCount: messages.length, + projectPath: info.projectPath, + sdkSessionId: sessionId, + }, + }, { status: 201 }); + } catch (error) { + const message = error instanceof Error ? error.stack || error.message : String(error); + console.error('[POST /api/claude-sessions/import] Error:', message); + return Response.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/claude-sessions/route.ts b/src/app/api/claude-sessions/route.ts new file mode 100644 index 00000000..841612b5 --- /dev/null +++ b/src/app/api/claude-sessions/route.ts @@ -0,0 +1,12 @@ +import { listClaudeSessions } from '@/lib/claude-session-parser'; + +export async function GET() { + try { + const sessions = listClaudeSessions(); + return Response.json({ sessions }); + } catch (error) { + const message = error instanceof Error ? error.stack || error.message : String(error); + console.error('[GET /api/claude-sessions] Error:', message); + return Response.json({ error: message }, { status: 500 }); + } +} diff --git a/src/components/layout/ChatListPanel.tsx b/src/components/layout/ChatListPanel.tsx index 96a76ba0..1cae4148 100644 --- a/src/components/layout/ChatListPanel.tsx +++ b/src/components/layout/ChatListPanel.tsx @@ -4,7 +4,7 @@ import { usePathname, useRouter } from "next/navigation"; import Link from "next/link"; import { useEffect, useState, useCallback } from "react"; import { HugeiconsIcon } from "@hugeicons/react"; -import { Delete02Icon, Search01Icon, Notification02Icon } from "@hugeicons/core-free-icons"; +import { Delete02Icon, Search01Icon, Notification02Icon, FileImportIcon } from "@hugeicons/core-free-icons"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -16,6 +16,7 @@ import { import { cn } from "@/lib/utils"; import { usePanel } from "@/hooks/usePanel"; import { ConnectionStatus } from "./ConnectionStatus"; +import { ImportSessionDialog } from "./ImportSessionDialog"; import type { ChatSession } from "@/types"; interface ChatListPanelProps { @@ -76,6 +77,7 @@ export function ChatListPanel({ open }: ChatListPanelProps) { const [hoveredSession, setHoveredSession] = useState(null); const [deletingSession, setDeletingSession] = useState(null); const [searchQuery, setSearchQuery] = useState(""); + const [importDialogOpen, setImportDialogOpen] = useState(false); const fetchSessions = useCallback(async () => { try { @@ -170,6 +172,26 @@ export function ChatListPanel({ open }: ChatListPanelProps) { + {/* Import CLI Session */} +
+ + + + + + Import conversations from Claude Code CLI + + +
+ {/* Chat sessions list */}
@@ -289,6 +311,12 @@ export function ChatListPanel({ open }: ChatListPanelProps) { v{process.env.NEXT_PUBLIC_APP_VERSION}
+ + {/* Import CLI Session Dialog */} + ); } diff --git a/src/components/layout/ImportSessionDialog.tsx b/src/components/layout/ImportSessionDialog.tsx new file mode 100644 index 00000000..f91f73bb --- /dev/null +++ b/src/components/layout/ImportSessionDialog.tsx @@ -0,0 +1,323 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Badge } from "@/components/ui/badge"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + Search01Icon, + Loading02Icon, + FolderOpenIcon, + GitBranchIcon, + ClockIcon, + FileImportIcon, + MessageAddIcon, +} from "@hugeicons/core-free-icons"; +import { cn } from "@/lib/utils"; + +interface ClaudeSessionInfo { + sessionId: string; + projectPath: string; + projectName: string; + cwd: string; + gitBranch: string; + version: string; + preview: string; + userMessageCount: number; + assistantMessageCount: number; + createdAt: string; + updatedAt: string; + fileSize: number; +} + +interface ImportSessionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function formatRelativeTime(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMin = Math.floor(diffMs / 60000); + const diffHr = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHr / 24); + + if (diffMin < 1) return "just now"; + if (diffMin < 60) return `${diffMin}m ago`; + if (diffHr < 24) return `${diffHr}h ago`; + if (diffDay < 7) return `${diffDay}d ago`; + return date.toLocaleDateString(); +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function ImportSessionDialog({ + open, + onOpenChange, +}: ImportSessionDialogProps) { + const router = useRouter(); + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(false); + const [importing, setImporting] = useState(null); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + + const fetchSessions = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/claude-sessions"); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Failed to fetch sessions"); + } + const data = await res.json(); + setSessions(data.sessions || []); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load sessions"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (open) { + fetchSessions(); + } + }, [open, fetchSessions]); + + const handleImport = async (sessionId: string) => { + setImporting(sessionId); + setError(null); + try { + const res = await fetch("/api/claude-sessions/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId }), + }); + + const data = await res.json(); + + if (res.status === 409 && data.existingSessionId) { + // Already imported — navigate to the existing session + onOpenChange(false); + router.push(`/chat/${data.existingSessionId}`); + return; + } + + if (!res.ok) { + throw new Error(data.error || "Failed to import session"); + } + + // Navigate to the newly imported session + onOpenChange(false); + window.dispatchEvent(new CustomEvent("session-created")); + router.push(`/chat/${data.session.id}`); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to import session"); + } finally { + setImporting(null); + } + }; + + const filteredSessions = searchQuery + ? sessions.filter( + (s) => + s.projectName.toLowerCase().includes(searchQuery.toLowerCase()) || + s.preview.toLowerCase().includes(searchQuery.toLowerCase()) || + s.cwd.toLowerCase().includes(searchQuery.toLowerCase()) || + s.gitBranch.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : sessions; + + return ( + + + + + + Import CLI Session + + + Browse and import conversations from Claude Code CLI. Imported + sessions can be resumed in CodePilot. + + + + {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="pl-8 text-sm" + /> +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Session List */} + +
+ {loading ? ( +
+ + + Scanning CLI sessions... + +
+ ) : filteredSessions.length === 0 ? ( +
+ +

+ {searchQuery + ? "No matching sessions" + : "No Claude Code CLI sessions found"} +

+

+ {searchQuery + ? "Try a different search term" + : "Sessions are stored in ~/.claude/projects/"} +

+
+ ) : ( + filteredSessions.map((session) => { + const isImporting = importing === session.sessionId; + const totalMessages = + session.userMessageCount + session.assistantMessageCount; + return ( +
+ {/* Top row: project name + import button */} +
+
+
+ + {session.projectName} + + {session.gitBranch && ( + + + {session.gitBranch} + + )} +
+

+ {session.preview} +

+
+ +
+ + {/* Bottom row: metadata */} +
+ + + {session.cwd} + + + + {totalMessages} msg{totalMessages !== 1 ? "s" : ""} + + + + {formatRelativeTime(session.updatedAt)} + + + {formatFileSize(session.fileSize)} + + {session.version && ( + v{session.version} + )} +
+
+ ); + }) + )} +
+
+
+
+ ); +} diff --git a/src/lib/claude-session-parser.ts b/src/lib/claude-session-parser.ts new file mode 100644 index 00000000..07380be8 --- /dev/null +++ b/src/lib/claude-session-parser.ts @@ -0,0 +1,527 @@ +/** + * Parser for Claude Code CLI session files (.jsonl). + * + * Claude Code stores conversation history as JSONL files in: + * ~/.claude/projects//.jsonl + * + * Each line is a JSON object with a `type` field: + * - "queue-operation": session lifecycle events (dequeue/enqueue) + * - "user": user messages with metadata (cwd, git branch, etc.) + * - "assistant": assistant responses with structured content blocks + * + * Messages are threaded via parentUuid → uuid chains. + */ + +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import type { MessageContentBlock } from '@/types'; + +// ========================================== +// Constants +// ========================================== + +/** Maximum file size (50 MB) to prevent memory issues with very large sessions */ +const MAX_FILE_SIZE = 50 * 1024 * 1024; + +// ========================================== +// Types for Claude Code JSONL entries +// ========================================== + +export interface ClaudeSessionInfo { + /** Session UUID (filename without .jsonl) */ + sessionId: string; + /** Decoded project directory path (best-effort from folder name) */ + projectPath: string; + /** Project folder name */ + projectName: string; + /** Working directory from the first user message (authoritative) */ + cwd: string; + /** Git branch from the first user message */ + gitBranch: string; + /** Claude Code version used */ + version: string; + /** First user message preview (truncated) */ + preview: string; + /** Number of user messages */ + userMessageCount: number; + /** Number of assistant messages */ + assistantMessageCount: number; + /** Session start timestamp */ + createdAt: string; + /** Last message timestamp */ + updatedAt: string; + /** File size in bytes */ + fileSize: number; +} + +export interface ParsedMessage { + role: 'user' | 'assistant'; + /** Plain text content for display */ + content: string; + /** Structured content blocks (for assistant messages with tool usage) */ + contentBlocks: MessageContentBlock[]; + /** Whether this message contains tool calls */ + hasToolBlocks: boolean; + /** Original timestamp from the JSONL entry */ + timestamp: string; +} + +export interface ParsedSession { + info: ClaudeSessionInfo; + messages: ParsedMessage[]; +} + +// Raw JSONL entry types +interface JournalEntry { + type: string; + timestamp?: string; + sessionId?: string; + [key: string]: unknown; +} + +interface UserEntry extends JournalEntry { + type: 'user'; + parentUuid: string | null; + cwd: string; + sessionId: string; + version: string; + gitBranch: string; + message: { + role: 'user'; + content: string | ContentBlock[]; + }; + uuid: string; + timestamp: string; +} + +interface AssistantEntry extends JournalEntry { + type: 'assistant'; + parentUuid: string; + cwd: string; + sessionId: string; + message: { + content: ContentBlock[]; + id?: string; + model?: string; + role: 'assistant'; + stop_reason?: string; + usage?: { + input_tokens: number; + output_tokens: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + }; + }; + uuid: string; + timestamp: string; +} + +interface ContentBlock { + type: string; + text?: string; + id?: string; + name?: string; + input?: unknown; + tool_use_id?: string; + content?: string | ContentBlock[]; + is_error?: boolean; +} + +// ========================================== +// Session Discovery +// ========================================== + +/** + * Get the Claude Code projects directory. + */ +export function getClaudeProjectsDir(): string { + return path.join(os.homedir(), '.claude', 'projects'); +} + +/** + * Decode a Claude Code project directory name back to a filesystem path. + * + * Claude Code encodes absolute paths by replacing each '/' with '-'. + * e.g., "/root/clawd" → "-root-clawd" + * + * NOTE: This is lossy — directory names containing hyphens are ambiguous. + * e.g., "-root-my-project" could be "/root/my-project" or "/root/my/project". + * The `cwd` field inside JSONL entries is the authoritative working directory; + * this function is only used as a fallback for display purposes. + */ +export function decodeProjectPath(encodedName: string): string { + if (!encodedName.startsWith('-')) { + return encodedName; + } + return encodedName.replace(/^-/, '/').replace(/-/g, '/'); +} + +/** + * List all available Claude Code CLI sessions. + * Scans ~/.claude/projects/ for .jsonl files and extracts metadata. + */ +export function listClaudeSessions(): ClaudeSessionInfo[] { + const projectsDir = getClaudeProjectsDir(); + + if (!fs.existsSync(projectsDir)) { + return []; + } + + const sessions: ClaudeSessionInfo[] = []; + + try { + const projectDirs = fs.readdirSync(projectsDir, { withFileTypes: true }); + + for (const projectDir of projectDirs) { + if (!projectDir.isDirectory()) continue; + + const projectPath = path.join(projectsDir, projectDir.name); + const decodedPath = decodeProjectPath(projectDir.name); + + try { + const files = fs.readdirSync(projectPath); + const jsonlFiles = files.filter(f => f.endsWith('.jsonl')); + + for (const jsonlFile of jsonlFiles) { + const filePath = path.join(projectPath, jsonlFile); + const sessionId = jsonlFile.replace('.jsonl', ''); + + try { + const info = extractSessionInfo(filePath, sessionId, decodedPath); + if (info) { + sessions.push(info); + } + } catch { + // Skip files that can't be parsed + } + } + } catch { + // Skip directories that can't be read + } + } + } catch { + // Projects directory can't be read + } + + // Sort by most recent first + sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + + return sessions; +} + +/** + * Read and split a JSONL file into lines, with size guard. + * Returns null if the file exceeds MAX_FILE_SIZE. + */ +function readJsonlLines(filePath: string): { lines: string[]; stat: fs.Stats } | null { + const stat = fs.statSync(filePath); + if (stat.size > MAX_FILE_SIZE) { + console.warn(`[claude-session-parser] Skipping ${filePath}: file too large (${(stat.size / 1024 / 1024).toFixed(1)} MB)`); + return null; + } + const content = fs.readFileSync(filePath, 'utf-8'); + const lines = content.split('\n').filter(l => l.trim()); + return { lines, stat }; +} + +/** + * Extract metadata from a session JSONL file without fully parsing all messages. + */ +function extractSessionInfo( + filePath: string, + sessionId: string, + projectPath: string, +): ClaudeSessionInfo | null { + const result = readJsonlLines(filePath); + if (!result) return null; + const { lines, stat } = result; + + if (lines.length === 0) return null; + + let cwd = ''; + let gitBranch = ''; + let version = ''; + let preview = ''; + let createdAt = ''; + let updatedAt = ''; + let userMessageCount = 0; + let assistantMessageCount = 0; + + for (const line of lines) { + try { + const entry = JSON.parse(line) as JournalEntry; + + if (entry.timestamp) { + if (!createdAt) createdAt = entry.timestamp as string; + updatedAt = entry.timestamp as string; + } + + if (entry.type === 'user') { + const userEntry = entry as UserEntry; + userMessageCount++; + + if (!cwd && userEntry.cwd) cwd = userEntry.cwd; + if (!gitBranch && userEntry.gitBranch) gitBranch = userEntry.gitBranch; + if (!version && userEntry.version) version = userEntry.version; + + if (!preview && userEntry.message?.content) { + const msgContent = userEntry.message.content; + if (typeof msgContent === 'string') { + preview = msgContent.slice(0, 120); + } else if (Array.isArray(msgContent)) { + const textBlock = msgContent.find(b => b.type === 'text'); + if (textBlock?.text) { + preview = textBlock.text.slice(0, 120); + } + } + } + } else if (entry.type === 'assistant') { + assistantMessageCount++; + } + } catch { + // Skip malformed lines + } + } + + // Skip empty sessions (only queue-operation entries, no actual messages) + if (userMessageCount === 0 && assistantMessageCount === 0) { + return null; + } + + // Use cwd from JSONL (authoritative) for projectName; fall back to decoded folder name + const effectivePath = cwd || projectPath; + + return { + sessionId, + projectPath: effectivePath, + projectName: path.basename(effectivePath), + cwd: effectivePath, + gitBranch: gitBranch || '', + version: version || '', + preview: preview || '(no preview)', + userMessageCount, + assistantMessageCount, + createdAt: createdAt || stat.birthtime.toISOString(), + updatedAt: updatedAt || stat.mtime.toISOString(), + fileSize: stat.size, + }; +} + +// ========================================== +// Session Parsing +// ========================================== + +/** + * Fully parse a Claude Code session JSONL file into messages. + * Reads the file once and extracts both metadata and messages in a single pass. + */ +export function parseClaudeSession(sessionId: string): ParsedSession | null { + const projectsDir = getClaudeProjectsDir(); + + if (!fs.existsSync(projectsDir)) return null; + + // Find the session file across all project directories + let filePath: string | null = null; + let projectPath = ''; + + try { + const projectDirs = fs.readdirSync(projectsDir, { withFileTypes: true }); + + for (const projectDir of projectDirs) { + if (!projectDir.isDirectory()) continue; + + const candidate = path.join(projectsDir, projectDir.name, `${sessionId}.jsonl`); + if (fs.existsSync(candidate)) { + filePath = candidate; + projectPath = decodeProjectPath(projectDir.name); + break; + } + } + } catch { + return null; + } + + if (!filePath) return null; + + const result = readJsonlLines(filePath); + if (!result) return null; + const { lines, stat } = result; + + if (lines.length === 0) return null; + + // Single pass: extract both metadata and messages + const messages: ParsedMessage[] = []; + let cwd = ''; + let gitBranch = ''; + let version = ''; + let preview = ''; + let createdAt = ''; + let updatedAt = ''; + let userMessageCount = 0; + let assistantMessageCount = 0; + + for (const line of lines) { + try { + const entry = JSON.parse(line) as JournalEntry; + + if (entry.timestamp) { + if (!createdAt) createdAt = entry.timestamp as string; + updatedAt = entry.timestamp as string; + } + + if (entry.type === 'user') { + const userEntry = entry as UserEntry; + userMessageCount++; + + if (!cwd && userEntry.cwd) cwd = userEntry.cwd; + if (!gitBranch && userEntry.gitBranch) gitBranch = userEntry.gitBranch; + if (!version && userEntry.version) version = userEntry.version; + + if (!preview && userEntry.message?.content) { + const msgContent = userEntry.message.content; + if (typeof msgContent === 'string') { + preview = msgContent.slice(0, 120); + } else if (Array.isArray(msgContent)) { + const textBlock = msgContent.find(b => b.type === 'text'); + if (textBlock?.text) { + preview = textBlock.text.slice(0, 120); + } + } + } + + const parsed = parseUserMessage(userEntry); + if (parsed) messages.push(parsed); + } else if (entry.type === 'assistant') { + assistantMessageCount++; + + const assistantEntry = entry as AssistantEntry; + const parsed = parseAssistantMessage(assistantEntry); + if (parsed) messages.push(parsed); + } + } catch { + // Skip malformed lines + } + } + + // Skip empty sessions + if (userMessageCount === 0 && assistantMessageCount === 0) { + return null; + } + + const effectivePath = cwd || projectPath; + + const info: ClaudeSessionInfo = { + sessionId, + projectPath: effectivePath, + projectName: path.basename(effectivePath), + cwd: effectivePath, + gitBranch: gitBranch || '', + version: version || '', + preview: preview || '(no preview)', + userMessageCount, + assistantMessageCount, + createdAt: createdAt || stat.birthtime.toISOString(), + updatedAt: updatedAt || stat.mtime.toISOString(), + fileSize: stat.size, + }; + + return { info, messages }; +} + +/** + * Parse a user message entry into a ParsedMessage. + */ +function parseUserMessage(entry: UserEntry): ParsedMessage | null { + const msgContent = entry.message?.content; + if (!msgContent) return null; + + let text: string; + if (typeof msgContent === 'string') { + text = msgContent; + } else if (Array.isArray(msgContent)) { + // User messages can have structured content (e.g., with images) + text = msgContent + .filter(b => b.type === 'text') + .map(b => b.text || '') + .join('\n'); + } else { + return null; + } + + if (!text.trim()) return null; + + return { + role: 'user', + content: text, + contentBlocks: [{ type: 'text', text }], + hasToolBlocks: false, + timestamp: entry.timestamp || new Date().toISOString(), + }; +} + +/** + * Parse an assistant message entry into a ParsedMessage. + * Handles text, tool_use, and tool_result content blocks. + */ +function parseAssistantMessage(entry: AssistantEntry): ParsedMessage | null { + const msgContent = entry.message?.content; + if (!msgContent || !Array.isArray(msgContent)) return null; + + const contentBlocks: MessageContentBlock[] = []; + const textParts: string[] = []; + let hasToolBlocks = false; + + for (const block of msgContent) { + switch (block.type) { + case 'text': { + if (block.text) { + contentBlocks.push({ type: 'text', text: block.text }); + textParts.push(block.text); + } + break; + } + case 'tool_use': { + hasToolBlocks = true; + contentBlocks.push({ + type: 'tool_use', + id: block.id || '', + name: block.name || '', + input: block.input, + }); + break; + } + case 'tool_result': { + hasToolBlocks = true; + const resultContent = typeof block.content === 'string' + ? block.content + : Array.isArray(block.content) + ? block.content + .filter(c => c.type === 'text') + .map(c => c.text || '') + .join('\n') + : ''; + contentBlocks.push({ + type: 'tool_result', + tool_use_id: block.tool_use_id || '', + content: resultContent, + is_error: block.is_error || false, + }); + break; + } + } + } + + if (contentBlocks.length === 0) return null; + + // Plain text content: join all text blocks + const plainText = textParts.join('\n'); + + return { + role: 'assistant', + content: plainText, + contentBlocks, + hasToolBlocks, + timestamp: entry.timestamp || new Date().toISOString(), + }; +}