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..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,6 +1,10 @@ import { observable } from "@trpc/server/observable"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { + readClaudeSessionMessages, + scanClaudeSessions, +} from "./utils/claude-session-scanner"; import { type ClaudeStreamEvent, chatSessionManager, @@ -112,6 +116,28 @@ 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 + .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() })) .subscription(({ input }) => { 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..035c336a878 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-reader.ts @@ -0,0 +1,170 @@ +import { createReadStream } from "node:fs"; +import { createInterface } from "node:readline"; +import { findSessionFilePath } from "./claude-session-scanner"; + +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[]; +} + +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; + } +} + +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); + } + } + + 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 }); + } +} + +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 {} + } + } 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 new file mode 100644 index 00000000000..cd4ff81279a --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/claude-session-scanner.ts @@ -0,0 +1,208 @@ +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}$/; + +/** Session metadata lives in the first ~2 JSONL lines, so 4KB is plenty. */ +const HEAD_BYTES = 4096; + +const BATCH_SIZE = 100; + +export interface ClaudeSessionInfo { + sessionId: string; + project: string; + cwd: string; + gitBranch: string | null; + display: string; + timestamp: number; +} + +export interface ClaudeSessionPage { + sessions: ClaudeSessionInfo[]; + nextCursor: number | null; + total: number; +} + +interface SessionFileEntry { + filePath: string; + projectDir: string; + sessionId: string; + mtime: number; +} + +let cachedIndex: SessionFileEntry[] | null = null; +let cacheTimestamp = 0; +const CACHE_TTL = 5 * 60_000; + +function decodeProjectDir(encoded: string): string { + return encoded.replace(/-/g, "/"); +} + +async function readSessionMeta(filePath: string): Promise<{ + sessionId: string; + cwd: string; + gitBranch: string | null; + display: string; + timestamp: number; +} | null> { + let fd: number | undefined; + try { + 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 { + 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 { + // JSON may be truncated at buffer boundary + } + } + return null; + } catch { + if (fd !== undefined) { + try { + await fsClose(fd); + } catch {} + } + return null; + } +} + +async function buildIndex(): Promise { + if (cachedIndex && Date.now() - cacheTimestamp < CACHE_TTL) { + return cachedIndex; + } + + const projectsDir = join(homedir(), ".claude", "projects"); + + let projectDirs: string[]; + try { + projectDirs = await readdir(projectsDir); + } catch { + return []; + } + + const entries: SessionFileEntry[] = []; + + 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", "")), + ); + + 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 {} + }), + ); + } catch {} + }), + ); + + if (i + BATCH_SIZE < projectDirs.length) { + await new Promise((resolve) => setImmediate(resolve)); + } + } + + 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; +} + +export async function scanClaudeSessions({ + cursor = 0, + limit = 30, +}: { + cursor?: number; + limit?: number; +}): Promise { + const index = await buildIndex(); + const page = index.slice(cursor, cursor + limit); + + const sessions: ClaudeSessionInfo[] = []; + await Promise.all( + page.map(async (entry) => { + const meta = await readSessionMeta(entry.filePath); + if (meta) { + sessions.push({ + ...meta, + project: decodeProjectDir(entry.projectDir), + }); + } + }), + ); + + // Index is sorted by mtime, but actual timestamps from metadata may differ + sessions.sort((a, b) => b.timestamp - a.timestamp); + + const nextOffset = cursor + limit; + return { + sessions, + nextCursor: nextOffset < index.length ? nextOffset : null, + total: index.length, + }; +} + +export async function findSessionFilePath({ + sessionId, +}: { + sessionId: string; +}): Promise { + const index = await buildIndex(); + 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 new file mode 100644 index 00000000000..a3a3ce0d66a --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/claude-session-scanner/index.ts @@ -0,0 +1,10 @@ +export type { + ClaudeSessionMessage, + ClaudeSessionMessagePart, +} from "./claude-session-reader"; +export { readClaudeSessionMessages } from "./claude-session-reader"; +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/ChatInterface/ChatInterface.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx index 00aa0a015f2..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 @@ -27,7 +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"; +import { extractTitleFromMessages } from "./utils/extract-title"; interface ChatInterfaceProps { sessionId: string; @@ -143,32 +145,37 @@ export function ChatInterface({ }, [sessionReady, config?.proxyUrl, doConnect]); 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]); + + 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]); + const handleRename = useCallback( + (title: string) => { + renameSessionRef.current.mutate({ sessionId, title }); + }, + [sessionId], + ); + + const { allMessages } = useClaudeCodeHistory({ + sessionId, + liveMessages: messages, + hasAutoTitled, + onRename: handleRename, + }); const handleSend = useCallback( (message: { text: string }) => { @@ -224,7 +231,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/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..db12f27dd69 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useClaudeCodeHistory/useClaudeCodeHistory.ts @@ -0,0 +1,48 @@ +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; +} + +export function useClaudeCodeHistory({ + sessionId, + liveMessages, + hasAutoTitled, + onRename, +}: UseClaudeCodeHistoryOptions) { + 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]); + + 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..8cbe215c8d5 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/extract-title.ts @@ -0,0 +1,19 @@ +const MAX_TITLE_LENGTH = 80; + +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; +} 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..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 @@ -6,7 +6,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { HiMiniChatBubbleLeftRight, HiMiniChevronDown, @@ -15,6 +15,50 @@ import { } from "react-icons/hi2"; import { electronTrpc } from "renderer/lib/electron-trpc"; +type TimeGroup = + | "Today" + | "Yesterday" + | "This Week" + | "Last Week" + | "This Month" + | "Older"; + +const TIME_GROUP_ORDER: TimeGroup[] = [ + "Today", + "Yesterday", + "This Week", + "Last Week", + "This Month", + "Older", +]; + +const PAGE_SIZE = 30; + +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); @@ -27,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; @@ -43,17 +96,133 @@ 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); const { data: sessions } = electronTrpc.aiChat.listSessions.useQuery( { workspaceId }, { 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, ); const displayTitle = currentSession?.title ?? "Chat"; + const grouped = useMemo(() => { + const unified: UnifiedSession[] = []; + const seenIds = new Set(); + + // Superset sessions first + 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 duplicates) + for (const s of allClaudeSessions) { + if (seenIds.has(s.sessionId)) continue; + seenIds.add(s.sessionId); + unified.push(s); + } + + // Group by time + const groups = new Map(); + for (const session of unified) { + const group = getTimeGroup(session.timestamp); + const existing = groups.get(group); + if (existing) { + existing.push(session); + } else { + groups.set(group, [session]); + } + } + + 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) ?? [], + })); + }, [sessions, allClaudeSessions]); + const handleSelect = useCallback( (sessionId: string) => { if (sessionId !== currentSessionId) { @@ -77,6 +246,8 @@ export function SessionSelector({ setIsOpen(false); }, [onNewChat]); + const loadedCount = allClaudeSessions.length + (sessions?.length ?? 0); + return ( @@ -89,53 +260,95 @@ export function SessionSelector({ - - Sessions + +
+ + Sessions + + + {isScanning + ? "Loading..." + : total > 0 + ? `${loadedCount} / ${total}` + : null} + +
- {sessions && sessions.length > 0 ? ( - sessions.map((session) => ( - handleSelect(session.sessionId)} - > -
- - {session.title} - +
+ {grouped.length > 0 ? ( + grouped.map((group) => ( +
+ + {group.label} + + {group.sessions.map((session) => ( + handleSelect(session.sessionId)} + > +
+ + {session.display} + + + {formatRelativeTime(session.timestamp)} + {session.gitBranch && ( + <> + {" · "} + + {session.gitBranch} + + + )} + {session.messagePreview && ( + <> + {" — "} + + {session.messagePreview} + + + )} + +
+ {session.sessionId !== currentSessionId && + session.source === "superset" && ( + + )} +
+ ))} +
+ )) + ) : !isScanning ? ( +
+ No sessions found +
+ ) : null} + + {/* Sentinel for infinite scroll */} + {hasMore && ( +
+ {isScanning && ( - {formatRelativeTime(session.lastActiveAt)} - {session.messagePreview && ( - <> - {" — "} - {session.messagePreview} - - )} + Loading more... -
- {session.sessionId !== currentSessionId && ( - )} - - )) - ) : ( -
- No previous sessions -
- )} +
+ )} +