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 000000000..b62458e83 Binary files /dev/null and b/packages/app/pr/screenshots/session-perf-compaction-smoke.png differ 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">