From 65fc1cdec4724ee58b493f10cb503db61f217ce5 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 7 Feb 2026 23:25:01 -0800 Subject: [PATCH 1/9] Grabbing session --- .../src/lib/trpc/routers/ai-chat/index.ts | 5 + .../claude-session-scanner.ts | 113 ++++++++++++++ .../utils/claude-session-scanner/index.ts | 2 + .../SessionSelector/SessionSelector.tsx | 140 +++++++++++++++++- 4 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts create mode 100644 apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/index.ts diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts index 15d9da54430..ab0bd596cb5 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts @@ -1,6 +1,7 @@ import { observable } from "@trpc/server/observable"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { scanClaudeSessions } from "./utils/claude-session-scanner"; import { type ClaudeStreamEvent, chatSessionManager, @@ -112,6 +113,10 @@ export const createAiChatRouter = () => { return chatSessionManager.getActiveSessions(); }), + scanClaudeSessions: publicProcedure.query(async () => { + return scanClaudeSessions(); + }), + streamEvents: publicProcedure .input(z.object({ sessionId: z.string().optional() })) .subscription(({ input }) => { diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts new file mode 100644 index 00000000000..a53b92387a5 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts @@ -0,0 +1,113 @@ +import { readdir, readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +export interface ClaudeSessionInfo { + sessionId: string; + project: string; + cwd: string; + gitBranch: string | null; + display: string; + timestamp: number; +} + +function decodeProjectDir(encoded: string): string { + return encoded.replace(/-/g, "/"); +} + +/** + * Reads the second line of a session JSONL to extract metadata. + * Line 0 = file-history-snapshot, Line 1 = first user message with sessionId, cwd, gitBranch, etc. + */ +async function readSessionMeta( + filePath: string, +): Promise<{ + sessionId: string; + cwd: string; + gitBranch: string | null; + display: string; + timestamp: number; +} | null> { + try { + const handle = await readFile(filePath, "utf-8"); + // Only read until we find the first user message (usually line index 1) + const lines = handle.split("\n", 5); + for (const line of lines) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line); + if (parsed.type === "user" && parsed.sessionId) { + return { + sessionId: parsed.sessionId, + cwd: parsed.cwd ?? "", + gitBranch: parsed.gitBranch ?? null, + display: + typeof parsed.message?.content === "string" + ? parsed.message.content.slice(0, 200) + : "", + timestamp: parsed.timestamp + ? new Date(parsed.timestamp).getTime() + : 0, + }; + } + } catch { + // skip non-JSON lines + } + } + return null; + } catch { + return null; + } +} + +/** + * Scans ~/.claude/projects/ for all resumable Claude Code sessions. + * Returns session metadata sorted by most recent first. + */ +export async function scanClaudeSessions(): Promise { + const projectsDir = join(homedir(), ".claude", "projects"); + + let projectDirs: string[]; + try { + projectDirs = await readdir(projectsDir); + } catch { + return []; + } + + const sessions: ClaudeSessionInfo[] = []; + + const scanPromises = projectDirs.map(async (projectDir) => { + const fullProjectDir = join(projectsDir, projectDir); + let files: string[]; + try { + files = await readdir(fullProjectDir); + } catch { + return; + } + + const sessionFiles = files.filter( + (f) => f.endsWith(".jsonl") && UUID_RE.test(f.replace(".jsonl", "")), + ); + + const metaPromises = sessionFiles.map(async (file) => { + const filePath = join(fullProjectDir, file); + const meta = await readSessionMeta(filePath); + if (meta) { + sessions.push({ + ...meta, + project: decodeProjectDir(projectDir), + }); + } + }); + + await Promise.all(metaPromises); + }); + + await Promise.all(scanPromises); + + sessions.sort((a, b) => b.timestamp - a.timestamp); + return sessions; +} diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/index.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/index.ts new file mode 100644 index 00000000000..4127c7af305 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/index.ts @@ -0,0 +1,2 @@ +export type { ClaudeSessionInfo } from "./claude-session-scanner"; +export { scanClaudeSessions } from "./claude-session-scanner"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx index 9088decf1fa..123c29d6e51 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx @@ -6,7 +6,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { HiMiniChatBubbleLeftRight, HiMiniChevronDown, @@ -15,6 +15,41 @@ import { } from "react-icons/hi2"; import { electronTrpc } from "renderer/lib/electron-trpc"; +type TimeGroup = + | "Today" + | "Yesterday" + | "This Week" + | "Last Week" + | "This Month" + | "Older"; + +function getTimeGroup(timestamp: number): TimeGroup { + const now = new Date(); + const date = new Date(timestamp); + + const startOfToday = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ); + const startOfYesterday = new Date(startOfToday.getTime() - 86_400_000); + const dayOfWeek = now.getDay() === 0 ? 7 : now.getDay(); + const startOfThisWeek = new Date( + startOfToday.getTime() - (dayOfWeek - 1) * 86_400_000, + ); + const startOfLastWeek = new Date( + startOfThisWeek.getTime() - 7 * 86_400_000, + ); + const startOfThisMonth = new Date(now.getFullYear(), now.getMonth(), 1); + + if (date >= startOfToday) return "Today"; + if (date >= startOfYesterday) return "Yesterday"; + if (date >= startOfThisWeek) return "This Week"; + if (date >= startOfLastWeek) return "Last Week"; + if (date >= startOfThisMonth) return "This Month"; + return "Older"; +} + function formatRelativeTime(timestamp: number): string { const diff = Date.now() - timestamp; const minutes = Math.floor(diff / 60_000); @@ -35,6 +70,15 @@ interface SessionSelectorProps { onDeleteSession: (sessionId: string) => void; } +const TIME_GROUP_ORDER: TimeGroup[] = [ + "Today", + "Yesterday", + "This Week", + "Last Week", + "This Month", + "Older", +]; + export function SessionSelector({ workspaceId, currentSessionId, @@ -43,17 +87,46 @@ export function SessionSelector({ onDeleteSession, }: SessionSelectorProps) { const [isOpen, setIsOpen] = useState(false); + const [showClaudeSessions, setShowClaudeSessions] = useState(false); const { data: sessions } = electronTrpc.aiChat.listSessions.useQuery( { workspaceId }, { enabled: isOpen }, ); + const { data: claudeSessions, isLoading: isScanning } = + electronTrpc.aiChat.scanClaudeSessions.useQuery(undefined, { + enabled: showClaudeSessions, + }); + const currentSession = sessions?.find( (s) => s.sessionId === currentSessionId, ); const displayTitle = currentSession?.title ?? "Chat"; + const groupedClaudeSessions = useMemo(() => { + if (!claudeSessions) return null; + const groups = new Map< + TimeGroup, + typeof claudeSessions + >(); + + for (const session of claudeSessions) { + const group = getTimeGroup(session.timestamp); + const existing = groups.get(group); + if (existing) { + existing.push(session); + } else { + groups.set(group, [session]); + } + } + + return TIME_GROUP_ORDER.filter((g) => groups.has(g)).map((group) => ({ + label: group, + sessions: groups.get(group)!, + })); + }, [claudeSessions]); + const handleSelect = useCallback( (sessionId: string) => { if (sessionId !== currentSessionId) { @@ -77,6 +150,10 @@ export function SessionSelector({ setIsOpen(false); }, [onNewChat]); + const handleToggleClaudeSessions = useCallback(() => { + setShowClaudeSessions((prev) => !prev); + }, []); + return ( @@ -89,7 +166,7 @@ export function SessionSelector({ - + Sessions @@ -137,6 +214,65 @@ export function SessionSelector({ )} + + + { + e.preventDefault(); + handleToggleClaudeSessions(); + }} + > + + {showClaudeSessions + ? "Hide Claude Code Sessions" + : "Browse Claude Code Sessions"} + + + + {showClaudeSessions && ( +
+ {isScanning ? ( +
+ Scanning sessions... +
+ ) : groupedClaudeSessions && groupedClaudeSessions.length > 0 ? ( + groupedClaudeSessions.map((group) => ( +
+ + {group.label} + + {group.sessions.map((session) => ( + handleSelect(session.sessionId)} + > + + {session.display || "Untitled session"} + + + {formatRelativeTime(session.timestamp)} + {session.gitBranch && ( + <> + {" · "} + + {session.gitBranch} + + + )} + + + ))} +
+ )) + ) : ( +
+ No Claude Code sessions found +
+ )} +
+ )} + From 759390f41c5e22abf817d2399a0069ada6214a6a Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 7 Feb 2026 23:29:42 -0800 Subject: [PATCH 2/9] Unified ui --- .../claude-session-scanner.ts | 14 +- .../SessionSelector/SessionSelector.tsx | 234 +++++++++--------- 2 files changed, 133 insertions(+), 115 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts index a53b92387a5..8e6d87d0309 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts @@ -108,6 +108,16 @@ export async function scanClaudeSessions(): Promise { await Promise.all(scanPromises); - sessions.sort((a, b) => b.timestamp - a.timestamp); - return sessions; + // Deduplicate by sessionId — keep most recent when same session appears in multiple project dirs + const seen = new Map(); + for (const session of sessions) { + const existing = seen.get(session.sessionId); + if (!existing || session.timestamp > existing.timestamp) { + seen.set(session.sessionId, session); + } + } + + const deduplicated = Array.from(seen.values()); + deduplicated.sort((a, b) => b.timestamp - a.timestamp); + return deduplicated; } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx index 123c29d6e51..aa0aa0857cb 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx @@ -23,6 +23,15 @@ type TimeGroup = | "This Month" | "Older"; +const TIME_GROUP_ORDER: TimeGroup[] = [ + "Today", + "Yesterday", + "This Week", + "Last Week", + "This Month", + "Older", +]; + function getTimeGroup(timestamp: number): TimeGroup { const now = new Date(); const date = new Date(timestamp); @@ -62,6 +71,15 @@ function formatRelativeTime(timestamp: number): string { return new Date(timestamp).toLocaleDateString(); } +interface UnifiedSession { + sessionId: string; + display: string; + timestamp: number; + gitBranch: string | null; + source: "superset" | "claude-code"; + messagePreview?: string; +} + interface SessionSelectorProps { workspaceId: string; currentSessionId: string; @@ -70,15 +88,6 @@ interface SessionSelectorProps { onDeleteSession: (sessionId: string) => void; } -const TIME_GROUP_ORDER: TimeGroup[] = [ - "Today", - "Yesterday", - "This Week", - "Last Week", - "This Month", - "Older", -]; - export function SessionSelector({ workspaceId, currentSessionId, @@ -87,7 +96,6 @@ export function SessionSelector({ onDeleteSession, }: SessionSelectorProps) { const [isOpen, setIsOpen] = useState(false); - const [showClaudeSessions, setShowClaudeSessions] = useState(false); const { data: sessions } = electronTrpc.aiChat.listSessions.useQuery( { workspaceId }, @@ -96,7 +104,7 @@ export function SessionSelector({ const { data: claudeSessions, isLoading: isScanning } = electronTrpc.aiChat.scanClaudeSessions.useQuery(undefined, { - enabled: showClaudeSessions, + enabled: isOpen, }); const currentSession = sessions?.find( @@ -104,14 +112,43 @@ export function SessionSelector({ ); const displayTitle = currentSession?.title ?? "Chat"; - const groupedClaudeSessions = useMemo(() => { - if (!claudeSessions) return null; - const groups = new Map< - TimeGroup, - typeof claudeSessions - >(); + const grouped = useMemo(() => { + const unified: UnifiedSession[] = []; + const seenIds = new Set(); - for (const session of claudeSessions) { + // Superset sessions first (they take priority) + if (sessions) { + for (const s of sessions) { + seenIds.add(s.sessionId); + unified.push({ + sessionId: s.sessionId, + display: s.title, + timestamp: s.lastActiveAt, + gitBranch: null, + source: "superset", + messagePreview: s.messagePreview, + }); + } + } + + // Claude Code sessions (skip any already tracked by superset) + if (claudeSessions) { + for (const s of claudeSessions) { + if (seenIds.has(s.sessionId)) continue; + seenIds.add(s.sessionId); + unified.push({ + sessionId: s.sessionId, + display: s.display || "Untitled session", + timestamp: s.timestamp, + gitBranch: s.gitBranch, + source: "claude-code", + }); + } + } + + // Group by time + const groups = new Map(); + for (const session of unified) { const group = getTimeGroup(session.timestamp); const existing = groups.get(group); if (existing) { @@ -121,11 +158,16 @@ export function SessionSelector({ } } + // Sort within each group by timestamp desc + for (const items of groups.values()) { + items.sort((a, b) => b.timestamp - a.timestamp); + } + return TIME_GROUP_ORDER.filter((g) => groups.has(g)).map((group) => ({ label: group, sessions: groups.get(group)!, })); - }, [claudeSessions]); + }, [sessions, claudeSessions]); const handleSelect = useCallback( (sessionId: string) => { @@ -150,10 +192,6 @@ export function SessionSelector({ setIsOpen(false); }, [onNewChat]); - const handleToggleClaudeSessions = useCallback(() => { - setShowClaudeSessions((prev) => !prev); - }, []); - return ( @@ -167,88 +205,40 @@ export function SessionSelector({ - Sessions - - - {sessions && sessions.length > 0 ? ( - sessions.map((session) => ( - handleSelect(session.sessionId)} - > -
- - {session.title} - - - {formatRelativeTime(session.lastActiveAt)} - {session.messagePreview && ( - <> - {" — "} - {session.messagePreview} - - )} - -
- {session.sessionId !== currentSessionId && ( - - )} -
- )) - ) : ( -
- No previous sessions -
- )} - +
+ + Sessions + + {isScanning && ( + + Scanning... + + )} +
- { - e.preventDefault(); - handleToggleClaudeSessions(); - }} - > - - {showClaudeSessions - ? "Hide Claude Code Sessions" - : "Browse Claude Code Sessions"} - - - - {showClaudeSessions && ( -
- {isScanning ? ( -
- Scanning sessions... -
- ) : groupedClaudeSessions && groupedClaudeSessions.length > 0 ? ( - groupedClaudeSessions.map((group) => ( -
- - {group.label} - - {group.sessions.map((session) => ( - handleSelect(session.sessionId)} - > - - {session.display || "Untitled session"} +
+ {grouped.length > 0 ? ( + grouped.map((group) => ( +
+ + {group.label} + + {group.sessions.map((session) => ( + handleSelect(session.sessionId)} + > +
+ + {session.display} {formatRelativeTime(session.timestamp)} @@ -260,18 +250,36 @@ export function SessionSelector({ )} + {session.messagePreview && ( + <> + {" — "} + + {session.messagePreview} + + + )} - - ))} -
- )) - ) : ( -
- No Claude Code sessions found +
+ {session.sessionId !== currentSessionId && + session.source === "superset" && ( + + )} +
+ ))}
- )} -
- )} + )) + ) : !isScanning ? ( +
+ No sessions found +
+ ) : null} +
From b8534997d3a8397e56388b2e461f85d85837aa4d Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 7 Feb 2026 23:36:01 -0800 Subject: [PATCH 3/9] Lazyload and paginate --- .../src/lib/trpc/routers/ai-chat/index.ts | 18 +- .../claude-session-scanner.ts | 188 ++++++++++++++---- .../utils/claude-session-scanner/index.ts | 2 +- .../SessionSelector/SessionSelector.tsx | 135 ++++++++++--- 4 files changed, 268 insertions(+), 75 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts index ab0bd596cb5..bd30054208f 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts @@ -113,9 +113,21 @@ export const createAiChatRouter = () => { return chatSessionManager.getActiveSessions(); }), - scanClaudeSessions: publicProcedure.query(async () => { - return scanClaudeSessions(); - }), + scanClaudeSessions: publicProcedure + .input( + z + .object({ + cursor: z.number().optional(), + limit: z.number().min(1).max(100).optional(), + }) + .optional(), + ) + .query(async ({ input }) => { + return scanClaudeSessions({ + cursor: input?.cursor ?? 0, + limit: input?.limit ?? 30, + }); + }), streamEvents: publicProcedure .input(z.object({ sessionId: z.string().optional() })) diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts index 8e6d87d0309..03aabdd9072 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts @@ -1,10 +1,22 @@ -import { readdir, readFile } from "node:fs/promises"; +import { close, open, read } from "node:fs"; +import { readdir, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; +import { promisify } from "node:util"; + +const fsOpen = promisify(open); +const fsRead = promisify(read); +const fsClose = promisify(close); const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +/** Max bytes to read from the start of each JSONL file (metadata is in the first ~2 lines). */ +const HEAD_BYTES = 4096; + +/** How many files to stat concurrently per batch. */ +const BATCH_SIZE = 100; + export interface ClaudeSessionInfo { sessionId: string; project: string; @@ -14,14 +26,28 @@ export interface ClaudeSessionInfo { timestamp: number; } +export interface ClaudeSessionPage { + sessions: ClaudeSessionInfo[]; + nextCursor: number | null; + total: number; +} + +interface SessionFileEntry { + filePath: string; + projectDir: string; + sessionId: string; + mtime: number; +} + +/** Cached index of session files sorted by mtime desc. Built once, reused for pagination. */ +let cachedIndex: SessionFileEntry[] | null = null; +let cacheTimestamp = 0; +const CACHE_TTL = 5 * 60_000; // 5 minutes + function decodeProjectDir(encoded: string): string { return encoded.replace(/-/g, "/"); } -/** - * Reads the second line of a session JSONL to extract metadata. - * Line 0 = file-history-snapshot, Line 1 = first user message with sessionId, cwd, gitBranch, etc. - */ async function readSessionMeta( filePath: string, ): Promise<{ @@ -31,10 +57,17 @@ async function readSessionMeta( display: string; timestamp: number; } | null> { + let fd: number | undefined; try { - const handle = await readFile(filePath, "utf-8"); - // Only read until we find the first user message (usually line index 1) - const lines = handle.split("\n", 5); + fd = await fsOpen(filePath, "r"); + const buffer = Buffer.alloc(HEAD_BYTES); + const { bytesRead } = await fsRead(fd, buffer, 0, HEAD_BYTES, 0); + await fsClose(fd); + fd = undefined; + + const head = buffer.toString("utf-8", 0, bytesRead); + const lines = head.split("\n"); + for (const line of lines) { if (!line.trim()) continue; try { @@ -54,20 +87,31 @@ async function readSessionMeta( }; } } catch { - // skip non-JSON lines + // Incomplete JSON at buffer boundary or non-JSON line } } return null; } catch { + if (fd !== undefined) { + try { + await fsClose(fd); + } catch { + // ignore + } + } return null; } } /** - * Scans ~/.claude/projects/ for all resumable Claude Code sessions. - * Returns session metadata sorted by most recent first. + * Build an index of all session files with their mtimes. + * Uses stat (no file reads) so it's fast even for 600+ files. */ -export async function scanClaudeSessions(): Promise { +async function buildIndex(): Promise { + if (cachedIndex && Date.now() - cacheTimestamp < CACHE_TTL) { + return cachedIndex; + } + const projectsDir = join(homedir(), ".claude", "projects"); let projectDirs: string[]; @@ -77,47 +121,103 @@ export async function scanClaudeSessions(): Promise { return []; } - const sessions: ClaudeSessionInfo[] = []; + const entries: SessionFileEntry[] = []; - const scanPromises = projectDirs.map(async (projectDir) => { - const fullProjectDir = join(projectsDir, projectDir); - let files: string[]; - try { - files = await readdir(fullProjectDir); - } catch { - return; - } + // Collect all session files with their mtimes + for (let i = 0; i < projectDirs.length; i += BATCH_SIZE) { + const batch = projectDirs.slice(i, i + BATCH_SIZE); + await Promise.all( + batch.map(async (projectDir) => { + const fullProjectDir = join(projectsDir, projectDir); + try { + const files = await readdir(fullProjectDir); + const sessionFiles = files.filter( + (f) => + f.endsWith(".jsonl") && + UUID_RE.test(f.replace(".jsonl", "")), + ); - const sessionFiles = files.filter( - (f) => f.endsWith(".jsonl") && UUID_RE.test(f.replace(".jsonl", "")), + await Promise.all( + sessionFiles.map(async (f) => { + const filePath = join(fullProjectDir, f); + try { + const s = await stat(filePath); + entries.push({ + filePath, + projectDir, + sessionId: f.replace(".jsonl", ""), + mtime: s.mtimeMs, + }); + } catch { + // skip + } + }), + ); + } catch { + // skip + } + }), ); - const metaPromises = sessionFiles.map(async (file) => { - const filePath = join(fullProjectDir, file); - const meta = await readSessionMeta(filePath); + // Yield between batches + if (i + BATCH_SIZE < projectDirs.length) { + await new Promise((resolve) => setImmediate(resolve)); + } + } + + // Deduplicate by sessionId — keep most recent mtime + const seen = new Map(); + for (const entry of entries) { + const existing = seen.get(entry.sessionId); + if (!existing || entry.mtime > existing.mtime) { + seen.set(entry.sessionId, entry); + } + } + + const deduplicated = Array.from(seen.values()); + deduplicated.sort((a, b) => b.mtime - a.mtime); + + cachedIndex = deduplicated; + cacheTimestamp = Date.now(); + return deduplicated; +} + +/** + * Scans ~/.claude/projects/ for resumable Claude Code sessions with cursor-based pagination. + * First call builds a lightweight index using stat (no file reads). + * Then reads metadata only for the requested page. + */ +export async function scanClaudeSessions({ + cursor = 0, + limit = 30, +}: { + cursor?: number; + limit?: number; +}): Promise { + const index = await buildIndex(); + const page = index.slice(cursor, cursor + limit); + + // Read metadata only for this page + const sessions: ClaudeSessionInfo[] = []; + await Promise.all( + page.map(async (entry) => { + const meta = await readSessionMeta(entry.filePath); if (meta) { sessions.push({ ...meta, - project: decodeProjectDir(projectDir), + project: decodeProjectDir(entry.projectDir), }); } - }); - - await Promise.all(metaPromises); - }); + }), + ); - await Promise.all(scanPromises); + // Re-sort this page by timestamp from the actual metadata + sessions.sort((a, b) => b.timestamp - a.timestamp); - // Deduplicate by sessionId — keep most recent when same session appears in multiple project dirs - const seen = new Map(); - for (const session of sessions) { - const existing = seen.get(session.sessionId); - if (!existing || session.timestamp > existing.timestamp) { - seen.set(session.sessionId, session); - } - } - - const deduplicated = Array.from(seen.values()); - deduplicated.sort((a, b) => b.timestamp - a.timestamp); - return deduplicated; + const nextOffset = cursor + limit; + return { + sessions, + nextCursor: nextOffset < index.length ? nextOffset : null, + total: index.length, + }; } diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/index.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/index.ts index 4127c7af305..6052f9f7424 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/index.ts @@ -1,2 +1,2 @@ -export type { ClaudeSessionInfo } from "./claude-session-scanner"; +export type { ClaudeSessionInfo, ClaudeSessionPage } from "./claude-session-scanner"; export { scanClaudeSessions } from "./claude-session-scanner"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx index aa0aa0857cb..b2dc0806fd8 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx @@ -6,7 +6,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { HiMiniChatBubbleLeftRight, HiMiniChevronDown, @@ -32,6 +32,8 @@ const TIME_GROUP_ORDER: TimeGroup[] = [ "Older", ]; +const PAGE_SIZE = 30; + function getTimeGroup(timestamp: number): TimeGroup { const now = new Date(); const date = new Date(timestamp); @@ -96,16 +98,89 @@ export function SessionSelector({ onDeleteSession, }: SessionSelectorProps) { const [isOpen, setIsOpen] = useState(false); + const [cursor, setCursor] = useState(0); + const [allClaudeSessions, setAllClaudeSessions] = useState( + [], + ); + const [hasMore, setHasMore] = useState(true); + const [total, setTotal] = useState(0); + const sentinelRef = useRef(null); + const scrollRef = useRef(null); + + // Reset pagination when dropdown closes + useEffect(() => { + if (!isOpen) { + setCursor(0); + setAllClaudeSessions([]); + setHasMore(true); + setTotal(0); + } + }, [isOpen]); const { data: sessions } = electronTrpc.aiChat.listSessions.useQuery( { workspaceId }, { enabled: isOpen }, ); - const { data: claudeSessions, isLoading: isScanning } = - electronTrpc.aiChat.scanClaudeSessions.useQuery(undefined, { - enabled: isOpen, - }); + const { data: claudePage, isLoading: isScanning } = + electronTrpc.aiChat.scanClaudeSessions.useQuery( + { cursor, limit: PAGE_SIZE }, + { + enabled: isOpen, + staleTime: 5 * 60_000, + }, + ); + + // Accumulate Claude sessions as pages load + useEffect(() => { + if (!claudePage) return; + setTotal(claudePage.total); + setHasMore(claudePage.nextCursor !== null); + + if (cursor === 0) { + setAllClaudeSessions( + claudePage.sessions.map((s) => ({ + sessionId: s.sessionId, + display: s.display || "Untitled session", + timestamp: s.timestamp, + gitBranch: s.gitBranch, + source: "claude-code" as const, + })), + ); + } else { + setAllClaudeSessions((prev) => { + const existingIds = new Set(prev.map((s) => s.sessionId)); + const newSessions = claudePage.sessions + .filter((s) => !existingIds.has(s.sessionId)) + .map((s) => ({ + sessionId: s.sessionId, + display: s.display || "Untitled session", + timestamp: s.timestamp, + gitBranch: s.gitBranch, + source: "claude-code" as const, + })); + return [...prev, ...newSessions]; + }); + } + }, [claudePage, cursor]); + + // IntersectionObserver to trigger loading more when sentinel is visible + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel || !isOpen) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && hasMore && !isScanning) { + setCursor((prev) => prev + PAGE_SIZE); + } + }, + { root: scrollRef.current, threshold: 0.1 }, + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [isOpen, hasMore, isScanning]); const currentSession = sessions?.find( (s) => s.sessionId === currentSessionId, @@ -116,7 +191,7 @@ export function SessionSelector({ const unified: UnifiedSession[] = []; const seenIds = new Set(); - // Superset sessions first (they take priority) + // Superset sessions first if (sessions) { for (const s of sessions) { seenIds.add(s.sessionId); @@ -131,19 +206,11 @@ export function SessionSelector({ } } - // Claude Code sessions (skip any already tracked by superset) - if (claudeSessions) { - for (const s of claudeSessions) { - if (seenIds.has(s.sessionId)) continue; - seenIds.add(s.sessionId); - unified.push({ - sessionId: s.sessionId, - display: s.display || "Untitled session", - timestamp: s.timestamp, - gitBranch: s.gitBranch, - source: "claude-code", - }); - } + // Claude Code sessions (skip duplicates) + for (const s of allClaudeSessions) { + if (seenIds.has(s.sessionId)) continue; + seenIds.add(s.sessionId); + unified.push(s); } // Group by time @@ -158,7 +225,6 @@ export function SessionSelector({ } } - // Sort within each group by timestamp desc for (const items of groups.values()) { items.sort((a, b) => b.timestamp - a.timestamp); } @@ -167,7 +233,7 @@ export function SessionSelector({ label: group, sessions: groups.get(group)!, })); - }, [sessions, claudeSessions]); + }, [sessions, allClaudeSessions]); const handleSelect = useCallback( (sessionId: string) => { @@ -192,6 +258,8 @@ export function SessionSelector({ setIsOpen(false); }, [onNewChat]); + const loadedCount = allClaudeSessions.length + (sessions?.length ?? 0); + return ( @@ -209,15 +277,17 @@ export function SessionSelector({ Sessions - {isScanning && ( - - Scanning... - - )} + + {isScanning + ? "Loading..." + : total > 0 + ? `${loadedCount} / ${total}` + : null} +
-
+
{grouped.length > 0 ? ( grouped.map((group) => (
@@ -279,6 +349,17 @@ export function SessionSelector({ No sessions found
) : null} + + {/* Sentinel for infinite scroll */} + {hasMore && ( +
+ {isScanning && ( + + Loading more... + + )} +
+ )}
From 8550e04b2ed0d8648d8542228771c5882a389ddc Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 7 Feb 2026 23:42:02 -0800 Subject: [PATCH 4/9] use effect removal --- .../components/SessionSelector/SessionSelector.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx index b2dc0806fd8..80c3b35402e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx @@ -107,15 +107,6 @@ export function SessionSelector({ const sentinelRef = useRef(null); const scrollRef = useRef(null); - // Reset pagination when dropdown closes - useEffect(() => { - if (!isOpen) { - setCursor(0); - setAllClaudeSessions([]); - setHasMore(true); - setTotal(0); - } - }, [isOpen]); const { data: sessions } = electronTrpc.aiChat.listSessions.useQuery( { workspaceId }, From cc82257668521eafd2dc73f9358076573fd84a5d Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Feb 2026 00:03:20 -0800 Subject: [PATCH 5/9] Translate Claude Code messages --- .../src/lib/trpc/routers/ai-chat/index.ts | 11 +- .../claude-session-scanner.ts | 169 +++++++++++++++++- .../utils/claude-session-scanner/index.ts | 11 +- .../ChatPane/ChatInterface/ChatInterface.tsx | 43 ++++- .../SessionSelector/SessionSelector.tsx | 5 +- 5 files changed, 222 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts index bd30054208f..6c5a8296c6d 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts @@ -1,7 +1,10 @@ import { observable } from "@trpc/server/observable"; import { z } from "zod"; import { publicProcedure, router } from "../.."; -import { scanClaudeSessions } from "./utils/claude-session-scanner"; +import { + readClaudeSessionMessages, + scanClaudeSessions, +} from "./utils/claude-session-scanner"; import { type ClaudeStreamEvent, chatSessionManager, @@ -113,6 +116,12 @@ export const createAiChatRouter = () => { return chatSessionManager.getActiveSessions(); }), + getClaudeSessionMessages: publicProcedure + .input(z.object({ sessionId: z.string() })) + .query(async ({ input }) => { + return readClaudeSessionMessages({ sessionId: input.sessionId }); + }), + scanClaudeSessions: publicProcedure .input( z diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts index 03aabdd9072..4978d1cb5c0 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts @@ -1,7 +1,8 @@ -import { close, open, read } from "node:fs"; +import { close, createReadStream, open, read } from "node:fs"; import { readdir, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; +import { createInterface } from "node:readline"; import { promisify } from "node:util"; const fsOpen = promisify(open); @@ -48,9 +49,7 @@ function decodeProjectDir(encoded: string): string { return encoded.replace(/-/g, "/"); } -async function readSessionMeta( - filePath: string, -): Promise<{ +async function readSessionMeta(filePath: string): Promise<{ sessionId: string; cwd: string; gitBranch: string | null; @@ -133,8 +132,7 @@ async function buildIndex(): Promise { const files = await readdir(fullProjectDir); const sessionFiles = files.filter( (f) => - f.endsWith(".jsonl") && - UUID_RE.test(f.replace(".jsonl", "")), + f.endsWith(".jsonl") && UUID_RE.test(f.replace(".jsonl", "")), ); await Promise.all( @@ -221,3 +219,162 @@ export async function scanClaudeSessions({ total: index.length, }; } + +// ============================================================================ +// Message Reading +// ============================================================================ + +export interface ClaudeSessionMessagePart { + type: string; + content?: string; + id?: string; + name?: string; + arguments?: Record; + state?: string; + toolCallId?: string; + error?: string; +} + +export interface ClaudeSessionMessage { + id: string; + role: "user" | "assistant"; + parts: ClaudeSessionMessagePart[]; +} + +function convertContentBlock( + block: Record, +): ClaudeSessionMessagePart | null { + switch (block.type) { + case "text": + return { type: "text", content: block.text as string }; + case "thinking": + return { type: "thinking", content: block.thinking as string }; + case "tool_use": + return { + type: "tool-call", + id: block.id as string, + name: block.name as string, + arguments: (block.input as Record) ?? {}, + state: "complete", + }; + case "tool_result": { + const raw = block.content; + const content = typeof raw === "string" ? raw : JSON.stringify(raw ?? ""); + return { + type: "tool-result", + toolCallId: block.tool_use_id as string, + content, + state: "complete", + }; + } + default: + return null; + } +} + +/** + * Reads all user/assistant messages from a Claude Code session JSONL file + * and returns them in UIMessage-compatible format. + * + * Tool results from user turns are merged into the preceding assistant message + * so that tool-call and tool-result parts are co-located for rendering. + */ +export async function readClaudeSessionMessages({ + sessionId, +}: { + sessionId: string; +}): Promise { + const index = await buildIndex(); + const entry = index.find((e) => e.sessionId === sessionId); + if (!entry) return []; + + const messages: ClaudeSessionMessage[] = []; + let messageCounter = 0; + + try { + const rl = createInterface({ + input: createReadStream(entry.filePath, { encoding: "utf-8" }), + crlfDelay: Number.POSITIVE_INFINITY, + }); + + for await (const line of rl) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line); + const msgId = parsed.uuid ?? `cc-msg-${++messageCounter}`; + + if (parsed.type === "user" && parsed.message) { + const content = parsed.message.content; + + if (typeof content === "string") { + messages.push({ + id: msgId, + role: "user", + parts: [{ type: "text", content }], + }); + } else if (Array.isArray(content)) { + const toolResultParts: ClaudeSessionMessagePart[] = []; + const otherParts: ClaudeSessionMessagePart[] = []; + + for (const block of content) { + const part = convertContentBlock( + block as Record, + ); + if (!part) continue; + if (part.type === "tool-result") { + toolResultParts.push(part); + } else { + otherParts.push(part); + } + } + + // Merge tool results into the last assistant message + if (toolResultParts.length > 0 && messages.length > 0) { + const lastMsg = messages[messages.length - 1]; + if (lastMsg && lastMsg.role === "assistant") { + lastMsg.parts.push(...toolResultParts); + } + } + + // Add remaining parts as user message + if (otherParts.length > 0) { + messages.push({ + id: msgId, + role: "user", + parts: otherParts, + }); + } + } + } else if (parsed.type === "assistant" && parsed.message) { + const content = parsed.message.content; + const parts: ClaudeSessionMessagePart[] = []; + + if (Array.isArray(content)) { + for (const block of content) { + const part = convertContentBlock( + block as Record, + ); + if (part) parts.push(part); + } + } else if (typeof content === "string") { + parts.push({ type: "text", content }); + } + + if (parts.length > 0) { + messages.push({ + id: msgId, + role: "assistant", + parts, + }); + } + } + } catch { + // Skip unparseable lines + } + } + } catch { + return []; + } + + return messages; +} diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/index.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/index.ts index 6052f9f7424..59835c4048b 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/index.ts @@ -1,2 +1,9 @@ -export type { ClaudeSessionInfo, ClaudeSessionPage } from "./claude-session-scanner"; -export { scanClaudeSessions } from "./claude-session-scanner"; +export type { + ClaudeSessionInfo, + ClaudeSessionMessage, + ClaudeSessionPage, +} from "./claude-session-scanner"; +export { + readClaudeSessionMessages, + scanClaudeSessions, +} from "./claude-session-scanner"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx index 00aa0a015f2..2e3af7cddb4 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx @@ -16,7 +16,7 @@ import { } from "@superset/ui/ai-elements/prompt-input"; import { Shimmer } from "@superset/ui/ai-elements/shimmer"; import { Suggestion, Suggestions } from "@superset/ui/ai-elements/suggestion"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { HiMiniAtSymbol, HiMiniChatBubbleLeftRight, @@ -29,6 +29,9 @@ import { ModelPicker } from "./components/ModelPicker"; import { MODELS, SUGGESTIONS } from "./constants"; import type { ModelOption } from "./types"; +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + interface ChatInterfaceProps { sessionId: string; workspaceId: string; @@ -114,6 +117,22 @@ export function ChatInterface({ { enabled: !!sessionId }, ); + // Fetch Claude Code session messages from JSONL files on disk + const isClaudeCodeSession = UUID_RE.test(sessionId); + const { data: claudeMessages } = + electronTrpc.aiChat.getClaudeSessionMessages.useQuery( + { sessionId }, + { enabled: isClaudeCodeSession, staleTime: 60_000 }, + ); + + // Combine CC history with live proxy messages + const allMessages = useMemo(() => { + const history = (claudeMessages ?? []) as typeof messages; + if (history.length === 0) return messages; + if (messages.length === 0) return history; + return [...history, ...messages]; + }, [claudeMessages, messages]); + useEffect(() => { if (!sessionId || !cwd) return; if (existingSession === undefined) return; @@ -170,6 +189,22 @@ export function ChatInterface({ hasAutoTitled.current = false; }, [sessionId]); + // Auto-title for Claude Code sessions from JSONL history + useEffect(() => { + if (hasAutoTitled.current) return; + if (!isClaudeCodeSession || !claudeMessages?.length) return; + + hasAutoTitled.current = true; + + const firstUser = claudeMessages.find((m) => m.role === "user"); + const textPart = firstUser?.parts?.find((p) => p.type === "text"); + const content = + (textPart as { content?: string } | undefined)?.content ?? "Chat"; + const title = content.length > 80 ? `${content.slice(0, 80)}...` : content; + + renameSessionRef.current.mutate({ sessionId, title }); + }, [claudeMessages, isClaudeCodeSession, sessionId]); + const handleSend = useCallback( (message: { text: string }) => { if (!message.text.trim()) return; @@ -224,7 +259,7 @@ export function ChatInterface({ )} - {messages.length === 0 ? ( + {allMessages.length === 0 ? ( <> ) : ( - messages.map((msg) => ( + allMessages.map((msg) => (
- {messages.length > 0 && ( + {allMessages.length > 0 && ( {SUGGESTIONS.map((s) => ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx index 80c3b35402e..ccee4cc095f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx @@ -48,9 +48,7 @@ function getTimeGroup(timestamp: number): TimeGroup { const startOfThisWeek = new Date( startOfToday.getTime() - (dayOfWeek - 1) * 86_400_000, ); - const startOfLastWeek = new Date( - startOfThisWeek.getTime() - 7 * 86_400_000, - ); + const startOfLastWeek = new Date(startOfThisWeek.getTime() - 7 * 86_400_000); const startOfThisMonth = new Date(now.getFullYear(), now.getMonth(), 1); if (date >= startOfToday) return "Today"; @@ -107,7 +105,6 @@ export function SessionSelector({ const sentinelRef = useRef(null); const scrollRef = useRef(null); - const { data: sessions } = electronTrpc.aiChat.listSessions.useQuery( { workspaceId }, { enabled: isOpen }, From 69fc6ce865632ee5bb71f11ac0412ca47381e080 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Feb 2026 00:08:22 -0800 Subject: [PATCH 6/9] Refactor --- .../claude-session-reader.ts | 206 ++++++++++++++++++ .../claude-session-scanner.ts | 159 +------------- .../utils/claude-session-scanner/index.ts | 11 +- .../ChatPane/ChatInterface/ChatInterface.tsx | 81 +++---- .../hooks/useClaudeCodeHistory/index.ts | 1 + .../useClaudeCodeHistory.ts | 64 ++++++ .../ChatInterface/utils/extract-title.ts | 23 ++ 7 files changed, 338 insertions(+), 207 deletions(-) create mode 100644 apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-reader.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useClaudeCodeHistory/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useClaudeCodeHistory/useClaudeCodeHistory.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/extract-title.ts diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-reader.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-reader.ts new file mode 100644 index 00000000000..d3deea5a4f9 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-reader.ts @@ -0,0 +1,206 @@ +/** + * Reads Claude Code session messages from JSONL files on disk. + * + * Converts Claude's native message format (text, thinking, tool_use, tool_result) + * into TanStack AI UIMessage-compatible parts for rendering. + * + * Tool results from user turns are merged into the preceding assistant message + * so that tool-call and tool-result parts are co-located for rendering. + */ + +import { createReadStream } from "node:fs"; +import { createInterface } from "node:readline"; +import { findSessionFilePath } from "./claude-session-scanner"; + +// ============================================================================ +// Types — discriminated union matching TanStack AI MessagePart shape +// ============================================================================ + +type TextPart = { type: "text"; content: string }; +type ThinkingPart = { type: "thinking"; content: string }; +type ToolCallPart = { + type: "tool-call"; + id: string; + name: string; + arguments: Record; + state: "complete"; +}; +type ToolResultPart = { + type: "tool-result"; + toolCallId: string; + content: string; + state: "complete"; +}; + +export type ClaudeSessionMessagePart = + | TextPart + | ThinkingPart + | ToolCallPart + | ToolResultPart; + +export interface ClaudeSessionMessage { + id: string; + role: "user" | "assistant"; + parts: ClaudeSessionMessagePart[]; +} + +// ============================================================================ +// Content block conversion +// ============================================================================ + +/** Map from Claude API content block format to UIMessage part format. */ +function convertContentBlock( + block: Record, +): ClaudeSessionMessagePart | null { + switch (block.type) { + case "text": + return { type: "text", content: block.text as string }; + case "thinking": + return { type: "thinking", content: block.thinking as string }; + case "tool_use": + return { + type: "tool-call", + id: block.id as string, + name: block.name as string, + arguments: (block.input as Record) ?? {}, + state: "complete", + }; + case "tool_result": { + const raw = block.content; + const content = typeof raw === "string" ? raw : JSON.stringify(raw ?? ""); + return { + type: "tool-result", + toolCallId: block.tool_use_id as string, + content, + state: "complete", + }; + } + default: + return null; + } +} + +// ============================================================================ +// JSONL line parsing +// ============================================================================ + +function parseUserLine( + parsed: Record, + msgId: string, + messages: ClaudeSessionMessage[], +): void { + const msg = parsed.message as { content: unknown } | undefined; + if (!msg) return; + + const content = msg.content; + + if (typeof content === "string") { + messages.push({ + id: msgId, + role: "user", + parts: [{ type: "text", content }], + }); + return; + } + + if (!Array.isArray(content)) return; + + const toolResultParts: ToolResultPart[] = []; + const otherParts: ClaudeSessionMessagePart[] = []; + + for (const block of content) { + const part = convertContentBlock(block as Record); + if (!part) continue; + if (part.type === "tool-result") { + toolResultParts.push(part); + } else { + otherParts.push(part); + } + } + + // Merge tool results into the last assistant message + if (toolResultParts.length > 0) { + const lastMsg = messages[messages.length - 1]; + if (lastMsg?.role === "assistant") { + lastMsg.parts.push(...toolResultParts); + } + } + + if (otherParts.length > 0) { + messages.push({ id: msgId, role: "user", parts: otherParts }); + } +} + +function parseAssistantLine( + parsed: Record, + msgId: string, + messages: ClaudeSessionMessage[], +): void { + const msg = parsed.message as { content: unknown } | undefined; + if (!msg) return; + + const content = msg.content; + const parts: ClaudeSessionMessagePart[] = []; + + if (Array.isArray(content)) { + for (const block of content) { + const part = convertContentBlock(block as Record); + if (part) parts.push(part); + } + } else if (typeof content === "string") { + parts.push({ type: "text", content }); + } + + if (parts.length > 0) { + messages.push({ id: msgId, role: "assistant", parts }); + } +} + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Reads all user/assistant messages from a Claude Code session JSONL file. + * + * Uses streaming line-by-line reads to handle large files efficiently. + * Returns messages in UIMessage-compatible format for direct rendering. + */ +export async function readClaudeSessionMessages({ + sessionId, +}: { + sessionId: string; +}): Promise { + const filePath = await findSessionFilePath({ sessionId }); + if (!filePath) return []; + + const messages: ClaudeSessionMessage[] = []; + let messageCounter = 0; + + try { + const rl = createInterface({ + input: createReadStream(filePath, { encoding: "utf-8" }), + crlfDelay: Number.POSITIVE_INFINITY, + }); + + for await (const line of rl) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line) as Record; + const msgId = (parsed.uuid as string) ?? `cc-msg-${++messageCounter}`; + + if (parsed.type === "user") { + parseUserLine(parsed, msgId, messages); + } else if (parsed.type === "assistant") { + parseAssistantLine(parsed, msgId, messages); + } + } catch { + // Skip unparseable lines + } + } + } catch { + return []; + } + + return messages; +} diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts index 4978d1cb5c0..f991c48027d 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts @@ -1,8 +1,7 @@ -import { close, createReadStream, open, read } from "node:fs"; +import { close, open, read } from "node:fs"; import { readdir, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; -import { createInterface } from "node:readline"; import { promisify } from "node:util"; const fsOpen = promisify(open); @@ -220,161 +219,15 @@ export async function scanClaudeSessions({ }; } -// ============================================================================ -// Message Reading -// ============================================================================ - -export interface ClaudeSessionMessagePart { - type: string; - content?: string; - id?: string; - name?: string; - arguments?: Record; - state?: string; - toolCallId?: string; - error?: string; -} - -export interface ClaudeSessionMessage { - id: string; - role: "user" | "assistant"; - parts: ClaudeSessionMessagePart[]; -} - -function convertContentBlock( - block: Record, -): ClaudeSessionMessagePart | null { - switch (block.type) { - case "text": - return { type: "text", content: block.text as string }; - case "thinking": - return { type: "thinking", content: block.thinking as string }; - case "tool_use": - return { - type: "tool-call", - id: block.id as string, - name: block.name as string, - arguments: (block.input as Record) ?? {}, - state: "complete", - }; - case "tool_result": { - const raw = block.content; - const content = typeof raw === "string" ? raw : JSON.stringify(raw ?? ""); - return { - type: "tool-result", - toolCallId: block.tool_use_id as string, - content, - state: "complete", - }; - } - default: - return null; - } -} - /** - * Reads all user/assistant messages from a Claude Code session JSONL file - * and returns them in UIMessage-compatible format. - * - * Tool results from user turns are merged into the preceding assistant message - * so that tool-call and tool-result parts are co-located for rendering. + * Find the JSONL file path for a Claude Code session by ID. + * Returns null if the session is not found in the index. */ -export async function readClaudeSessionMessages({ +export async function findSessionFilePath({ sessionId, }: { sessionId: string; -}): Promise { +}): Promise { const index = await buildIndex(); - const entry = index.find((e) => e.sessionId === sessionId); - if (!entry) return []; - - const messages: ClaudeSessionMessage[] = []; - let messageCounter = 0; - - try { - const rl = createInterface({ - input: createReadStream(entry.filePath, { encoding: "utf-8" }), - crlfDelay: Number.POSITIVE_INFINITY, - }); - - for await (const line of rl) { - if (!line.trim()) continue; - try { - const parsed = JSON.parse(line); - const msgId = parsed.uuid ?? `cc-msg-${++messageCounter}`; - - if (parsed.type === "user" && parsed.message) { - const content = parsed.message.content; - - if (typeof content === "string") { - messages.push({ - id: msgId, - role: "user", - parts: [{ type: "text", content }], - }); - } else if (Array.isArray(content)) { - const toolResultParts: ClaudeSessionMessagePart[] = []; - const otherParts: ClaudeSessionMessagePart[] = []; - - for (const block of content) { - const part = convertContentBlock( - block as Record, - ); - if (!part) continue; - if (part.type === "tool-result") { - toolResultParts.push(part); - } else { - otherParts.push(part); - } - } - - // Merge tool results into the last assistant message - if (toolResultParts.length > 0 && messages.length > 0) { - const lastMsg = messages[messages.length - 1]; - if (lastMsg && lastMsg.role === "assistant") { - lastMsg.parts.push(...toolResultParts); - } - } - - // Add remaining parts as user message - if (otherParts.length > 0) { - messages.push({ - id: msgId, - role: "user", - parts: otherParts, - }); - } - } - } else if (parsed.type === "assistant" && parsed.message) { - const content = parsed.message.content; - const parts: ClaudeSessionMessagePart[] = []; - - if (Array.isArray(content)) { - for (const block of content) { - const part = convertContentBlock( - block as Record, - ); - if (part) parts.push(part); - } - } else if (typeof content === "string") { - parts.push({ type: "text", content }); - } - - if (parts.length > 0) { - messages.push({ - id: msgId, - role: "assistant", - parts, - }); - } - } - } catch { - // Skip unparseable lines - } - } - } catch { - return []; - } - - return messages; + return index.find((e) => e.sessionId === sessionId)?.filePath ?? null; } diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/index.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/index.ts index 59835c4048b..a3a3ce0d66a 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/index.ts @@ -1,9 +1,10 @@ export type { - ClaudeSessionInfo, ClaudeSessionMessage, + ClaudeSessionMessagePart, +} from "./claude-session-reader"; +export { readClaudeSessionMessages } from "./claude-session-reader"; +export type { + ClaudeSessionInfo, ClaudeSessionPage, } from "./claude-session-scanner"; -export { - readClaudeSessionMessages, - scanClaudeSessions, -} from "./claude-session-scanner"; +export { scanClaudeSessions } from "./claude-session-scanner"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx index 2e3af7cddb4..1d4402bcf31 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx @@ -16,7 +16,7 @@ import { } from "@superset/ui/ai-elements/prompt-input"; import { Shimmer } from "@superset/ui/ai-elements/shimmer"; import { Suggestion, Suggestions } from "@superset/ui/ai-elements/suggestion"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { HiMiniAtSymbol, HiMiniChatBubbleLeftRight, @@ -27,10 +27,9 @@ import { ChatMessageItem } from "./components/ChatMessageItem"; import { ContextIndicator } from "./components/ContextIndicator"; import { ModelPicker } from "./components/ModelPicker"; import { MODELS, SUGGESTIONS } from "./constants"; +import { useClaudeCodeHistory } from "./hooks/useClaudeCodeHistory"; import type { ModelOption } from "./types"; - -const UUID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +import { extractTitleFromMessages } from "./utils/extract-title"; interface ChatInterfaceProps { sessionId: string; @@ -66,6 +65,8 @@ export function ChatInterface({ : undefined, }); + // ── Session lifecycle ──────────────────────────────────────────────── + const connectRef = useRef(connect); connectRef.current = connect; const hasConnected = useRef(false); @@ -117,22 +118,6 @@ export function ChatInterface({ { enabled: !!sessionId }, ); - // Fetch Claude Code session messages from JSONL files on disk - const isClaudeCodeSession = UUID_RE.test(sessionId); - const { data: claudeMessages } = - electronTrpc.aiChat.getClaudeSessionMessages.useQuery( - { sessionId }, - { enabled: isClaudeCodeSession, staleTime: 60_000 }, - ); - - // Combine CC history with live proxy messages - const allMessages = useMemo(() => { - const history = (claudeMessages ?? []) as typeof messages; - if (history.length === 0) return messages; - if (messages.length === 0) return history; - return [...history, ...messages]; - }, [claudeMessages, messages]); - useEffect(() => { if (!sessionId || !cwd) return; if (existingSession === undefined) return; @@ -161,49 +146,45 @@ export function ChatInterface({ } }, [sessionReady, config?.proxyUrl, doConnect]); + // ── Auto-title ─────────────────────────────────────────────────────── + const hasAutoTitled = useRef(false); + + // biome-ignore lint/correctness/useExhaustiveDependencies: must reset when session changes useEffect(() => { - if (hasAutoTitled.current) return; - if (!sessionId) return; + hasAutoTitled.current = false; + }, [sessionId]); + + // Auto-title from live proxy messages (non-CC sessions) + useEffect(() => { + if (hasAutoTitled.current || !sessionId) return; const userMsg = messages.find((m) => m.role === "user"); const assistantMsg = messages.find((m) => m.role === "assistant"); if (!userMsg || !assistantMsg) return; hasAutoTitled.current = true; - - const textPart = userMsg.parts?.find((p) => p.type === "text"); - const firstUserText = - (textPart && "content" in textPart - ? (textPart.content as string) - : undefined - )?.slice(0, 80) ?? "Chat"; - const title = - firstUserText.length === 80 ? `${firstUserText}...` : firstUserText; - + const title = extractTitleFromMessages(messages) ?? "Chat"; renameSessionRef.current.mutate({ sessionId, title }); }, [messages, sessionId]); - // biome-ignore lint/correctness/useExhaustiveDependencies: must reset when session changes - useEffect(() => { - hasAutoTitled.current = false; - }, [sessionId]); + // ── Claude Code history ────────────────────────────────────────────── - // Auto-title for Claude Code sessions from JSONL history - useEffect(() => { - if (hasAutoTitled.current) return; - if (!isClaudeCodeSession || !claudeMessages?.length) return; - - hasAutoTitled.current = true; + const handleRename = useCallback( + (title: string) => { + renameSessionRef.current.mutate({ sessionId, title }); + }, + [sessionId], + ); - const firstUser = claudeMessages.find((m) => m.role === "user"); - const textPart = firstUser?.parts?.find((p) => p.type === "text"); - const content = - (textPart as { content?: string } | undefined)?.content ?? "Chat"; - const title = content.length > 80 ? `${content.slice(0, 80)}...` : content; + const { allMessages } = useClaudeCodeHistory({ + sessionId, + liveMessages: messages, + hasAutoTitled, + onRename: handleRename, + }); - renameSessionRef.current.mutate({ sessionId, title }); - }, [claudeMessages, isClaudeCodeSession, sessionId]); + // ── Event handlers ─────────────────────────────────────────────────── const handleSend = useCallback( (message: { text: string }) => { @@ -244,6 +225,8 @@ export function ChatInterface({ [stop], ); + // ── Render ─────────────────────────────────────────────────────────── + return (
{error && ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useClaudeCodeHistory/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useClaudeCodeHistory/index.ts new file mode 100644 index 00000000000..c6151f613b5 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useClaudeCodeHistory/index.ts @@ -0,0 +1 @@ +export { useClaudeCodeHistory } from "./useClaudeCodeHistory"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useClaudeCodeHistory/useClaudeCodeHistory.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useClaudeCodeHistory/useClaudeCodeHistory.ts new file mode 100644 index 00000000000..7901c799acc --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useClaudeCodeHistory/useClaudeCodeHistory.ts @@ -0,0 +1,64 @@ +/** + * Hook for loading Claude Code session history from on-disk JSONL files. + * + * Detects UUID-format session IDs (Claude Code sessions), fetches their + * messages via tRPC, merges them with live proxy messages, and handles + * auto-titling from the first user message. + */ + +import type { UIMessage } from "@superset/durable-session/react"; +import { useEffect, useMemo } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { extractTitleFromMessages } from "../../utils/extract-title"; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +interface UseClaudeCodeHistoryOptions { + sessionId: string; + liveMessages: UIMessage[]; + hasAutoTitled: React.MutableRefObject; + onRename: (title: string) => void; +} + +interface UseClaudeCodeHistoryReturn { + /** Combined message list: CC history + live proxy messages */ + allMessages: UIMessage[]; + /** Whether the current session is a Claude Code session */ + isClaudeCodeSession: boolean; +} + +export function useClaudeCodeHistory({ + sessionId, + liveMessages, + hasAutoTitled, + onRename, +}: UseClaudeCodeHistoryOptions): UseClaudeCodeHistoryReturn { + const isClaudeCodeSession = UUID_RE.test(sessionId); + + const { data: claudeMessages } = + electronTrpc.aiChat.getClaudeSessionMessages.useQuery( + { sessionId }, + { enabled: isClaudeCodeSession, staleTime: 60_000 }, + ); + + const allMessages = useMemo(() => { + const history = (claudeMessages ?? []) as UIMessage[]; + if (history.length === 0) return liveMessages; + if (liveMessages.length === 0) return history; + return [...history, ...liveMessages]; + }, [claudeMessages, liveMessages]); + + // Auto-title CC sessions from JSONL history + useEffect(() => { + if (hasAutoTitled.current) return; + if (!isClaudeCodeSession || !claudeMessages?.length) return; + + hasAutoTitled.current = true; + + const title = extractTitleFromMessages(claudeMessages); + if (title) onRename(title); + }, [claudeMessages, isClaudeCodeSession, hasAutoTitled, onRename]); + + return { allMessages, isClaudeCodeSession }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/extract-title.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/extract-title.ts new file mode 100644 index 00000000000..38ff967861a --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/extract-title.ts @@ -0,0 +1,23 @@ +const MAX_TITLE_LENGTH = 80; + +/** + * Extract a display title from the first user message's text content. + * Returns null if no suitable text is found. + */ +export function extractTitleFromMessages( + messages: Array<{ + role: string; + parts?: Array<{ type: string; content?: string }>; + }>, +): string | null { + const firstUser = messages.find((m) => m.role === "user"); + if (!firstUser?.parts) return null; + + const textPart = firstUser.parts.find((p) => p.type === "text"); + const content = textPart?.content; + if (!content) return null; + + return content.length > MAX_TITLE_LENGTH + ? `${content.slice(0, MAX_TITLE_LENGTH)}...` + : content; +} From b82065e8adc00f46ed229d15eec9d8c222bcb213 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Feb 2026 00:10:50 -0800 Subject: [PATCH 7/9] Deslop --- .../claude-session-reader.ts | 33 +-------------- .../claude-session-scanner.ts | 40 ++++--------------- .../ChatPane/ChatInterface/ChatInterface.tsx | 11 ----- .../useClaudeCodeHistory.ts | 18 +-------- .../ChatInterface/utils/extract-title.ts | 4 -- 5 files changed, 10 insertions(+), 96 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-reader.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-reader.ts index d3deea5a4f9..037a896602c 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-reader.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-reader.ts @@ -1,9 +1,4 @@ /** - * Reads Claude Code session messages from JSONL files on disk. - * - * Converts Claude's native message format (text, thinking, tool_use, tool_result) - * into TanStack AI UIMessage-compatible parts for rendering. - * * Tool results from user turns are merged into the preceding assistant message * so that tool-call and tool-result parts are co-located for rendering. */ @@ -12,10 +7,6 @@ import { createReadStream } from "node:fs"; import { createInterface } from "node:readline"; import { findSessionFilePath } from "./claude-session-scanner"; -// ============================================================================ -// Types — discriminated union matching TanStack AI MessagePart shape -// ============================================================================ - type TextPart = { type: "text"; content: string }; type ThinkingPart = { type: "thinking"; content: string }; type ToolCallPart = { @@ -44,11 +35,6 @@ export interface ClaudeSessionMessage { parts: ClaudeSessionMessagePart[]; } -// ============================================================================ -// Content block conversion -// ============================================================================ - -/** Map from Claude API content block format to UIMessage part format. */ function convertContentBlock( block: Record, ): ClaudeSessionMessagePart | null { @@ -80,10 +66,6 @@ function convertContentBlock( } } -// ============================================================================ -// JSONL line parsing -// ============================================================================ - function parseUserLine( parsed: Record, msgId: string, @@ -118,7 +100,6 @@ function parseUserLine( } } - // Merge tool results into the last assistant message if (toolResultParts.length > 0) { const lastMsg = messages[messages.length - 1]; if (lastMsg?.role === "assistant") { @@ -156,16 +137,6 @@ function parseAssistantLine( } } -// ============================================================================ -// Public API -// ============================================================================ - -/** - * Reads all user/assistant messages from a Claude Code session JSONL file. - * - * Uses streaming line-by-line reads to handle large files efficiently. - * Returns messages in UIMessage-compatible format for direct rendering. - */ export async function readClaudeSessionMessages({ sessionId, }: { @@ -194,9 +165,7 @@ export async function readClaudeSessionMessages({ } else if (parsed.type === "assistant") { parseAssistantLine(parsed, msgId, messages); } - } catch { - // Skip unparseable lines - } + } catch {} } } catch { return []; diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts index f991c48027d..00611d31abf 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts @@ -11,10 +11,9 @@ const fsClose = promisify(close); const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; -/** Max bytes to read from the start of each JSONL file (metadata is in the first ~2 lines). */ +/** Session metadata lives in the first ~2 JSONL lines, so 4KB is plenty. */ const HEAD_BYTES = 4096; -/** How many files to stat concurrently per batch. */ const BATCH_SIZE = 100; export interface ClaudeSessionInfo { @@ -39,10 +38,9 @@ interface SessionFileEntry { mtime: number; } -/** Cached index of session files sorted by mtime desc. Built once, reused for pagination. */ let cachedIndex: SessionFileEntry[] | null = null; let cacheTimestamp = 0; -const CACHE_TTL = 5 * 60_000; // 5 minutes +const CACHE_TTL = 5 * 60_000; function decodeProjectDir(encoded: string): string { return encoded.replace(/-/g, "/"); @@ -85,7 +83,7 @@ async function readSessionMeta(filePath: string): Promise<{ }; } } catch { - // Incomplete JSON at buffer boundary or non-JSON line + // JSON may be truncated at buffer boundary } } return null; @@ -93,18 +91,13 @@ async function readSessionMeta(filePath: string): Promise<{ if (fd !== undefined) { try { await fsClose(fd); - } catch { - // ignore - } + } catch {} } return null; } } -/** - * Build an index of all session files with their mtimes. - * Uses stat (no file reads) so it's fast even for 600+ files. - */ +/** Uses stat() only (no file reads) so it's fast even for 600+ files. */ async function buildIndex(): Promise { if (cachedIndex && Date.now() - cacheTimestamp < CACHE_TTL) { return cachedIndex; @@ -121,7 +114,6 @@ async function buildIndex(): Promise { const entries: SessionFileEntry[] = []; - // Collect all session files with their mtimes for (let i = 0; i < projectDirs.length; i += BATCH_SIZE) { const batch = projectDirs.slice(i, i + BATCH_SIZE); await Promise.all( @@ -145,24 +137,18 @@ async function buildIndex(): Promise { sessionId: f.replace(".jsonl", ""), mtime: s.mtimeMs, }); - } catch { - // skip - } + } catch {} }), ); - } catch { - // skip - } + } catch {} }), ); - // Yield between batches if (i + BATCH_SIZE < projectDirs.length) { await new Promise((resolve) => setImmediate(resolve)); } } - // Deduplicate by sessionId — keep most recent mtime const seen = new Map(); for (const entry of entries) { const existing = seen.get(entry.sessionId); @@ -179,11 +165,6 @@ async function buildIndex(): Promise { return deduplicated; } -/** - * Scans ~/.claude/projects/ for resumable Claude Code sessions with cursor-based pagination. - * First call builds a lightweight index using stat (no file reads). - * Then reads metadata only for the requested page. - */ export async function scanClaudeSessions({ cursor = 0, limit = 30, @@ -194,7 +175,6 @@ export async function scanClaudeSessions({ const index = await buildIndex(); const page = index.slice(cursor, cursor + limit); - // Read metadata only for this page const sessions: ClaudeSessionInfo[] = []; await Promise.all( page.map(async (entry) => { @@ -208,7 +188,7 @@ export async function scanClaudeSessions({ }), ); - // Re-sort this page by timestamp from the actual metadata + // Index is sorted by mtime, but actual timestamps from metadata may differ sessions.sort((a, b) => b.timestamp - a.timestamp); const nextOffset = cursor + limit; @@ -219,10 +199,6 @@ export async function scanClaudeSessions({ }; } -/** - * Find the JSONL file path for a Claude Code session by ID. - * Returns null if the session is not found in the index. - */ export async function findSessionFilePath({ sessionId, }: { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx index 1d4402bcf31..cc7c08925d5 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx @@ -65,8 +65,6 @@ export function ChatInterface({ : undefined, }); - // ── Session lifecycle ──────────────────────────────────────────────── - const connectRef = useRef(connect); connectRef.current = connect; const hasConnected = useRef(false); @@ -146,8 +144,6 @@ export function ChatInterface({ } }, [sessionReady, config?.proxyUrl, doConnect]); - // ── Auto-title ─────────────────────────────────────────────────────── - const hasAutoTitled = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: must reset when session changes @@ -155,7 +151,6 @@ export function ChatInterface({ hasAutoTitled.current = false; }, [sessionId]); - // Auto-title from live proxy messages (non-CC sessions) useEffect(() => { if (hasAutoTitled.current || !sessionId) return; @@ -168,8 +163,6 @@ export function ChatInterface({ renameSessionRef.current.mutate({ sessionId, title }); }, [messages, sessionId]); - // ── Claude Code history ────────────────────────────────────────────── - const handleRename = useCallback( (title: string) => { renameSessionRef.current.mutate({ sessionId, title }); @@ -184,8 +177,6 @@ export function ChatInterface({ onRename: handleRename, }); - // ── Event handlers ─────────────────────────────────────────────────── - const handleSend = useCallback( (message: { text: string }) => { if (!message.text.trim()) return; @@ -225,8 +216,6 @@ export function ChatInterface({ [stop], ); - // ── Render ─────────────────────────────────────────────────────────── - return (
{error && ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useClaudeCodeHistory/useClaudeCodeHistory.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useClaudeCodeHistory/useClaudeCodeHistory.ts index 7901c799acc..db12f27dd69 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useClaudeCodeHistory/useClaudeCodeHistory.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useClaudeCodeHistory/useClaudeCodeHistory.ts @@ -1,11 +1,3 @@ -/** - * Hook for loading Claude Code session history from on-disk JSONL files. - * - * Detects UUID-format session IDs (Claude Code sessions), fetches their - * messages via tRPC, merges them with live proxy messages, and handles - * auto-titling from the first user message. - */ - import type { UIMessage } from "@superset/durable-session/react"; import { useEffect, useMemo } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; @@ -21,19 +13,12 @@ interface UseClaudeCodeHistoryOptions { onRename: (title: string) => void; } -interface UseClaudeCodeHistoryReturn { - /** Combined message list: CC history + live proxy messages */ - allMessages: UIMessage[]; - /** Whether the current session is a Claude Code session */ - isClaudeCodeSession: boolean; -} - export function useClaudeCodeHistory({ sessionId, liveMessages, hasAutoTitled, onRename, -}: UseClaudeCodeHistoryOptions): UseClaudeCodeHistoryReturn { +}: UseClaudeCodeHistoryOptions) { const isClaudeCodeSession = UUID_RE.test(sessionId); const { data: claudeMessages } = @@ -49,7 +34,6 @@ export function useClaudeCodeHistory({ return [...history, ...liveMessages]; }, [claudeMessages, liveMessages]); - // Auto-title CC sessions from JSONL history useEffect(() => { if (hasAutoTitled.current) return; if (!isClaudeCodeSession || !claudeMessages?.length) return; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/extract-title.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/extract-title.ts index 38ff967861a..8cbe215c8d5 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/extract-title.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/extract-title.ts @@ -1,9 +1,5 @@ const MAX_TITLE_LENGTH = 80; -/** - * Extract a display title from the first user message's text content. - * Returns null if no suitable text is found. - */ export function extractTitleFromMessages( messages: Array<{ role: string; From 5069ead9123b2dccc07fd26c67c5801aec106561 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Feb 2026 00:12:11 -0800 Subject: [PATCH 8/9] Deslop --- .../utils/claude-session-scanner/claude-session-reader.ts | 5 ----- .../utils/claude-session-scanner/claude-session-scanner.ts | 1 - 2 files changed, 6 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-reader.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-reader.ts index 037a896602c..035c336a878 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-reader.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-reader.ts @@ -1,8 +1,3 @@ -/** - * Tool results from user turns are merged into the preceding assistant message - * so that tool-call and tool-result parts are co-located for rendering. - */ - import { createReadStream } from "node:fs"; import { createInterface } from "node:readline"; import { findSessionFilePath } from "./claude-session-scanner"; diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts index 00611d31abf..cd4ff81279a 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts @@ -97,7 +97,6 @@ async function readSessionMeta(filePath: string): Promise<{ } } -/** Uses stat() only (no file reads) so it's fast even for 600+ files. */ async function buildIndex(): Promise { if (cachedIndex && Date.now() - cacheTimestamp < CACHE_TTL) { return cachedIndex; From 14dcc4e5abfe78842fb5b23a8ea0d4b4b3d0d4bf Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Feb 2026 00:14:52 -0800 Subject: [PATCH 9/9] Lint --- .../ChatPane/components/SessionSelector/SessionSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx index ccee4cc095f..c6ca0630957 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx @@ -219,7 +219,7 @@ export function SessionSelector({ return TIME_GROUP_ORDER.filter((g) => groups.has(g)).map((group) => ({ label: group, - sessions: groups.get(group)!, + sessions: groups.get(group) ?? [], })); }, [sessions, allClaudeSessions]);