From 27ecc46dc71268d3f5ef9796d84b0b4705385bbe Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:09:10 +1000 Subject: [PATCH] fix(app): improve renderer startup performance --- .../e2e/performance/REAL_DESKTOP_PROFILE.md | 132 ++++ .../e2e/performance/real-desktop-profile.ts | 675 ++++++++++++++++++ packages/app/src/app.tsx | 19 +- .../app/src/components/settings-models.tsx | 2 + .../app/src/components/settings-v2/models.tsx | 2 + .../app/src/context/global-sync/bootstrap.ts | 17 - .../context/global-sync/child-store.test.ts | 5 +- .../src/context/global-sync/child-store.ts | 15 +- .../context/global-sync/home-session-index.ts | 28 +- packages/app/src/context/server-sync.tsx | 79 +- .../context/session-message-decode.test.ts | 26 +- .../app/src/context/session-message-decode.ts | 19 +- .../src/context/session-message-decoder.ts | 13 +- .../context/session-message-decoder.worker.ts | 18 +- packages/app/src/hooks/use-providers.ts | 6 + .../pages/home/home-sessions-controller.tsx | 85 +-- packages/app/src/pages/layout-new.tsx | 32 +- packages/desktop/src/main/index.ts | 18 +- packages/desktop/src/renderer/i18n/index.ts | 120 +--- packages/desktop/src/renderer/index.tsx | 4 +- 20 files changed, 1104 insertions(+), 211 deletions(-) create mode 100644 packages/app/e2e/performance/REAL_DESKTOP_PROFILE.md create mode 100644 packages/app/e2e/performance/real-desktop-profile.ts diff --git a/packages/app/e2e/performance/REAL_DESKTOP_PROFILE.md b/packages/app/e2e/performance/REAL_DESKTOP_PROFILE.md new file mode 100644 index 00000000000..7f81e4ec889 --- /dev/null +++ b/packages/app/e2e/performance/REAL_DESKTOP_PROFILE.md @@ -0,0 +1,132 @@ +# Real desktop renderer profile + +## Scope + +- Revision: `60e23fee161982d572fafbf6cc0df63ac8ac87cc` +- Runtime: production Electron preview from the current `dev` build +- Data: the live `opencode.db` used by Beta, queried read-only for targets active in the preceding 24 hours +- Contents are never written to reports. Reports contain rank labels, counts, sizes, durations, and source locations only. +- The database is shared by channels, so rows cannot be attributed exclusively to the Beta executable. + +The final attributed run covered 26 active sessions: 7 roots and 19 child/subagent sessions. The selected root-session targets were: + +| Rank | Serialized bytes in window | Messages | Parts | User turns | +| --- | ---: | ---: | ---: | ---: | +| p50 | 13,211 | 6 | 14 | 3 | +| p95/max | 7,665,253 | 421 | 1,883 | 51 | + +The p95 and maximum were the same root session. The harness intentionally runs it twice, so those rows are warm/cold repetitions rather than independent samples. + +## Confirmed main-thread work + +### 1. Response parsing and synchronous continuation + +The largest consistently attributed script entry starts at `Response.text.then` in `packages/sdk/js/src/v2/gen/client/client.gen.ts:171-172`. The client reads the complete body as text and calls synchronous `JSON.parse` on the renderer. The LoAF duration also includes the promise microtasks that synchronously ingest the parsed result and update the UI. + +- Large-session navigation: 139-391 ms attributed to this entry point, including 38-88 ms forced style/layout. +- History pagination: 388 ms attributed, including 64 ms forced style/layout. +- Home: two response continuations took 77 ms and 61 ms, including 22 ms forced style/layout. + +This is the first boundary to split or move off-thread. The measurement does not imply that all attributed time is `JSON.parse`; it includes downstream work in the same promise checkpoint. + +### 2. Timeline measurement and scroll reconciliation + +History loading was the heaviest repeatable workflow. + +| Metric | Clean-run range | +| --- | ---: | +| Long tasks | 6-9 | +| Total long-task time | 763-1,097 ms | +| Worst long task | 138-309 ms | +| Workflow time | 4.7-5.3 s | + +The attributed run found: + +- TanStack Virtual `ResizeObserver`: 173 ms, including 42 ms forced layout. +- TanStack Virtual scroll handler: 160 ms. +- `ScrollView` scroll handler at `packages/ui/src/components/scroll-view.tsx:173-202`: 65 ms, including 64 ms forced layout. +- The timeline's virtualizer and resize anchoring are configured at `packages/app/src/pages/session/timeline/message-timeline.tsx:413-486`. + +The next optimization target is the response-to-projection-to-measurement pipeline, especially reducing synchronous DOM reads and repeated resize/scroll reactions while prepending history. + +### 3. Renderer-side Shiki WASM startup + +A small-session navigation loaded `@shikijs/engine-oniguruma` in the renderer and spent 58-62 ms evaluating its WASM module. Session Markdown parsing/highlighting and Pierre highlighting have workers, but renderer-side Shiki/Pierre initialization is still reachable. The bundled path is created by `getSharedHighlighter(... preferredHighlighter: "shiki-wasm")` and should be audited separately from worker execution. + +### 4. Home startup and geometry + +Across clean runs, Home produced 3-4 long tasks, 277-394 ms total, with a 127-172 ms maximum. + +Attribution showed: + +- Main module evaluation: 137 ms. +- Two response continuations: 77 ms and 61 ms. +- 22 ms forced style/layout. +- CPU samples in `ScrollView.updateThumb` and `home-scroll-controller.ts:56-69` (`getComputedStyle`, every header's `offsetTop`, and `scrollTop`). + +### 5. Review opening + +The current real workspace review was not a major hotspot. Clean runs ranged from zero to two long tasks, with a 0-106 ms maximum. The attributed run had a 35 ms delegated click handler and 11 ms forced style/layout. This corpus had one diff viewer and does not validate large-review behavior. + +## Markdown renderer conclusion + +No current real text part exceeded 19,466 characters in the initial corpus audit. In the desktop traces: + +- Marked, KaTeX, and Shiki parsing did not appear as renderer CPU hotspots because they run in the Markdown worker. +- Markdown worker response handling appeared as a 43 ms entry in one run. +- Renderer `postMessage`, `innerHTML`, HTML parsing, sanitization-related DOM parsing, and token DOM updates appeared in low-single-digit samples and accumulated tens of milliseconds, but none was independently responsible for the observed long tasks. +- Checksumming did not appear above the 1 ms CPU reporting threshold. + +For this real 24-hour corpus, Markdown's remaining renderer stages are secondary to response ingestion and timeline layout. This does not establish safety for an exceptional multi-megabyte text part. + +## Reviewed but not measured + +The source audit also found scale-sensitive renderer paths that this real workflow did not exercise enough to assign runtime numbers: + +- Terminal buffer serialization/restoration over up to 10,000 rows. +- In-file search text-node scans, DOM `Range` creation, and match geometry. +- Large file/diff preprocessing and virtualized file DOM work. +- Command-palette filtering and unvirtualized result rendering. +- Draft recursive serialization and blob hashing. +- Generic persisted-state parse/merge/stringify. +- Large review trees and large diff switching. + +No synthetic data was introduced merely to force these paths. They remain candidates for a future profile when corresponding real data exists. + +## Reproduce + +From `packages/app`: + +```powershell +$env:OPENCODE_PROFILE_DIAGNOSTICS = "0" +$env:OPENCODE_PROFILE_OUTPUT = "C:\tmp\opencode\markdown-profile-results\run" +bun run e2e/performance/real-desktop-profile.ts +``` + +The script builds and launches Electron, points it at the shared real database, enables Electron 42's `AlwaysLogLOAFURL` feature for custom-protocol script attribution, profiles the workflows, and terminates the complete process tree in `finally`. + +For Chrome traces and a 1 ms CPU sampler: + +```powershell +$env:OPENCODE_PROFILE_DIAGNOSTICS = "1" +$env:OPENCODE_PROFILE_OUTPUT = "C:\tmp\opencode\markdown-profile-results\diagnostic" +bun run e2e/performance/real-desktop-profile.ts +``` + +Diagnostic timing is perturbed by tracing and sampling. Use clean runs for blocking-time numbers and diagnostic runs only for attribution. + +Useful outputs from this audit: + +- `C:\tmp\opencode\markdown-profile-results\loaf-positions\renderer-profile.json` +- `C:\tmp\opencode\markdown-profile-results\diagnostic\renderer-profile.json` +- `C:\tmp\opencode\markdown-profile-results\diagnostic\traces` + +## LoAF attribution + +Electron custom protocols normally produce an empty `PerformanceLongAnimationFrameTiming.scripts` array. Electron 42 supports custom-protocol attribution behind `--enable-features=AlwaysLogLOAFURL`; the harness enables it through an environment-guarded `app.commandLine.appendSwitch` and verifies it with an 80 ms calibration callback. + +References: + +- https://developer.chrome.com/docs/web-platform/long-animation-frames +- https://www.electronjs.org/docs/latest/api/command-line-switches#chromium-features-relevant-to-electron-apps +- https://github.com/electron/electron/pull/49706 diff --git a/packages/app/e2e/performance/real-desktop-profile.ts b/packages/app/e2e/performance/real-desktop-profile.ts new file mode 100644 index 00000000000..2e3dbaaab7f --- /dev/null +++ b/packages/app/e2e/performance/real-desktop-profile.ts @@ -0,0 +1,675 @@ +import { Database } from "bun:sqlite" +import { chromium, type CDPSession, type Page } from "@playwright/test" +import path from "node:path" +import { startChromeTrace } from "./chrome-trace" + +const root = path.resolve(import.meta.dir, "../../../..") +const desktop = path.join(root, "packages/desktop") +const databasePath = process.env.OPENCODE_PROFILE_DB ?? "C:/Users/Lukem/.local/share/opencode/opencode.db" +const output = process.env.OPENCODE_PROFILE_OUTPUT ?? "C:/tmp/opencode/markdown-profile-results" +const cdpPort = process.env.OPENCODE_PROFILE_CDP_PORT ?? String(19_000 + (process.pid % 1_000)) +const endpoint = process.env.OPENCODE_PROFILE_CDP ?? `http://127.0.0.1:${cdpPort}` +const diagnostics = process.env.OPENCODE_PROFILE_DIAGNOSTICS !== "0" +const profileCPU = process.env.OPENCODE_PROFILE_CPU === "1" +const windowEnd = Number(process.env.OPENCODE_PROFILE_WINDOW_END ?? Date.now()) +const windowStart = windowEnd - 24 * 60 * 60 * 1_000 + +type Target = { + label: "p50" | "p95" | "max" + id: string + directory: string + title: string + bytes: number + messages: number + parts: number + userTurns: number +} + +type ProbeResult = { + longTasks: number[] + animationFrames: { + duration: number + blockingDuration: number + forcedStyleAndLayoutDuration: number + scripts: { + function: string + source: string + position: number + invoker: string + invokerType: string + duration: number + forcedStyleAndLayoutDuration: number + }[] + }[] + frameGaps: number[] + responseText: { url: string; duration: number }[] +} + +const targets = loadTargets() +const typingText = loadTypingText(targets.find((target) => target.label === "max")!) +await Bun.$`mkdir -p ${output}` +process.env.OPENCODE_PERFORMANCE_TRACE_DIR = path.join(output, "traces") +process.env.OPENCODE_PERFORMANCE_RUN_ID = new Date(windowEnd).toISOString().replace(/[:.]/g, "-") + +if (process.env.OPENCODE_PROFILE_SKIP_BUILD !== "1") await run(["bun", "run", "build"], desktop) + +const child = Bun.spawn(["bun", "run", "preview"], { + cwd: desktop, + env: { + ...process.env, + OPENCODE_DB: databasePath, + OPENCODE_CHANNEL: "dev", + OPENCODE_PROFILE_LOAF: "1", + OPENCODE_PROFILE_CDP_PORT: cdpPort, + OPENCODE_PROFILE_USER_DATA: + process.env.OPENCODE_PROFILE_USER_DATA ?? "C:/tmp/opencode/markdown-profile-user-data", + }, + stdout: "pipe", + stderr: "pipe", +}) +const stdout = drain(child.stdout) +const stderr = drain(child.stderr) +let browser: Awaited> | undefined + +try { + await waitForCDP() + browser = await chromium.connectOverCDP(endpoint) + const page = await waitForRenderer(browser) + await page.waitForFunction(() => typeof window.api === "object", undefined, { timeout: 60_000 }) + await installProbe(page) + await page.evaluate(() => { + const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}") + localStorage.setItem( + "settings.v3", + JSON.stringify({ ...settings, general: { ...settings.general, newLayoutDesigns: true } }), + ) + }) + + const results = [] + results.push(await profileHome(page)) + results.push(await profileCalibration(page)) + for (const target of targets) { + results.push(await profileSession(page, target)) + } + results.push(await profileComposer(page)) + results.push(await profileHistoryBoundary(page, targets.find((target) => target.label === "max")!)) + const review = await profileReview(page) + if (review) results.push(review) + + const report = { + schemaVersion: 1, + source: "real-opencode-db", + diagnostics, + profileCPU, + window: { + start: new Date(windowStart).toISOString(), + end: new Date(windowEnd).toISOString(), + }, + revision: (await Bun.$`git rev-parse HEAD`.cwd(root).text()).trim(), + targets: targets.map(({ id: _, directory: __, title: ___, ...target }) => target), + results, + } + const file = path.join(output, "renderer-profile.json") + await Bun.write(file, JSON.stringify(report, null, 2)) + console.log(`PROFILE_REPORT ${file}`) + console.log(JSON.stringify(report, null, 2)) +} finally { + await browser?.close().catch(() => {}) + await killTree(child.pid) + await Promise.allSettled([stdout, stderr]) +} + +async function profileCalibration(page: Page) { + await resetProbe(page) + await page.evaluate( + () => + new Promise((resolve) => { + setTimeout(function opencodeProfileCalibration() { + const end = performance.now() + 80 + while (performance.now() < end) { + // Deliberate benchmark-only main-thread block. + } + requestAnimationFrame(() => setTimeout(resolve, 100)) + }) + }), + ) + const metrics = await collectProbe(page) + return { name: "attribution-calibration", ...summarizeProbe(metrics) } +} + +function loadTargets() { + const database = new Database(databasePath, { readonly: true }) + database.run("PRAGMA query_only = ON") + const sessions = database + .query( + `SELECT id, directory, title + FROM session AS candidate + WHERE parent_id IS NULL + AND EXISTS ( + SELECT 1 + FROM message + WHERE session_id = candidate.id AND time_created >= ? AND time_created < ? + )`, + ) + .all(windowStart, windowEnd) as { id: string; directory: string; title: string }[] + const messageRows = database.query( + `SELECT id, data + FROM message + WHERE session_id = ? AND time_created >= ? AND time_created < ? + ORDER BY time_created, id`, + ) + const partRows = database.query(`SELECT data FROM part WHERE message_id = ? ORDER BY id`) + const ranked = sessions + .map((session) => { + const messages = messageRows.all(session.id, windowStart, windowEnd) as { id: string; data: string }[] + const parts = messages.flatMap((message) => partRows.all(message.id) as { data: string }[]) + return { + ...session, + bytes: + messages.reduce((sum, message) => sum + Buffer.byteLength(message.data), 0) + + parts.reduce((sum, part) => sum + Buffer.byteLength(part.data), 0), + messages: messages.length, + parts: parts.length, + userTurns: messages.filter((message) => JSON.parse(message.data).role === "user").length, + } + }) + .filter((session) => session.messages > 0) + .sort((a, b) => a.bytes - b.bytes || a.id.localeCompare(b.id)) + database.close() + if (ranked.length === 0) throw new Error("No sessions found in the profile window") + const select = (label: Target["label"], percentile: number) => ({ + label, + ...ranked[Math.max(0, Math.ceil(ranked.length * percentile) - 1)]!, + }) + return [select("p50", 0.5), select("p95", 0.95), select("max", 1)] satisfies Target[] +} + +function loadTypingText(target: Target) { + const database = new Database(databasePath, { readonly: true }) + database.run("PRAGMA query_only = ON") + const messages = database + .query( + `SELECT id, data + FROM message + WHERE session_id = ? AND time_created >= ? AND time_created < ? + ORDER BY time_created, id`, + ) + .all(target.id, windowStart, windowEnd) as { id: string; data: string }[] + const parts = database.query(`SELECT data FROM part WHERE message_id = ? ORDER BY id`) + const text = messages + .filter((message) => JSON.parse(message.data).role === "user") + .flatMap((message) => + (parts.all(message.id) as { data: string }[]).flatMap((part) => { + const data = JSON.parse(part.data) + return data.type === "text" && typeof data.text === "string" ? [data.text] : [] + }), + ) + .sort((a, b) => b.length - a.length)[0] + database.close() + if (!text) throw new Error("No real user prompt found for composer profiling") + return text +} + +async function profileHome(page: Page) { + const stopTrace = diagnostics ? await startChromeTrace(page, "home") : undefined + const cpu = await startCPUProfile(page) + const started = performance.now() + await setDesktopRoute(page, "/") + await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 }) + await page.waitForSelector('[data-component="home-session-row"]', { timeout: 60_000 }) + await waitForQuietDOM(page) + const elapsedMs = performance.now() - started + const metrics = await collectProbe(page) + const dom = await page.evaluate(() => ({ + elements: document.getElementsByTagName("*").length, + timelineRows: document.querySelectorAll("[data-timeline-row]").length, + messageRows: document.querySelectorAll("[data-message-id]").length, + markdownRoots: document.querySelectorAll('[data-component="markdown"]').length, + diffViewers: document.querySelectorAll('[data-component="file"][data-mode="diff"]').length, + })) + return { + name: "home", + elapsedMs, + ...summarizeProbe(metrics), + dom, + cpu: await cpu.stop(), + trace: await stopTrace?.(), + } +} + +async function profileSession(page: Page, target: Target) { + await setDesktopRoute(page, "/") + await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 }) + await page.waitForSelector('[data-component="home-session-row"]', { timeout: 60_000 }) + await waitForQuietDOM(page) + await resetProbe(page) + const stopTrace = diagnostics ? await startChromeTrace(page, `session-${target.label}`) : undefined + const cpu = await startCPUProfile(page) + const started = performance.now() + await page.evaluate((title) => { + const button = [...document.querySelectorAll('[data-component="home-session-row"]')].find( + (element) => element.textContent?.includes(title), + ) + if (!button) throw new Error("Ranked root session was not found on Home") + button.click() + }, target.title) + await page.waitForSelector('[data-component="prompt-input"]', { timeout: 60_000 }) + await waitForQuietDOM(page) + const elapsedMs = performance.now() - started + const metrics = await collectProbe(page) + const dom = await page.evaluate(() => ({ + elements: document.getElementsByTagName("*").length, + timelineRows: document.querySelectorAll("[data-timeline-row]").length, + messageRows: document.querySelectorAll("[data-message-id]").length, + markdownRoots: document.querySelectorAll('[data-component="markdown"]').length, + diffViewers: document.querySelectorAll('[data-component="file"][data-mode="diff"]').length, + })) + return { + name: `session-${target.label}`, + context: { + serializedBytes: target.bytes, + messages: target.messages, + parts: target.parts, + userTurns: target.userTurns, + }, + elapsedMs, + ...summarizeProbe(metrics), + dom, + cpu: await cpu.stop(), + trace: await stopTrace?.(), + } +} + +async function profileComposer(page: Page) { + const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]').first() + await editor.click() + await page.keyboard.press("Control+A") + await page.keyboard.press("Backspace") + const printable = [...typingText].filter( + (character) => character !== "\r" && character !== "\n" && character !== "\t", + ) + const measured = printable.slice(-120).join("") + const prefix = printable.slice(0, -measured.length).join("") + if (prefix) await page.keyboard.insertText(prefix) + await waitForQuietDOM(page) + await resetProbe(page) + const stopTrace = diagnostics ? await startChromeTrace(page, "composer-typing") : undefined + const cpu = await startCPUProfile(page) + const durations: number[] = [] + for (const character of measured) { + const started = performance.now() + await page.keyboard.type(character) + durations.push(performance.now() - started) + } + await waitForQuietDOM(page) + const metrics = await collectProbe(page) + await page.keyboard.press("Control+A") + await page.keyboard.press("Backspace") + return { + name: "composer-typing", + context: { promptCharacters: printable.length, measuredCharacters: measured.length }, + typing: { + totalMs: sum(durations), + meanMs: sum(durations) / durations.length, + p50Ms: percentile(durations, 0.5), + p95Ms: percentile(durations, 0.95), + maxMs: Math.max(...durations), + }, + ...summarizeProbe(metrics), + cpu: await cpu.stop(), + trace: await stopTrace?.(), + } +} + +async function profileHistoryBoundary(page: Page, target: Target) { + await page.waitForSelector('[data-component="prompt-input"]', { timeout: 60_000 }) + await waitForQuietDOM(page) + await resetProbe(page) + const stopTrace = diagnostics ? await startChromeTrace(page, "session-max-history-boundary") : undefined + const cpu = await startCPUProfile(page) + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }).first() + const started = performance.now() + let requests = 0 + const onResponse = (response: { url(): string }) => { + if (/\/session\/[^/]+\/message(?:\?|$)/.test(response.url())) requests++ + } + page.on("response", onResponse) + await scroller.evaluate((element) => { + element.scrollTop = 0 + element.dispatchEvent(new WheelEvent("wheel", { deltaY: -10_000, bubbles: true })) + element.dispatchEvent(new Event("scroll", { bubbles: true })) + }) + const timeout = Date.now() + 60_000 + while (requests === 0 && Date.now() < timeout) await page.waitForTimeout(50) + if (requests === 0) throw new Error("History boundary did not request a page") + await waitForQuietDOM(page) + page.off("response", onResponse) + const elapsedMs = performance.now() - started + const metrics = await collectProbe(page) + return { + name: "session-max-history-boundary", + context: { + serializedBytes: target.bytes, + messages: target.messages, + parts: target.parts, + userTurns: target.userTurns, + }, + elapsedMs, + messageRequests: requests, + ...summarizeProbe(metrics), + cpu: await cpu.stop(), + trace: await stopTrace?.(), + } +} + +async function profileReview(page: Page) { + const button = page.getByRole("button", { name: "Toggle review" }) + if (!(await button.isVisible().catch(() => false))) return + const panel = page.locator("#review-panel") + if (await panel.isVisible().catch(() => false)) { + await button.click() + await panel.waitFor({ state: "hidden", timeout: 60_000 }) + await waitForQuietDOM(page) + } + await resetProbe(page) + const stopTrace = diagnostics ? await startChromeTrace(page, "review-open") : undefined + const cpu = await startCPUProfile(page) + const started = performance.now() + await button.click() + await panel.waitFor({ state: "visible", timeout: 60_000 }) + await waitForQuietDOM(page) + const elapsedMs = performance.now() - started + const metrics = await collectProbe(page) + const dom = await page.evaluate(() => ({ + elements: document.getElementsByTagName("*").length, + diffViewers: document.querySelectorAll('[data-component="file"][data-mode="diff"]').length, + diffLines: document.querySelectorAll("[data-line]").length, + })) + return { + name: "review-open", + elapsedMs, + ...summarizeProbe(metrics), + dom, + cpu: await cpu.stop(), + trace: await stopTrace?.(), + } +} + +async function installProbe(page: Page) { + await page.addInitScript((attributeResponses) => { + const state = { + longTasks: [] as number[], + animationFrames: [] as ProbeResult["animationFrames"], + frameGaps: [] as number[], + responseText: [] as ProbeResult["responseText"], + } + ;(window as Window & { __opencodeRendererProfile?: typeof state }).__opencodeRendererProfile = state + if (PerformanceObserver.supportedEntryTypes.includes("longtask")) { + new PerformanceObserver((list) => + state.longTasks.push(...list.getEntries().map((entry) => entry.duration)), + ).observe({ + type: "longtask", + }) + } + if (PerformanceObserver.supportedEntryTypes.includes("long-animation-frame")) { + new PerformanceObserver((list) => + state.animationFrames.push( + ...list.getEntries().map((entry) => { + const frame = entry as PerformanceEntry & { + blockingDuration: number + scripts?: { + duration: number + forcedStyleAndLayoutDuration?: number + sourceFunctionName?: string + sourceURL?: string + sourceCharPosition?: number + invoker?: string + invokerType?: string + }[] + } + return { + duration: frame.duration, + blockingDuration: frame.blockingDuration, + forcedStyleAndLayoutDuration: + frame.scripts?.reduce((sum, script) => sum + (script.forcedStyleAndLayoutDuration ?? 0), 0) ?? 0, + scripts: + frame.scripts?.map((script) => ({ + function: script.sourceFunctionName || "(anonymous)", + source: script.sourceURL?.split("/").at(-1) || "(document)", + position: script.sourceCharPosition ?? -1, + invoker: script.invoker ?? "(unknown)", + invokerType: script.invokerType ?? "(unknown)", + duration: script.duration, + forcedStyleAndLayoutDuration: script.forcedStyleAndLayoutDuration ?? 0, + })) ?? [], + } + }), + ), + ).observe({ type: "long-animation-frame" }) + } + let previous = performance.now() + const frame = (now: number) => { + const gap = now - previous + if (gap > 20) state.frameGaps.push(gap) + previous = now + requestAnimationFrame(frame) + } + requestAnimationFrame(frame) + if (attributeResponses) { + const responseText = Response.prototype.text + Response.prototype.text = function () { + const started = performance.now() + const url = this.url + return responseText.call(this).then((text) => { + state.responseText.push({ url, duration: performance.now() - started }) + return text + }) + } + } + }, process.env.OPENCODE_PROFILE_RESPONSE_URLS === "1") +} + +async function resetProbe(page: Page) { + await page.evaluate(() => { + const state = (window as Window & { __opencodeRendererProfile?: ProbeResult }).__opencodeRendererProfile + if (!state) return + state.longTasks.length = 0 + state.animationFrames.length = 0 + state.frameGaps.length = 0 + state.responseText.length = 0 + }) +} + +async function collectProbe(page: Page) { + return page.evaluate( + () => (window as Window & { __opencodeRendererProfile?: ProbeResult }).__opencodeRendererProfile!, + ) +} + +function summarizeProbe(probe: ProbeResult) { + const scripts = new Map< + string, + { + function: string + source: string + position: number + invoker: string + invokerType: string + durationMs: number + forcedStyleAndLayoutMs: number + } + >() + probe.animationFrames + .flatMap((frame) => frame.scripts) + .forEach((script) => { + const key = `${script.source}:${script.position}:${script.invoker}` + const current = scripts.get(key) ?? { + function: script.function, + source: script.source, + position: script.position, + invoker: script.invoker, + invokerType: script.invokerType, + durationMs: 0, + forcedStyleAndLayoutMs: 0, + } + current.durationMs += script.duration + current.forcedStyleAndLayoutMs += script.forcedStyleAndLayoutDuration + scripts.set(key, current) + }) + return { + longTasks: { + count: probe.longTasks.length, + totalMs: sum(probe.longTasks), + maxMs: Math.max(0, ...probe.longTasks), + }, + longAnimationFrames: { + count: probe.animationFrames.length, + totalBlockingMs: sum(probe.animationFrames.map((frame) => frame.blockingDuration)), + maxDurationMs: Math.max(0, ...probe.animationFrames.map((frame) => frame.duration)), + forcedStyleAndLayoutMs: sum(probe.animationFrames.map((frame) => frame.forcedStyleAndLayoutDuration)), + scripts: [...scripts.values()].sort((a, b) => b.durationMs - a.durationMs).slice(0, 15), + }, + frameGaps: { + count: probe.frameGaps.length, + maxMs: Math.max(0, ...probe.frameGaps), + }, + responseText: probe.responseText + .map((item) => ({ + path: (() => { + try { + return new URL(item.url).pathname + } catch { + return item.url + } + })(), + durationMs: item.duration, + })) + .sort((a, b) => b.durationMs - a.durationMs), + } +} + +async function startCPUProfile(page: Page) { + if (!profileCPU) return { stop: async () => [] } + const session = await page.context().newCDPSession(page) + await session.send("Profiler.enable") + await session.send("Profiler.setSamplingInterval", { interval: 1_000 }) + await session.send("Profiler.start") + return { + async stop() { + const result = await session.send("Profiler.stop") + await session.detach() + const self = new Map() + result.profile.samples?.forEach((id, index) => { + self.set(id, (self.get(id) ?? 0) + (result.profile.timeDeltas?.[index] ?? 0) / 1_000) + }) + return result.profile.nodes + .map((node) => ({ + function: node.callFrame.functionName || "(anonymous)", + source: sourceName(node.callFrame.url), + line: node.callFrame.lineNumber + 1, + selfMs: self.get(node.id) ?? 0, + })) + .filter((node) => node.selfMs >= 1) + .sort((a, b) => b.selfMs - a.selfMs) + .slice(0, 40) + }, + } +} + +async function setDesktopRoute(page: Page, route: string) { + await page.evaluate(async (value) => { + const api = window.api as typeof window.api & { getWindowID?: () => Promise } + const id = (await api.getWindowID?.()) ?? "browser" + localStorage.setItem(`opencode.desktop.window.${id}.last-active-url`, value) + }, route) +} + +async function waitForQuietDOM(page: Page) { + await page.evaluate( + () => + new Promise((resolve) => { + let timer = setTimeout(done, 750) + const observer = new MutationObserver(() => { + clearTimeout(timer) + timer = setTimeout(done, 750) + }) + observer.observe(document.body, { childList: true, subtree: true, characterData: true }) + function done() { + observer.disconnect() + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + } + }), + ) +} + +async function waitForCDP() { + const timeout = Date.now() + 5 * 60_000 + while (Date.now() < timeout) { + const ready = await fetch(`${endpoint}/json/version`) + .then((response) => response.ok) + .catch(() => false) + if (ready) return + if (child.exitCode !== null) + throw new Error( + `Desktop exited before CDP was ready (${child.exitCode})\n${await stdout}\n${await stderr}`, + ) + await Bun.sleep(250) + } + throw new Error("Timed out waiting for desktop CDP") +} + +async function waitForRenderer(browser: Awaited>) { + const timeout = Date.now() + 60_000 + while (Date.now() < timeout) { + const page = browser + .contexts() + .flatMap((context) => context.pages()) + .find((candidate) => candidate.url().startsWith("oc://renderer")) + if (page) return page + await Bun.sleep(100) + } + throw new Error("Desktop renderer target was not found") +} + +async function run(command: string[], cwd: string) { + const child = Bun.spawn(command, { cwd, env: processEnv(), stdout: "inherit", stderr: "inherit" }) + const code = await child.exited + if (code !== 0) throw new Error(`${command.join(" ")} exited with ${code}`) +} + +function processEnv() { + return { ...process.env, OPENCODE_DB: databasePath, OPENCODE_CHANNEL: "dev" } +} + +async function drain(stream: ReadableStream) { + const decoder = new TextDecoder() + let output = "" + for await (const chunk of stream) output = (output + decoder.decode(chunk, { stream: true })).slice(-50_000) + return output + decoder.decode() +} + +async function killTree(pid: number) { + if (process.platform !== "win32") { + process.kill(pid, "SIGTERM") + return + } + const child = Bun.spawn(["taskkill", "/pid", String(pid), "/T", "/F"], { stdout: "ignore", stderr: "ignore" }) + await child.exited +} + +function sourceName(value: string) { + if (!value) return "(native)" + try { + return new URL(value).pathname.split("/").at(-1) || "(document)" + } catch { + return path.basename(value) + } +} + +function sum(values: number[]) { + return values.reduce((total, value) => total + value, 0) +} + +function percentile(values: number[], quantile: number) { + return values.toSorted((a, b) => a - b)[Math.max(0, Math.ceil(values.length * quantile) - 1)] ?? 0 +} diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 0c950871a33..6517c870192 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -3,7 +3,6 @@ import * as Sentry from "@sentry/solid" import { I18nProvider } from "@opencode-ai/ui/context" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { FileComponentProvider } from "@opencode-ai/ui/context/file" -import { File } from "@opencode-ai/session-ui/file" import { Font } from "@opencode-ai/ui/font" import { Splash } from "@opencode-ai/ui/logo" import { ThemeProvider } from "@opencode-ai/ui/theme/context" @@ -58,18 +57,24 @@ import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" import { SDKProvider, useSDK } from "@/context/sdk" import { WslServersProvider } from "@/wsl/context" import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout" -import LegacyLayout from "@/pages/layout" -import NewLayout from "@/pages/layout-new" import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" import { createSessionLineage } from "@/pages/session/session-lineage" -import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" -import { NewHome } from "@/pages/home" -import { LegacyHome } from "@/pages/home/legacy-home" - const NewSession = lazy(() => import("@/pages/new-session")) +const NewLayout = lazy(() => import("@/pages/layout-new")) +const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome }))) +const LegacyLayout = lazy(() => import("@/pages/layout")) +const LegacyHome = lazy(() => import("@/pages/home/legacy-home").then((module) => ({ default: module.LegacyHome }))) +const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File }))) +const SessionPage = lazy(() => import("@/pages/session").then((module) => ({ default: module.SessionPage }))) +const SessionRouteErrorBoundary = lazy(() => + import("@/pages/session").then((module) => ({ default: module.SessionRouteErrorBoundary })), +) +const TargetSessionRouteContent = lazy(() => + import("@/pages/session").then((module) => ({ default: module.TargetSessionRouteContent })), +) const SessionRoute = () => { const settings = useSettings() diff --git a/packages/app/src/components/settings-models.tsx b/packages/app/src/components/settings-models.tsx index f3d9e1522ff..c29acd0b8ea 100644 --- a/packages/app/src/components/settings-models.tsx +++ b/packages/app/src/components/settings-models.tsx @@ -7,6 +7,7 @@ import { TextField } from "@opencode-ai/ui/text-field" import { type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" import { useModels } from "@/context/models" +import { useServerSync } from "@/context/server-sync" import { popularProviders } from "@/hooks/use-providers" import { SettingsList } from "./settings-list" import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker" @@ -43,6 +44,7 @@ export const SettingsModels: Component = () => { const SettingsModelsContent: Component = () => { const language = useLanguage() const models = useModels() + useServerSync()().loadProviders() const list = useFilteredList({ items: (_filter) => models.list(), diff --git a/packages/app/src/components/settings-v2/models.tsx b/packages/app/src/components/settings-v2/models.tsx index 482e8cbffbd..7007cbef64a 100644 --- a/packages/app/src/components/settings-v2/models.tsx +++ b/packages/app/src/components/settings-v2/models.tsx @@ -9,6 +9,7 @@ import { createStore } from "solid-js/store" import { useLanguage } from "@/context/language" import { useModels } from "@/context/models" import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" import { popularProviders } from "@/hooks/use-providers" import { Persist, persisted } from "@/utils/persist" import { SettingsListV2 } from "./parts/list" @@ -23,6 +24,7 @@ export const SettingsModelsV2: Component = () => { const language = useLanguage() const models = useModels() const serverSdk = useServerSDK() + useServerSync()().loadProviders() const [store, setStore] = persisted( Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"), createStore({ collapsed: {} as Record }), diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 0f3e4738164..e4b437b5dca 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -152,12 +152,6 @@ export async function bootstrapGlobal(input: { queryClient: QueryClient }) { const slow = [ - () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK, input.protocol)), - () => - input.queryClient.fetchQuery( - loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol), - ), - () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)), () => input.queryClient .fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)) @@ -524,17 +518,6 @@ export async function bootstrapDirectory(input: { input.queryClient.fetchQuery( loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol), )), - () => - input.queryClient - .fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol)) - .catch((err) => { - const project = getFilename(input.directory) - showToast({ - variant: "error", - title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(err, input.translate), - }) - }), ].filter(Boolean) as (() => Promise)[] await waitForPaint() diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index e05b7c39a96..05d7831d403 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -263,10 +263,13 @@ describe("createChildStoreManager", () => { manager.child("/project") expect(queries[0]?.().enabled).toBe(true) expect(queries[3]?.().enabled).toBe(true) - expect(queries[4]?.().enabled).toBe(true) + expect(queries[4]?.().enabled).toBe(false) expect(queries[5]?.().enabled).toBe(true) expect(bootstraps).toEqual(["/project"]) + manager.enableProviders("/project") + expect(queries[4]?.().enabled).toBe(true) + manager.child("/project", { bootstrap: false }) expect(queries[0]?.().enabled).toBe(true) } finally { diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 4eaa785789f..bfea6237d74 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -47,6 +47,7 @@ export function createChildStoreManager(input: { const mcpToggles = new Map void>() const activeDirectories = new Set() const activationToggles = new Map void>() + const providerToggles = new Map void>() const markKey = (key: DirectoryKey) => { if (!key) return @@ -122,6 +123,7 @@ export function createChildStoreManager(input: { mcpToggles.delete(key) activeDirectories.delete(key) activationToggles.delete(key) + providerToggles.delete(key) const dispose = disposers.get(key) if (dispose) { dispose() @@ -187,6 +189,7 @@ export function createChildStoreManager(input: { const initialIcon = icon[0].value const [mcpEnabled, setMcpEnabled] = createSignal(false) const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false) + const [providerEnabled, setProviderEnabled] = createSignal(false) const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() })) const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() })) @@ -194,7 +197,7 @@ export function createChildStoreManager(input: { const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() })) const providerQuery = useQuery(() => ({ ...input.queryOptions.providers(key), - enabled: instanceQueriesEnabled(), + enabled: providerEnabled(), })) const referenceQuery = useQuery(() => ({ ...input.queryOptions.references(key), @@ -206,7 +209,7 @@ export function createChildStoreManager(input: { projectMeta: initialMeta, icon: initialIcon, get provider_ready() { - return instanceQueriesEnabled() && !providerQuery.isLoading + return providerEnabled() && !providerQuery.isLoading }, get provider() { const EMPTY = { all: new Map(), connected: [], default: {} } @@ -263,6 +266,7 @@ export function createChildStoreManager(input: { disposers.set(key, dispose) mcpToggles.set(key, setMcpEnabled) activationToggles.set(key, setInstanceQueriesEnabled) + providerToggles.set(key, setProviderEnabled) const onPersistedInit = (init: Promise | string | null, run: () => void) => { if (!(init instanceof Promise)) return @@ -329,6 +333,12 @@ export function createChildStoreManager(input: { if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1]) } + function enableProviders(directory: string) { + const key = directoryKey(directory) + ensureChild(directory) + providerToggles.get(key)?.(true) + } + // Passive Home/project metadata reads must not initialize the directory. // A real directory access enables these queries once for the store lifetime. // TODO(v2): After Home switches to v2.project.list and root-filtered, @@ -387,6 +397,7 @@ export function createChildStoreManager(input: { mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)), active: (directory: string) => activeDirectories.has(directoryKey(directory)), disableMcp, + enableProviders, disposeDirectory, runEviction, vcsCache, diff --git a/packages/app/src/context/global-sync/home-session-index.ts b/packages/app/src/context/global-sync/home-session-index.ts index 03a085e34d5..e5f8422f0c5 100644 --- a/packages/app/src/context/global-sync/home-session-index.ts +++ b/packages/app/src/context/global-sync/home-session-index.ts @@ -22,6 +22,7 @@ export const homeSessionIndexKey = (server: string) => ["home", "session-index", export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const type HomeSessionPage = { data?: V2SessionListResponse } +type ProjectedHomeSessionPage = { data?: { data: Session[]; cursor: { next?: string } } } export async function loadHomeSessionIndex( list: ( @@ -31,7 +32,30 @@ export async function loadHomeSessionIndex( eventSequence = 0, signal?: AbortSignal, ) { - const data: SessionV2Info[] = [] + return loadHomeSessionPages(list, parseHomeSessionIndex, eventSequence, signal) +} + +export async function loadProjectedHomeSessionIndex( + list: ( + input: { limit: number; order: "desc"; cursor?: string }, + options: { signal?: AbortSignal }, + ) => Promise, + eventSequence = 0, + signal?: AbortSignal, +) { + return loadHomeSessionPages(list, (sessions) => sessions, eventSequence, signal) +} + +async function loadHomeSessionPages( + list: ( + input: { limit: number; order: "desc"; cursor?: string }, + options: { signal?: AbortSignal }, + ) => Promise<{ data?: { data: T[]; cursor: { next?: string } } }>, + project: (sessions: T[]) => Session[], + eventSequence: number, + signal?: AbortSignal, +) { + const data: T[] = [] let cursor: string | undefined for (;;) { @@ -46,7 +70,7 @@ export async function loadHomeSessionIndex( const page = response.data! data.push(...page.data) if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next) - return { sessions: parseHomeSessionIndex(data), eventSequence } + return { sessions: project(data), eventSequence } cursor = page.cursor.next } } diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 82fa994eb28..f16c79ade44 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -8,7 +8,7 @@ import type { } from "@opencode-ai/sdk/v2/client" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" -import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js" +import { type Accessor, batch, createMemo, createSignal, getOwner, onCleanup, onMount, untrack } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" import type { InitError } from "../pages/error" @@ -237,31 +237,40 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { serverSDK.protocol, ) + const [providersEnabled, setProvidersEnabled] = createSignal(false) + const [backgroundEnabled, setBackgroundEnabled] = createSignal(false) const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ - queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)], + queries: [ + { ...queryOptionsApi.globalConfig(), enabled: backgroundEnabled() }, + { ...queryOptionsApi.providers(null), enabled: providersEnabled() }, + { ...queryOptionsApi.path(null), enabled: backgroundEnabled() }, + ], })) const activeSessionsQuery = useQuery(() => - loadActiveSessionsQuery(serverSDK.scope, { - active: async () => { - if ((await serverSDK.protocol) === "v1") { - const statuses = (await serverSDK.client.session.status()).data ?? {} - seedActiveSessionStatuses(session, statuses) - for (const sessionID of Object.keys(statuses)) { + ({ + ...loadActiveSessionsQuery(serverSDK.scope, { + active: async () => { + if ((await serverSDK.protocol) === "v1") { + const statuses = (await serverSDK.client.session.status()).data ?? {} + seedActiveSessionStatuses(session, statuses) + for (const sessionID of Object.keys(statuses)) { + void session.resolve(sessionID).catch(() => undefined) + } + return Object.fromEntries( + Object.entries(statuses).flatMap(([sessionID, status]) => + status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]], + ), + ) + } + const active = await serverSDK.api.session.active() + seedActiveSessionStatuses(session, active) + for (const sessionID of Object.keys(active)) { void session.resolve(sessionID).catch(() => undefined) } - return Object.fromEntries( - Object.entries(statuses).flatMap(([sessionID, status]) => - status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]], - ), - ) - } - const active = await serverSDK.api.session.active() - seedActiveSessionStatuses(session, active) - for (const sessionID of Object.keys(active)) { - void session.resolve(sessionID).catch(() => undefined) - } - return active - }, + return active + }, + }), + enabled: backgroundEnabled(), }), ) @@ -301,10 +310,36 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { let bootingRoot = false let eventFrame: number | undefined let eventTimer: ReturnType | undefined + let providerFrame: number | undefined + let providerIdle: number | undefined + let providerTimer: ReturnType | undefined + + onMount(() => { + providerFrame = requestAnimationFrame(() => { + providerFrame = requestAnimationFrame(() => { + providerFrame = undefined + providerTimer = setTimeout(() => { + providerTimer = undefined + if ("requestIdleCallback" in window) { + providerIdle = requestIdleCallback(() => { + setProvidersEnabled(true) + setBackgroundEnabled(true) + }, { timeout: 5_000 }) + return + } + setProvidersEnabled(true) + setBackgroundEnabled(true) + }, 10_000) + }) + }) + }) onCleanup(() => { if (eventFrame !== undefined) cancelAnimationFrame(eventFrame) if (eventTimer !== undefined) clearTimeout(eventTimer) + if (providerFrame !== undefined) cancelAnimationFrame(providerFrame) + if (providerIdle !== undefined) cancelIdleCallback(providerIdle) + if (providerTimer !== undefined) clearTimeout(providerTimer) }) const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => { @@ -684,7 +719,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { child: children.child, peek: children.peek, disableMcp: children.disableMcp, + enableProviders: children.enableProviders, queryOptions: queryOptionsApi, + loadProviders: () => setProvidersEnabled(true), refreshProviders, // bootstrap, updateConfig: updateConfigMutation.mutateAsync, diff --git a/packages/app/src/context/session-message-decode.test.ts b/packages/app/src/context/session-message-decode.test.ts index 6bcaff013a7..494c3f6b861 100644 --- a/packages/app/src/context/session-message-decode.test.ts +++ b/packages/app/src/context/session-message-decode.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" -import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client" -import { decodeLegacyMessagePage, decodeLegacySessionList } from "./session-message-decode" +import type { Message, Part, Session, SessionV2Info } from "@opencode-ai/sdk/v2/client" +import { decodeHomeSessionPage, decodeLegacyMessagePage, decodeLegacySessionList } from "./session-message-decode" test("decodes and projects a legacy message page", () => { const info = { @@ -40,3 +40,25 @@ test("decodes and projects a legacy session list", () => { expect.objectContaining({ id: session.id, title: session.title, location: { directory: "/repo" } }), ]) }) + +test("bounds Home sessions by directory before returning from the decoder", () => { + const session = (id: string, directory: string, updated: number) => + ({ + id, + projectID: "project", + location: { directory }, + subpath: "", + title: id, + time: { created: updated, updated }, + }) as SessionV2Info + const page = { + data: [session("old", "/repo", 1), session("new", "/repo", 2), session("other", "/other", 3)], + cursor: {}, + } + const result = decodeHomeSessionPage(new TextEncoder().encode(JSON.stringify(page)).buffer, { + directories: ["/repo"], + limit: 1, + }) + + expect(result.data.map((item) => item.id)).toEqual(["new"]) +}) diff --git a/packages/app/src/context/session-message-decode.ts b/packages/app/src/context/session-message-decode.ts index 391ded2d756..f99c25d4cc1 100644 --- a/packages/app/src/context/session-message-decode.ts +++ b/packages/app/src/context/session-message-decode.ts @@ -1,6 +1,9 @@ import type { SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise" -import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client" +import type { Message, Part, Session, V2SessionListResponse } from "@opencode-ai/sdk/v2/client" import { message as cleanMessage } from "@/utils/diffs" +import { pathKey } from "@/utils/path-key" +import { parseHomeSessionIndex } from "./global-sync/home-session-index" +import { takeRecentSessions } from "./global-sync/session-trim" export type DecodedLegacyMessagePage = { session: Message[] @@ -47,6 +50,20 @@ export function decodeLegacySessionList(buffer: ArrayBuffer) { return (text ? (JSON.parse(text) as Session[]) : []).map(legacySessionInfo) } +export function decodeHomeSessionPage(buffer: ArrayBuffer, options?: { directories: string[]; limit: number }) { + const text = new TextDecoder().decode(buffer) + const page = (text ? JSON.parse(text) : { data: [], cursor: {} }) as V2SessionListResponse + const sessions = parseHomeSessionIndex(page.data) + if (!options) return { data: sessions, cursor: page.cursor } + const directories = new Set(options.directories.map(pathKey)) + return { + data: [...Map.groupBy(sessions, (session) => pathKey(session.directory))] + .filter(([directory]) => directories.has(directory)) + .flatMap(([, items]) => takeRecentSessions(items, options.limit, Number.NEGATIVE_INFINITY)), + cursor: page.cursor, + } +} + export function legacySessionInfo(session: Session): SessionInfo { return { id: session.id, diff --git a/packages/app/src/context/session-message-decoder.ts b/packages/app/src/context/session-message-decoder.ts index 72d945c8a1d..37a2f94aee8 100644 --- a/packages/app/src/context/session-message-decoder.ts +++ b/packages/app/src/context/session-message-decoder.ts @@ -1,6 +1,7 @@ import SessionMessageDecoderWorkerUrl from "./session-message-decoder.worker.ts?worker&url" import type { DecodedLegacyMessagePage } from "./session-message-decode" import type { SessionInfo } from "@opencode-ai/client/promise" +import type { Session } from "@opencode-ai/sdk/v2/client" type Response = { id: number; data?: unknown; error?: string } @@ -16,11 +17,19 @@ export function decodeSessionList(buffer: ArrayBuffer) { return decode("sessions", buffer) } -function decode(type: "messages" | "sessions", buffer: ArrayBuffer) { +export function decodeHomeSessionPage(buffer: ArrayBuffer, options: { directories: string[]; limit: number }) { + return decode<{ data: Session[]; cursor: { next?: string } }>("homeSessions", buffer, options) +} + +function decode( + type: "messages" | "sessions" | "homeSessions", + buffer: ArrayBuffer, + options?: { directories: string[]; limit: number }, +) { const id = ++nextID return new Promise((resolve, reject) => { pending.set(id, { resolve: (value) => resolve(value as T), reject }) - getWorker().postMessage({ id, type, buffer }, [buffer]) + getWorker().postMessage({ id, type, buffer, options }, [buffer]) }) } diff --git a/packages/app/src/context/session-message-decoder.worker.ts b/packages/app/src/context/session-message-decoder.worker.ts index 52b9fc45d59..b98411d7e71 100644 --- a/packages/app/src/context/session-message-decoder.worker.ts +++ b/packages/app/src/context/session-message-decoder.worker.ts @@ -1,15 +1,21 @@ -import { decodeLegacyMessagePage, decodeLegacySessionList } from "./session-message-decode" +import { decodeHomeSessionPage, decodeLegacyMessagePage, decodeLegacySessionList } from "./session-message-decode" -type DecoderRequest = { id: number; type: "messages" | "sessions"; buffer: ArrayBuffer } +type DecoderRequest = { + id: number + type: "messages" | "sessions" | "homeSessions" + buffer: ArrayBuffer + options?: { directories: string[]; limit: number } +} self.onmessage = (event: MessageEvent) => { try { self.postMessage({ id: event.data.id, - data: - event.data.type === "messages" - ? decodeLegacyMessagePage(event.data.buffer) - : decodeLegacySessionList(event.data.buffer), + data: (() => { + if (event.data.type === "messages") return decodeLegacyMessagePage(event.data.buffer) + if (event.data.type === "sessions") return decodeLegacySessionList(event.data.buffer) + return decodeHomeSessionPage(event.data.buffer, event.data.options) + })(), }) } catch (error) { self.postMessage({ id: event.data.id, error: error instanceof Error ? error.message : String(error) }) diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index 60ad01c8674..d1bf5d84ad5 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -21,6 +21,12 @@ export function useProviders(directory: Accessor) { const serverSync = useServerSync() const params = useParams() const dir = () => (directory ? directory() : decode64(params.dir)) + createEffect(() => { + const value = dir() + if (value) { + serverSync().enableProviders(value) + } + }) const providers = () => { const value = dir() const projectStore = value ? serverSync().child(value)[0] : undefined diff --git a/packages/app/src/pages/home/home-sessions-controller.tsx b/packages/app/src/pages/home/home-sessions-controller.tsx index c0ff0c88ae5..f175b014eef 100644 --- a/packages/app/src/pages/home/home-sessions-controller.tsx +++ b/packages/app/src/pages/home/home-sessions-controller.tsx @@ -1,16 +1,16 @@ import type { Session } from "@opencode-ai/sdk/v2/client" -import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useQuery } from "@tanstack/solid-query" import { DateTime } from "luxon" -import { type Accessor, createEffect, createMemo, createRoot, type JSX, startTransition } from "solid-js" +import { type Accessor, createEffect, createMemo, createSignal, onCleanup, type JSX, startTransition } from "solid-js" import { produce } from "solid-js/store" import { useCommand } from "@/context/command" import { - loadHomeSessionIndex, - retainHomeSessions, + loadProjectedHomeSessionIndex, type HomeSessionEvents, } from "@/context/global-sync/home-session-index" +import { takeRecentSessions } from "@/context/global-sync/session-trim" +import { decodeHomeSessionPage } from "@/context/session-message-decoder" import type { LocalProject } from "@/context/layout" import { useLanguage } from "@/context/language" import { ServerConnection } from "@/context/server" @@ -24,6 +24,7 @@ import { archiveHomeSession } from "../home-session-archive" import type { HomeController } from "./home-controller" const HOME_SESSION_LIMIT = 64 +const HOME_SESSION_RENDER_BATCH = 4 export type HomeSessionRecord = { session: Session project: LocalProject @@ -66,8 +67,17 @@ export function createHomeSessionsController(home: HomeController) { if (!ctx) return { sessions: [], eventSequence: 0 } const cache = homeSessions() const eventSequence = cache.eventSequence() - const index = await loadHomeSessionIndex( - (input, options) => ctx.sdk.client.v2.session.list(input, options), + const index = await loadProjectedHomeSessionIndex( + async (input, options) => { + const response = await ctx.sdk.client.v2.session.list(input, { ...options, parseAs: "arrayBuffer" }) + if (!(response.data instanceof ArrayBuffer)) throw new Error("Home session response is not an ArrayBuffer") + return { + data: await decodeHomeSessionPage(response.data, { + directories: projectDirectories(), + limit: HOME_SESSION_LIMIT, + }), + } + }, eventSequence, signal, ) @@ -79,13 +89,16 @@ export function createHomeSessionsController(home: HomeController) { refetchOnMount: true, refetchOnReconnect: true, })) - const indexedSessions = createMemo(() => - retainHomeSessions( - homeSessions().sessions(sessionLoad.data, sessionEventLoad.data), + const indexedSessions = createMemo(() => { + const directories = new Set(projectDirectories().map(pathKey)) + return takeRecentSessions( + homeSessions() + .sessions(sessionLoad.data, sessionEventLoad.data) + .filter((session) => directories.has(pathKey(session.directory))), HOME_SESSION_LIMIT, - Date.now(), - ), - ) + Number.NEGATIVE_INFINITY, + ) + }) const allRecords = createMemo(() => buildHomeSessionRecords({ sessions: indexedSessions, @@ -94,43 +107,21 @@ export function createHomeSessionsController(home: HomeController) { projectByID, }), ) - const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT)) - const groups = createMemo(() => groupSessions(records(), language)) - const prefetched = new Set() - + const [visible, setVisible] = createSignal(HOME_SESSION_RENDER_BATCH) + let revealFrame: number | undefined createEffect(() => { - const ctx = home.server.focusedContext() - const conn = home.server.focused() - if (!ctx || !conn) return - records() - .slice(0, 2) - .forEach((record) => { - const key = `${ServerConnection.key(conn)}\0${record.session.id}` - if (prefetched.has(key)) return - prefetched.add(key) - createRoot((dispose) => { - try { - void ctx.sync.session - .sync(record.session.id) - .then(() => - Promise.all( - (ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) => - (ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => { - if (part.type !== "text" || !part.text) return [] - return preloadMarkdown(part.text, part.id) - }), - ), - ), - ) - .catch(() => {}) - .finally(dispose) - } catch { - dispose() - } - }) - }) + const count = Math.min(allRecords().length, HOME_SESSION_LIMIT) + if (visible() >= count || revealFrame !== undefined) return + revealFrame = requestAnimationFrame(() => { + revealFrame = undefined + setVisible((current) => Math.min(current + HOME_SESSION_RENDER_BATCH, count)) + }) }) - + onCleanup(() => { + if (revealFrame !== undefined) cancelAnimationFrame(revealFrame) + }) + const records = createMemo(() => allRecords().slice(0, visible())) + const groups = createMemo(() => groupSessions(records(), language)) command.register("home.palette", () => [ { id: "command.palette", diff --git a/packages/app/src/pages/layout-new.tsx b/packages/app/src/pages/layout-new.tsx index 6d76ca84017..3c658413aab 100644 --- a/packages/app/src/pages/layout-new.tsx +++ b/packages/app/src/pages/layout-new.tsx @@ -1,11 +1,15 @@ -import { createEffect, Suspense, type ParentProps } from "solid-js" +import { createEffect, lazy, Suspense, type ParentProps } from "solid-js" import { createStore } from "solid-js/store" import { DebugBar } from "@/components/debug-bar" -import { TabsInfoPopup } from "@/components/help-button" -import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" +import type { TitlebarUpdate } from "@/components/titlebar" import { usePlatform } from "@/context/platform" import { setV2Toast, ToastRegion } from "@/utils/toast" +const Titlebar = lazy(() => import("@/components/titlebar").then((module) => ({ default: module.Titlebar }))) +const TabsInfoPopup = lazy(() => + import("@/components/help-button").then((module) => ({ default: module.TabsInfoPopup })), +) + export default function NewLayout(props: ParentProps) { const platform = usePlatform() const [state, setState] = createStore({ debugTools: true }) @@ -30,19 +34,23 @@ export default function NewLayout(props: ParentProps) { "padding-bottom": "env(safe-area-inset-bottom, 0px)", }} > - setState("debugTools", (value) => !value) } - : undefined - } - /> + }> + setState("debugTools", (value) => !value) } + : undefined + } + /> +
{props.children}
{import.meta.env.DEV && state.debugTools && } - + + + ) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 7fb56391a6f..8f00f4c2398 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -142,8 +142,11 @@ const main = Effect.gen(function* () { app.setAppUserModelId(appId) app.setPath( "userData", - onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId), + process.env.OPENCODE_PROFILE_USER_DATA ?? + (onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId)), ) + if (process.env.OPENCODE_PROFILE_USER_DATA) + app.setPath("sessionData", join(process.env.OPENCODE_PROFILE_USER_DATA, "session")) if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session")) initializeOldLayoutEligibility(app.getPath("userData")) logger = initLogging() @@ -191,9 +194,16 @@ const main = Effect.gen(function* () { ensureLoopbackNoProxy() useEnvProxy() app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") - const features = app.commandLine.getSwitchValue("enable-features") - app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature) - if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222") + const features = [ + jsCallStackFeature, + process.env.OPENCODE_PROFILE_LOAF === "1" ? "AlwaysLogLOAFURL" : "", + app.commandLine.getSwitchValue("enable-features"), + ] + .filter(Boolean) + .join(",") + app.commandLine.appendSwitch("enable-features", features) + if (!app.isPackaged) + app.commandLine.appendSwitch("remote-debugging-port", process.env.OPENCODE_PROFILE_CDP_PORT ?? "9222") if (!app.requestSingleInstanceLock()) { app.quit() diff --git a/packages/desktop/src/renderer/i18n/index.ts b/packages/desktop/src/renderer/i18n/index.ts index e9ffa1e4c9d..8a1aa07372c 100644 --- a/packages/desktop/src/renderer/i18n/index.ts +++ b/packages/desktop/src/renderer/i18n/index.ts @@ -1,62 +1,7 @@ import * as i18n from "@solid-primitives/i18n" import { dict as desktopEn } from "./en" -import { dict as desktopZh } from "./zh" -import { dict as desktopZht } from "./zht" -import { dict as desktopKo } from "./ko" -import { dict as desktopDe } from "./de" -import { dict as desktopEs } from "./es" -import { dict as desktopFr } from "./fr" -import { dict as desktopDa } from "./da" -import { dict as desktopJa } from "./ja" -import { dict as desktopPl } from "./pl" -import { dict as desktopRu } from "./ru" -import { dict as desktopUk } from "./uk" -import { dict as desktopAr } from "./ar" -import { dict as desktopNo } from "./no" -import { dict as desktopBr } from "./br" -import { dict as desktopBs } from "./bs" -import { dict as desktopTr } from "./tr" -import { dict as desktopHi } from "./hi" -import { dict as desktopNl } from "./nl" -import { dict as desktopId } from "./id" -import { dict as desktopVi } from "./vi" -import { dict as desktopIt } from "./it" -import { dict as desktopUr } from "./ur" -import { dict as desktopPa } from "./pa" -import { dict as desktopAz } from "./az" -import { dict as desktopFi } from "./fi" -import { dict as desktopSv } from "./sv" -import { dict as desktopTh } from "./th" - import { dict as appEn } from "../../../../app/src/i18n/en" -import { dict as appZh } from "../../../../app/src/i18n/zh" -import { dict as appZht } from "../../../../app/src/i18n/zht" -import { dict as appKo } from "../../../../app/src/i18n/ko" -import { dict as appDe } from "../../../../app/src/i18n/de" -import { dict as appEs } from "../../../../app/src/i18n/es" -import { dict as appFr } from "../../../../app/src/i18n/fr" -import { dict as appDa } from "../../../../app/src/i18n/da" -import { dict as appJa } from "../../../../app/src/i18n/ja" -import { dict as appPl } from "../../../../app/src/i18n/pl" -import { dict as appRu } from "../../../../app/src/i18n/ru" -import { dict as appUk } from "../../../../app/src/i18n/uk" -import { dict as appAr } from "../../../../app/src/i18n/ar" -import { dict as appNo } from "../../../../app/src/i18n/no" -import { dict as appBr } from "../../../../app/src/i18n/br" -import { dict as appBs } from "../../../../app/src/i18n/bs" -import { dict as appTr } from "../../../../app/src/i18n/tr" -import { dict as appHi } from "../../../../app/src/i18n/hi" -import { dict as appNl } from "../../../../app/src/i18n/nl" -import { dict as appId } from "../../../../app/src/i18n/id" -import { dict as appVi } from "../../../../app/src/i18n/vi" -import { dict as appIt } from "../../../../app/src/i18n/it" -import { dict as appUr } from "../../../../app/src/i18n/ur" -import { dict as appPa } from "../../../../app/src/i18n/pa" -import { dict as appAz } from "../../../../app/src/i18n/az" -import { dict as appFi } from "../../../../app/src/i18n/fi" -import { dict as appSv } from "../../../../app/src/i18n/sv" -import { dict as appTh } from "../../../../app/src/i18n/th" export type Locale = | "en" @@ -217,35 +162,40 @@ function pickLocale(value: unknown): Locale | null { const base = i18n.flatten({ ...appEn, ...desktopEn }) -function build(locale: Locale): Dictionary { +const loaders = { + zh: () => Promise.all([import("../../../../app/src/i18n/zh"), import("./zh")]), + zht: () => Promise.all([import("../../../../app/src/i18n/zht"), import("./zht")]), + ko: () => Promise.all([import("../../../../app/src/i18n/ko"), import("./ko")]), + de: () => Promise.all([import("../../../../app/src/i18n/de"), import("./de")]), + es: () => Promise.all([import("../../../../app/src/i18n/es"), import("./es")]), + fr: () => Promise.all([import("../../../../app/src/i18n/fr"), import("./fr")]), + da: () => Promise.all([import("../../../../app/src/i18n/da"), import("./da")]), + ja: () => Promise.all([import("../../../../app/src/i18n/ja"), import("./ja")]), + pl: () => Promise.all([import("../../../../app/src/i18n/pl"), import("./pl")]), + ru: () => Promise.all([import("../../../../app/src/i18n/ru"), import("./ru")]), + uk: () => Promise.all([import("../../../../app/src/i18n/uk"), import("./uk")]), + ar: () => Promise.all([import("../../../../app/src/i18n/ar"), import("./ar")]), + no: () => Promise.all([import("../../../../app/src/i18n/no"), import("./no")]), + br: () => Promise.all([import("../../../../app/src/i18n/br"), import("./br")]), + bs: () => Promise.all([import("../../../../app/src/i18n/bs"), import("./bs")]), + tr: () => Promise.all([import("../../../../app/src/i18n/tr"), import("./tr")]), + hi: () => Promise.all([import("../../../../app/src/i18n/hi"), import("./hi")]), + nl: () => Promise.all([import("../../../../app/src/i18n/nl"), import("./nl")]), + id: () => Promise.all([import("../../../../app/src/i18n/id"), import("./id")]), + vi: () => Promise.all([import("../../../../app/src/i18n/vi"), import("./vi")]), + it: () => Promise.all([import("../../../../app/src/i18n/it"), import("./it")]), + ur: () => Promise.all([import("../../../../app/src/i18n/ur"), import("./ur")]), + pa: () => Promise.all([import("../../../../app/src/i18n/pa"), import("./pa")]), + az: () => Promise.all([import("../../../../app/src/i18n/az"), import("./az")]), + fi: () => Promise.all([import("../../../../app/src/i18n/fi"), import("./fi")]), + sv: () => Promise.all([import("../../../../app/src/i18n/sv"), import("./sv")]), + th: () => Promise.all([import("../../../../app/src/i18n/th"), import("./th")]), +} + +async function build(locale: Locale): Promise { if (locale === "en") return base - if (locale === "zh") return { ...base, ...i18n.flatten(appZh), ...i18n.flatten(desktopZh) } - if (locale === "zht") return { ...base, ...i18n.flatten(appZht), ...i18n.flatten(desktopZht) } - if (locale === "de") return { ...base, ...i18n.flatten(appDe), ...i18n.flatten(desktopDe) } - if (locale === "es") return { ...base, ...i18n.flatten(appEs), ...i18n.flatten(desktopEs) } - if (locale === "fr") return { ...base, ...i18n.flatten(appFr), ...i18n.flatten(desktopFr) } - if (locale === "da") return { ...base, ...i18n.flatten(appDa), ...i18n.flatten(desktopDa) } - if (locale === "ja") return { ...base, ...i18n.flatten(appJa), ...i18n.flatten(desktopJa) } - if (locale === "pl") return { ...base, ...i18n.flatten(appPl), ...i18n.flatten(desktopPl) } - if (locale === "ru") return { ...base, ...i18n.flatten(appRu), ...i18n.flatten(desktopRu) } - if (locale === "uk") return { ...base, ...i18n.flatten(appUk), ...i18n.flatten(desktopUk) } - if (locale === "ar") return { ...base, ...i18n.flatten(appAr), ...i18n.flatten(desktopAr) } - if (locale === "no") return { ...base, ...i18n.flatten(appNo), ...i18n.flatten(desktopNo) } - if (locale === "br") return { ...base, ...i18n.flatten(appBr), ...i18n.flatten(desktopBr) } - if (locale === "bs") return { ...base, ...i18n.flatten(appBs), ...i18n.flatten(desktopBs) } - if (locale === "tr") return { ...base, ...i18n.flatten(appTr), ...i18n.flatten(desktopTr) } - if (locale === "hi") return { ...base, ...i18n.flatten(appHi), ...i18n.flatten(desktopHi) } - if (locale === "nl") return { ...base, ...i18n.flatten(appNl), ...i18n.flatten(desktopNl) } - if (locale === "id") return { ...base, ...i18n.flatten(appId), ...i18n.flatten(desktopId) } - if (locale === "vi") return { ...base, ...i18n.flatten(appVi), ...i18n.flatten(desktopVi) } - if (locale === "it") return { ...base, ...i18n.flatten(appIt), ...i18n.flatten(desktopIt) } - if (locale === "ur") return { ...base, ...i18n.flatten(appUr), ...i18n.flatten(desktopUr) } - if (locale === "pa") return { ...base, ...i18n.flatten(appPa), ...i18n.flatten(desktopPa) } - if (locale === "az") return { ...base, ...i18n.flatten(appAz), ...i18n.flatten(desktopAz) } - if (locale === "fi") return { ...base, ...i18n.flatten(appFi), ...i18n.flatten(desktopFi) } - if (locale === "sv") return { ...base, ...i18n.flatten(appSv), ...i18n.flatten(desktopSv) } - if (locale === "th") return { ...base, ...i18n.flatten(appTh), ...i18n.flatten(desktopTh) } - return { ...base, ...i18n.flatten(appKo), ...i18n.flatten(desktopKo) } + const dictionaries = await loaders[locale]() + return { ...base, ...i18n.flatten(dictionaries[0].dict), ...i18n.flatten(dictionaries[1].dict) } } const state = { @@ -254,8 +204,6 @@ const state = { init: undefined as Promise | undefined, } -state.dict = build(state.locale) - const translate = i18n.translator(() => state.dict, i18n.resolveTemplate) export function t(key: keyof Dictionary, params?: Record) { @@ -272,7 +220,7 @@ export function initI18n(): Promise { const next = pickLocale(value) ?? state.locale state.locale = next - state.dict = build(next) + state.dict = await build(next) return next })().catch(() => state.locale) diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index b5c0254c0e4..ddb4eb1f81b 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -22,7 +22,7 @@ import { createMemoryHistory, MemoryRouter, type BaseRouterProps } from "@solidj import { createEffect, createMemo, createResource, createSignal, onCleanup, Show } from "solid-js" import { render } from "solid-js/web" import pkg from "../../package.json" -import { t } from "./i18n" +import { initI18n, t } from "./i18n" import { initializationData } from "./initialization" import { DesktopFirstLaunchOnboarding } from "./onboarding" import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom" @@ -60,6 +60,8 @@ if (import.meta.env.VITE_SENTRY_DSN) { }) } +void initI18n() + const [updaterState, setUpdaterState] = createSignal({ status: "disabled" }) void window.api.updater.subscribe(setUpdaterState)