From 433e3698abca850cfe1e2936dcc0febf7c27c38b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 6 Jul 2026 17:24:54 -0500 Subject: [PATCH 1/7] fix(desktop): guard terminal ResizeObserver against feedback loop The ResizeObserver callback unconditionally called fit(), refresh(), and the terminalResize IPC on every fire. Since fit() mutates the terminal's dimensions, it could re-fire the observer, sustaining a frame-rate loop that pins the renderer main thread at 100% CPU and freezes the window. Track the last propagated cols/rows and early-return when the grid size is unchanged, so the loop can no longer sustain itself. --- .../src/lib/components/terminal/Terminal.svelte | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/desktop/src/renderer/src/lib/components/terminal/Terminal.svelte b/desktop/src/renderer/src/lib/components/terminal/Terminal.svelte index d43950756..a34c61f8c 100644 --- a/desktop/src/renderer/src/lib/components/terminal/Terminal.svelte +++ b/desktop/src/renderer/src/lib/components/terminal/Terminal.svelte @@ -32,6 +32,8 @@ let containerEl: HTMLDivElement | undefined = $state() let term: Terminal | undefined let fitAddon: FitAddon | undefined let resizeObserver: ResizeObserver | undefined +let lastCols = -1 +let lastRows = -1 const darkTheme = { background: "#1e1e2e", @@ -187,11 +189,13 @@ onMount(async () => { } resizeObserver = new ResizeObserver(() => { - if (fitAddon && term) { - fitAddon.fit() - term.refresh(0, term.rows - 1) - terminalResize(sessionId, term.cols, term.rows) - } + if (!fitAddon || !term) return + fitAddon.fit() + if (term.cols === lastCols && term.rows === lastRows) return + lastCols = term.cols + lastRows = term.rows + term.refresh(0, term.rows - 1) + terminalResize(sessionId, term.cols, term.rows) }) resizeObserver.observe(containerEl) }) From 4daf17d28d2a29cab01701d470d84f05a77cf128 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 6 Jul 2026 17:30:30 -0500 Subject: [PATCH 2/7] fix(desktop): coalesce streamed log lines and memoize parser The workspace creation wizard (launch tab) and workspace detail page froze the renderer at 100% CPU while streaming container logs, even though the underlying CLI completed successfully. Two compounding causes on the log-streaming path: - Each CommandProgress event triggered a full state flush (outputLines = [...outputLines, msg]) plus a scroll rAF, so a fast stream produced one reactive flush per line. - LogTable re-derived parsed = lines.map(parseLogLine) over the entire buffer on every append, making parse work quadratic in line count. Coalesce incoming lines into a single per-frame flush via requestAnimationFrame, and memoize parseLogLine so each unique line is parsed once. Pending state and the frame handle are cleared on reset, new operation start, and component destroy. --- .../workspace/WorkspaceWizard.svelte | 36 ++++++++++++++++--- .../src/renderer/src/lib/utils/log-parser.ts | 17 +++++++++ .../src/pages/WorkspaceDetailPage.svelte | 29 +++++++++++++-- 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte index bd23fbf72..e30c52c14 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte @@ -208,6 +208,8 @@ let launchedWorkspaceId = $state(null) let confirmCancelOpen = $state(false) let unlisten: UnlistenFn | null = null let watchdog: ReturnType | null = null +let pendingLines: string[] = [] +let flushHandle: number | null = null // Image platform compatibility (image source only) let hostPlatform = $state("") @@ -331,6 +333,11 @@ function reset() { ideSearch = "" commandId = null outputLines = [] + pendingLines = [] + if (flushHandle !== null) { + cancelAnimationFrame(flushHandle) + flushHandle = null + } launchRunning = false launchError = "" launchSuccess = false @@ -400,16 +407,32 @@ onMount(() => { onDestroy(() => { unlisten?.() clearWatchdog() + if (flushHandle !== null) { + cancelAnimationFrame(flushHandle) + flushHandle = null + } }) +function flushLines() { + flushHandle = null + if (pendingLines.length === 0) return + outputLines = [...outputLines, ...pendingLines] + pendingLines = [] + outputEl?.scrollIntoView?.({ block: "end", behavior: "smooth" }) +} + function handleProgress(progress: CommandProgress, wsId: string | undefined) { if (progress.message) { - outputLines = [...outputLines, progress.message] - requestAnimationFrame(() => { - outputEl?.scrollIntoView?.({ block: "end", behavior: "smooth" }) - }) + pendingLines.push(progress.message) + if (flushHandle === null) { + flushHandle = requestAnimationFrame(flushLines) + } } if (progress.done) { + if (flushHandle !== null) { + cancelAnimationFrame(flushHandle) + } + flushLines() launchRunning = false clearWatchdog() if (isCommandSuccess(progress.message)) { @@ -430,6 +453,11 @@ async function handleLaunch() { launchError = "" launchSuccess = false outputLines = [] + pendingLines = [] + if (flushHandle !== null) { + cancelAnimationFrame(flushHandle) + flushHandle = null + } commandId = null launchedWorkspaceId = null clearWatchdog() diff --git a/desktop/src/renderer/src/lib/utils/log-parser.ts b/desktop/src/renderer/src/lib/utils/log-parser.ts index 6c9eda1d9..9349389c5 100644 --- a/desktop/src/renderer/src/lib/utils/log-parser.ts +++ b/desktop/src/renderer/src/lib/utils/log-parser.ts @@ -86,7 +86,24 @@ function tryParseInnerLog( * * ANSI codes are stripped before parsing. */ +const parseCache = new Map() +const PARSE_CACHE_MAX = 10_000 + export function parseLogLine(raw: string): ParsedLogLine { + const cached = parseCache.get(raw) + if (cached) return cached + + const result = parseLogLineUncached(raw) + + if (parseCache.size >= PARSE_CACHE_MAX) { + const oldest = parseCache.keys().next().value + if (oldest !== undefined) parseCache.delete(oldest) + } + parseCache.set(raw, result) + return result +} + +function parseLogLineUncached(raw: string): ParsedLogLine { const clean = stripAnsi(raw) // Zap console format (tab-separated): ISO8601\tLEVEL\tmessage\tsource.go:NNN diff --git a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte index ef7c6bdbf..93416baf6 100644 --- a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte +++ b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte @@ -112,6 +112,8 @@ let operationLabel = $state("") let operationRunning = $state(false) let unlisten: UnlistenFn | null = null let tableEndEl = $state(null) +let pendingLines: string[] = [] +let flushHandle: number | null = null let logEntries = $state([]) let selectedLog = $state(null) @@ -155,6 +157,14 @@ function scrollToBottom() { } } +function flushLines() { + flushHandle = null + if (pendingLines.length === 0) return + outputLines = [...outputLines, ...pendingLines] + pendingLines = [] + scrollToBottom() +} + async function copyToClipboard(text: string) { try { await navigator.clipboard.writeText(text) @@ -169,10 +179,16 @@ onMount(async () => { unlisten = await onCommandProgress((progress) => { if (commandId && progress.commandId === commandId) { if (progress.message) { - outputLines = [...outputLines, progress.message] - requestAnimationFrame(scrollToBottom) + pendingLines.push(progress.message) + if (flushHandle === null) { + flushHandle = requestAnimationFrame(flushLines) + } } if (progress.done) { + if (flushHandle !== null) { + cancelAnimationFrame(flushHandle) + } + flushLines() operationRunning = false const success = isCommandSuccess(progress.message) if (success) { @@ -213,6 +229,10 @@ onMount(async () => { onDestroy(() => { unlisten?.() + if (flushHandle !== null) { + cancelAnimationFrame(flushHandle) + flushHandle = null + } // Clean up SSH session if navigating away if (sshSessionId) { if (!sshExited) { @@ -313,6 +333,11 @@ function startStreamingOp(label: string) { operationLabel = label operationRunning = true outputLines = [] + pendingLines = [] + if (flushHandle !== null) { + cancelAnimationFrame(flushHandle) + flushHandle = null + } activeTab = "logs" } From 32618d43618e523fb805ef37db339328dd6e2c8a Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 6 Jul 2026 17:32:56 -0500 Subject: [PATCH 3/7] fix(desktop): cap LogTable rendered rows to bound DOM size Opening a large persisted log (observed: 3.4 MB / 12,456 lines for a single workspace launch) rendered every line as a table row at once, producing tens of thousands of DOM nodes in one synchronous pass and freezing the renderer regardless of parse cost. Cap LogTable at the most recent maxLines rows (default 5000) and show a notice for the hidden count. Covers both the live-stream and the persisted-log-file render sites. The Copy actions still operate on the full buffer, so no data is lost. --- .../src/lib/components/log/LogTable.svelte | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/desktop/src/renderer/src/lib/components/log/LogTable.svelte b/desktop/src/renderer/src/lib/components/log/LogTable.svelte index 1c11c8074..9c3a913e6 100644 --- a/desktop/src/renderer/src/lib/components/log/LogTable.svelte +++ b/desktop/src/renderer/src/lib/components/log/LogTable.svelte @@ -3,10 +3,16 @@ import { badgeVariants } from "$lib/components/ui/badge/index.js" import * as Table from "$lib/components/ui/table/index.js" import { parseLogLine } from "$lib/utils/log-parser.js" -let { lines, class: className = "" }: { lines: string[]; class?: string } = - $props() +let { + lines, + class: className = "", + maxLines = 5000, +}: { lines: string[]; class?: string; maxLines?: number } = $props() -let parsed = $derived(lines.map(parseLogLine)) +let truncated = $derived(lines.length > maxLines) +let hiddenCount = $derived(truncated ? lines.length - maxLines : 0) +let visibleLines = $derived(truncated ? lines.slice(lines.length - maxLines) : lines) +let parsed = $derived(visibleLines.map(parseLogLine)) @@ -18,6 +24,13 @@ let parsed = $derived(lines.map(parseLogLine)) + {#if truncated} + + + {hiddenCount.toLocaleString()} earlier lines hidden — showing the most recent {maxLines.toLocaleString()} + + + {/if} {#each parsed as line, i (i)} Date: Mon, 6 Jul 2026 18:00:11 -0500 Subject: [PATCH 4/7] perf(desktop): virtualize LogTable for smooth large-log rendering Replace the fixed 5000-line render cap with a windowed virtual list: LogTable now renders only the rows visible in its own scroll viewport (plus overscan), so a 12k-line log mounts a handful of DOM nodes instead of thousands. Rows are fixed-height and absolutely positioned within a spacer sized to the full line count, giving a native scrollbar over the entire log with no pagination controls and no truncation. The component owns its viewport and an optional follow mode that pins to the tail as new lines stream in, but only while the user is already at the bottom. Consumers drop their outer scroll wrappers and manual scrollIntoView hooks (outputEl / tableEndEl / scrollToBottom). --- .../src/lib/components/log/LogTable.svelte | 132 +++++++++++++----- .../workspace/WorkspaceWizard.svelte | 7 +- .../src/pages/WorkspaceDetailPage.svelte | 41 ++---- 3 files changed, 109 insertions(+), 71 deletions(-) diff --git a/desktop/src/renderer/src/lib/components/log/LogTable.svelte b/desktop/src/renderer/src/lib/components/log/LogTable.svelte index 9c3a913e6..50f957764 100644 --- a/desktop/src/renderer/src/lib/components/log/LogTable.svelte +++ b/desktop/src/renderer/src/lib/components/log/LogTable.svelte @@ -1,62 +1,118 @@ - - - - Time - Level - Message - - - - {#if truncated} - - - {hiddenCount.toLocaleString()} earlier lines hidden — showing the most recent {maxLines.toLocaleString()} - - - {/if} - {#each parsed as line, i (i)} - +
+
Time
+
Level
+
Message
+
+ +
+ {#each visible as row (row.index)} +
- {line.time} - - {#if line.level} +
{row.line.time}
+
+ {#if row.line.level} - {line.level} + {row.line.level} {/if} - - {line.message} - +
+
{row.line.message}
+
{/each} - - +
+ diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte index e30c52c14..77c5f0ea5 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte @@ -200,7 +200,6 @@ let ideSearch = $state("") // Launch state let commandId = $state(null) let outputLines = $state([]) -let outputEl = $state(null) let launchRunning = $state(false) let launchError = $state("") let launchSuccess = $state(false) @@ -418,7 +417,6 @@ function flushLines() { if (pendingLines.length === 0) return outputLines = [...outputLines, ...pendingLines] pendingLines = [] - outputEl?.scrollIntoView?.({ block: "end", behavior: "smooth" }) } function handleProgress(progress: CommandProgress, wsId: string | undefined) { @@ -1149,10 +1147,7 @@ function selectTemplate(t: { name: string; source: string }) { Copy -
- -
-
+ {:else if launchRunning}
diff --git a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte index 93416baf6..9b0841b9a 100644 --- a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte +++ b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte @@ -111,7 +111,6 @@ let commandId = $state(null) let operationLabel = $state("") let operationRunning = $state(false) let unlisten: UnlistenFn | null = null -let tableEndEl = $state(null) let pendingLines: string[] = [] let flushHandle: number | null = null @@ -151,18 +150,11 @@ let filteredIdes = $derived( ), ) -function scrollToBottom() { - if (tableEndEl) { - tableEndEl.scrollIntoView({ block: "end", behavior: "smooth" }) - } -} - function flushLines() { flushHandle = null if (pendingLines.length === 0) return outputLines = [...outputLines, ...pendingLines] pendingLines = [] - scrollToBottom() } async function copyToClipboard(text: string) { @@ -708,18 +700,15 @@ async function handleRenameConfirmed() {
{/if} -
- {#if outputLines.length === 0} -
-

- {operationRunning ? "Waiting for output..." : "No output yet. Run an operation to see live output."} -

-
- {:else} - -
- {/if} -
+ {#if outputLines.length === 0} +
+

+ {operationRunning ? "Waiting for output..." : "No output yet. Run an operation to see live output."} +

+
+ {:else} + + {/if} @@ -786,13 +775,11 @@ async function handleRenameConfirmed() { -
- {#if selectedLog === entry.filename} - - {:else} -

Loading...

- {/if} -
+ {#if selectedLog === entry.filename} + + {:else} +

Loading...

+ {/if}
{/each} From ad8bddff546864ef997e62d019782b0bb5beb8b8 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 6 Jul 2026 18:05:11 -0500 Subject: [PATCH 5/7] test(desktop): polyfill ResizeObserver in jsdom setup The virtualized LogTable uses a ResizeObserver, which jsdom does not implement. Rendering LogTable consumers in tests threw an uncaught ReferenceError, failing the suite even though all assertions passed. Add a no-op ResizeObserver polyfill alongside the existing matchMedia one. --- desktop/src/renderer/src/lib/__mocks__/setup.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/desktop/src/renderer/src/lib/__mocks__/setup.ts b/desktop/src/renderer/src/lib/__mocks__/setup.ts index ef543cbb6..ccda9d0ce 100644 --- a/desktop/src/renderer/src/lib/__mocks__/setup.ts +++ b/desktop/src/renderer/src/lib/__mocks__/setup.ts @@ -33,6 +33,15 @@ if ( } } +// jsdom doesn't implement ResizeObserver +if (typeof globalThis.ResizeObserver === "undefined") { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } +} + // jsdom doesn't implement matchMedia if (typeof window !== "undefined" && typeof window.matchMedia !== "function") { Object.defineProperty(window, "matchMedia", { From b81bae1d3faca52da2d4f97928ebc4363b38cd24 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 7 Jul 2026 01:58:39 -0500 Subject: [PATCH 6/7] refactor(desktop): address LogTable review feedback - ProviderWizard: drop the outer scroll wrappers around LogTable so the table's own viewport is the only scroll container (was a double scrollbar with wrong height after LogTable started owning its viewport); pass maxHeightClass instead. - WorkspaceWizard / WorkspaceDetailPage: push into the outputLines $state array in place instead of reallocating the full buffer each flush; length reset stays reactive for LogTable via lines.length. - LogTable: restore table semantics for assistive tech with role=table/row/rowgroup/columnheader/cell and aria-rowcount/rowindex, which the virtualized div layout had dropped. --- .../src/lib/components/log/LogTable.svelte | 19 ++++++++++++------- .../components/provider/ProviderWizard.svelte | 8 ++------ .../workspace/WorkspaceWizard.svelte | 4 ++-- .../src/pages/WorkspaceDetailPage.svelte | 4 ++-- 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/desktop/src/renderer/src/lib/components/log/LogTable.svelte b/desktop/src/renderer/src/lib/components/log/LogTable.svelte index 50f957764..9aead14a0 100644 --- a/desktop/src/renderer/src/lib/components/log/LogTable.svelte +++ b/desktop/src/renderer/src/lib/components/log/LogTable.svelte @@ -73,20 +73,25 @@ $effect(() => {
-
Time
-
Level
-
Message
+
Time
+
Level
+
Message
-
+
{#each visible as row (row.index)}
{ ].filter(Boolean).join(" ")} style="top: {row.index * rowHeight}px; height: {rowHeight}px" > -
{row.line.time}
-
+
{row.line.time}
+
{#if row.line.level} { {/if}
-
{row.line.message}
+
{row.line.message}
{/each}
diff --git a/desktop/src/renderer/src/lib/components/provider/ProviderWizard.svelte b/desktop/src/renderer/src/lib/components/provider/ProviderWizard.svelte index d810c1a0e..8f0a54e7d 100644 --- a/desktop/src/renderer/src/lib/components/provider/ProviderWizard.svelte +++ b/desktop/src/renderer/src/lib/components/provider/ProviderWizard.svelte @@ -529,14 +529,10 @@ function handleDone() { > Show logs -
- -
+ {:else} -
- -
+ {/if} {:else if initRunning}
diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte index 77c5f0ea5..4a1fdd3f6 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte @@ -415,8 +415,8 @@ onDestroy(() => { function flushLines() { flushHandle = null if (pendingLines.length === 0) return - outputLines = [...outputLines, ...pendingLines] - pendingLines = [] + outputLines.push(...pendingLines) + pendingLines.length = 0 } function handleProgress(progress: CommandProgress, wsId: string | undefined) { diff --git a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte index 9b0841b9a..5f31f9a5d 100644 --- a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte +++ b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte @@ -153,8 +153,8 @@ let filteredIdes = $derived( function flushLines() { flushHandle = null if (pendingLines.length === 0) return - outputLines = [...outputLines, ...pendingLines] - pendingLines = [] + outputLines.push(...pendingLines) + pendingLines.length = 0 } async function copyToClipboard(text: string) { From 77be37eb571bc7137683d96c43ba95b3a55f0a57 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 7 Jul 2026 06:22:45 -0500 Subject: [PATCH 7/7] fix(desktop): merge LogTable classes with twMerge so overrides win MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LogTable built its root class by string interpolation, so a consumer's className was appended after the base rounded-md/max-h-96 rather than overriding them — Tailwind resolves same-property conflicts by generated stylesheet order, letting the base classes win. Route the root class through cn() (clsx + tailwind-merge) so passed classes like rounded-none and max-h-48 deterministically override the defaults. --- desktop/src/renderer/src/lib/components/log/LogTable.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/desktop/src/renderer/src/lib/components/log/LogTable.svelte b/desktop/src/renderer/src/lib/components/log/LogTable.svelte index 9aead14a0..2166fe3b8 100644 --- a/desktop/src/renderer/src/lib/components/log/LogTable.svelte +++ b/desktop/src/renderer/src/lib/components/log/LogTable.svelte @@ -1,6 +1,7 @@