feat: import and resume Claude Code CLI sessions (closes #10)

Add ability to browse and import conversation history from Claude Code CLI
into CodePilot, with full resume support via SDK session ID.

Features:
- JSONL parser for Claude Code session files (~/.claude/projects/)
- REST API: GET /api/claude-sessions (list) + POST /api/claude-sessions/import
- Import dialog UI accessible from sidebar 'Import CLI Session' button
- Imported sessions bind sdk_session_id for seamless resume in CodePilot
- Duplicate import protection (409 with redirect to existing session)
- File size guard (50MB max) to prevent memory issues

Files changed:
- src/lib/claude-session-parser.ts — JSONL parser core
- src/app/api/claude-sessions/route.ts — list API
- src/app/api/claude-sessions/import/route.ts — import API with dedup
- src/components/layout/ImportSessionDialog.tsx — browse & import UI
- src/components/layout/ChatListPanel.tsx — sidebar integration
- src/__tests__/unit/claude-session-parser.test.ts — 17 unit tests
This commit is contained in:
liuxiaopai-ai committed 2026-02-08 22:04:12 +08:00
1 parent 164f730da1
commit 187cc23127
6 files changed
+1502 -1

No files matched your search

@@ -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');
});
});
});
@@ -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 });
}
}
+12
View File
@@ -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 });
}
}
+29 -1
View File
@@ -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<string | null>(null);
const [deletingSession, setDeletingSession] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [importDialogOpen, setImportDialogOpen] = useState(false);
const fetchSessions = useCallback(async () => {
try {
@@ -170,6 +172,26 @@ export function ChatListPanel({ open }: ChatListPanelProps) {
</div>
</div>
{/* Import CLI Session */}
<div className="px-3 pb-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 h-7 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setImportDialogOpen(true)}
>
<HugeiconsIcon icon={FileImportIcon} className="h-3 w-3" />
Import CLI Session
</Button>
</TooltipTrigger>
<TooltipContent side="right">
Import conversations from Claude Code CLI
</TooltipContent>
</Tooltip>
</div>
{/* Chat sessions list */}
<ScrollArea className="flex-1 min-h-0 px-3">
<div className="flex flex-col pb-3">
@@ -289,6 +311,12 @@ export function ChatListPanel({ open }: ChatListPanelProps) {
v{process.env.NEXT_PUBLIC_APP_VERSION}
</span>
</div>
{/* Import CLI Session Dialog */}
<ImportSessionDialog
open={importDialogOpen}
onOpenChange={setImportDialogOpen}
/>
</aside>
);
}
@@ -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<ClaudeSessionInfo[]>([]);
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<HugeiconsIcon
icon={FileImportIcon}
className="h-5 w-5 text-primary"
/>
Import CLI Session
</DialogTitle>
<DialogDescription>
Browse and import conversations from Claude Code CLI. Imported
sessions can be resumed in CodePilot.
</DialogDescription>
</DialogHeader>
{/* Search */}
<div className="relative">
<HugeiconsIcon
icon={Search01Icon}
className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder="Search by project, message, or branch..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 text-sm"
/>
</div>
{/* Error */}
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
{/* Session List */}
<ScrollArea className="flex-1 min-h-0 -mx-6 px-6">
<div className="flex flex-col gap-2 pb-2">
{loading ? (
<div className="flex items-center justify-center py-12">
<HugeiconsIcon
icon={Loading02Icon}
className="h-5 w-5 animate-spin text-muted-foreground"
/>
<span className="ml-2 text-sm text-muted-foreground">
Scanning CLI sessions...
</span>
</div>
) : filteredSessions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<HugeiconsIcon
icon={FolderOpenIcon}
className="h-8 w-8 mb-2 opacity-40"
/>
<p className="text-sm">
{searchQuery
? "No matching sessions"
: "No Claude Code CLI sessions found"}
</p>
<p className="text-xs mt-1 opacity-60">
{searchQuery
? "Try a different search term"
: "Sessions are stored in ~/.claude/projects/"}
</p>
</div>
) : (
filteredSessions.map((session) => {
const isImporting = importing === session.sessionId;
const totalMessages =
session.userMessageCount + session.assistantMessageCount;
return (
<div
key={session.sessionId}
className={cn(
"group flex flex-col gap-1.5 rounded-lg border p-3 transition-colors",
"hover:bg-accent/50",
isImporting && "opacity-60 pointer-events-none"
)}
>
{/* Top row: project name + import button */}
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm truncate">
{session.projectName}
</span>
{session.gitBranch && (
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 h-4 shrink-0"
>
<HugeiconsIcon
icon={GitBranchIcon}
className="h-2.5 w-2.5 mr-0.5"
/>
{session.gitBranch}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground line-clamp-2 break-all">
{session.preview}
</p>
</div>
<Button
size="sm"
variant="outline"
className="shrink-0 h-7 text-xs"
onClick={() => handleImport(session.sessionId)}
disabled={isImporting}
>
{isImporting ? (
<>
<HugeiconsIcon
icon={Loading02Icon}
className="h-3 w-3 mr-1 animate-spin"
/>
Importing...
</>
) : (
<>
<HugeiconsIcon
icon={FileImportIcon}
className="h-3 w-3 mr-1"
/>
Import
</>
)}
</Button>
</div>
{/* Bottom row: metadata */}
<div className="flex items-center gap-3 text-[10px] text-muted-foreground/60">
<span
className="flex items-center gap-0.5 truncate"
title={session.cwd}
>
<HugeiconsIcon
icon={FolderOpenIcon}
className="h-2.5 w-2.5 shrink-0"
/>
{session.cwd}
</span>
<span className="flex items-center gap-0.5 shrink-0">
<HugeiconsIcon
icon={MessageAddIcon}
className="h-2.5 w-2.5"
/>
{totalMessages} msg{totalMessages !== 1 ? "s" : ""}
</span>
<span className="flex items-center gap-0.5 shrink-0">
<HugeiconsIcon
icon={ClockIcon}
className="h-2.5 w-2.5"
/>
{formatRelativeTime(session.updatedAt)}
</span>
<span className="shrink-0">
{formatFileSize(session.fileSize)}
</span>
{session.version && (
<span className="shrink-0">v{session.version}</span>
)}
</div>
</div>
);
})
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
+527
View File
@@ -0,0 +1,527 @@
/**
* Parser for Claude Code CLI session files (.jsonl).
*
* Claude Code stores conversation history as JSONL files in:
* ~/.claude/projects/<encoded-project-path>/<session-uuid>.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(),
};
}