From b9e1ab16efa91076d1aacbfefb01bfa3fd567351 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Wed, 18 Feb 2026 17:08:38 -0800 Subject: [PATCH] perf(app): add session compaction and dev-mode perf diagnostics --- .../session-perf-compaction-smoke.png | Bin 0 -> 45409 bytes packages/app/src/app/app.tsx | 311 ++++++++++++------ .../app/components/session/message-list.tsx | 11 + packages/app/src/app/context/session.ts | 64 +++- packages/app/src/app/lib/opencode-session.ts | 41 ++- packages/app/src/app/lib/perf-log.ts | 91 +++++ packages/app/src/app/pages/config.tsx | 6 + packages/app/src/app/pages/session.tsx | 197 ++++++++++- 8 files changed, 599 insertions(+), 122 deletions(-) create mode 100644 packages/app/pr/screenshots/session-perf-compaction-smoke.png create mode 100644 packages/app/src/app/lib/perf-log.ts diff --git a/packages/app/pr/screenshots/session-perf-compaction-smoke.png b/packages/app/pr/screenshots/session-perf-compaction-smoke.png new file mode 100644 index 0000000000000000000000000000000000000000..b62458e8361ba3e4e6efef2ab07b92ba5f2d6d70 GIT binary patch literal 45409 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 literal 0 HcmV?d00001 diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index a80297c2e..bfb0ac101 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -41,11 +41,13 @@ import { createClient, unwrap, waitForHealthy, type OpencodeAuth } from "./lib/o import { abortSession as abortSessionTyped, abortSessionSafe, + compactSession as compactSessionTyped, revertSession, unrevertSession, shellInSession, listCommands as listCommandsTyped, } from "./lib/opencode-session"; +import { clearPerfLogs, finishPerf, perfNow, recordPerfLog } from "./lib/perf-log"; import { DEFAULT_MODEL, HIDE_TITLEBAR_PREF_KEY, @@ -589,6 +591,11 @@ export default function App() { const [developerMode, setDeveloperMode] = createSignal(false); const [documentVisible, setDocumentVisible] = createSignal(true); + createEffect(() => { + if (developerMode()) return; + clearPerfLogs(); + }); + const [selectedSessionId, setSelectedSessionId] = createSignal( null ); @@ -847,6 +854,15 @@ export default function App() { const c = client(); if (!c) return; + + const compactShortcut = /^\/compact(?:\s+.*)?$/i.test(content); + const compactCommand = resolvedDraft.command?.name === "compact" || compactShortcut; + const commandName = compactCommand ? "compact" : (resolvedDraft.command?.name ?? null); + if (compactCommand && !selectedSessionId()) { + setError("Select a session with messages before running /compact."); + return; + } + let sessionID = selectedSessionId(); if (!sessionID) { await createSessionAndOpen(); @@ -859,8 +875,24 @@ export default function App() { setBusyStartedAt(Date.now()); setError(null); + const perfEnabled = developerMode(); + const startedAt = perfNow(); + const visible = messages(); + const visibleParts = visible.reduce((total, message) => total + message.parts.length, 0); + recordPerfLog(perfEnabled, "session.prompt", "start", { + sessionID, + mode: resolvedDraft.mode, + command: commandName, + charCount: content.length, + attachmentCount: resolvedDraft.attachments.length, + messageCount: visible.length, + partCount: visibleParts, + }); + try { - setLastPromptSent(content); + if (!compactCommand) { + setLastPromptSent(content); + } setPrompt(""); const model = selectedSessionModel(); @@ -869,7 +901,22 @@ export default function App() { if (resolvedDraft.mode === "shell") { await shellInSession(c, sessionID, content); - } else if (resolvedDraft.command) { + } else if (resolvedDraft.command || compactCommand) { + if (compactCommand) { + await compactCurrentSession(sessionID); + finishPerf(perfEnabled, "session.prompt", "done", startedAt, { + sessionID, + mode: resolvedDraft.mode, + command: commandName, + }); + return; + } + + const command = resolvedDraft.command; + if (!command) { + throw new Error("Command was not resolved."); + } + // Slash command: route through session.command() API const selected = selectedSessionModel(); const modelString = `${selected.providerID}/${selected.modelID}`; @@ -879,8 +926,8 @@ export default function App() { unwrap( await c.session.command({ sessionID, - command: resolvedDraft.command.name, - arguments: resolvedDraft.command.arguments, + command: command.name, + arguments: command.arguments, agent: agent ?? undefined, model: modelString, variant: modelVariant() ?? undefined, @@ -910,7 +957,19 @@ export default function App() { return copy; }); } + + finishPerf(perfEnabled, "session.prompt", "done", startedAt, { + sessionID, + mode: resolvedDraft.mode, + command: commandName, + }); } catch (e) { + finishPerf(perfEnabled, "session.prompt", "error", startedAt, { + sessionID, + mode: resolvedDraft.mode, + command: commandName, + error: e instanceof Error ? e.message : safeStringify(e), + }); const message = e instanceof Error ? e.message : safeStringify(e); setError(addOpencodeCacheHint(message)); } finally { @@ -942,6 +1001,52 @@ export default function App() { }); } + async function compactCurrentSession(sessionIdOverride?: string) { + const c = client(); + if (!c) { + throw new Error("Not connected to a server"); + } + + const sessionID = (sessionIdOverride ?? selectedSessionId() ?? "").trim(); + if (!sessionID) { + throw new Error("Select a session before compacting."); + } + + const visible = messages(); + if (!visible.length) { + throw new Error("Nothing to compact yet."); + } + + const model = selectedSessionModel(); + const startedAt = perfNow(); + const modelLabel = `${model.providerID}/${model.modelID}`; + recordPerfLog(developerMode(), "session.compact", "start", { + sessionID, + messageCount: visible.length, + model: modelLabel, + variant: modelVariant() ?? null, + }); + + try { + await compactSessionTyped(c, sessionID, model, { + directory: workspaceProjectDir().trim() || undefined, + }); + finishPerf(developerMode(), "session.compact", "done", startedAt, { + sessionID, + messageCount: visible.length, + model: modelLabel, + }); + } catch (error) { + finishPerf(developerMode(), "session.compact", "error", startedAt, { + sessionID, + messageCount: visible.length, + model: modelLabel, + error: error instanceof Error ? error.message : safeStringify(error), + }); + throw error; + } + } + const messageIdFromInfo = (message: MessageWithParts) => { const id = (message.info as { id?: string | number }).id; if (typeof id === "string") return id; @@ -1144,10 +1249,21 @@ export default function App() { return list.filter((agent) => !agent.hidden && agent.mode !== "subagent"); } + const BUILTIN_COMPACT_COMMAND = { + id: "builtin:compact", + name: "compact", + description: "Summarize this session to reduce context size.", + source: "command" as const, + }; + async function listCommands(): Promise<{ id: string; name: string; description?: string; source?: "command" | "mcp" | "skill" }[]> { const c = client(); if (!c) return []; - return listCommandsTyped(c, workspaceStore.activeWorkspaceRoot().trim() || undefined); + const list = await listCommandsTyped(c, workspaceStore.activeWorkspaceRoot().trim() || undefined); + if (list.some((entry) => entry.name === "compact")) { + return list; + } + return [BUILTIN_COMPACT_COMMAND, ...list]; } function setSessionAgent(sessionID: string, agent: string | null) { @@ -3208,13 +3324,19 @@ export default function App() { } async function connectMcp(entry: (typeof MCP_QUICK_CONNECT)[number]) { - console.log("[connectMcp] called with entry:", entry); - + const startedAt = perfNow(); const isRemoteWorkspace = workspaceStore.activeWorkspaceDisplay().workspaceType === "remote" || (!isTauriRuntime() && openworkServerStatus() === "connected"); const projectDir = workspaceProjectDir().trim(); - console.log("[connectMcp] projectDir:", projectDir); + const entryType = entry.type ?? "remote"; + + recordPerfLog(developerMode(), "mcp.connect", "start", { + name: entry.name, + type: entryType, + workspaceType: isRemoteWorkspace ? "remote" : "local", + projectDir: projectDir || null, + }); const openworkClient = openworkServerClient(); let openworkWorkspaceId = openworkServerWorkspaceId(); @@ -3238,26 +3360,30 @@ export default function App() { openworkCapabilities?.mcp?.write; if (isRemoteWorkspace && !canUseOpenworkServer) { - console.log("[connectMcp] ❌ openwork server unavailable"); setMcpStatus("OpenWork server unavailable. MCP config is read-only."); + finishPerf(developerMode(), "mcp.connect", "blocked", startedAt, { + reason: "openwork-server-unavailable", + }); return; } if (!canUseOpenworkServer && !isTauriRuntime()) { - console.log("[connectMcp] ❌ not Tauri runtime"); setMcpStatus(t("mcp.desktop_required", currentLocale())); + finishPerf(developerMode(), "mcp.connect", "blocked", startedAt, { + reason: "desktop-required", + }); return; } - console.log("[connectMcp] ✓ runtime ready"); if (!isRemoteWorkspace && !projectDir) { - console.log("[connectMcp] ❌ no projectDir"); setMcpStatus(t("mcp.pick_workspace_first", currentLocale())); + finishPerf(developerMode(), "mcp.connect", "blocked", startedAt, { + reason: "missing-workspace", + }); return; } let activeClient = client(); - console.log("[connectMcp] activeClient:", activeClient ? "exists" : "null"); if (!activeClient) { const openworkBaseUrl = openworkServerBaseUrl().trim(); const auth = openworkServerAuth(); @@ -3268,8 +3394,10 @@ export default function App() { } } if (!activeClient) { - console.log("[connectMcp] ❌ no activeClient"); setMcpStatus(t("mcp.connect_server_first", currentLocale())); + finishPerf(developerMode(), "mcp.connect", "blocked", startedAt, { + reason: "no-active-client", + }); return; } @@ -3288,24 +3416,24 @@ export default function App() { } } if (!resolvedProjectDir) { - console.log("[connectMcp] ❌ no projectDir after lookup"); setMcpStatus(t("mcp.pick_workspace_first", currentLocale())); + finishPerf(developerMode(), "mcp.connect", "blocked", startedAt, { + reason: "missing-workspace-after-discovery", + }); return; } const slug = entry.name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); - const entryType = entry.type ?? "remote"; - console.log("[connectMcp] slug:", slug); try { setMcpStatus(null); setMcpConnectingName(entry.name); - console.log("[connectMcp] connecting name set to:", entry.name); const mcpEntryConfig: Record = { type: entryType, enabled: true, }; + if (entryType === "remote") { if (!entry.url) { throw new Error("Missing MCP URL."); @@ -3315,62 +3443,52 @@ export default function App() { mcpEntryConfig["oauth"] = {}; } } + if (entryType === "local") { if (!entry.command?.length) { throw new Error("Missing MCP command."); } mcpEntryConfig["command"] = entry.command; } + if (canUseOpenworkServer && openworkClient && openworkWorkspaceId) { await openworkClient.addMcp(openworkWorkspaceId, { name: slug, config: mcpEntryConfig, }); - console.log("[connectMcp] added MCP via OpenWork server"); } else { - // Step 1: Read existing opencode.json config - console.log("[connectMcp] reading opencode config for projectDir:", projectDir); const configFile = await readOpencodeConfig("project", resolvedProjectDir); - console.log("[connectMcp] config file result:", configFile); - // Step 2: Parse and merge the MCP entry into the config let existingConfig: Record = {}; if (configFile.exists && configFile.content?.trim()) { try { existingConfig = parse(configFile.content) ?? {}; - console.log("[connectMcp] parsed existing config:", existingConfig); } catch (parseErr) { - console.warn("[connectMcp] failed to parse existing config, starting fresh:", parseErr); + recordPerfLog(developerMode(), "mcp.connect", "config-parse-failed", { + error: parseErr instanceof Error ? parseErr.message : String(parseErr), + }); existingConfig = {}; } } - // Ensure base structure if (!existingConfig["$schema"]) { existingConfig["$schema"] = "https://opencode.ai/config.json"; } - // Ensure mcp object exists const mcpSection = (existingConfig["mcp"] as Record) ?? {}; existingConfig["mcp"] = mcpSection; - - // Add the new MCP server entry mcpSection[slug] = mcpEntryConfig; - console.log("[connectMcp] merged MCP config:", existingConfig); - // Step 3: Write the updated config back const writeResult = await writeOpencodeConfig( "project", resolvedProjectDir, `${JSON.stringify(existingConfig, null, 2)}\n` ); - console.log("[connectMcp] writeOpencodeConfig result:", writeResult); if (!writeResult.ok) { throw new Error(writeResult.stderr || writeResult.stdout || "Failed to write opencode.json"); } } - // Step 4: Call SDK mcp.add to update runtime state const mcpAddConfig = entryType === "remote" ? { @@ -3385,25 +3503,18 @@ export default function App() { enabled: true, }; - const mcpAddPayload = { - directory: resolvedProjectDir, - name: slug, - config: mcpAddConfig, - }; - console.log("[connectMcp] calling activeClient.mcp.add with:", mcpAddPayload); - - const rawResult = await activeClient.mcp.add(mcpAddPayload); - console.log("[connectMcp] mcp.add raw result:", rawResult); - - const status = unwrap(rawResult); - console.log("[connectMcp] mcp.add unwrapped status:", status); + const status = unwrap( + await activeClient.mcp.add({ + directory: resolvedProjectDir, + name: slug, + config: mcpAddConfig, + }), + ); setMcpStatuses(status as McpStatusMap); await refreshMcpServers(); - // Step 5: If OAuth, open the auth modal (modal handles the auth flow) if (entry.oauth) { - console.log("[connectMcp] entry has OAuth, opening auth modal for:", entry.name); setMcpAuthEntry(entry); setMcpAuthModalOpen(true); } else { @@ -3411,13 +3522,20 @@ export default function App() { } await refreshMcpServers(); - console.log("[connectMcp] ✓ done"); + finishPerf(developerMode(), "mcp.connect", "done", startedAt, { + name: entry.name, + type: entryType, + slug, + }); } catch (e) { - console.error("[connectMcp] ❌ error:", e); setMcpStatus(e instanceof Error ? e.message : t("mcp.connect_failed", currentLocale())); + finishPerf(developerMode(), "mcp.connect", "error", startedAt, { + name: entry.name, + type: entryType, + error: e instanceof Error ? e.message : safeStringify(e), + }); } finally { setMcpConnectingName(null); - console.log("[connectMcp] finally block, connecting name cleared"); } } @@ -3555,35 +3673,46 @@ export default function App() { } async function createSessionAndOpen() { - console.log("[DEBUG] createSessionAndOpen"); - console.log("[DEBUG] current baseUrl:", baseUrl()); - console.log("[DEBUG] engine info:", engine()); - console.log("[DEBUG] creating session"); const c = client(); if (!c) { - console.log("[DEBUG] no client available!"); return; } + const perfEnabled = developerMode(); + const startedAt = perfNow(); + const runId = (() => { + const key = "__openwork_create_session_run__"; + const w = window as typeof window & { [key]?: number }; + w[key] = (w[key] ?? 0) + 1; + return w[key]; + })(); + + const mark = (event: string, payload?: Record) => { + const elapsed = Math.round((perfNow() - startedAt) * 100) / 100; + recordPerfLog(perfEnabled, "session.create", event, { + runId, + elapsedMs: elapsed, + ...(payload ?? {}), + }); + }; + + mark("start", { + baseUrl: baseUrl(), + workspace: workspaceStore.activeWorkspaceRoot().trim() || null, + }); + // Abort any in-flight refresh operations to free up connection resources - console.log("[DEBUG] aborting in-flight refreshes"); abortRefreshes(); // Small delay to allow pending requests to settle await new Promise((resolve) => setTimeout(resolve, 50)); - console.log("[DEBUG] client found"); setBusy(true); - console.log("[DEBUG] busy set"); setBusyLabel("status.creating_task"); - console.log("[DEBUG] busy label set"); setBusyStartedAt(Date.now()); - console.log("[DEBUG] busy started at set"); setError(null); - console.log("[DEBUG] error set"); setCreatingSession(true); - console.log("[DEBUG] with timeout defined"); const withTimeout = async ( promise: Promise, ms: number, @@ -3605,55 +3734,39 @@ export default function App() { } }; - const runId = (() => { - const key = "__openwork_create_session_run__"; - const w = window as typeof window & { [key]?: number }; - w[key] = (w[key] ?? 0) + 1; - return w[key]; - })(); - - const mark = (() => { - const start = Date.now(); - return (label: string, payload?: unknown) => { - const elapsedMs = Date.now() - start; - if (payload === undefined) { - console.log(`[run ${runId}] ${label} (+${elapsedMs}ms)`); - } else { - console.log(`[run ${runId}] ${label} (+${elapsedMs}ms)`, payload); - } - }; - })(); - try { // Quick health check to detect stale connection - mark("checking health"); + mark("health:start"); try { - const healthResult = await withTimeout(c.global.health(), 3_000, "health"); - mark("health ok", healthResult); + await withTimeout(c.global.health(), 3_000, "health"); + mark("health:ok"); } catch (healthErr) { - mark("health FAILED", healthErr); + mark("health:error", { + error: healthErr instanceof Error ? healthErr.message : safeStringify(healthErr), + }); throw new Error(t("app.connection_lost", currentLocale())); } let rawResult: Awaited>; try { - mark("creating session"); + mark("session:create:start"); rawResult = await c.session.create({ directory: workspaceStore.activeWorkspaceRoot().trim(), }); - mark("session created"); + mark("session:create:ok"); } catch (createErr) { - mark("session create error", createErr); + mark("session:create:error", { + error: createErr instanceof Error ? createErr.message : safeStringify(createErr), + }); throw createErr; } - mark("raw result received"); + const session = unwrap(rawResult); - mark("session unwrapped"); // Immediately select and show the new session before background list refresh. setBusyLabel("status.loading_session"); + mark("session:select:start", { sessionID: session.id }); await selectSession(session.id); - mark("selectSession (immediate)"); - mark("session selected"); + mark("session:select:ok", { sessionID: session.id }); // Inject the new session into the reactive sessions() store so // the createEffect bridge (sessions → sidebar) will always include it, @@ -3662,7 +3775,6 @@ export default function App() { if (!currentStoreSessions.some((s) => s.id === session.id)) { setSessions([session, ...currentStoreSessions]); } - mark("session injected into store"); const newItem: SidebarSessionItem = { id: session.id, @@ -3683,9 +3795,7 @@ export default function App() { [wsId]: "ready", })); } - mark("sidebar injected"); - mark("view set to session"); // setSessionViewLockUntil(Date.now() + 1200); goToSession(session.id); @@ -3695,10 +3805,16 @@ export default function App() { // race with the store injection — the server may not have indexed the // session yet, so reconcile() would wipe it from the store, causing // the sidebar to flash and the route guard to bounce back. - mark("done (SSE will sync)"); + finishPerf(perfEnabled, "session.create", "done", startedAt, { + runId, + sessionID: session.id, + }); return session.id; } catch (e) { - mark("error caught", e); + finishPerf(perfEnabled, "session.create", "error", startedAt, { + runId, + error: e instanceof Error ? e.message : safeStringify(e), + }); const message = e instanceof Error ? e.message : t("app.unknown_error", currentLocale()); setError(addOpencodeCacheHint(message)); return undefined; @@ -4771,6 +4887,7 @@ export default function App() { sessionRevertMessageId: selectedSession()?.revert?.messageID ?? null, undoLastUserMessage: undoLastUserMessage, redoLastUserMessage: redoLastUserMessage, + compactSession: compactCurrentSession, lastPromptSent: lastPromptSent(), retryLastPrompt: retryLastPrompt, newTaskDisabled: newTaskDisabled(), diff --git a/packages/app/src/app/components/session/message-list.tsx b/packages/app/src/app/components/session/message-list.tsx index 2caa1af7b..53a22aeee 100644 --- a/packages/app/src/app/components/session/message-list.tsx +++ b/packages/app/src/app/components/session/message-list.tsx @@ -6,6 +6,7 @@ import { Check, ChevronDown, ChevronRight, Copy, Eye, File, FileEdit, FolderSear import type { MessageGroup, MessageWithParts } from "../../types"; import { groupMessageParts, summarizeStep } from "../../utils"; import PartView from "../part-view"; +import { perfNow, recordPerfLog } from "../../lib/perf-log"; export type MessageListProps = { messages: MessageWithParts[]; @@ -195,6 +196,7 @@ export default function MessageList(props: MessageListProps) { }); const messageBlocks = createMemo(() => { + const startedAt = perfNow(); const blocks: MessageBlockItem[] = []; for (const message of props.messages) { @@ -237,6 +239,15 @@ export default function MessageList(props: MessageListProps) { }); } + const elapsedMs = Math.round((perfNow() - startedAt) * 100) / 100; + if (props.developerMode && (elapsedMs >= 8 || props.messages.length >= 120)) { + recordPerfLog(true, "session.render", "message-blocks", { + messageCount: props.messages.length, + blockCount: blocks.length, + ms: elapsedMs, + }); + } + return blocks; }); diff --git a/packages/app/src/app/context/session.ts b/packages/app/src/app/context/session.ts index 2b06ebe6f..d7052bd89 100644 --- a/packages/app/src/app/context/session.ts +++ b/packages/app/src/app/context/session.ts @@ -25,6 +25,7 @@ import { safeStringify, } from "../utils"; import { unwrap } from "../lib/opencode"; +import { finishPerf, perfNow, recordPerfLog } from "../lib/perf-log"; export type SessionModelState = { overrides: Record; @@ -500,10 +501,17 @@ export function createSessionStore(options: { w[key] = (w[key] ?? 0) + 1; return w[key]; })(); - const mark = (() => { - const start = Date.now(); - return (label: string) => console.log(`[selectSession run ${runId}] ${label} (+${Date.now() - start}ms)`); - })(); + const perfEnabled = options.developerMode(); + const startedAt = perfNow(); + const mark = (event: string, payload?: Record) => { + const elapsedMs = Math.round((perfNow() - startedAt) * 100) / 100; + recordPerfLog(perfEnabled, "session.select", event, { + runId, + sessionID, + elapsedMs, + ...(payload ?? {}), + }); + }; mark("start"); options.setSelectedSessionId(sessionID); @@ -513,8 +521,10 @@ export function createSessionStore(options: { try { await withTimeout(c.global.health(), 3000, "health"); mark("health ok"); - } catch { - mark("health FAILED"); + } catch (error) { + mark("health FAILED", { + error: error instanceof Error ? error.message : safeStringify(error), + }); throw new Error("Server connection lost. Please reload."); } @@ -555,8 +565,10 @@ export function createSessionStore(options: { return; } setStore("todos", sessionID, list); - } catch { - mark("session.todo failed/timeout"); + } catch (error) { + mark("session.todo failed/timeout", { + error: error instanceof Error ? error.message : safeStringify(error), + }); setStore("todos", sessionID, []); } @@ -568,11 +580,18 @@ export function createSessionStore(options: { mark("aborting: selection changed before permissions applied"); return; } - } catch { - mark("permission.list failed/timeout"); + } catch (error) { + mark("permission.list failed/timeout", { + error: error instanceof Error ? error.message : safeStringify(error), + }); } - mark("selectSession complete"); + finishPerf(perfEnabled, "session.select", "complete", startedAt, { + runId, + sessionID, + messageCount: msgs.length, + todoCount: (store.todos[sessionID] ?? []).length, + }); } async function respondPermission(requestID: string, reply: "once" | "always" | "reject") { @@ -930,12 +949,26 @@ export function createSessionStore(options: { if (eventsToApply.length === 0) return; last = Date.now(); + const startedAt = perfNow(); + let applied = 0; batch(() => { for (const event of eventsToApply) { if (!event) continue; + applied += 1; void applyEvent(event); } }); + + const elapsedMs = Math.round((perfNow() - startedAt) * 100) / 100; + const dropped = eventsToApply.length - applied; + if (sessionDebugEnabled() && (elapsedMs >= 12 || applied >= 40 || dropped >= 20)) { + recordPerfLog(true, "session.sse", "flush", { + queued: eventsToApply.length, + applied, + dropped, + ms: elapsedMs, + }); + } }; const schedule = () => { @@ -951,6 +984,7 @@ export function createSessionStore(options: { // Reset reconnect counter on successful connection reconnectAttempt = 0; + recordPerfLog(sessionDebugEnabled(), "session.sse", "connected"); for await (const raw of sub.stream) { if (cancelled) break; @@ -978,6 +1012,7 @@ export function createSessionStore(options: { // Stream ended normally - attempt reconnect unless cancelled if (!cancelled) { options.setSseConnected(false); + recordPerfLog(sessionDebugEnabled(), "session.sse", "stream-ended"); scheduleReconnect(controller); } } catch (e) { @@ -988,6 +1023,9 @@ export function createSessionStore(options: { // Mark SSE as disconnected and schedule reconnect options.setSseConnected(false); + recordPerfLog(sessionDebugEnabled(), "session.sse", "stream-error", { + error: message, + }); scheduleReconnect(controller); } }; @@ -999,6 +1037,10 @@ export function createSessionStore(options: { // Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s reconnectAttempt++; const delay = Math.min(1000 * Math.pow(2, reconnectAttempt - 1), 30000); + recordPerfLog(sessionDebugEnabled(), "session.sse", "reconnect-scheduled", { + attempt: reconnectAttempt, + delayMs: delay, + }); reconnectTimer = setTimeout(() => { if (cancelled) return; diff --git a/packages/app/src/app/lib/opencode-session.ts b/packages/app/src/app/lib/opencode-session.ts index c03e29299..713638e8f 100644 --- a/packages/app/src/app/lib/opencode-session.ts +++ b/packages/app/src/app/lib/opencode-session.ts @@ -8,7 +8,7 @@ * (e.g. `shellAsync`) that may not be present in older SDK versions. */ import type { Session } from "@opencode-ai/sdk/v2/client"; -import type { Client } from "../types"; +import type { Client, ModelRef } from "../types"; import { unwrap } from "./opencode"; // --------------------------------------------------------------------------- @@ -54,6 +54,45 @@ export async function unrevertSession( return unwrap(await client.session.unrevert({ sessionID })) as Session; } +/** + * Compact/summarize a long session to reduce context size. + * Uses `session.summarize` when available and falls back to `/compact` command. + */ +export async function compactSession( + client: Client, + sessionID: string, + model: ModelRef, + options?: { directory?: string }, +): Promise { + const session = client.session as { summarize?: (input: { + sessionID: string; + directory?: string; + providerID: string; + modelID: string; + }) => Promise }; + + if (typeof session.summarize === "function") { + const result = await session.summarize({ + sessionID, + directory: options?.directory, + providerID: model.providerID, + modelID: model.modelID, + }); + assertNoClientError(result); + return; + } + + const modelString = `${model.providerID}/${model.modelID}`; + const result = await client.session.command({ + sessionID, + command: "compact", + arguments: "", + model: modelString, + directory: options?.directory, + }); + assertNoClientError(result); +} + // --------------------------------------------------------------------------- // Shell execution // --------------------------------------------------------------------------- diff --git a/packages/app/src/app/lib/perf-log.ts b/packages/app/src/app/lib/perf-log.ts new file mode 100644 index 000000000..8f6b97c1f --- /dev/null +++ b/packages/app/src/app/lib/perf-log.ts @@ -0,0 +1,91 @@ +export type PerfLogRecord = { + id: number; + at: string; + ts: number; + scope: string; + event: string; + payload?: Record; +}; + +type PerfRoot = typeof globalThis & { + __openworkPerfSeq?: number; + __openworkPerfLogs?: PerfLogRecord[]; +}; + +const PERF_LOG_LIMIT = 500; + +export const perfNow = () => { + if (typeof performance !== "undefined" && typeof performance.now === "function") { + return performance.now(); + } + return Date.now(); +}; + +const round = (value: number) => Math.round(value * 100) / 100; + +export const recordPerfLog = ( + enabled: boolean, + scope: string, + event: string, + payload?: Record, +) => { + if (!enabled) return; + + const root = globalThis as PerfRoot; + const id = (root.__openworkPerfSeq ?? 0) + 1; + root.__openworkPerfSeq = id; + + const entry: PerfLogRecord = { + id, + at: new Date().toISOString(), + ts: Date.now(), + scope, + event, + payload, + }; + + const logs = root.__openworkPerfLogs ?? []; + logs.push(entry); + if (logs.length > PERF_LOG_LIMIT) { + logs.splice(0, logs.length - PERF_LOG_LIMIT); + } + root.__openworkPerfLogs = logs; + + try { + if (payload === undefined) { + console.log(`[OWPERF] ${scope}:${event}`); + return; + } + console.log(`[OWPERF] ${scope}:${event}`, payload); + } catch { + // ignore + } +}; + +export const readPerfLogs = (limit = 120) => { + const root = globalThis as PerfRoot; + const logs = root.__openworkPerfLogs ?? []; + if (limit <= 0) return []; + if (logs.length <= limit) return logs.slice(); + return logs.slice(logs.length - limit); +}; + +export const clearPerfLogs = () => { + const root = globalThis as PerfRoot; + root.__openworkPerfLogs = []; + root.__openworkPerfSeq = 0; +}; + +export const finishPerf = ( + enabled: boolean, + scope: string, + event: string, + startedAt: number, + payload?: Record, +) => { + if (!enabled) return; + recordPerfLog(enabled, scope, event, { + ...(payload ?? {}), + ms: round(perfNow() - startedAt), + }); +}; diff --git a/packages/app/src/app/pages/config.tsx b/packages/app/src/app/pages/config.tsx index 8fc64d034..28b88b206 100644 --- a/packages/app/src/app/pages/config.tsx +++ b/packages/app/src/app/pages/config.tsx @@ -1,6 +1,7 @@ import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; import { isTauriRuntime } from "../utils"; +import { readPerfLogs } from "../lib/perf-log"; import Button from "../components/button"; import TextInput from "../components/text-input"; @@ -140,6 +141,7 @@ export default function ConfigView(props: ConfigViewProps) { const urlOverride = props.openworkServerSettings.urlOverride?.trim() ?? ""; const token = props.openworkServerSettings.token?.trim() ?? ""; const host = hostInfo(); + const perfLogs = props.developerMode ? readPerfLogs(80) : []; return { capturedAt: new Date().toISOString(), runtime: { @@ -178,6 +180,10 @@ export default function ConfigView(props: ConfigViewProps) { hostConnectUrl: hostConnectUrl() || null, hostConnectUrlUsesMdns: hostConnectUrlUsesMdns(), }, + performance: { + retainedEntries: perfLogs.length, + recent: perfLogs, + }, }; }); diff --git a/packages/app/src/app/pages/session.tsx b/packages/app/src/app/pages/session.tsx index c808efb45..75e494d61 100644 --- a/packages/app/src/app/pages/session.tsx +++ b/packages/app/src/app/pages/session.tsx @@ -69,6 +69,7 @@ import { normalizeDirectoryPath, parseTemplateFrontmatter, } from "../utils"; +import { finishPerf, perfNow, recordPerfLog } from "../lib/perf-log"; import browserSetupTemplate from "../data/commands/browser-setup.md?raw"; import soulSetupTemplate from "../data/commands/give-me-a-soul.md?raw"; @@ -134,6 +135,7 @@ export type SessionViewProps = { sessionRevertMessageId: string | null; undoLastUserMessage: () => Promise; redoLastUserMessage: () => Promise; + compactSession: () => Promise; lastPromptSent: string; retryLastPrompt: () => void; newTaskDisabled: boolean; @@ -223,7 +225,10 @@ const SOUL_SETUP_TEMPLATE = (() => { })(); const INITIAL_MESSAGE_WINDOW = 140; +const INITIAL_PART_WINDOW = 700; const MESSAGE_WINDOW_LOAD_CHUNK = 120; +const MAX_SEARCH_MESSAGE_CHARS = 4_000; +const MAX_SEARCH_HITS = 2_000; export default function SessionView(props: SessionViewProps) { let messagesEndEl: HTMLDivElement | undefined; @@ -250,8 +255,9 @@ export default function SessionView(props: SessionViewProps) { const [scrollOnNextUpdate, setScrollOnNextUpdate] = createSignal(false); const [searchOpen, setSearchOpen] = createSignal(false); const [searchQuery, setSearchQuery] = createSignal(""); + const [searchQueryDebounced, setSearchQueryDebounced] = createSignal(""); const [activeSearchHitIndex, setActiveSearchHitIndex] = createSignal(0); - const [historyActionBusy, setHistoryActionBusy] = createSignal<"undo" | "redo" | null>(null); + const [historyActionBusy, setHistoryActionBusy] = createSignal<"undo" | "redo" | "compact" | null>(null); const [messageWindowStart, setMessageWindowStart] = createSignal(0); const [messageWindowSessionId, setMessageWindowSessionId] = createSignal(null); const [messageWindowExpanded, setMessageWindowExpanded] = createSignal(false); @@ -297,39 +303,68 @@ export default function SessionView(props: SessionViewProps) { const messageTextForSearch = (message: MessageWithParts) => { const chunks: string[] = []; + let used = 0; + const push = (value: string) => { + const next = value.trim(); + if (!next) return; + if (used >= MAX_SEARCH_MESSAGE_CHARS) return; + const remaining = MAX_SEARCH_MESSAGE_CHARS - used; + if (next.length > remaining) { + chunks.push(next.slice(0, Math.max(0, remaining))); + used = MAX_SEARCH_MESSAGE_CHARS; + return; + } + chunks.push(next); + used += next.length; + }; + for (const part of message.parts) { if (part.type === "text") { const text = (part as { text?: string }).text ?? ""; - if (text) chunks.push(text); + push(text); continue; } if (part.type === "agent") { const name = (part as { name?: string }).name ?? ""; - if (name) chunks.push(`@${name}`); + push(name ? `@${name}` : ""); continue; } if (part.type === "file") { const file = part as { label?: string; path?: string; filename?: string }; const label = file.label ?? file.path ?? file.filename ?? ""; - if (label) chunks.push(label); + push(label); continue; } if (part.type === "tool") { const state = (part as { state?: { title?: string; output?: string; error?: string } }).state; - if (state?.title) chunks.push(state.title); - if (state?.output) chunks.push(state.output); - if (state?.error) chunks.push(state.error); + push(state?.title ?? ""); + push(state?.output ?? ""); + push(state?.error ?? ""); } } return chunks.join("\n"); }; + createEffect(() => { + const value = searchQuery(); + if (typeof window === "undefined") { + setSearchQueryDebounced(value); + return; + } + const id = window.setTimeout(() => setSearchQueryDebounced(value), 90); + onCleanup(() => window.clearTimeout(id)); + }); + const searchHits = createMemo(() => { - const query = searchQuery().trim().toLowerCase(); + if (!searchOpen()) return []; + const query = searchQueryDebounced().trim().toLowerCase(); if (!query) return []; + const startedAt = perfNow(); const hits: SearchHit[] = []; - for (const message of props.messages) { + let capped = false; + + outer: for (const message of props.messages) { const messageId = messageIdFromInfo(message); if (!messageId) continue; const haystack = messageTextForSearch(message).toLowerCase(); @@ -337,9 +372,25 @@ export default function SessionView(props: SessionViewProps) { let index = haystack.indexOf(query); while (index !== -1) { hits.push({ messageId }); + if (hits.length >= MAX_SEARCH_HITS) { + capped = true; + break outer; + } index = haystack.indexOf(query, index + Math.max(1, query.length)); } } + + const elapsedMs = Math.round((perfNow() - startedAt) * 100) / 100; + if (props.developerMode && (elapsedMs >= 8 || capped)) { + recordPerfLog(true, "session.search", "scan", { + queryLength: query.length, + messageCount: props.messages.length, + hitCount: hits.length, + capped, + ms: elapsedMs, + }); + } + return hits; }); @@ -368,6 +419,28 @@ export default function SessionView(props: SessionViewProps) { }); const searchActive = createMemo(() => searchOpen() && searchQuery().trim().length > 0); + const totalPartCount = createMemo(() => props.messages.reduce((total, message) => total + message.parts.length, 0)); + + const computeWindowStart = (messages: MessageWithParts[]) => { + const total = messages.length; + if (!total) return 0; + + let count = 0; + let parts = 0; + + for (let index = total - 1; index >= 0; index -= 1) { + const nextCount = count + 1; + const nextParts = parts + (messages[index]?.parts.length ?? 0); + if (nextCount > INITIAL_MESSAGE_WINDOW || nextParts > INITIAL_PART_WINDOW) { + return Math.min(total - 1, index + 1); + } + count = nextCount; + parts = nextParts; + } + + return 0; + }; + const renderedMessages = createMemo(() => { if (messageWindowExpanded() || searchActive()) return props.messages; @@ -393,12 +466,50 @@ export default function SessionView(props: SessionViewProps) { const hidden = hiddenMessageCount(); if (hidden <= 0) return; const nextStart = Math.max(0, messageWindowStart() - MESSAGE_WINDOW_LOAD_CHUNK); + if (props.developerMode) { + recordPerfLog(true, "session.window", "reveal", { + sessionID: props.selectedSessionId, + hiddenBefore: hidden, + nextStart, + }); + } setMessageWindowStart(nextStart); if (nextStart === 0) { setMessageWindowExpanded(true); } }; + let lastWindowPerfSignature = ""; + createEffect(() => { + if (!props.developerMode) { + lastWindowPerfSignature = ""; + return; + } + + const signature = [ + props.selectedSessionId ?? "", + props.messages.length, + totalPartCount(), + renderedMessages().length, + hiddenMessageCount(), + messageWindowExpanded() ? "1" : "0", + searchActive() ? "1" : "0", + ].join("|"); + + if (signature === lastWindowPerfSignature) return; + lastWindowPerfSignature = signature; + + recordPerfLog(true, "session.window", "state", { + sessionID: props.selectedSessionId, + messageCount: props.messages.length, + renderedMessageCount: renderedMessages().length, + hiddenMessageCount: hiddenMessageCount(), + partCount: totalPartCount(), + expanded: messageWindowExpanded(), + searchActive: searchActive(), + }); + }); + const canUndoLastMessage = createMemo(() => { if (!props.selectedSessionId) return false; const revert = props.sessionRevertMessageId; @@ -412,11 +523,17 @@ export default function SessionView(props: SessionViewProps) { return false; }); + const hasUserMessages = createMemo(() => + props.messages.some((message) => (message.info as { role?: string }).role === "user"), + ); + const canRedoLastMessage = createMemo(() => { if (!props.selectedSessionId) return false; return Boolean(props.sessionRevertMessageId); }); + const canCompactSession = createMemo(() => Boolean(props.selectedSessionId) && hasUserMessages()); + const touchedFiles = createMemo(() => { const out: string[] = []; const seen = new Set(); @@ -633,7 +750,7 @@ export default function SessionView(props: SessionViewProps) { createEffect( on( - () => [props.selectedSessionId, props.messages.length] as const, + () => [props.selectedSessionId, props.messages.length, totalPartCount()] as const, ([sessionId, count], previous) => { const previousSessionId = previous?.[0] ?? null; if (sessionId !== previousSessionId) { @@ -646,7 +763,7 @@ export default function SessionView(props: SessionViewProps) { if (messageWindowExpanded()) return; if (count === 0) return; - const targetStart = count > INITIAL_MESSAGE_WINDOW ? count - INITIAL_MESSAGE_WINDOW : 0; + const targetStart = computeWindowStart(props.messages); if (messageWindowSessionId() !== sessionId) { setMessageWindowStart(targetStart); setMessageWindowSessionId(sessionId); @@ -980,6 +1097,7 @@ export default function SessionView(props: SessionViewProps) { () => { setSearchOpen(false); setSearchQuery(""); + setSearchQueryDebounced(""); setActiveSearchHitIndex(0); }, ), @@ -1068,7 +1186,7 @@ export default function SessionView(props: SessionViewProps) { () => [ props.messages.length, props.todos.length, - props.messages.reduce((acc, m) => acc + m.parts.length, 0), + totalPartCount(), ], (current, previous) => { if (!previous) return; @@ -1182,6 +1300,7 @@ export default function SessionView(props: SessionViewProps) { const closeSearch = () => { setSearchOpen(false); + setSearchQueryDebounced(""); }; const moveSearchHit = (offset: number) => { @@ -1231,6 +1350,35 @@ export default function SessionView(props: SessionViewProps) { } }; + const compactSessionHistory = async () => { + if (historyActionBusy()) return; + if (!canCompactSession()) { + setToastMessage("Nothing to compact yet."); + return; + } + + const sessionID = props.selectedSessionId; + const startedAt = perfNow(); + setHistoryActionBusy("compact"); + setToastMessage("Compacting session context..."); + try { + await props.compactSession(); + setToastMessage("Session compacted."); + finishPerf(props.developerMode, "session.compact", "ui-done", startedAt, { + sessionID, + }); + } catch (error) { + const message = error instanceof Error ? error.message : props.safeStringify(error); + setToastMessage(message || "Failed to compact session"); + finishPerf(props.developerMode, "session.compact", "ui-error", startedAt, { + sessionID, + error: message, + }); + } finally { + setHistoryActionBusy(null); + } + }; + const triggerFlyout = ( sourceEl: Element | null, @@ -2318,6 +2466,18 @@ export default function SessionView(props: SessionViewProps) { +
(sessionMenuRef = el)} class="relative">