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 3cbf81751d7..15d9da54430 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts @@ -3,7 +3,8 @@ import { z } from "zod"; import { publicProcedure, router } from "../.."; import { type ClaudeStreamEvent, - claudeSessionManager, + chatSessionManager, + sessionStore, } from "./utils/session-manager"; export const createAiChatRouter = () => { @@ -20,11 +21,28 @@ export const createAiChatRouter = () => { .input( z.object({ sessionId: z.string(), + workspaceId: z.string(), cwd: z.string(), }), ) .mutation(async ({ input }) => { - await claudeSessionManager.startSession({ + await chatSessionManager.startSession({ + sessionId: input.sessionId, + workspaceId: input.workspaceId, + cwd: input.cwd, + }); + return { success: true }; + }), + + restoreSession: publicProcedure + .input( + z.object({ + sessionId: z.string(), + cwd: z.string(), + }), + ) + .mutation(async ({ input }) => { + await chatSessionManager.restoreSession({ sessionId: input.sessionId, cwd: input.cwd, }); @@ -34,25 +52,64 @@ export const createAiChatRouter = () => { interrupt: publicProcedure .input(z.object({ sessionId: z.string() })) .mutation(async ({ input }) => { - await claudeSessionManager.interrupt({ sessionId: input.sessionId }); + await chatSessionManager.interrupt({ + sessionId: input.sessionId, + }); return { success: true }; }), stopSession: publicProcedure .input(z.object({ sessionId: z.string() })) .mutation(async ({ input }) => { - await claudeSessionManager.stopSession({ sessionId: input.sessionId }); + await chatSessionManager.deactivateSession({ + sessionId: input.sessionId, + }); return { success: true }; }), + deleteSession: publicProcedure + .input(z.object({ sessionId: z.string() })) + .mutation(async ({ input }) => { + await chatSessionManager.deleteSession({ + sessionId: input.sessionId, + }); + return { success: true }; + }), + + renameSession: publicProcedure + .input( + z.object({ + sessionId: z.string(), + title: z.string(), + }), + ) + .mutation(async ({ input }) => { + await chatSessionManager.updateSessionMeta(input.sessionId, { + title: input.title, + }); + return { success: true }; + }), + + listSessions: publicProcedure + .input(z.object({ workspaceId: z.string() })) + .query(async ({ input }) => { + return sessionStore.listByWorkspace(input.workspaceId); + }), + + getSession: publicProcedure + .input(z.object({ sessionId: z.string() })) + .query(async ({ input }) => { + return (await sessionStore.get(input.sessionId)) ?? null; + }), + isSessionActive: publicProcedure .input(z.object({ sessionId: z.string() })) .query(({ input }) => { - return claudeSessionManager.isSessionActive(input.sessionId); + return chatSessionManager.isSessionActive(input.sessionId); }), getActiveSessions: publicProcedure.query(() => { - return claudeSessionManager.getActiveSessions(); + return chatSessionManager.getActiveSessions(); }), streamEvents: publicProcedure @@ -66,10 +123,10 @@ export const createAiChatRouter = () => { emit.next(event); }; - claudeSessionManager.on("event", onEvent); + chatSessionManager.on("event", onEvent); return () => { - claudeSessionManager.off("event", onEvent); + chatSessionManager.off("event", onEvent); }; }); }), diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/agent-provider/claude-sdk-provider.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/agent-provider/claude-sdk-provider.ts new file mode 100644 index 00000000000..9f81f42a36b --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/agent-provider/claude-sdk-provider.ts @@ -0,0 +1,53 @@ +import { buildClaudeEnv } from "../auth"; +import type { + AgentProvider, + AgentProviderSpec, + AgentRegistration, +} from "./types"; + +const CLAUDE_AGENT_URL = + process.env.CLAUDE_AGENT_URL || "http://localhost:9090"; + +export class ClaudeSdkProvider implements AgentProvider { + readonly spec: AgentProviderSpec = { + id: "claude-sdk", + name: "Claude", + }; + + getAgentRegistration({ + sessionId, + cwd, + }: { + sessionId: string; + cwd: string; + }): AgentRegistration { + const env = buildClaudeEnv(); + + return { + id: "claude", + endpoint: `${CLAUDE_AGENT_URL}/`, + triggers: "user-messages", + bodyTemplate: { + sessionId, + cwd, + env, + }, + }; + } + + async getProviderSessionId(sessionId: string): Promise { + try { + const res = await fetch(`${CLAUDE_AGENT_URL}/sessions/${sessionId}`); + if (!res.ok) return undefined; + + const data = (await res.json()) as { + claudeSessionId?: string; + }; + return data.claudeSessionId; + } catch { + return undefined; + } + } + + async cleanup(_sessionId: string): Promise {} +} diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/agent-provider/index.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/agent-provider/index.ts new file mode 100644 index 00000000000..dca152d0589 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/agent-provider/index.ts @@ -0,0 +1,6 @@ +export { ClaudeSdkProvider } from "./claude-sdk-provider"; +export type { + AgentProvider, + AgentProviderSpec, + AgentRegistration, +} from "./types"; diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/agent-provider/types.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/agent-provider/types.ts new file mode 100644 index 00000000000..3990a3332eb --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/agent-provider/types.ts @@ -0,0 +1,24 @@ +export interface AgentProviderSpec { + id: string; + name: string; +} + +export interface AgentRegistration { + id: string; + endpoint: string; + triggers: string; + bodyTemplate: Record; +} + +export interface AgentProvider { + readonly spec: AgentProviderSpec; + + getAgentRegistration(opts: { + sessionId: string; + cwd: string; + }): AgentRegistration; + + getProviderSessionId(sessionId: string): Promise; + + cleanup(sessionId: string): Promise; +} diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/index.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/index.ts index 36e6f2e4017..4033e0ec257 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/index.ts @@ -1,7 +1,20 @@ +import { ClaudeSdkProvider } from "../agent-provider"; +import { SessionStore } from "../session-store"; +import { ChatSessionManager } from "./session-manager"; + export type { ClaudeStreamEvent, ErrorEvent, SessionEndEvent, SessionStartEvent, } from "./session-manager"; -export { claudeSessionManager } from "./session-manager"; +export { ChatSessionManager } from "./session-manager"; + +const provider = new ClaudeSdkProvider(); +const sessionStore = new SessionStore(); + +export const chatSessionManager = new ChatSessionManager( + provider, + sessionStore, +); +export { sessionStore }; diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts index ebd7c4e0985..67acd460401 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts @@ -1,9 +1,8 @@ import { EventEmitter } from "node:events"; -import { buildClaudeEnv } from "../auth"; +import type { AgentProvider } from "../agent-provider"; +import type { SessionStore } from "../session-store"; const PROXY_URL = process.env.DURABLE_STREAM_URL || "http://localhost:8080"; -const CLAUDE_AGENT_URL = - process.env.CLAUDE_AGENT_URL || "http://localhost:9090"; const DURABLE_STREAM_AUTH_TOKEN = process.env.DURABLE_STREAM_AUTH_TOKEN || process.env.DURABLE_STREAM_TOKEN; @@ -44,10 +43,92 @@ interface ActiveSession { cwd: string; } -class ClaudeSessionManager extends EventEmitter { +export class ChatSessionManager extends EventEmitter { private sessions = new Map(); + constructor( + private readonly provider: AgentProvider, + private readonly store: SessionStore, + ) { + super(); + } + async startSession({ + sessionId, + workspaceId, + cwd, + }: { + sessionId: string; + workspaceId: string; + cwd: string; + }): Promise { + if (this.sessions.has(sessionId)) { + console.warn(`[chat/session] Session ${sessionId} already active`); + return; + } + + console.log(`[chat/session] Starting session ${sessionId} in ${cwd}`); + const headers = buildProxyHeaders(); + + try { + const createRes = await fetch(`${PROXY_URL}/v1/sessions/${sessionId}`, { + method: "PUT", + headers, + }); + if (!createRes.ok) { + throw new Error( + `PUT /v1/sessions/${sessionId} failed: ${createRes.status}`, + ); + } + + const registration = this.provider.getAgentRegistration({ + sessionId, + cwd, + }); + const registerRes = await fetch( + `${PROXY_URL}/v1/sessions/${sessionId}/agents`, + { + method: "POST", + headers, + body: JSON.stringify({ agents: [registration] }), + }, + ); + if (!registerRes.ok) { + throw new Error( + `POST /v1/sessions/${sessionId}/agents failed: ${registerRes.status}`, + ); + } + + this.sessions.set(sessionId, { sessionId, cwd }); + + await this.store.create({ + sessionId, + workspaceId, + provider: this.provider.spec.id, + title: "New chat", + cwd, + createdAt: Date.now(), + lastActiveAt: Date.now(), + }); + + this.emit("event", { + type: "session_start", + sessionId, + } satisfies SessionStartEvent); + + console.log(`[chat/session] Session ${sessionId} started`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[chat/session] Failed to start session:`, message); + this.emit("event", { + type: "error", + sessionId, + error: message, + } satisfies ErrorEvent); + } + } + + async restoreSession({ sessionId, cwd, }: { @@ -55,11 +136,10 @@ class ClaudeSessionManager extends EventEmitter { cwd: string; }): Promise { if (this.sessions.has(sessionId)) { - console.warn(`[claude/session] Session ${sessionId} already running`); return; } - console.log(`[claude/session] Initializing session ${sessionId} in ${cwd}`); + console.log(`[chat/session] Restoring session ${sessionId}`); const headers = buildProxyHeaders(); try { @@ -73,26 +153,16 @@ class ClaudeSessionManager extends EventEmitter { ); } - const env = buildClaudeEnv(); + const registration = this.provider.getAgentRegistration({ + sessionId, + cwd, + }); const registerRes = await fetch( `${PROXY_URL}/v1/sessions/${sessionId}/agents`, { method: "POST", headers, - body: JSON.stringify({ - agents: [ - { - id: "claude", - endpoint: `${CLAUDE_AGENT_URL}/`, - triggers: "user-messages", - bodyTemplate: { - sessionId, - cwd, - env, - }, - }, - ], - }), + body: JSON.stringify({ agents: [registration] }), }, ); if (!registerRes.ok) { @@ -103,15 +173,19 @@ class ClaudeSessionManager extends EventEmitter { this.sessions.set(sessionId, { sessionId, cwd }); + await this.store.update(sessionId, { + lastActiveAt: Date.now(), + }); + this.emit("event", { type: "session_start", sessionId, } satisfies SessionStartEvent); - console.log(`[claude/session] Session ${sessionId} started`); + console.log(`[chat/session] Session ${sessionId} restored`); } catch (error) { const message = error instanceof Error ? error.message : String(error); - console.error(`[claude/session] Failed to start session:`, message); + console.error(`[chat/session] Failed to restore session:`, message); this.emit("event", { type: "error", sessionId, @@ -123,12 +197,12 @@ class ClaudeSessionManager extends EventEmitter { async interrupt({ sessionId }: { sessionId: string }): Promise { if (!this.sessions.has(sessionId)) { console.warn( - `[claude/session] Session ${sessionId} not found for interrupt`, + `[chat/session] Session ${sessionId} not found for interrupt`, ); return; } - console.log(`[claude/session] Interrupting session ${sessionId}`); + console.log(`[chat/session] Interrupting session ${sessionId}`); try { await fetch(`${PROXY_URL}/v1/sessions/${sessionId}/stop`, { method: "POST", @@ -136,16 +210,52 @@ class ClaudeSessionManager extends EventEmitter { body: JSON.stringify({}), }); } catch (error) { - console.error(`[claude/session] Interrupt failed:`, error); + console.error(`[chat/session] Interrupt failed:`, error); } } - async stopSession({ sessionId }: { sessionId: string }): Promise { + // Removes from active set but preserves the proxy session for later restore + async deactivateSession({ sessionId }: { sessionId: string }): Promise { if (!this.sessions.has(sessionId)) { return; } - console.log(`[claude/session] Stopping session ${sessionId}`); + console.log(`[chat/session] Deactivating session ${sessionId}`); + + try { + await fetch(`${PROXY_URL}/v1/sessions/${sessionId}/stop`, { + method: "POST", + headers: buildProxyHeaders(), + body: JSON.stringify({}), + }); + } catch {} + + try { + const providerSessionId = + await this.provider.getProviderSessionId(sessionId); + if (providerSessionId) { + await this.store.update(sessionId, { + providerSessionId, + lastActiveAt: Date.now(), + }); + } else { + await this.store.update(sessionId, { + lastActiveAt: Date.now(), + }); + } + } catch {} + + this.sessions.delete(sessionId); + + this.emit("event", { + type: "session_end", + sessionId, + exitCode: null, + } satisfies SessionEndEvent); + } + + async deleteSession({ sessionId }: { sessionId: string }): Promise { + console.log(`[chat/session] Deleting session ${sessionId}`); const headers = buildProxyHeaders(); try { @@ -154,21 +264,17 @@ class ClaudeSessionManager extends EventEmitter { headers, body: JSON.stringify({}), }); - } catch (error) { - console.warn(`[claude/session] Stop request failed (non-fatal):`, error); - } + } catch {} try { await fetch(`${PROXY_URL}/v1/sessions/${sessionId}`, { method: "DELETE", headers, }); - } catch (error) { - console.warn( - `[claude/session] Delete request failed (non-fatal):`, - error, - ); - } + } catch {} + + await this.provider.cleanup(sessionId); + await this.store.archive(sessionId); this.sessions.delete(sessionId); @@ -179,6 +285,17 @@ class ClaudeSessionManager extends EventEmitter { } satisfies SessionEndEvent); } + async updateSessionMeta( + sessionId: string, + patch: { + title?: string; + messagePreview?: string; + providerSessionId?: string; + }, + ): Promise { + await this.store.update(sessionId, patch); + } + isSessionActive(sessionId: string): boolean { return this.sessions.has(sessionId); } @@ -187,5 +304,3 @@ class ClaudeSessionManager extends EventEmitter { return Array.from(this.sessions.keys()); } } - -export const claudeSessionManager = new ClaudeSessionManager(); diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-store/index.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-store/index.ts new file mode 100644 index 00000000000..d0faff5de57 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-store/index.ts @@ -0,0 +1,2 @@ +export type { ChatSessionMeta } from "./session-store"; +export { SessionStore } from "./session-store"; diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-store/session-store.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-store/session-store.ts new file mode 100644 index 00000000000..e9dd51ac0eb --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-store/session-store.ts @@ -0,0 +1,96 @@ +import { existsSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { JSONFilePreset } from "lowdb/node"; + +export interface ChatSessionMeta { + sessionId: string; + workspaceId: string; + provider: string; + providerSessionId?: string; + title: string; + cwd: string; + createdAt: number; + lastActiveAt: number; + messagePreview?: string; + isArchived: boolean; +} + +interface SessionStoreData { + sessions: ChatSessionMeta[]; +} + +const SUPERSET_DIR = + process.env.NODE_ENV === "development" ? ".superset-dev" : ".superset"; +const STORE_PATH = join(homedir(), SUPERSET_DIR, "chat-sessions.json"); + +export class SessionStore { + private db: Awaited< + ReturnType> + > | null = null; + + private async ensureDb() { + if (this.db) return this.db; + + const dir = dirname(STORE_PATH); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + this.db = await JSONFilePreset(STORE_PATH, { + sessions: [], + }); + return this.db; + } + + async create(meta: Omit): Promise { + const db = await this.ensureDb(); + const existing = db.data.sessions.find( + (s) => s.sessionId === meta.sessionId, + ); + if (existing) { + Object.assign(existing, meta, { isArchived: false }); + } else { + db.data.sessions.push({ ...meta, isArchived: false }); + } + await db.write(); + } + + async update( + sessionId: string, + patch: Partial< + Pick< + ChatSessionMeta, + | "providerSessionId" + | "title" + | "lastActiveAt" + | "messagePreview" + | "isArchived" + > + >, + ): Promise { + const db = await this.ensureDb(); + const session = db.data.sessions.find((s) => s.sessionId === sessionId); + if (!session) return; + Object.assign(session, patch); + await db.write(); + } + + async get(sessionId: string): Promise { + const db = await this.ensureDb(); + return db.data.sessions.find( + (s) => s.sessionId === sessionId && !s.isArchived, + ); + } + + async listByWorkspace(workspaceId: string): Promise { + const db = await this.ensureDb(); + return db.data.sessions + .filter((s) => s.workspaceId === workspaceId && !s.isArchived) + .sort((a, b) => b.lastActiveAt - a.lastActiveAt); + } + + async archive(sessionId: string): Promise { + await this.update(sessionId, { isArchived: true }); + } +} 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 0cd3af92538..00aa0a015f2 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 @@ -31,10 +31,15 @@ import type { ModelOption } from "./types"; interface ChatInterfaceProps { sessionId: string; + workspaceId: string; cwd: string; } -export function ChatInterface({ sessionId, cwd }: ChatInterfaceProps) { +export function ChatInterface({ + sessionId, + workspaceId, + cwd, +}: ChatInterfaceProps) { const [selectedModel, setSelectedModel] = useState(MODELS[1]); const [modelSelectorOpen, setModelSelectorOpen] = useState(false); @@ -83,30 +88,88 @@ export function ChatInterface({ sessionId, cwd }: ChatInterfaceProps) { console.error("[chat] Start session failed:", err); }, }); + const restoreSession = electronTrpc.aiChat.restoreSession.useMutation({ + onSuccess: () => { + console.log("[chat] Session restored"); + setSessionReady(true); + }, + onError: (err) => { + console.error("[chat] Restore session failed:", err); + }, + }); const stopSession = electronTrpc.aiChat.stopSession.useMutation(); + const renameSession = electronTrpc.aiChat.renameSession.useMutation(); const startSessionRef = useRef(startSession); startSessionRef.current = startSession; + const restoreSessionRef = useRef(restoreSession); + restoreSessionRef.current = restoreSession; const stopSessionRef = useRef(stopSession); stopSessionRef.current = stopSession; + const renameSessionRef = useRef(renameSession); + renameSessionRef.current = renameSession; + + const { data: existingSession } = electronTrpc.aiChat.getSession.useQuery( + { sessionId }, + { enabled: !!sessionId }, + ); useEffect(() => { if (!sessionId || !cwd) return; + if (existingSession === undefined) return; + hasConnected.current = false; setSessionReady(false); - startSessionRef.current.mutate({ sessionId, cwd }); + + if (existingSession) { + restoreSessionRef.current.mutate({ sessionId, cwd }); + } else { + startSessionRef.current.mutate({ + sessionId, + workspaceId, + cwd, + }); + } + return () => { stopSessionRef.current.mutate({ sessionId }); }; - }, [sessionId, cwd]); + }, [sessionId, cwd, workspaceId, existingSession]); - // Connect once both session is ready and config has loaded useEffect(() => { if (sessionReady && config?.proxyUrl) { doConnect(); } }, [sessionReady, config?.proxyUrl, doConnect]); + const hasAutoTitled = useRef(false); + useEffect(() => { + if (hasAutoTitled.current) return; + if (!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; + + renameSessionRef.current.mutate({ sessionId, title }); + }, [messages, sessionId]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: must reset when session changes + useEffect(() => { + hasAutoTitled.current = false; + }, [sessionId]); + const handleSend = useCallback( (message: { text: string }) => { if (!message.text.trim()) return; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx index 2e92f0f8b32..0173706082e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx @@ -1,8 +1,11 @@ +import { useCallback } from "react"; import type { MosaicBranch } from "react-mosaic-component"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; +import { generateId } from "renderer/stores/tabs/utils"; import { BasePaneWindow, PaneToolbarActions } from "../components"; import { ChatInterface } from "./ChatInterface"; +import { SessionSelector } from "./components/SessionSelector"; interface ChatPaneProps { paneId: string; @@ -31,6 +34,7 @@ export function ChatPane({ setFocusedPane, }: ChatPaneProps) { const pane = useTabsStore((s) => s.panes[paneId]); + const switchChatSession = useTabsStore((s) => s.switchChatSession); const sessionId = pane?.chat?.sessionId ?? ""; const { data: workspace } = electronTrpc.workspaces.get.useQuery( @@ -38,6 +42,30 @@ export function ChatPane({ { enabled: !!workspaceId }, ); + const deleteSession = electronTrpc.aiChat.deleteSession.useMutation(); + + const handleSelectSession = useCallback( + (newSessionId: string) => { + switchChatSession(paneId, newSessionId); + }, + [paneId, switchChatSession], + ); + + const handleNewChat = useCallback(() => { + const newSessionId = generateId("chat-session"); + switchChatSession(paneId, newSessionId); + }, [paneId, switchChatSession]); + + const handleDeleteSession = useCallback( + (sessionIdToDelete: string) => { + deleteSession.mutate({ sessionId: sessionIdToDelete }); + if (sessionIdToDelete === sessionId) { + handleNewChat(); + } + }, + [deleteSession, sessionId, handleNewChat], + ); + return ( (
- - Chat - +
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 new file mode 100644 index 00000000000..9088decf1fa --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/SessionSelector.tsx @@ -0,0 +1,148 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { useCallback, useState } from "react"; +import { + HiMiniChatBubbleLeftRight, + HiMiniChevronDown, + HiMiniPlus, + HiMiniTrash, +} from "react-icons/hi2"; +import { electronTrpc } from "renderer/lib/electron-trpc"; + +function formatRelativeTime(timestamp: number): string { + const diff = Date.now() - timestamp; + const minutes = Math.floor(diff / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ago`; + return new Date(timestamp).toLocaleDateString(); +} + +interface SessionSelectorProps { + workspaceId: string; + currentSessionId: string; + onSelectSession: (sessionId: string) => void; + onNewChat: () => void; + onDeleteSession: (sessionId: string) => void; +} + +export function SessionSelector({ + workspaceId, + currentSessionId, + onSelectSession, + onNewChat, + onDeleteSession, +}: SessionSelectorProps) { + const [isOpen, setIsOpen] = useState(false); + + const { data: sessions } = electronTrpc.aiChat.listSessions.useQuery( + { workspaceId }, + { enabled: isOpen }, + ); + + const currentSession = sessions?.find( + (s) => s.sessionId === currentSessionId, + ); + const displayTitle = currentSession?.title ?? "Chat"; + + const handleSelect = useCallback( + (sessionId: string) => { + if (sessionId !== currentSessionId) { + onSelectSession(sessionId); + } + setIsOpen(false); + }, + [currentSessionId, onSelectSession], + ); + + const handleDelete = useCallback( + (e: React.MouseEvent, sessionId: string) => { + e.stopPropagation(); + onDeleteSession(sessionId); + }, + [onDeleteSession], + ); + + const handleNewChat = useCallback(() => { + onNewChat(); + setIsOpen(false); + }, [onNewChat]); + + return ( + + + + + + 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 +
+ )} + + + + + New Chat + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/index.ts new file mode 100644 index 00000000000..a660c32f337 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/index.ts @@ -0,0 +1 @@ +export { SessionSelector } from "./SessionSelector"; diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index c178d7a7fe4..d19a0f22746 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -1006,6 +1006,23 @@ export const useTabsStore = create()( return moveResult.newTabId; }, + // Chat operations + switchChatSession: (paneId, sessionId) => { + const state = get(); + const pane = state.panes[paneId]; + if (!pane?.chat) return; + + set({ + panes: { + ...state.panes, + [paneId]: { + ...pane, + chat: { sessionId }, + }, + }, + }); + }, + // Query helpers getTabsByWorkspace: (workspaceId) => { return get().tabs.filter((t) => t.workspaceId === workspaceId); diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index dd6b7acb523..b34a0dfd9ed 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -134,6 +134,10 @@ export interface TabsStore extends TabsState { movePaneToTab: (paneId: string, targetTabId: string) => void; movePaneToNewTab: (paneId: string) => string; + // Chat operations + /** Switch a chat pane to a different session */ + switchChatSession: (paneId: string, sessionId: string) => void; + // Query helpers getTabsByWorkspace: (workspaceId: string) => Tab[]; getActiveTab: (workspaceId: string) => Tab | null; diff --git a/apps/streams/src/claude-agent.ts b/apps/streams/src/claude-agent.ts index 64f8caf0b8d..6c277ad1a14 100644 --- a/apps/streams/src/claude-agent.ts +++ b/apps/streams/src/claude-agent.ts @@ -1,39 +1,16 @@ -/** - * Claude Agent Endpoint - * - * Hono app that acts as an AI agent the proxy can invoke. - * The proxy's `invokeAgent()` POSTs to this endpoint and parses the SSE response. - * - * Flow: - * 1. Proxy sends { messages, stream, sessionId, cwd, env } - * 2. Agent extracts latest user message as the prompt - * 3. Runs `query()` from @anthropic-ai/claude-agent-sdk - * 4. Converts each SDKMessage to TanStack AI AG-UI chunks - * 5. Returns SSE response with `data: {chunk}\n\n` lines - * - * Session state: Maintains Map for multi-turn resume. - * Binary path: From CLAUDE_BINARY_PATH env var. - * Auth: From environment (ANTHROPIC_API_KEY or OAuth via ~/.claude/.credentials.json). - */ - +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { query } from "@anthropic-ai/claude-agent-sdk"; import { Hono } from "hono"; import { z } from "zod"; import { createConverter } from "./sdk-to-ai-chunks"; -// ============================================================================ -// Constants -// ============================================================================ - const DEFAULT_MODEL = "claude-sonnet-4-5-20250929"; const MAX_AGENT_TURNS = 25; const SESSION_MAX_SIZE = 1000; const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours -// ============================================================================ -// Request Validation -// ============================================================================ - const agentRequestSchema = z.object({ messages: z .array(z.object({ role: z.string(), content: z.string() })) @@ -44,10 +21,6 @@ const agentRequestSchema = z.object({ env: z.record(z.string(), z.string()).optional(), }); -// ============================================================================ -// Session State -// ============================================================================ - interface SessionEntry { claudeSessionId: string; lastAccessedAt: number; @@ -55,6 +28,40 @@ interface SessionEntry { const claudeSessions = new Map(); +const SESSIONS_DIR = + process.env.DURABLE_STREAMS_DATA_DIR ?? + join(homedir(), ".superset", "chat-streams"); +const SESSIONS_FILE = join(SESSIONS_DIR, "claude-sessions.json"); + +function loadPersistedSessions(): void { + try { + if (existsSync(SESSIONS_FILE)) { + const raw = readFileSync(SESSIONS_FILE, "utf-8"); + const entries = JSON.parse(raw) as Array<[string, SessionEntry]>; + for (const [key, entry] of entries) { + claudeSessions.set(key, entry); + } + console.log(`[claude-agent] Loaded ${entries.length} persisted sessions`); + } + } catch (err) { + console.warn("[claude-agent] Failed to load persisted sessions:", err); + } +} + +function persistSessions(): void { + try { + if (!existsSync(SESSIONS_DIR)) { + mkdirSync(SESSIONS_DIR, { recursive: true }); + } + const entries = Array.from(claudeSessions.entries()); + writeFileSync(SESSIONS_FILE, JSON.stringify(entries), "utf-8"); + } catch (err) { + console.warn("[claude-agent] Failed to persist sessions:", err); + } +} + +loadPersistedSessions(); + function evictStaleSessions(): void { const now = Date.now(); for (const [key, entry] of claudeSessions) { @@ -63,7 +70,6 @@ function evictStaleSessions(): void { } } - // If still over capacity, evict oldest entries if (claudeSessions.size > SESSION_MAX_SIZE) { const sorted = [...claudeSessions.entries()].sort( (a, b) => a[1].lastAccessedAt - b[1].lastAccessedAt, @@ -89,12 +95,9 @@ function setClaudeSessionId(sessionId: string, claudeSessionId: string): void { claudeSessionId, lastAccessedAt: Date.now(), }); + persistSessions(); } -// ============================================================================ -// App -// ============================================================================ - const app = new Hono(); app.post("/", async (c) => { @@ -116,7 +119,6 @@ app.post("/", async (c) => { const { messages, sessionId, cwd, env: agentEnv } = parsed.data; - // Extract prompt from latest user message const latestUserMessage = messages?.filter((m) => m.role === "user").pop(); if (!latestUserMessage) { @@ -126,17 +128,13 @@ app.post("/", async (c) => { const prompt = latestUserMessage.content; const claudeSessionId = sessionId ? getClaudeSessionId(sessionId) : undefined; - // Build environment for Claude binary const baseEnv = agentEnv ?? (process.env as unknown as Record); const queryEnv: Record = { ...baseEnv }; - - // Ensure CLAUDE_CODE_ENTRYPOINT is set queryEnv.CLAUDE_CODE_ENTRYPOINT = "sdk-ts"; const binaryPath = process.env.CLAUDE_BINARY_PATH; - // Run Claude query const abortController = new AbortController(); const result = query({ prompt, @@ -153,10 +151,8 @@ app.post("/", async (c) => { }, }); - // Create stateful converter const converter = createConverter(); - // Abort handling: when the fetch is aborted, interrupt the query const requestSignal = c.req.raw.signal; const abortHandler = () => { abortController.abort(); @@ -165,7 +161,6 @@ app.post("/", async (c) => { }; requestSignal.addEventListener("abort", abortHandler, { once: true }); - // Return SSE response const encoder = new TextEncoder(); const readable = new ReadableStream({ async start(controller) { @@ -173,7 +168,6 @@ app.post("/", async (c) => { for await (const message of result) { if (requestSignal.aborted) break; - // Extract claudeSessionId from system init const msg = message as Record; if (msg.type === "system" && msg.subtype === "init") { const sdkSessionId = msg.session_id as string | undefined; @@ -183,7 +177,6 @@ app.post("/", async (c) => { continue; } - // Convert SDKMessage to AG-UI chunks const chunks = converter.convert(message); for (const chunk of chunks) { controller.enqueue( @@ -212,7 +205,7 @@ app.post("/", async (c) => { controller.enqueue(encoder.encode("data: [DONE]\n\n")); } catch (enqueueErr) { console.debug( - "[claude-agent] Controller already closed, could not write error event:", + "[claude-agent] Controller already closed:", enqueueErr, ); } @@ -246,7 +239,17 @@ app.post("/", async (c) => { }); }); -// Health check for the agent +app.get("/sessions/:sessionId", (c) => { + const sessionId = c.req.param("sessionId"); + const claudeSessionId = getClaudeSessionId(sessionId); + + if (!claudeSessionId) { + return c.json({ error: "Session not found" }, 404); + } + + return c.json({ claudeSessionId }); +}); + app.get("/health", (c) => { return c.json({ status: "ok", diff --git a/apps/streams/src/index.ts b/apps/streams/src/index.ts index eeb2b58b21a..b9ee7cca0f8 100644 --- a/apps/streams/src/index.ts +++ b/apps/streams/src/index.ts @@ -1,3 +1,6 @@ +import { existsSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { DurableStreamTestServer } from "@durable-streams/server"; import { serve } from "@hono/node-server"; import { claudeAgentApp } from "./claude-agent"; @@ -9,14 +12,21 @@ const AGENT_PORT = parseInt(process.env.CLAUDE_AGENT_PORT ?? "9090", 10); const DURABLE_STREAMS_URL = process.env.DURABLE_STREAMS_URL ?? `http://127.0.0.1:${INTERNAL_PORT}`; -// Start internal durable stream server +const DATA_DIR = + process.env.DURABLE_STREAMS_DATA_DIR ?? + join(homedir(), ".superset", "chat-streams"); + +if (!existsSync(DATA_DIR)) { + mkdirSync(DATA_DIR, { recursive: true }); +} + const durableStreamServer = new DurableStreamTestServer({ port: INTERNAL_PORT, + dataDir: DATA_DIR, }); await durableStreamServer.start(); console.log(`[streams] Durable stream server on port ${INTERNAL_PORT}`); -// Start proxy server const { app } = createServer({ baseUrl: DURABLE_STREAMS_URL, cors: true, @@ -27,7 +37,6 @@ const proxyServer = serve({ fetch: app.fetch, port: PORT }, (info) => { console.log(`[streams] Proxy running on http://localhost:${info.port}`); }); -// Start Claude agent endpoint const agentServer = serve( { fetch: claudeAgentApp.fetch, port: AGENT_PORT }, (info) => { @@ -37,7 +46,6 @@ const agentServer = serve( }, ); -// Graceful shutdown process.on("SIGINT", async () => { proxyServer.close(); agentServer.close();