fix(app): improve renderer startup performance

This commit is contained in:
LukeParkerDev 2026-08-04 22:09:10 +10:00
parent 8c7c69c749
commit 27ecc46dc7
20 changed files with 1104 additions and 211 deletions

View file

@ -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

View file

@ -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<ReturnType<typeof chromium.connectOverCDP>> | 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<void>((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<HTMLButtonElement>('[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<number, number>()
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<string> }
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<void>((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<ReturnType<typeof chromium.connectOverCDP>>) {
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<Uint8Array>) {
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
}

View file

@ -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()

View file

@ -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<ModelItem>({
items: (_filter) => models.list(),

View file

@ -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<string, boolean> }),

View file

@ -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<any>)[]
await waitForPaint()

View file

@ -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 {

View file

@ -47,6 +47,7 @@ export function createChildStoreManager(input: {
const mcpToggles = new Map<string, (enabled: boolean) => void>()
const activeDirectories = new Set<string>()
const activationToggles = new Map<string, (enabled: boolean) => void>()
const providerToggles = new Map<string, (enabled: boolean) => 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> | 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,

View file

@ -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<ProjectedHomeSessionPage>,
eventSequence = 0,
signal?: AbortSignal,
) {
return loadHomeSessionPages(list, (sessions) => sessions, eventSequence, signal)
}
async function loadHomeSessionPages<T>(
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
}
}

View file

@ -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<typeof setTimeout> | undefined
let providerFrame: number | undefined
let providerIdle: number | undefined
let providerTimer: ReturnType<typeof setTimeout> | 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,

View file

@ -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"])
})

View file

@ -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,

View file

@ -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<SessionInfo[]>("sessions", buffer)
}
function decode<T>(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<T>(
type: "messages" | "sessions" | "homeSessions",
buffer: ArrayBuffer,
options?: { directories: string[]; limit: number },
) {
const id = ++nextID
return new Promise<T>((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])
})
}

View file

@ -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<DecoderRequest>) => {
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) })

View file

@ -21,6 +21,12 @@ export function useProviders(directory: Accessor<string | undefined>) {
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

View file

@ -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<string>()
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",

View file

@ -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)",
}}
>
<Titlebar
update={update}
debugTools={
import.meta.env.DEV
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
: undefined
}
/>
<Suspense fallback={<div class="h-10 shrink-0" />}>
<Titlebar
update={update}
debugTools={
import.meta.env.DEV
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
: undefined
}
/>
</Suspense>
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<Suspense>{props.children}</Suspense>
</main>
{import.meta.env.DEV && state.debugTools && <DebugBar inline />}
<TabsInfoPopup />
<Suspense>
<TabsInfoPopup />
</Suspense>
<ToastRegion v2 />
</div>
)

View file

@ -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()

View file

@ -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<Dictionary> {
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<Locale> | undefined,
}
state.dict = build(state.locale)
const translate = i18n.translator(() => state.dict, i18n.resolveTemplate)
export function t(key: keyof Dictionary, params?: Record<string, string | number>) {
@ -272,7 +220,7 @@ export function initI18n(): Promise<Locale> {
const next = pickLocale(value) ?? state.locale
state.locale = next
state.dict = build(next)
state.dict = await build(next)
return next
})().catch(() => state.locale)

View file

@ -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<UpdaterState>({ status: "disabled" })
void window.api.updater.subscribe(setUpdaterState)