diff --git a/CHANGELOG.md b/CHANGELOG.md index c2a7caad43..6191097eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,18 @@ file is the human-readable summary of what changed and why. new name with the next update. On macOS an existing install keeps its `CoDev Code.app` bundle name on disk (updates replace the app in place); fresh installs get `CoDev.app`. +- **A project's live updates no longer die with the first view that opened + it** — the event subscription for a directory was owned by whichever tab or + view touched that directory first, so closing that one could silently stop + live updates (new messages, session changes) for every other view of the + same directory until they were all closed and reopened. The subscription now + lives as long as anything is using it. +- **Debug logs now capture what the app actually reported** — renderer log + lines with structured data reached the dev terminal and the exported debug + bundle as `[object Object]`; they now carry the data. A connection blip + while a server restarts (a sandbox reset, the app relaunching its server) no + longer logs an error — only a connection that keeps failing does — and two + harmless browser notices no longer show up as errors in the dev terminal. - **New sessions show up in Recents right away** — starting a session left the sidebar's Recents list unchanged; the session only appeared once something made that list reload, which is why switching between Code and Work and back diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 121ff9c782..29e04177ee 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -188,6 +188,11 @@ from the squash message first, or use a plain merge. | Browser-opened docs-site links: `packages/tui/src/app.tsx`, `packages/app/src/desktop-menu.ts`, `packages/app/src/components/{settings-general,settings-v2/general,dialog-custom-provider}.tsx`, `packages/app/src/pages/{home,layout,error}.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/opencode/src/cli/cmd/{providers,github.handler}.ts` | Links the app opens in the user's browser repointed from `opencode.ai` to the fork's hosted docs. Each one derives its base from `DOCS_URL` (`packages/codev-brand/src/index.ts`, base64-encoded) rather than hardcoding the host: the site is locale-routed and the Code docs live under `/code`, and no URL takes a trailing slash (the site 308-redirects `…/themes/` to `…/themes`). Sites: TUI "Open docs" command and app Documentation menu item → docs root; settings theme "Learn more" (v1 + v2) → `…/code/themes`; custom-provider dialog link → `…/code/providers#custom-provider`; CLI Cloudflare-gateway hint → `…/code/providers#cloudflare-ai-gateway`; GitHub-agent hint → `…/code/github`; home/layout sidebar Help buttons `opencode.ai/desktop-feedback` → docs root. The error page's report button `opencode.ai/desktop-feedback` → `github.com/quickbeard/codev-code/issues` instead (it's a report action, not a docs lookup), icon `discord` → `github`, and the en/es `error.page.report.*` strings → "…CoDev team" / "on GitHub". Help submenu (rendered by the shipped web UI's Windows app menu too): "Support Forum" (upstream Discord invite) deleted — the fork has no forum; "Share Feedback"/"Report a Bug" → `github.com/quickbeard/codev-code/issues/new?template=feature-request.yml`/`bug-report.yml` (the fork's template names are hyphenated, unlike upstream's underscored ones). | Fork users should land on the fork's docs and report issues to the fork, not upstream's site/Discord. NOT repointed: Zen/Go product URLs (`opencode.ai/zen`, `/go`, `/auth`), `$schema`/theme-schema ids, API/share/changelog endpoints, MCP OAuth `client_uri`, prompt-file mentions. Also NOT on this base: the ripgrep mirror (`/codev/docs/code/downloads/…`, see its own row) — it is a `public/` static asset, not locale-routed content, and 404s under `/en/docs/code`. | Keep our URLs, icon, and strings; take upstream's surrounding changes. Repoint any new browser-opened `opencode.ai` docs/help link to the fork docs base; leave the NOT-repointed list. | | `packages/desktop/src/main/index.ts`, `packages/desktop/src/main/windows.ts` | APP_NAMES + window title → "CoDev" (Dev/Beta variants too) | Desktop app display name ("Code" dropped once the app carried a Work section beside Code). | Keep our names. | | `packages/desktop/electron-builder.config.ts` | `productName` ×3 → "CoDev…"; artifactName → `codev-code-desktop-…` (deliberately kept when the product name dropped "Code" — the update feed's `latest*.yml` and the hand-upload flow reference these artifact names, and nothing user-visible shows them); protocol display name. Auto-update `publish` repointed off GitHub entirely: upstream's beta `anomalyco/opencode-beta` and prod `anomalyco/opencode` collapse to one shared `generic` config (`const publish`) pointing at the fork's MinIO bucket — `url` from `process.env.CODEV_UPDATE_URL` falling back to a base64 `UPDATE_URL` const (same `atob` convention as `packages/codev-brand/src/index.ts`), plus two fields that are load-bearing rather than cosmetic: `channel: "latest"` and `useMultipleRangeRequest: false`. Also a second `extraResources` entry shipping `../opencode/dist/node` → `server-bundle` (filter `node.js` + `*.wasm`, dropping the sourcemap, which outweighs the bundle) so the `@codev/sandbox` providers have a real on-disk server directory to mount into guests; `src/main/sandbox/bundle.ts` (fork-only) resolves it under `process.resourcesPath` by that name. Also the sandbox vz helper: `mac.extraResources` ships `packages/codev-sandbox/native/vzhelper/dist/codev-vmm` (built universal by `scripts/prebuild.ts`) as `Resources/codev-vmm`, and an `afterSign` hook (`signVmmHelper`) re-signs it with `vzhelper.entitlements` (`com.apple.security.virtualization` — an entitlement the Electron app itself must not carry) under the same identity that signed the app, hardened runtime on, timestamp only for non-ad-hoc, before electron-builder's built-in notarization runs. | Installer/product branding, and a packaged build must not offer upstream OpenCode releases as CoDev updates. GitHub could never have worked here: `quickbeard/codev-code` is private and `GitHubProvider.getLatestTagName` requests `/releases/latest` unauthenticated, and the repo has never carried a desktop asset. A bucket also splits nothing — one channel, one prefix. **`channel: "latest"` is required, not a default restated**: `detectUpdateChannel` is on by default, so with it absent `getResolvedPublishConfig` takes the channel from `AppInfo.channel`, which is the version's first semver prerelease component — `8` for `1.18.4-8` — and `getUpdateInfoFileName` then writes `8.yml`/`8-mac.yml`/`8-linux.yml` while the app requests `latest.yml` (`autoUpdater.channel` in `src/main/updater.ts`), i.e. a permanent `ERR_UPDATER_CHANNEL_FILE_NOT_FOUND`. **`useMultipleRangeRequest: false` is required for MinIO**, which answers a multipart range request with the whole object; electron-updater hardcodes the same `false` for its own `s3` provider citing minio/minio#5285 but defaults it to `true` for any `generic` URL that is not `s3.amazonaws.com`. Beta keeps a `publish` block despite the fork shipping no beta installers because `UPDATER_ENABLED` covers that channel and a build with no publish config gets no `app-update.yml` at all, so every check would fail with ENOENT. `${channel}` is deliberately NOT used as a URL macro — `macroExpander` expands it to that same `AppInfo.channel` (`8`), not `CODEV_CHANNEL`. appId is `ai.codev.desktop{,.beta,.dev}` and `rpm.packageName` is `codev`/`codev-beta`/`codev-dev` — see the desktop identity row for the full rename and what it costs. | Keep our names and the whole `publish` const; take upstream's other config. Never drop `channel` or `useMultipleRangeRequest` to "match upstream" — both are covered by `electron-builder.config.test.ts`. If upstream reworks its publish targets, re-point ours rather than merging theirs. Note `generic` has no publisher (`createPublisher` returns null), so installers reach the bucket by hand; build with `--publish never`. Keep the `server-bundle` extraResource and the `codev-vmm` extraResource + `afterSign` hook (all covered by `electron-builder.config.test.ts`) — renaming either strands packaged sandbox providers, and dropping `afterSign` ships a helper without the virtualization entitlement, which fails at VM creation (or fails notarization if left ad-hoc signed). | +| `packages/app/src/utils/refcount.ts` (+ `.test.ts`) | `createRefCountMap` creates each item in its own root owned by the scope that created the map (disposed when the last pinning caller releases it), and callers without a reactive owner borrow the item without pinning it. | Upstream ran `create` in the *first caller's* scope, so a shared item's internal cleanups (e.g. the dir SDK context's event unsubscribe in `server-sdk.tsx`) fired when that first view closed while other holders still used the item — live updates for the directory silently died. Ownerless callers (event handlers, async continuations: `notification.tsx` session lookup, `layout.tsx` openSession) also registered cleanups Solid warns can never run and pinned items forever. | Keep ours; take upstream's surrounding changes. The two added tests ("ownerless caller borrows without pinning", "item's internal cleanups survive its first caller") pin the semantics. | +| `packages/app/src/context/server-sdk.tsx` | Event-stream failures log only after 3 consecutive failed attempts (`logStreamIssue`), with the error normalized to `name: message`; any received event resets the count. | The first failed attempt usually races a server being stopped or restarted (sandbox reset, sidecar relaunch): the stream dies before the renderer learns the server is going away and the context is disposed before a real retry, so logging attempt one produced a spurious `[global-sdk] event stream error` for normal lifecycle. A genuinely unreachable server still logs within about a second (250 ms reconnect delay). | Keep the threshold and shared `logStreamIssue`; take upstream's surrounding stream changes. | +| `packages/app/src/context/command.tsx` | The DEV duplicate-command-id warning fires only for duplicates still present 1 s later (`warnIfPersistentDuplicate`); runtime keep-first behavior unchanged. | Router transitions keep the outgoing page mounted while the incoming one registers, so ids both pages provide (`command.palette`, `input.focus`) are briefly duplicated on every draft→session navigation; only a duplicate that survives the transition is a registration bug. | Keep ours; take upstream's surrounding command changes. | +| `packages/desktop/src/main/logging.ts` | Added `initChromiumNoiseFilter`: an electron-log hook dropping two benign Chromium intervention messages (ResizeObserver loop, autofocus blocked) from the console transport for renderer-spied messages; file logs keep them. | The `spyRendererConsole` pipe surfaces Chromium interventions the app can neither cause nor fix as red terminal lines during `bun dev:desktop`. | Keep the hook; take upstream's surrounding logging changes. | +| `packages/desktop/src/renderer/index.tsx` | Calls `installConsoleFormatting()` (fork-only `./console-format`) before anything else logs. | `spyRendererConsole` receives Chromium's flattened console-message string, so any renderer `console.*` with an object argument reached the terminal and the exported debug logs as `[object Object]`; the wrapper serializes structured arguments (Error-aware, circular-safe, truncated) at the source. | Keep the import and call; take upstream's surrounding entry changes. | | `packages/desktop/src/main/updater.ts` | `autoUpdater.allowDowngrade` `true` → `false`. | Upstream's `true` is safe against immutable GitHub release tags. The fork's feed is a mutable bucket path, where re-uploading or failing to replace a stale `latest.yml` would actively downgrade every client on the next 10-minute poll. | Keep `false`; take upstream's other updater settings. `channel = "latest"` and `allowPrerelease = false` stay as upstream has them — the first must match `publish.channel` in `electron-builder.config.ts`, the second is inert for the `generic` provider (it only steers GitHub tag resolution). | | Dual-path WSL install: `packages/desktop/src/main/wsl/{runtime,servers}.ts` | `runtime.ts`: three internals gain `export` (`runPowerShell`, `runInteractiveCommand`, `resolveSystem32Command`) — no behavior change. `servers.ts`: the `install-wsl` job calls `installWslRuntimeDualPath` and the `install-distro` job calls `installWslDistroDualPath` (both from the fork-only `install-bundle.ts`, which holds all new logic) instead of `installWslRuntimeElevated` / `installWslDistro` directly; both return the same `WslCommandResult` shape, so the surrounding job/error handling is untouched. | The customer fleet sits behind whitelist-only proxies that cannot reach Microsoft's servers. The dual path downloads the pinned bundle (`wsl.zip`: runtime MSI + Ubuntu-24.04 image) from the codev storage host, enables VirtualMachinePlatform via offline dism + installs the MSI behind one UAC prompt, and falls back to upstream's `wsl --install` / `--web-download` paths only when the bundle cannot be *obtained* — a completed-but-failed elevated install is returned as-is, never retried behind a second UAC prompt. | Keep the two call swaps and the three `export` keywords; take upstream's surrounding changes. If upstream reworks `installWslRuntimeElevated` / `installWslDistro`, keep them exported from `runtime.ts` — `install-bundle.ts` calls them as the fallback path. | | Desktop sandbox wiring: `packages/desktop/src/main/{index,store-keys}.ts`, `packages/desktop/package.json` | Hooks the fork-only `src/main/sandbox/{bundle,controller,ipc}.ts` modules into startup: `index.ts` constructs the sandbox controller beside the WSL servers controller, adds `sandboxes.stopAll()` to `stopSidecars`, registers the `sandbox-*` IPC channels next to the WSL ones, and calls `sandboxes.initialize()` (all platforms) once the local server is ready; `store-keys.ts` gains `SANDBOXES_KEY = "sandboxes"`; `package.json` gains the `@codev/sandbox` and `@codev/gateway` workspace devDependencies (bundled into the main process by electron-vite, so not runtime dependencies; gateway supplies the sandbox egress broker's credentials and config fragment). | The desktop app is where sandboxes are created and run; `createSandboxController` generalizes the proven WSL-servers controller pattern to the per-platform `@codev/sandbox` providers (macOS vz / Windows wsl / Linux bwrap). | Keep the wiring lines and take upstream's surrounding changes; they are additive and order-insensitive apart from `initialize()` needing the local server ready first. Do not rename the `sandbox-*` channels or the `sandboxes` store key — preload/renderer and persisted sandbox configs depend on them. | diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index f88033bd7e..ae5a59b792 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -263,6 +263,26 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex suspendCount: 0, }) const warnedDuplicates = new Set() + const pendingDuplicates = new Set() + // Route transitions keep the outgoing page mounted while the incoming one + // registers, so ids both pages provide (command.palette, input.focus) are + // briefly registered twice. Only a duplicate that survives the transition + // is a registration bug worth reporting. + const warnIfPersistentDuplicate = (id: string) => { + if (warnedDuplicates.has(id) || pendingDuplicates.has(id)) return + pendingDuplicates.add(id) + setTimeout(() => { + pendingDuplicates.delete(id) + if (warnedDuplicates.has(id)) return + const count = store.registrations.reduce( + (total, reg) => total + reg.options().filter((opt) => opt.id === id).length, + 0, + ) + if (count < 2) return + warnedDuplicates.add(id) + console.warn(`[command] duplicate command id "${id}" registered; keeping first entry`) + }, 1000) + } type CommandCatalog = Record const [catalog, setCatalog, _, catalogReady] = persisted( @@ -284,10 +304,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex for (const reg of store.registrations) { for (const opt of reg.options()) { if (seen.has(opt.id)) { - if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) { - warnedDuplicates.add(opt.id) - console.warn(`[command] duplicate command id "${opt.id}" registered; keeping first entry`) - } + if (import.meta.env.DEV) warnIfPersistentDuplicate(opt.id) continue } seen.add(opt.id) diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 06597e56e7..f9e1f4c177 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -136,7 +136,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed)) } + // A server being stopped or restarted (a sandbox reset, the sidecar + // relaunching) kills the stream before the renderer learns the server is + // going away, so the first failed attempts are usually that race, not a + // fault. Only reconnection that keeps failing is worth an error. + const STREAM_ERROR_LOG_THRESHOLD = 3 + let streamFailures = 0 let streamErrorLogged = false + const logStreamIssue = (message: string, error: unknown) => { + streamFailures++ + if (streamErrorLogged || streamFailures < STREAM_ERROR_LOG_THRESHOLD) return + streamErrorLogged = true + console.error(message, { + url: server.http.url, + fetch: eventFetch ? "platform" : "webview", + attempts: streamFailures, + error: error instanceof Error ? `${error.name}: ${error.message}` : error, + }) + } const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) let attempt: AbortController | undefined let run: Promise | undefined @@ -178,19 +195,14 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS signal: attempt.signal, onSseError: (error) => { if (isStreamClosed(error, attempt?.signal)) return - if (streamErrorLogged) return - streamErrorLogged = true - console.error("[global-sdk] event stream error", { - url: server.http.url, - fetch: eventFetch ? "platform" : "webview", - error, - }) + logStreamIssue("[global-sdk] event stream error", error) }, }) let yielded = Date.now() resetHeartbeat() for await (const event of events.stream) { resetHeartbeat() + streamFailures = 0 streamErrorLogged = false if (event.payload.type !== "sync") { const directory = event.directory ?? "global" @@ -203,13 +215,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS await wait(0) } } catch (error) { - if (!isStreamClosed(error, attempt?.signal) && !streamErrorLogged) { - streamErrorLogged = true - console.error("[global-sdk] event stream failed", { - url: server.http.url, - fetch: eventFetch ? "platform" : "webview", - error, - }) + if (!isStreamClosed(error, attempt?.signal)) { + logStreamIssue("[global-sdk] event stream failed", error) } } finally { abort.signal.removeEventListener("abort", onAbort) diff --git a/packages/app/src/utils/refcount.test.ts b/packages/app/src/utils/refcount.test.ts index f48d80db35..cc39f6958b 100644 --- a/packages/app/src/utils/refcount.test.ts +++ b/packages/app/src/utils/refcount.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { createRoot } from "solid-js" +import { createRoot, onCleanup } from "solid-js" import { createRefCountMap } from "./refcount" import { pathKey } from "./path-key" @@ -46,4 +46,49 @@ describe("createRefCountMap", () => { second() expect(removed).toEqual(["C:/repo"]) }) + + test("an ownerless caller borrows without pinning", () => { + const removed: string[] = [] + const map = createRefCountMap( + (key) => key, + (key) => removed.push(key), + ) + + // No reactive owner: an event handler or async continuation. It must get + // the item but must not hold a pin nobody can ever release. + expect(map("/project")).toBe("/project") + + const dispose = createRoot((dispose) => { + map("/project") + return dispose + }) + dispose() + expect(removed).toEqual(["/project"]) + }) + + test("an item's internal cleanups survive its first caller", () => { + const cleaned: string[] = [] + const map = createRoot(() => + createRefCountMap((key) => { + onCleanup(() => cleaned.push(key)) + return key + }), + ) + + const first = createRoot((dispose) => { + map("/project") + return dispose + }) + const second = createRoot((dispose) => { + map("/project") + return dispose + }) + + // The first caller created the item; its disposal must not tear the shared + // item down while the second caller still holds it. + first() + expect(cleaned).toEqual([]) + second() + expect(cleaned).toEqual(["/project"]) + }) }) diff --git a/packages/app/src/utils/refcount.ts b/packages/app/src/utils/refcount.ts index c284491cce..15bfd5fb8f 100644 --- a/packages/app/src/utils/refcount.ts +++ b/packages/app/src/utils/refcount.ts @@ -1,32 +1,43 @@ -import { onCleanup } from "solid-js" +import { createRoot, getOwner, onCleanup } from "solid-js" export function createRefCountMap( create: (key: string) => T, remove?: (key: string) => void, identity: (key: string) => string = (key) => key, ) { - const items = new Map() + const owner = getOwner() + const items = new Map void }>() const refCounts = new Map() return (key: string) => { const id = identity(key) - onCleanup(() => { - refCounts.set(id, (refCounts.get(id) ?? 0) - 1) - if (refCounts.get(id) === 0) { - remove?.(id) - items.delete(id) + // Callers without a reactive owner (event handlers, async continuations) + // borrow the item without pinning it: they have no scope whose disposal + // could release the pin, so counting them would leak the item forever and + // make Solid warn that the cleanup can never run. + if (getOwner()) { + refCounts.set(id, (refCounts.get(id) ?? 0) + 1) + onCleanup(() => { + const next = (refCounts.get(id) ?? 0) - 1 + refCounts.set(id, next) + if (next > 0) return refCounts.delete(id) - } - }) + const item = items.get(id) + items.delete(id) + remove?.(id) + item?.dispose() + }) + } const cached = items.get(id) - if (cached) { - refCounts.set(id, (refCounts.get(id) ?? 0) + 1) - return cached - } - const item = create(key) + if (cached) return cached.value + // The item is shared by every caller, so its internal cleanups must not + // land in the first caller's scope (they would run on that caller's + // disposal while other holders still use the item). Each item gets its own + // root under the scope that created the map, disposed when the last + // pinning caller releases it. + const item = createRoot((dispose) => ({ value: create(key), dispose }), owner ?? undefined) items.set(id, item) - refCounts.set(id, 1) - return item + return item.value } } diff --git a/packages/desktop/src/main/logging.ts b/packages/desktop/src/main/logging.ts index 7e89d2b694..d73dbbf526 100644 --- a/packages/desktop/src/main/logging.ts +++ b/packages/desktop/src/main/logging.ts @@ -29,6 +29,7 @@ export function initLogging() { ) log.initialize({ preload: false, spyRendererConsole: true }) initConsoleTransport() + initChromiumNoiseFilter() cleanup() return (logger = log) } @@ -203,3 +204,21 @@ function initConsoleTransport() { function isBrokenPipe(err: unknown) { return typeof err === "object" && err !== null && "code" in err && err.code === "EPIPE" } + +// Chromium interventions the renderer can neither cause nor fix; they read as +// failures in the terminal, so keep them out of it while the log files stay +// complete for debugging. +const CHROMIUM_NOISE = [ + "ResizeObserver loop completed with undelivered notifications", + "Autofocus processing was blocked", +] + +function initChromiumNoiseFilter() { + log.hooks.push((message, _transport, transportName) => { + if (transportName !== "console") return message + if (message.variables?.processType !== "renderer") return message + const text = message.data[0] + if (typeof text === "string" && CHROMIUM_NOISE.some((noise) => text.includes(noise))) return false + return message + }) +} diff --git a/packages/desktop/src/renderer/console-format.test.ts b/packages/desktop/src/renderer/console-format.test.ts new file mode 100644 index 0000000000..234c5f5b64 --- /dev/null +++ b/packages/desktop/src/renderer/console-format.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "bun:test" +import { formatConsoleArg } from "./console-format" + +test("passes primitives through untouched", () => { + expect(formatConsoleArg("message")).toBe("message") + expect(formatConsoleArg(42)).toBe(42) + expect(formatConsoleArg(false)).toBe(false) + expect(formatConsoleArg(null)).toBe(null) + expect(formatConsoleArg(undefined)).toBe(undefined) +}) + +test("serializes plain objects instead of [object Object]", () => { + expect(formatConsoleArg({ pending: true, url: "http://127.0.0.1:1" })).toBe( + '{"pending":true,"url":"http://127.0.0.1:1"}', + ) +}) + +test("keeps an Error's name, message and stack", () => { + const formatted = formatConsoleArg({ error: new TypeError("fetch failed") }) as string + const parsed = JSON.parse(formatted) + expect(parsed.error.name).toBe("TypeError") + expect(parsed.error.message).toBe("fetch failed") + expect(typeof parsed.error.stack).toBe("string") +}) + +test("survives circular references", () => { + const value: Record = { name: "loop" } + value.self = value + expect(formatConsoleArg(value)).toBe('{"name":"loop","self":"[circular]"}') +}) + +test("truncates oversized payloads", () => { + const formatted = formatConsoleArg({ blob: "x".repeat(20_000) }) as string + expect(formatted.length).toBeLessThan(9_000) + expect(formatted).toContain("chars)") +}) diff --git a/packages/desktop/src/renderer/console-format.ts b/packages/desktop/src/renderer/console-format.ts new file mode 100644 index 0000000000..781f36719d --- /dev/null +++ b/packages/desktop/src/renderer/console-format.ts @@ -0,0 +1,41 @@ +// The terminal and the exported log files receive renderer console output +// through Chromium's console-message event (electron-log's spyRendererConsole +// in src/main/logging.ts), which flattens every non-string argument to +// "[object Object]" before the main process ever sees it. Serialize structured +// arguments up front so the forwarded line carries the data; devtools shows +// the same JSON text. +const MAX_ARG_LENGTH = 8_192 + +export function installConsoleFormatting() { + for (const level of ["debug", "log", "info", "warn", "error"] as const) { + const original = console[level].bind(console) + console[level] = (...args: unknown[]) => original(...args.map(formatConsoleArg)) + } +} + +export function formatConsoleArg(arg: unknown) { + if (arg === null || arg === undefined) return arg + if (typeof arg === "string" || typeof arg === "number" || typeof arg === "boolean") return arg + // A throwing getter or unserializable exotic object must not break logging. + try { + const json = JSON.stringify(arg, makeReplacer()) + if (json === undefined) return String(arg) + if (json.length > MAX_ARG_LENGTH) return `${json.slice(0, MAX_ARG_LENGTH)}… (${json.length} chars)` + return json + } catch { + return String(arg) + } +} + +function makeReplacer() { + const seen = new WeakSet() + return (_key: string, value: unknown) => { + if (value instanceof Error) return { name: value.name, message: value.message, stack: value.stack } + if (typeof value === "bigint" || typeof value === "function" || typeof value === "symbol") return String(value) + if (typeof value === "object" && value !== null) { + if (seen.has(value)) return "[circular]" + seen.add(value) + } + return value + } +} diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 8d65b82acf..fde029197f 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -22,6 +22,7 @@ import { createMemoryHistory, MemoryRouter, type BaseRouterProps } from "@solidj import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js" import { render } from "solid-js/web" import pkg from "../../package.json" +import { installConsoleFormatting } from "./console-format" import { initI18n, t } from "./i18n" import { initializationData, initializationReady } from "./initialization" import { DesktopFirstLaunchOnboarding } from "./onboarding" @@ -32,6 +33,8 @@ import "./styles.css" import { Splash } from "@opencode-ai/ui/logo" import { useTheme } from "@opencode-ai/ui/theme/context" +installConsoleFormatting() + const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { throw new Error(t("error.dev.rootNotFound"))