import { createSignal, createMemo, Show, onMount, onCleanup, createEffect } from "solid-js"; import { InputBar } from "./components/InputBar"; import { MessageList } from "./components/MessageList"; import { TopBar } from "./components/TopBar"; import { ContextIndicator } from "./components/ContextIndicator"; import { FileChangesSummary } from "./components/FileChangesSummary"; import { useOpenCode, type Event as OpenCodeEvent, type Session as SDKSession, type Agent as SDKAgent } from "./hooks/useOpenCode"; import { applyPartUpdate, applyMessageUpdate } from "./utils/messageUtils"; import type { Message, Agent, Session, Permission, ContextInfo, FileChangesInfo, MessagePart, IncomingMessage } from "./types"; export interface QueuedMessage { id: string; text: string; agent: string | null; } import { vscode } from "./utils/vscode"; const DEBUG = false; const NEW_SESSION_KEY = "__new__"; function App() { const [messages, setMessages] = createSignal([]); const [thinkingSessions, setThinkingSessions] = createSignal>(new Set()); const [agents, setAgents] = createSignal([]); const [defaultAgent, setDefaultAgent] = createSignal(null); const [sessions, setSessions] = createSignal([]); const [currentSessionId, setCurrentSessionId] = createSignal(null); const [currentSessionTitle, setCurrentSessionTitle] = createSignal("New Session"); const [contextInfo, setContextInfo] = createSignal(null); const [fileChanges, setFileChanges] = createSignal(null); const [currentModelContextLimit, setCurrentModelContextLimit] = createSignal(200000); // Per-session drafts and agent selection const [drafts, setDrafts] = createSignal>(new Map()); const [sessionAgents, setSessionAgents] = createSignal>(new Map()); // Pending permissions const [pendingPermissions, setPendingPermissions] = createSignal>(new Map()); // Editing state for previous messages const [editingMessageId, setEditingMessageId] = createSignal(null); const [editingText, setEditingText] = createSignal(""); // Message queue for queuing messages while generating const [messageQueue, setMessageQueue] = createSignal([]); // Get SDK hook const { isReady: sdkIsReady, workspaceRoot: sdkWorkspaceRoot, initData, listSessions, getSession, createSession, getAgents, getMessages, getConfig, abortSession, sendPrompt, subscribeToEvents, respondToPermission, revertToMessage, client, hostError, setHostError, clearHostError, } = useOpenCode(); // Get the current session key for drafts/agents const sessionKey = () => currentSessionId() || NEW_SESSION_KEY; // Current input for the active session const input = () => drafts().get(sessionKey()) || ""; const setInput = (value: string) => { const key = sessionKey(); setDrafts((prev) => { const next = new Map(prev); next.set(key, value); return next; }); }; // Current agent for the active session const selectedAgent = () => sessionAgents().get(sessionKey()) || defaultAgent(); const setSelectedAgent = (agent: string | null) => { if (!agent) return; const key = sessionKey(); setSessionAgents((prev) => { const next = new Map(prev); next.set(key, agent); return next; }); }; const hasMessages = createMemo(() => messages().some((m) => m.type === "user" || m.type === "assistant") ); const isThinking = () => { const sessionId = currentSessionId(); return sessionId ? thinkingSessions().has(sessionId) : false; }; const setIsThinking = (sessionId: string, thinking: boolean) => { setThinkingSessions((prev) => { const next = new Set(prev); if (thinking) { next.add(sessionId); } else { next.delete(sessionId); } return next; }); }; const sessionsToShow = createMemo(() => { return sessions().filter(s => s.id !== currentSessionId() || currentSessionId() !== null); }); // Helper: check if title is default timestamp-based const isDefaultTitle = (title: string) => /^(New session|Child session) - \d{4}-\d{2}-\d{2}T/.test(title); // Helper: map legacy/SDK messages to UI format function mapMessagesToUI(incomingMessages: unknown[]): Message[] { return incomingMessages.map((raw: unknown) => { const r = raw as Record; const m = (r?.info ?? r) as Record; const parts = (r?.parts ?? m?.parts ?? []) as MessagePart[]; const text = (m?.text as string) ?? (Array.isArray(parts) ? parts .filter((p) => p?.type === "text" && typeof p.text === "string") .map((p) => p.text) .join("\n") : ""); const role = (m?.role as string) ?? "assistant"; return { id: m.id as string, type: role === "user" ? "user" : "assistant", text, parts, } as Message; }); } // Initialize from initData when SDK is ready createEffect(async () => { if (!sdkIsReady()) return; const init = initData(); if (!init) return; // Restore current session const sessionId = init.currentSessionId ?? null; const title = init.currentSessionTitle ?? "New Session"; setCurrentSessionId(sessionId); setCurrentSessionTitle(isDefaultTitle(title) ? "New Session" : title); // Load messages for current session if (sessionId && init.currentSessionMessages && init.currentSessionMessages.length > 0) { const msgs = mapMessagesToUI(init.currentSessionMessages); setMessages(msgs); // Extract agent from last user message const lastUserMsg = [...init.currentSessionMessages].reverse().find((raw: unknown) => { const r = raw as Record; const m = (r?.info ?? r) as Record; return m?.role === "user"; }); if (lastUserMsg) { const info = ((lastUserMsg as Record)?.info ?? lastUserMsg) as Record; if (info?.agent) { setSessionAgents((prev) => { const next = new Map(prev); next.set(sessionId, info.agent as string); return next; }); } } } }); // Load agents when SDK is ready createEffect(async () => { if (!sdkIsReady()) return; try { const res = await getAgents(); const agentList = (res?.data ?? []) as Agent[]; // Filter to primary/all agents const filteredAgents = agentList.filter(a => a.mode === "primary" || a.mode === "all"); setAgents(filteredAgents); // Use persisted default from extension, or first agent const init = initData(); const persistedDefault = init?.defaultAgent; if (persistedDefault && filteredAgents.some(a => a.name === persistedDefault)) { setDefaultAgent(persistedDefault); } else if (!defaultAgent() && filteredAgents.length > 0) { setDefaultAgent(filteredAgents[0].name); } } catch (err) { console.error("[App] Failed to load agents:", err); } }); // Load sessions when SDK is ready createEffect(async () => { if (!sdkIsReady()) return; try { const res = await listSessions(); const sessionList = (res?.data ?? []) as Session[]; setSessions(sessionList); } catch (err) { console.error("[App] Failed to load sessions:", err); } }); // Update context info from assistant message tokens async function updateContextInfo(tokens: Record, modelID: string, providerID: string) { try { const c = client(); if (!c) return; // Try to get model context limit from config const configResult = await c.config.providers(); const providers = configResult?.data as Record }> | undefined; const contextLimit = providers?.[providerID]?.models?.[modelID]?.limit?.context; if (contextLimit) { setCurrentModelContextLimit(contextLimit); } const cache = tokens.cache as { read?: number } | undefined; const usedTokens = ((tokens.input as number) || 0) + ((tokens.output as number) || 0) + (cache?.read || 0); if (usedTokens === 0) return; const limit = currentModelContextLimit(); const percentage = Math.min(100, (usedTokens / limit) * 100); setContextInfo({ usedTokens, limitTokens: limit, percentage, }); } catch (error) { console.error("[App] Error updating context info:", error); } } // Handle SSE events function handleEvent(event: OpenCodeEvent) { const activeSessionId = currentSessionId(); // Helper to get session ID from event const getEventSessionId = (e: OpenCodeEvent): string | undefined => { const props = (e as unknown as { properties: Record }).properties; return (props?.sessionID as string) ?? (props?.info as Record)?.sessionID as string ?? ((props?.part as Record)?.sessionID as string) ?? ((props?.info as Record)?.id === activeSessionId ? activeSessionId : undefined); }; const evSessionId = getEventSessionId(event); // Filter events for current session only (for message/part events) const shouldFilterBySession = ["message.updated", "message.removed", "message.part.updated", "message.part.removed"].includes(event.type); if (shouldFilterBySession && activeSessionId && evSessionId && evSessionId !== activeSessionId) { if (DEBUG) console.log("[App] Ignoring event for inactive session:", evSessionId); return; } if (DEBUG) console.log("[App] SSE event:", event.type, event); switch (event.type) { case "message.part.updated": { const props = (event as unknown as { properties: { part: MessagePart & { messageID: string }; delta?: string } }).properties; const part = props.part; if (DEBUG) console.log("[App] Part update:", part); setMessages((prev) => applyPartUpdate(prev, part)); break; } case "message.updated": { const props = (event as unknown as { properties: { info: IncomingMessage & { tokens?: Record; modelID?: string; providerID?: string } } }).properties; const info = props.info; if (DEBUG) console.log("[App] Message update:", info); setMessages((prev) => applyMessageUpdate(prev, info)); // Update context info for assistant messages if (info.role === "assistant" && info.tokens) { updateContextInfo(info.tokens, info.modelID || "", info.providerID || ""); } break; } case "message.removed": { const props = (event as unknown as { properties: { messageID: string } }).properties; console.log("[App] Message removed:", props.messageID); setMessages((prev) => prev.filter((m) => m.id !== props.messageID)); break; } case "session.idle": { const sessionId = evSessionId || activeSessionId; console.log("[App] Session idle - streaming complete", sessionId); if (sessionId) setIsThinking(sessionId, false); // Process next queued message if any processNextQueuedMessage(); break; } case "session.updated": { const props = (event as unknown as { properties: { info: Session } }).properties; const session = props.info; // Update session title if it changed if (session.id === activeSessionId && session.title && !isDefaultTitle(session.title)) { setCurrentSessionTitle(session.title); } // Update sessions list setSessions((prev) => prev.map((s) => s.id === session.id ? { ...s, ...session } : s)); // Update file changes from session summary if (session.summary?.diffs) { const diffs = session.summary.diffs; setFileChanges({ fileCount: diffs.length, additions: diffs.reduce((sum, d) => sum + (d.additions || 0), 0), deletions: diffs.reduce((sum, d) => sum + (d.deletions || 0), 0), }); } break; } case "session.created": { const props = (event as unknown as { properties: { info: Session } }).properties; const session = props.info; setSessions((prev) => { if (prev.some((s) => s.id === session.id)) return prev; return [...prev, session]; }); break; } case "permission.updated": { const permission = (event as unknown as { properties: Permission }).properties; console.log("[App] Permission required:", permission); const key = permission.callID || permission.id; setPendingPermissions((prev) => { const next = new Map(prev); next.set(key, permission); return next; }); break; } case "permission.replied": { const props = (event as unknown as { properties: { permissionID: string } }).properties; console.log("[App] Permission replied:", props.permissionID); break; } case "session.error": { const props = (event as unknown as { properties: { error?: { data?: { message?: string } } } }).properties; const errorMessage = props.error?.data?.message || "Unknown error"; const sessionId = evSessionId || activeSessionId; console.error("[App] Session error:", errorMessage); if (sessionId) setIsThinking(sessionId, false); setMessages((prev) => [ ...prev, { id: crypto.randomUUID(), type: "assistant", text: `Error: ${errorMessage}`, }, ]); break; } default: if (DEBUG) console.log("[App] Unhandled event:", event.type); } } // Start SSE subscription onMount(() => { let cleanup: (() => void) | undefined; const startSSE = async () => { // Wait for SDK to be ready while (!sdkIsReady()) { await new Promise((r) => setTimeout(r, 100)); } try { console.log("[App] Starting SSE subscription"); cleanup = subscribeToEvents((event) => { handleEvent(event); }); } catch (err) { console.error("[App] SSE subscription failed:", err); } }; startSSE(); onCleanup(() => { cleanup?.(); }); }); // Handlers const handleSubmit = async () => { const text = input().trim(); if (!text || !sdkIsReady()) return; const agent = agents().some((a) => a.name === selectedAgent()) ? selectedAgent() : null; // Ensure we have a session let sessionId = currentSessionId(); if (!sessionId) { try { const res = await createSession(); const newSession = res?.data as Session | undefined; if (!newSession?.id) { console.error("[App] Failed to create session"); return; } sessionId = newSession.id; setCurrentSessionId(sessionId); setCurrentSessionTitle("New Session"); setSessions((prev) => [...prev, newSession]); } catch (err) { console.error("[App] Failed to create session:", err); return; } } setInput(""); setIsThinking(sessionId, true); try { await sendPrompt(sessionId, text, agent); } catch (err) { console.error("[App] sendPrompt failed:", err); setIsThinking(sessionId, false); setMessages((prev) => [ ...prev, { id: crypto.randomUUID(), type: "assistant", text: `Error: ${(err as Error).message}`, }, ]); } }; const processNextQueuedMessage = async () => { const queue = messageQueue(); if (queue.length === 0) return; const [next, ...rest] = queue; setMessageQueue(rest); const sessionId = currentSessionId(); if (!sessionId || !sdkIsReady()) return; setIsThinking(sessionId, true); try { await sendPrompt(sessionId, next.text, next.agent); } catch (err) { console.error("[App] Queue sendPrompt failed:", err); setIsThinking(sessionId, false); setMessageQueue([]); // Clear queue on error setMessages((prev) => [ ...prev, { id: crypto.randomUUID(), type: "assistant", text: `Error: ${(err as Error).message}`, }, ]); } }; const handleQueueMessage = () => { const text = input().trim(); if (!text || !sdkIsReady()) return; const agent = agents().some((a) => a.name === selectedAgent()) ? selectedAgent() : null; const queuedMessage: QueuedMessage = { id: crypto.randomUUID(), text, agent, }; setMessageQueue((prev) => [...prev, queuedMessage]); setInput(""); }; const handleRemoveFromQueue = (id: string) => { setMessageQueue((prev) => prev.filter((m) => m.id !== id)); }; const handleEditQueuedMessage = (id: string) => { const queue = messageQueue(); const index = queue.findIndex((m) => m.id === id); if (index === -1) return; const message = queue[index]; // Remove this message and all after it setMessageQueue(queue.slice(0, index)); // Put the message text in the input setInput(message.text); // Set the agent if different if (message.agent) { setSelectedAgent(message.agent); } }; const handleSessionSelect = async (sessionId: string) => { if (!sdkIsReady()) return; setCurrentSessionId(sessionId); setFileChanges(null); setContextInfo(null); setMessageQueue([]); // Clear queue on session switch try { // Load messages const res = await getMessages(sessionId); const sdkMessages = (res?.data ?? []) as unknown[]; setMessages(mapMessagesToUI(sdkMessages)); // Extract agent from last user message const lastUser = [...sdkMessages].reverse().find((m: unknown) => { const msg = m as Record; const info = (msg?.info ?? msg) as Record; return info?.role === "user"; }); if (lastUser) { const info = ((lastUser as Record)?.info ?? lastUser) as Record; if (info?.agent) { setSessionAgents((prev) => { const next = new Map(prev); next.set(sessionId, info.agent as string); return next; }); } } // Get session title const sessionRes = await getSession(sessionId); const session = sessionRes?.data as Session | undefined; if (session?.title) { setCurrentSessionTitle(isDefaultTitle(session.title) ? "New Session" : session.title); } // Update file changes if available if (session?.summary?.diffs) { const diffs = session.summary.diffs; setFileChanges({ fileCount: diffs.length, additions: diffs.reduce((sum, d) => sum + (d.additions || 0), 0), deletions: diffs.reduce((sum, d) => sum + (d.deletions || 0), 0), }); } // Find last assistant message to restore context info const lastAssistantMsg = [...sdkMessages].reverse().find((m: unknown) => { const msg = m as Record; const info = (msg?.info ?? msg) as Record; return info?.role === "assistant" && info?.tokens; }); if (lastAssistantMsg) { const info = ((lastAssistantMsg as Record)?.info ?? lastAssistantMsg) as Record; if (info.tokens) { updateContextInfo( info.tokens as Record, (info.modelID as string) || "", (info.providerID as string) || "" ); } } } catch (err) { console.error("[App] Failed to switch session:", err); } }; const handleNewSession = async () => { if (!sdkIsReady()) return; try { const res = await createSession(); const newSession = res?.data as Session | undefined; if (!newSession?.id) return; setSessions((prev) => [...prev, newSession]); setCurrentSessionId(newSession.id); setCurrentSessionTitle("New Session"); setMessages([]); setFileChanges(null); setContextInfo(null); setMessageQueue([]); } catch (err) { console.error("[App] Failed to create session:", err); } }; const handleCancel = async () => { const sessionId = currentSessionId(); if (!sdkIsReady() || !sessionId) return; try { await abortSession(sessionId); } finally { setIsThinking(sessionId, false); } }; const handleAgentChange = (agent: string | null) => { setSelectedAgent(agent); // Persist as global default for new sessions if (agent && !currentSessionId()) { vscode.postMessage({ type: "agent-changed", agent }); } }; const handleStartEdit = (messageId: string, text: string) => { setEditingMessageId(messageId); setEditingText(text); }; const handleCancelEdit = () => { setEditingMessageId(null); setEditingText(""); }; const handleSubmitEdit = async (newText: string) => { const messageId = editingMessageId(); const sessionId = currentSessionId(); if (!messageId || !sessionId || !newText.trim() || !sdkIsReady()) return; const agent = agents().some((a) => a.name === selectedAgent()) ? selectedAgent() : null; // Optimistically truncate messages const messageIndex = messages().findIndex((m) => m.id === messageId); if (messageIndex !== -1) { setMessages(messages().slice(0, messageIndex)); } setIsThinking(sessionId, true); setEditingMessageId(null); setEditingText(""); try { await revertToMessage(sessionId, messageId); await sendPrompt(sessionId, newText.trim(), agent); } catch (err) { console.error("[App] Failed to edit message:", err); setIsThinking(sessionId, false); setMessages((prev) => [ ...prev, { id: crypto.randomUUID(), type: "assistant", text: `Error editing message: ${(err as Error).message}`, }, ]); } }; const handlePermissionResponse = async ( permissionId: string, response: "once" | "always" | "reject" ) => { console.log(`[App] Permission response: ${response} for ${permissionId}`); const perms = pendingPermissions(); let permission: Permission | undefined; for (const [, perm] of perms.entries()) { if (perm.id === permissionId) { permission = perm; break; } } const sessionId = permission?.sessionID || currentSessionId(); if (!sessionId || !sdkIsReady()) { console.error("[App] Cannot respond to permission: no session ID"); return; } try { await respondToPermission(sessionId, permissionId, response); } finally { setPendingPermissions((prev) => { const next = new Map(prev); for (const [key, perm] of next.entries()) { if (perm.id === permissionId) { next.delete(key); break; } } return next; }); } }; return (
{hostError()}
); } export default App;