From 4062b304092dd3efbd99ea216ae90e8ad7c79088 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:42:39 +1000 Subject: [PATCH 01/13] fix(app): reduce session history page size --- .../app/e2e/regression/session-timeline-history-root.spec.ts | 2 +- packages/app/src/context/server-session.ts | 2 +- packages/app/src/pages/layout.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts index e5ef7998ea7..4ea64c3155c 100644 --- a/packages/app/e2e/regression/session-timeline-history-root.spec.ts +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -18,7 +18,7 @@ import { installSseTransport } from "../utils/sse-transport" import { expectSessionTitle } from "../utils/waits" const initialPageSize = 20 -const historyPageSize = 200 +const historyPageSize = 50 const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) => assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 69bec61ba2f..904f4b9a90f 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -29,7 +29,7 @@ const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const initialMessagePageSize = 20 -const historyMessagePageSize = 200 +const historyMessagePageSize = 50 const sessionInfoLimit = 2_048 const emptyIDs: ReadonlySet = new Set() diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 95f8c01843f..d7a1a7e8cf0 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -634,7 +634,7 @@ export default function LegacyLayout(props: ParentProps) { running: number } - const prefetchChunk = 200 + const prefetchChunk = 50 const prefetchConcurrency = 2 const prefetchPendingLimit = 10 const span = 4 From 7192fa8b7a6263e61e5e7f6b8223d4404651a27a Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:47:24 +1000 Subject: [PATCH 02/13] fix(app): yield before session history ingestion --- packages/app/src/context/server-session.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 904f4b9a90f..967e76660fa 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -33,6 +33,12 @@ const historyMessagePageSize = 50 const sessionInfoLimit = 2_048 const emptyIDs: ReadonlySet = new Set() +function yieldToMain() { + const scheduler = (globalThis as { scheduler?: { yield: () => Promise } }).scheduler + if (scheduler) return scheduler.yield() + return new Promise((resolve) => setTimeout(resolve, 0)) +} + function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) { const boundary = source.find( (message) => @@ -552,6 +558,7 @@ export function createServerSession( if (!response.data.length) break } const response = pages.at(-1)! + await yieldToMain() const source = pages.flatMap((page) => page.data).toReversed() const normalized = normalizeSessionMessages(sessionID, source) return { @@ -570,6 +577,7 @@ export function createServerSession( onAttempt?.() return client.session.messages({ sessionID, limit, before }) }) + await yieldToMain() const items = (response.data ?? []).filter((item) => !!item?.info?.id) return { session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)), From 7b7335b7e9b59e0a2df1fbdfc4226aba3b007294 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:24:03 +1000 Subject: [PATCH 03/13] fix(app): avoid renderer Shiki WASM startup --- packages/session-ui/src/pierre/worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/session-ui/src/pierre/worker.ts b/packages/session-ui/src/pierre/worker.ts index 1c1bebb40b5..9456bddc70a 100644 --- a/packages/session-ui/src/pierre/worker.ts +++ b/packages/session-ui/src/pierre/worker.ts @@ -24,7 +24,7 @@ function createPool(lineDiffType: "none" | "word-alt") { { theme: "OpenCode", lineDiffType, - preferredHighlighter: "shiki-wasm", + preferredHighlighter: "shiki-js", }, ) From 1277ceb426c408005d55d60f8c22f3c95cd1f8f3 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:16:10 +1000 Subject: [PATCH 04/13] fix(app): stop automatic session neighbor prefetch --- packages/app/src/pages/layout.tsx | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index d7a1a7e8cf0..b60b4949fb0 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -777,18 +777,11 @@ export default function LegacyLayout(props: ParentProps) { } createEffect(() => { + if (params.id) return const sessions = currentSessions() if (sessions.length === 0) return - - const index = params.id ? sessions.findIndex((s) => s.id === params.id) : 0 - if (index === -1) return - - if (!params.id) { - const first = sessions[index] - if (first) prefetchSession(first, "high") - } - - warm(sessions, index) + const first = sessions[0] + if (first) prefetchSession(first, "high") }) function navigateSessionByOffset(offset: number) { From a47dabff22a4313f59420c4a4bc72a0cbaa3399f Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:33:53 +1000 Subject: [PATCH 05/13] fix(app): parse session messages off thread --- packages/app/src/context/server-session.ts | 18 +++++++-- packages/app/src/context/server-sync.tsx | 2 + .../src/context/session-message-decoder.ts | 38 +++++++++++++++++++ .../context/session-message-decoder.worker.ts | 12 ++++++ 4 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 packages/app/src/context/session-message-decoder.ts create mode 100644 packages/app/src/context/session-message-decoder.worker.ts diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 967e76660fa..798f2952856 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -189,7 +189,11 @@ function reconcileFetched( return [...result.values()].sort((a, b) => cmp(a.id, b.id)) } -type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> } +type ServerSessionOptions = { + retry?: typeof retry + protocol?: Promise<"v1" | "v2"> + decodeMessages?: (buffer: ArrayBuffer) => Promise +} export function createServerSession( client: OpencodeClient, @@ -573,9 +577,17 @@ export function createServerSession( complete: response.data.length === 0, } } - const response = await (options?.retry ?? retry)(() => { + const response = await (options?.retry ?? retry)(async () => { onAttempt?.() - return client.session.messages({ sessionID, limit, before }) + if (!options?.decodeMessages) return client.session.messages({ sessionID, limit, before }) + const response = await client.session.messages({ sessionID, limit, before }, { parseAs: "arrayBuffer" }) + if (!(response.data instanceof ArrayBuffer)) throw new Error("Session messages response is not an ArrayBuffer") + return { + ...response, + data: await options.decodeMessages>["data"]>>( + response.data, + ), + } }) await yieldToMain() const items = (response.data ?? []).filter((item) => !!item?.info?.id) diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 13a0b74bc6f..82fa994eb28 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -59,6 +59,7 @@ import type { } from "@opencode-ai/client/promise" import { toggleMcp } from "./global-sync/mcp" import { createServerSession, type ServerSession } from "./server-session" +import { decodeSessionMessages } from "./session-message-decoder" type GlobalStore = { ready: boolean @@ -226,6 +227,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, { protocol: serverSDK.protocol, + decodeMessages: decodeSessionMessages, }) const queryOptionsApi = makeQueryOptionsApi( serverSDK.scope, diff --git a/packages/app/src/context/session-message-decoder.ts b/packages/app/src/context/session-message-decoder.ts new file mode 100644 index 00000000000..691fa00e6fd --- /dev/null +++ b/packages/app/src/context/session-message-decoder.ts @@ -0,0 +1,38 @@ +import SessionMessageDecoderWorkerUrl from "./session-message-decoder.worker.ts?worker&url" + +type Response = { id: number; data?: unknown; error?: string } + +let worker: Worker | undefined +let nextID = 0 +const pending = new Map void; reject: (error: Error) => void }>() + +export function decodeSessionMessages(buffer: ArrayBuffer) { + const id = ++nextID + return new Promise((resolve, reject) => { + pending.set(id, { resolve: (value) => resolve(value as T), reject }) + getWorker().postMessage({ id, buffer }, [buffer]) + }) +} + +function getWorker() { + if (worker) return worker + worker = new Worker(SessionMessageDecoderWorkerUrl, { type: "module" }) + worker.onmessage = (event: MessageEvent) => { + const request = pending.get(event.data.id) + if (!request) return + pending.delete(event.data.id) + if (event.data.error) { + request.reject(new Error(event.data.error)) + return + } + request.resolve(event.data.data) + } + worker.onerror = (event) => { + const error = new Error(event.message) + pending.forEach((request) => request.reject(error)) + pending.clear() + worker?.terminate() + worker = undefined + } + return worker +} diff --git a/packages/app/src/context/session-message-decoder.worker.ts b/packages/app/src/context/session-message-decoder.worker.ts new file mode 100644 index 00000000000..024fee72d7d --- /dev/null +++ b/packages/app/src/context/session-message-decoder.worker.ts @@ -0,0 +1,12 @@ +type DecoderRequest = { id: number; buffer: ArrayBuffer } + +self.onmessage = (event: MessageEvent) => { + try { + const text = new TextDecoder().decode(event.data.buffer) + self.postMessage({ id: event.data.id, data: text ? JSON.parse(text) : {} }) + } catch (error) { + self.postMessage({ id: event.data.id, error: error instanceof Error ? error.message : String(error) }) + } +} + +export {} From ccc11dc92d8d1cce4e65e9124516105d6290e4e9 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:07:35 +1000 Subject: [PATCH 06/13] fix(app): move hot response processing off thread --- packages/app/src/context/server-sdk.tsx | 6 +- packages/app/src/context/server-session.ts | 17 ++-- .../context/session-message-decode.test.ts | 42 ++++++++++ .../app/src/context/session-message-decode.ts | 77 ++++++++++++++++++ .../src/context/session-message-decoder.ts | 14 +++- .../context/session-message-decoder.worker.ts | 13 ++- packages/app/src/pages/session.tsx | 8 +- packages/app/src/utils/server-compat.test.ts | 4 + packages/app/src/utils/server-compat.ts | 81 +++++++------------ packages/app/src/utils/vcs-diff-data.ts | 20 +++++ packages/app/src/utils/vcs-diff-decoder.ts | 61 ++++++++++++++ .../app/src/utils/vcs-diff-decoder.worker.ts | 13 +++ 12 files changed, 290 insertions(+), 66 deletions(-) create mode 100644 packages/app/src/context/session-message-decode.test.ts create mode 100644 packages/app/src/context/session-message-decode.ts create mode 100644 packages/app/src/utils/vcs-diff-data.ts create mode 100644 packages/app/src/utils/vcs-diff-decoder.ts create mode 100644 packages/app/src/utils/vcs-diff-decoder.worker.ts diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 7dd2a6e59ed..df64b33c10e 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -13,6 +13,8 @@ import { useGlobal } from "./global" import { ServerScope } from "@/utils/server-scope" import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol" import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat" +import { decodeVcsDiff } from "@/utils/vcs-diff-decoder" +import { decodeSessionList } from "./session-message-decoder" const isAbortError = (error: unknown) => error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" @@ -346,7 +348,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS throwOnError: true, directory, }) - const api = createCompatibleApi({ protocol, current: currentApi, legacy }) + const api = createCompatibleApi({ protocol, current: currentApi, legacy, decodeVcsDiff, decodeSessionList }) return { server, @@ -432,6 +434,8 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { current: serverSDK.currentApi, legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }), directory, + decodeVcsDiff, + decodeSessionList, }), event: emitter, get url() { diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 798f2952856..49daa9bd972 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -22,6 +22,7 @@ import { normalizeSessionMessages } from "@/utils/session-message" import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer" import type { ServerApi } from "@/utils/server" +import type { DecodedLegacyMessagePage } from "./session-message-decode" type MessageApi = ServerApi["message"] @@ -192,7 +193,7 @@ function reconcileFetched( type ServerSessionOptions = { retry?: typeof retry protocol?: Promise<"v1" | "v2"> - decodeMessages?: (buffer: ArrayBuffer) => Promise + decodeMessages?: (buffer: ArrayBuffer) => Promise } export function createServerSession( @@ -582,14 +583,16 @@ export function createServerSession( if (!options?.decodeMessages) return client.session.messages({ sessionID, limit, before }) const response = await client.session.messages({ sessionID, limit, before }, { parseAs: "arrayBuffer" }) if (!(response.data instanceof ArrayBuffer)) throw new Error("Session messages response is not an ArrayBuffer") - return { - ...response, - data: await options.decodeMessages>["data"]>>( - response.data, - ), - } + return { response, decoded: await options.decodeMessages(response.data) } }) await yieldToMain() + if ("decoded" in response) + return { + ...response.decoded, + sourceMode: before ? ("older" as const) : ("latest" as const), + cursor: response.response.response.headers.get("x-next-cursor") ?? undefined, + complete: !response.response.response.headers.get("x-next-cursor"), + } const items = (response.data ?? []).filter((item) => !!item?.info?.id) return { session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)), diff --git a/packages/app/src/context/session-message-decode.test.ts b/packages/app/src/context/session-message-decode.test.ts new file mode 100644 index 00000000000..6bcaff013a7 --- /dev/null +++ b/packages/app/src/context/session-message-decode.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test" +import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client" +import { decodeLegacyMessagePage, decodeLegacySessionList } from "./session-message-decode" + +test("decodes and projects a legacy message page", () => { + const info = { + id: "message", + sessionID: "session", + role: "user", + time: { created: 1 }, + agent: "build", + model: { providerID: "provider", modelID: "model" }, + } as Message + const part = { + id: "part", + sessionID: "session", + messageID: info.id, + type: "text", + text: "hello", + } as Part + const result = decodeLegacyMessagePage(new TextEncoder().encode(JSON.stringify([{ info, parts: [part] }])).buffer) + + expect(result.session).toEqual([info]) + expect(result.part).toEqual([{ id: info.id, part: [part] }]) + expect(result.source).toEqual([{ id: info.id, type: "user", text: "hello", time: info.time }]) +}) + +test("decodes and projects a legacy session list", () => { + const session = { + id: "session", + projectID: "project", + directory: "/repo", + title: "Session", + version: "1", + time: { created: 1, updated: 1 }, + } as Session + const result = decodeLegacySessionList(new TextEncoder().encode(JSON.stringify([session])).buffer) + + expect(result).toEqual([ + expect.objectContaining({ id: session.id, title: session.title, location: { directory: "/repo" } }), + ]) +}) diff --git a/packages/app/src/context/session-message-decode.ts b/packages/app/src/context/session-message-decode.ts new file mode 100644 index 00000000000..391ded2d756 --- /dev/null +++ b/packages/app/src/context/session-message-decode.ts @@ -0,0 +1,77 @@ +import type { SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise" +import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client" +import { message as cleanMessage } from "@/utils/diffs" + +export type DecodedLegacyMessagePage = { + session: Message[] + part: { id: string; part: Part[] }[] + source: SessionMessageInfo[] +} + +export function decodeLegacyMessagePage(buffer: ArrayBuffer): DecodedLegacyMessagePage { + const text = new TextDecoder().decode(buffer) + const items = (text ? (JSON.parse(text) as { info?: Message; parts?: Part[] }[]) : []).filter( + (item): item is { info: Message; parts: Part[] } => !!item.info?.id && Array.isArray(item.parts), + ) + return { + session: items.map((item) => cleanMessage(item.info)).sort((a, b) => compare(a.id, b.id)), + part: items.map((item) => ({ + id: item.info.id, + part: item.parts.filter((part) => !!part?.id).sort((a, b) => compare(a.id, b.id)), + })), + source: items + .slice() + .sort((a, b) => compare(a.info.id, b.info.id)) + .map((item) => + item.info.role === "user" + ? { + id: item.info.id, + type: "user" as const, + text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"), + time: item.info.time, + } + : { + id: item.info.id, + type: "assistant" as const, + agent: item.info.agent ?? item.info.mode, + model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant }, + content: [], + time: item.info.time, + }, + ), + } +} + +export function decodeLegacySessionList(buffer: ArrayBuffer) { + const text = new TextDecoder().decode(buffer) + return (text ? (JSON.parse(text) as Session[]) : []).map(legacySessionInfo) +} + +export function legacySessionInfo(session: Session): SessionInfo { + return { + id: session.id, + parentID: session.parentID, + projectID: session.projectID, + agent: session.agent, + model: session.model && { + id: session.model.id, + providerID: session.model.providerID, + variant: session.model.variant, + }, + cost: session.cost ?? 0, + tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: session.time, + title: session.title, + location: { directory: session.directory, workspaceID: session.workspaceID }, + subpath: session.path, + revert: session.revert && { + messageID: session.revert.messageID, + partID: session.revert.partID, + snapshot: session.revert.snapshot, + }, + } +} + +function compare(a: string, b: string) { + return a < b ? -1 : a > b ? 1 : 0 +} diff --git a/packages/app/src/context/session-message-decoder.ts b/packages/app/src/context/session-message-decoder.ts index 691fa00e6fd..72d945c8a1d 100644 --- a/packages/app/src/context/session-message-decoder.ts +++ b/packages/app/src/context/session-message-decoder.ts @@ -1,4 +1,6 @@ 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" type Response = { id: number; data?: unknown; error?: string } @@ -6,11 +8,19 @@ let worker: Worker | undefined let nextID = 0 const pending = new Map void; reject: (error: Error) => void }>() -export function decodeSessionMessages(buffer: ArrayBuffer) { +export function decodeSessionMessages(buffer: ArrayBuffer) { + return decode("messages", buffer) +} + +export function decodeSessionList(buffer: ArrayBuffer) { + return decode("sessions", buffer) +} + +function decode(type: "messages" | "sessions", buffer: ArrayBuffer) { const id = ++nextID return new Promise((resolve, reject) => { pending.set(id, { resolve: (value) => resolve(value as T), reject }) - getWorker().postMessage({ id, buffer }, [buffer]) + getWorker().postMessage({ id, type, buffer }, [buffer]) }) } diff --git a/packages/app/src/context/session-message-decoder.worker.ts b/packages/app/src/context/session-message-decoder.worker.ts index 024fee72d7d..52b9fc45d59 100644 --- a/packages/app/src/context/session-message-decoder.worker.ts +++ b/packages/app/src/context/session-message-decoder.worker.ts @@ -1,9 +1,16 @@ -type DecoderRequest = { id: number; buffer: ArrayBuffer } +import { decodeLegacyMessagePage, decodeLegacySessionList } from "./session-message-decode" + +type DecoderRequest = { id: number; type: "messages" | "sessions"; buffer: ArrayBuffer } self.onmessage = (event: MessageEvent) => { try { - const text = new TextDecoder().decode(event.data.buffer) - self.postMessage({ id: event.data.id, data: text ? JSON.parse(text) : {} }) + self.postMessage({ + id: event.data.id, + data: + event.data.type === "messages" + ? decodeLegacyMessagePage(event.data.buffer) + : decodeLegacySessionList(event.data.buffer), + }) } catch (error) { self.postMessage({ id: event.data.id, error: error instanceof Error ? error.message : String(error) }) } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 4de1446cdff..003317340c2 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -690,8 +690,12 @@ export default function Page() { queryFn: mode ? () => sdk() - .api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode }) - .then((result) => result.data) + .api.vcs.diff({ + location: { directory: sdk().directory }, + mode: mode === "git" ? "working" : mode, + context: 0, + }) + .then((result) => result.data.map((diff) => ({ ...diff, patch: "" }))) .catch((error) => { console.debug("[session-review] failed to load vcs diff", { mode, error }) return [] diff --git a/packages/app/src/utils/server-compat.test.ts b/packages/app/src/utils/server-compat.test.ts index 52e5ec6e3be..854ca8c4158 100644 --- a/packages/app/src/utils/server-compat.test.ts +++ b/packages/app/src/utils/server-compat.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test" import { createApiForServer, createSdkForServer } from "./server" import { createCompatibleApi } from "./server-compat" +import { decodeVcsDiffData } from "./vcs-diff-data" +import { decodeLegacySessionList } from "@/context/session-message-decode" function setup( protocol: "v1" | "v2" | Promise<"v1" | "v2">, @@ -48,6 +50,8 @@ function setup( current: createApiForServer({ server, fetch: fetcher }), legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }), directory: "/repo", + decodeVcsDiff: async (buffer) => decodeVcsDiffData(buffer), + decodeSessionList: async (buffer) => decodeLegacySessionList(buffer), }) return { api, requests } } diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts index 1df1338b71e..9e1b62614d2 100644 --- a/packages/app/src/utils/server-compat.ts +++ b/packages/app/src/utils/server-compat.ts @@ -1,7 +1,8 @@ import type { ServerApi } from "./server" import type { ServerProtocol } from "./server-protocol" -import type { AgentPartInput, FilePartInput, OpencodeClient, Session, TextPartInput } from "@opencode-ai/sdk/v2/client" +import type { AgentPartInput, FilePartInput, OpencodeClient, TextPartInput } from "@opencode-ai/sdk/v2/client" import type { + FileDiffInfo, Project, ProjectCurrent, SessionApi, @@ -15,6 +16,7 @@ import type { SessionShellInput, SessionShellOutput, } from "@opencode-ai/client/promise" +import { legacySessionInfo } from "@/context/session-message-decode" type LegacyClient = OpencodeClient type LegacyFor = (directory?: string) => LegacyClient @@ -51,6 +53,8 @@ type CompatibleInput = { current: ServerApi legacy: LegacyFor directory?: string + decodeVcsDiff: (buffer: ArrayBuffer) => Promise + decodeSessionList: (buffer: ArrayBuffer) => Promise } function mime(uri: string) { @@ -58,31 +62,6 @@ function mime(uri: string) { return match?.[1] ?? "application/octet-stream" } -function sessionInfo(session: Session): SessionInfo { - return { - id: session.id, - parentID: session.parentID, - projectID: session.projectID, - agent: session.agent, - model: session.model && { - id: session.model.id, - providerID: session.model.providerID, - variant: session.model.variant, - }, - cost: session.cost ?? 0, - tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: session.time, - title: session.title, - location: { directory: session.directory, workspaceID: session.workspaceID }, - subpath: session.path, - revert: session.revert && { - messageID: session.revert.messageID, - partID: session.revert.partID, - snapshot: session.revert.snapshot, - }, - } -} - export function createCompatibleApi(input: CompatibleInput): CompatibleApi { const v1 = createV1Api(input) return lazyApi( @@ -148,29 +127,34 @@ function createV1Api(input: CompatibleInput): CompatibleApi { search: value.search, limit: value.limit, }, - options, + { ...options, parseAs: "arrayBuffer" }, ) - return { data: (result.data ?? []).map(sessionInfo), cursor: {} } + if (!(result.data instanceof ArrayBuffer)) throw new Error("Session list response is not an ArrayBuffer") + return { data: await input.decodeSessionList(result.data), cursor: {} } } - const result = await legacy({ directory: value?.directory }).session.list({ - directory: value?.directory, - roots: value?.parentID === null ? true : undefined, - search: value?.search, - limit: value?.limit, - }) - return { data: (result.data ?? []).map(sessionInfo), cursor: {} } + const result = await legacy({ directory: value?.directory }).session.list( + { + directory: value?.directory, + roots: value?.parentID === null ? true : undefined, + search: value?.search, + limit: value?.limit, + }, + { parseAs: "arrayBuffer" }, + ) + if (!(result.data instanceof ArrayBuffer)) throw new Error("Session list response is not an ArrayBuffer") + return { data: await input.decodeSessionList(result.data), cursor: {} } }, async create(value?: Parameters[0]) { const result = await legacy(value?.location ?? undefined).session.create({ directory: directory(value?.location ?? undefined), }) if (!result.data) throw new Error("Failed to create session") - return sessionInfo(result.data) + return legacySessionInfo(result.data) }, async get(value: Parameters[0]) { const result = await legacy().session.get(value) if (!result.data) throw new Error(`Session not found: ${value.sessionID}`) - return sessionInfo(result.data) + return legacySessionInfo(result.data) }, async active() { const result = await legacy().session.status() @@ -192,7 +176,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi { async fork(value: Parameters[0]) { const result = await legacy().session.fork(value) if (!result.data) throw new Error("Failed to fork session") - return sessionInfo(result.data) + return legacySessionInfo(result.data) }, async interrupt(value: Parameters[0]) { await legacy().session.abort(value) @@ -341,20 +325,15 @@ function createV1Api(input: CompatibleInput): CompatibleApi { return located(result.data ?? [], value?.location) }, async diff(value: Parameters[0]) { - const result = await legacy(value.location).vcs.diff({ - mode: value.mode === "working" ? "git" : value.mode, - context: value.context, - }) - return located( - (result.data ?? []).map((file) => ({ - file: file.file, - patch: file.patch ?? "", - additions: file.additions, - deletions: file.deletions, - status: file.status ?? "modified", - })), - value.location, + const result = await legacy(value.location).vcs.diff( + { + mode: value.mode === "working" ? "git" : value.mode, + context: value.context, + }, + { parseAs: "arrayBuffer" }, ) + if (!(result.data instanceof ArrayBuffer)) throw new Error("VCS diff response is not an ArrayBuffer") + return located(await input.decodeVcsDiff(result.data), value.location) }, }, file: { diff --git a/packages/app/src/utils/vcs-diff-data.ts b/packages/app/src/utils/vcs-diff-data.ts new file mode 100644 index 00000000000..7664ddafd65 --- /dev/null +++ b/packages/app/src/utils/vcs-diff-data.ts @@ -0,0 +1,20 @@ +import type { FileDiffInfo } from "@opencode-ai/client/promise" + +export function decodeVcsDiffData(buffer: ArrayBuffer): FileDiffInfo[] { + const text = new TextDecoder().decode(buffer) + return (text ? JSON.parse(text) : []).map( + (file: { + file: string + patch?: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" + }) => ({ + file: file.file, + patch: file.patch ?? "", + additions: file.additions, + deletions: file.deletions, + status: file.status ?? "modified", + }), + ) +} diff --git a/packages/app/src/utils/vcs-diff-decoder.ts b/packages/app/src/utils/vcs-diff-decoder.ts new file mode 100644 index 00000000000..be4abedaf03 --- /dev/null +++ b/packages/app/src/utils/vcs-diff-decoder.ts @@ -0,0 +1,61 @@ +import type { FileDiffInfo } from "@opencode-ai/client/promise" +import VcsDiffDecoderWorkerUrl from "./vcs-diff-decoder.worker.ts?worker&url" + +type Response = { id: number; data?: FileDiffInfo[]; error?: string } + +let worker: Worker | undefined +let nextID = 0 +const pending = new Map void; reject: (error: Error) => void }>() +let lastInput = 0 +document.addEventListener( + "beforeinput", + () => { + lastInput = performance.now() + }, + { capture: true }, +) + +export function decodeVcsDiff(buffer: ArrayBuffer) { + const id = ++nextID + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }) + getWorker().postMessage({ id, buffer }, [buffer]) + }) +} + +function getWorker() { + if (worker) return worker + worker = new Worker(VcsDiffDecoderWorkerUrl, { type: "module" }) + worker.onmessage = (event: MessageEvent) => { + const request = pending.get(event.data.id) + if (!request) return + pending.delete(event.data.id) + if (event.data.error) { + request.reject(new Error(event.data.error)) + return + } + resolveWhenInputIdle(request.resolve, event.data.data ?? []) + } + worker.onerror = (event) => { + const error = new Error(event.message) + pending.forEach((request) => request.reject(error)) + pending.clear() + worker?.terminate() + worker = undefined + } + return worker +} + +function resolveWhenInputIdle(resolve: (value: FileDiffInfo[]) => void, value: FileDiffInfo[], initial = true) { + const active = document.activeElement + const editing = + active instanceof HTMLInputElement || + active instanceof HTMLTextAreaElement || + (active instanceof HTMLElement && active.isContentEditable) + const delay = Math.max(lastInput + 100 - performance.now(), initial && editing ? 100 : 0) + if (delay <= 0) { + resolve(value) + return + } + setTimeout(() => resolveWhenInputIdle(resolve, value, false), delay) +} diff --git a/packages/app/src/utils/vcs-diff-decoder.worker.ts b/packages/app/src/utils/vcs-diff-decoder.worker.ts new file mode 100644 index 00000000000..27b90384568 --- /dev/null +++ b/packages/app/src/utils/vcs-diff-decoder.worker.ts @@ -0,0 +1,13 @@ +import { decodeVcsDiffData } from "./vcs-diff-data" + +type Request = { id: number; buffer: ArrayBuffer } + +self.onmessage = (event: MessageEvent) => { + try { + self.postMessage({ id: event.data.id, data: decodeVcsDiffData(event.data.buffer) }) + } catch (error) { + self.postMessage({ id: event.data.id, error: error instanceof Error ? error.message : String(error) }) + } +} + +export {} From 9b16d0b069b7a83530d77b4eb7c2194e50beb6a3 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:07:44 +1000 Subject: [PATCH 07/13] fix(session-ui): bound completed markdown rendering --- .../src/components/markdown-stream.test.ts | 6 +++ .../src/components/markdown-stream.ts | 15 +++++- .../session-ui/src/components/markdown.tsx | 49 +++++++++++++------ 3 files changed, 54 insertions(+), 16 deletions(-) diff --git a/packages/session-ui/src/components/markdown-stream.test.ts b/packages/session-ui/src/components/markdown-stream.test.ts index baa0803f44b..ce18bc628f1 100644 --- a/packages/session-ui/src/components/markdown-stream.test.ts +++ b/packages/session-ui/src/components/markdown-stream.test.ts @@ -158,6 +158,12 @@ describe("markdown stream", () => { expect(final.blocks[2]).toEqual({ raw: "- final item", src: "- final item", mode: "full" }) }) + test("splits completed markdown into bounded top-level blocks", () => { + const result = project(undefined, "# Plan\n\nFirst paragraph.\n\nSecond paragraph.", false) + + expect(result.blocks.map((block) => block.raw)).toEqual(["# Plan", "First paragraph.", "Second paragraph."]) + }) + test("catches up paced text before finalizing", () => { const live = project(undefined, "# Plan\n\nFinished paragraph.\n\n- final", true) const final = project(live, `${live.text} item`, false) diff --git a/packages/session-ui/src/components/markdown-stream.ts b/packages/session-ui/src/components/markdown-stream.ts index 18cf4157beb..efebf4ba710 100644 --- a/packages/session-ui/src/components/markdown-stream.ts +++ b/packages/session-ui/src/components/markdown-stream.ts @@ -51,7 +51,7 @@ function heal(text: string) { } export function stream(text: string, live: boolean): Block[] { - if (!live) return completedProjection(text).blocks + if (!live) return completedBlocks(text) if (refs(text)) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[] const tokens = marked.lexer(text) const tail = tokens.findLastIndex((token) => token.type !== "space") @@ -85,6 +85,17 @@ export function stream(text: string, live: boolean): Block[] { return [...result, { raw, src: openCode(code.raw), mode: "code", language: language(code.lang) }] } +function completedBlocks(text: string) { + if (refs(text)) return completedProjection(text).blocks + const tokens = marked.lexer(text) + return tokens.flatMap((token): Block[] => { + if (token.type === "space") return [] + if (token.type !== "code") return [{ raw: token.raw, src: token.raw, mode: "full" }] + const code = token as Tokens.Code + return [{ raw: code.raw, src: code.text, mode: "code", language: language(code.lang), complete: true }] + }) +} + export function project(previous: Projection | undefined, text: string, live: boolean): Projection { if (!live) { const current = @@ -93,7 +104,7 @@ export function project(previous: Projection | undefined, text: string, live: bo : previous && text.startsWith(previous.text) ? project(previous, text, true) : undefined - if (!current) return completedProjection(text) + if (!current) return { text, blocks: completedBlocks(text) } return { text, blocks: current.blocks.map((block) => { diff --git a/packages/session-ui/src/components/markdown.tsx b/packages/session-ui/src/components/markdown.tsx index 95894ba2712..b0ac55cbb12 100644 --- a/packages/session-ui/src/components/markdown.tsx +++ b/packages/session-ui/src/components/markdown.tsx @@ -491,6 +491,8 @@ export function Markdown( ) let copyCleanup: (() => void) | undefined + let renderFrame: number | undefined + let renderGeneration = 0 createEffect(() => { const container = root() @@ -499,6 +501,9 @@ export function Markdown( const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner) : [] if (!container) return if (isServer) return + const generation = ++renderGeneration + if (renderFrame !== undefined) cancelAnimationFrame(renderFrame) + renderFrame = undefined if (content.length === 0) { disposeCopyButtons(container) container.innerHTML = "" @@ -515,24 +520,40 @@ export function Markdown( }) activeCodeKeys.clear() nextCodeKeys.forEach((key) => activeCodeKeys.add(key)) - content.forEach((block, index) => updateBlock(container, index, block, labels)) - while (container.children.length > content.length) { - const child = container.lastElementChild - if (!child) break - disposeCopyButtons(child) - child.remove() + let index = 0 + const update = () => { + renderFrame = undefined + if (generation !== renderGeneration) return + const deadline = performance.now() + 8 + while (index < content.length && performance.now() < deadline) { + updateBlock(container, index, content[index]!, labels) + index += 1 + } + if (index < content.length) { + renderFrame = requestAnimationFrame(update) + return + } + while (container.children.length > content.length) { + const child = container.lastElementChild + if (!child) break + disposeCopyButtons(child) + child.remove() + } + container + .querySelectorAll('[data-slot="markdown-copy-button"]') + .forEach((button) => setCopyState(button, labels, button.dataset.copied === "true")) + if (!copyCleanup) + copyCleanup = setupCodeCopy(container, () => ({ + copy: i18n.t("ui.message.copy"), + copied: i18n.t("ui.message.copied"), + })) } - container - .querySelectorAll('[data-slot="markdown-copy-button"]') - .forEach((button) => setCopyState(button, labels, button.dataset.copied === "true")) - if (!copyCleanup) - copyCleanup = setupCodeCopy(container, () => ({ - copy: i18n.t("ui.message.copy"), - copied: i18n.t("ui.message.copied"), - })) + update() }) onCleanup(() => { + renderGeneration += 1 + if (renderFrame !== undefined) cancelAnimationFrame(renderFrame) if (copyCleanup) copyCleanup() disposeMarkdownProjection(owner) activeCodeKeys.forEach(disposeCode) From 8c7c69c74950794c686547f30b690ac6fbd49efc Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:07:54 +1000 Subject: [PATCH 08/13] fix(session-ui): parse composer input once --- .../src/v2/components/prompt-input/index.tsx | 49 ++++++++++--------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx index ff4ff0f1d40..529605f3126 100644 --- a/packages/session-ui/src/v2/components/prompt-input/index.tsx +++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -52,9 +52,11 @@ export function PromptInputV2(props: PromptInputV2Props) { const view = props.controller.view let editor: HTMLDivElement | undefined let localInput = false - const updateCursor = () => { + const updateCursor = (event: KeyboardEvent | PointerEvent) => { if (!editor || !window.getSelection()?.isCollapsed) return - props.controller.onCursor(promptInputV2Cursor(editor)) + if (event instanceof KeyboardEvent && !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) + return + props.controller.onCursor(parsePromptInputV2Editor(editor).cursor) } const mode = createMemo(() => state.mode) const buttons = createMemo(() => ({ @@ -163,8 +165,7 @@ export function PromptInputV2(props: PromptInputV2Props) { class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword" classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }} onInput={(event) => { - const cursor = promptInputV2Cursor(event.currentTarget) - const prompt = parsePromptInputV2Editor(event.currentTarget) + const { prompt, cursor } = parsePromptInputV2Editor(event.currentTarget) const images = props.controller.parts().filter((part) => part.type === "image") localInput = true props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor) @@ -300,8 +301,13 @@ function renderPromptInputV2Editor(editor: HTMLDivElement, prompt: PromptInputV2 function parsePromptInputV2Editor(editor: HTMLDivElement) { const parts: Exclude[] = [] + const selection = window.getSelection() + const anchorNode = selection && editor.contains(selection.anchorNode) ? selection.anchorNode : undefined + const anchorOffset = anchorNode ? selection!.anchorOffset : 0 let buffer = "" let position = 0 + let cursor: number | undefined + const offset = () => position + buffer.length const flush = () => { if (!buffer) return @@ -336,43 +342,42 @@ function parsePromptInputV2Editor(editor: HTMLDivElement) { } const visit = (node: Node) => { if (node.nodeType === Node.TEXT_NODE) { + if (node === anchorNode) cursor = offset() + Math.min(anchorOffset, node.textContent?.length ?? 0) buffer += node.textContent ?? "" return } if (!(node instanceof HTMLElement)) return if (node.dataset.mention) { + if (node === anchorNode) cursor = offset() + (anchorOffset > 0 ? (node.textContent?.length ?? 0) : 0) mention(node) return } if (node.tagName === "BR") { + if (node === anchorNode) cursor = offset() + (anchorOffset > 0 ? 1 : 0) buffer += "\n" return } - Array.from(node.childNodes).forEach(visit) + Array.from(node.childNodes).forEach((child, index) => { + if (node === anchorNode && anchorOffset === index) cursor = offset() + visit(child) + }) + if (node === anchorNode && anchorOffset >= node.childNodes.length) cursor = offset() } Array.from(editor.childNodes).forEach((node, index, nodes) => { + if (editor === anchorNode && anchorOffset === index) cursor = offset() visit(node) if (node instanceof HTMLElement && ["DIV", "P"].includes(node.tagName) && index < nodes.length - 1) buffer += "\n" }) + if (editor === anchorNode && anchorOffset >= editor.childNodes.length) cursor = offset() flush() - if ( - parts.every((part) => part.type === "text") && - parts.every((part) => part.content.replace(/[\n\u200B]/g, "") === "") - ) { - return [{ type: "text" as const, content: "", start: 0, end: 0 }] - } - if (parts.length > 0) return parts - return [{ type: "text" as const, content: "", start: 0, end: 0 }] -} - -function promptInputV2Cursor(editor: HTMLDivElement) { - const selection = window.getSelection() - if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0 - const range = selection.getRangeAt(0).cloneRange() - range.selectNodeContents(editor) - range.setEnd(selection.anchorNode!, selection.anchorOffset) - return range.toString().length + const result = + parts.length === 0 || + (parts.every((part) => part.type === "text") && + parts.every((part) => part.content.replace(/[\n\u200B]/g, "") === "")) + ? [{ type: "text" as const, content: "", start: 0, end: 0 }] + : parts + return { prompt: result, cursor: cursor ?? offset() } } export function PromptInputV2Attachments(props: { 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 09/13] 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) From f826f7fc9b20e4a5b47f5bdcc9ceb291f930fbfb Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:32:22 +1000 Subject: [PATCH 10/13] docs(app): remove performance report --- .../e2e/performance/REAL_DESKTOP_PROFILE.md | 132 ------------------ 1 file changed, 132 deletions(-) delete mode 100644 packages/app/e2e/performance/REAL_DESKTOP_PROFILE.md diff --git a/packages/app/e2e/performance/REAL_DESKTOP_PROFILE.md b/packages/app/e2e/performance/REAL_DESKTOP_PROFILE.md deleted file mode 100644 index 7f81e4ec889..00000000000 --- a/packages/app/e2e/performance/REAL_DESKTOP_PROFILE.md +++ /dev/null @@ -1,132 +0,0 @@ -# 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 From 067dfa341f89b1d7c92e5ee195c6c255494d6998 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:36:11 +1000 Subject: [PATCH 11/13] refactor(app): make desktop profiler portable --- packages/app/e2e/performance/README.md | 26 + .../e2e/performance/desktop-profile/corpus.ts | 137 ++++ .../desktop-profile/desktop-state.ts | 116 +++ .../desktop-profile/options.test.ts | 54 ++ .../performance/desktop-profile/options.ts | 88 +++ .../e2e/performance/desktop-profile/probe.ts | 192 +++++ .../performance/desktop-profile/progress.ts | 7 + .../performance/desktop-profile/runtime.ts | 156 ++++ .../desktop-profile/scenario-utils.ts | 75 ++ .../performance/desktop-profile/scenarios.ts | 178 +++++ .../e2e/performance/desktop-profile/types.ts | 50 ++ .../app/e2e/performance/profile-desktop.ts | 107 +++ .../e2e/performance/real-desktop-profile.ts | 676 +----------------- packages/app/package.json | 3 +- 14 files changed, 1189 insertions(+), 676 deletions(-) create mode 100644 packages/app/e2e/performance/desktop-profile/corpus.ts create mode 100644 packages/app/e2e/performance/desktop-profile/desktop-state.ts create mode 100644 packages/app/e2e/performance/desktop-profile/options.test.ts create mode 100644 packages/app/e2e/performance/desktop-profile/options.ts create mode 100644 packages/app/e2e/performance/desktop-profile/probe.ts create mode 100644 packages/app/e2e/performance/desktop-profile/progress.ts create mode 100644 packages/app/e2e/performance/desktop-profile/runtime.ts create mode 100644 packages/app/e2e/performance/desktop-profile/scenario-utils.ts create mode 100644 packages/app/e2e/performance/desktop-profile/scenarios.ts create mode 100644 packages/app/e2e/performance/desktop-profile/types.ts create mode 100644 packages/app/e2e/performance/profile-desktop.ts diff --git a/packages/app/e2e/performance/README.md b/packages/app/e2e/performance/README.md index ce868d573bb..675057366ba 100644 --- a/packages/app/e2e/performance/README.md +++ b/packages/app/e2e/performance/README.md @@ -56,6 +56,32 @@ Benchmarks do not assert machine-dependent performance budgets. Streaming proces Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing. +## Desktop profiler + +The desktop profiler launches the existing production build directly, creates isolated desktop state, chooses an available CDP port, and writes reports under the OS temporary directory by default. + +```sh +bun run profile:desktop --help +``` + +Create a private partial snapshot from the default local database and run Home once: + +```sh +bun run profile:desktop --partial-snapshot-out /tmp/opencode-perf.db \ + --window-end 2026-08-04T06:14:26.878Z \ + --scenarios home,calibration --skip-build +``` + +Repeat against the immutable partial snapshot: + +```sh +bun run profile:desktop --mode partial-snapshot --db /tmp/opencode-perf.db \ + --window-end 2026-08-04T06:14:26.878Z \ + --scenarios home,calibration --runs 3 --skip-build +``` + +Partial snapshots contain private application data and must not be committed or shared. The profiler copies each partial snapshot to a per-run working database and remaps selected project paths to temporary workspaces, leaving the source snapshot unchanged. `PROFILE_SUMMARY` is the compact comparison output; `PROFILE_REPORT` points to the complete JSON report with the database hash, invocation parameters, raw runs, and attribution data. + ## Chrome traces Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically: diff --git a/packages/app/e2e/performance/desktop-profile/corpus.ts b/packages/app/e2e/performance/desktop-profile/corpus.ts new file mode 100644 index 00000000000..fe7cf155622 --- /dev/null +++ b/packages/app/e2e/performance/desktop-profile/corpus.ts @@ -0,0 +1,137 @@ +import { Database } from "bun:sqlite" +import { mkdir, rm } from "node:fs/promises" +import path from "node:path" +import { progress } from "./progress" +import type { Options, Target } from "./types" + +export async function createPartialSnapshot(source: string, destination: string, options: Options, targets: Target[]) { + await mkdir(path.dirname(destination), { recursive: true }) + await rm(destination, { force: true }) + const input = new Database(source, { readonly: true }) + const schema = input + .query( + `SELECT type, name, sql FROM sqlite_schema + WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' + ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 ELSE 2 END, name`, + ) + .all() as { type: string; name: string; sql: string }[] + input.close() + + const output = new Database(destination, { create: true }) + output.run("PRAGMA foreign_keys = OFF") + schema.filter((item) => item.type === "table").forEach((item) => output.run(item.sql)) + output.run("ATTACH DATABASE ? AS source", source) + const selected = [...new Set(targets.map((target) => target.id))] + const placeholders = selected.map(() => "?").join(",") + + for (const table of schema.filter((item) => item.type === "table").map((item) => item.name)) { + progress("copying partial snapshot table", { table }) + if (table === "event") continue + if (table === "message") { + output.run( + `INSERT INTO main.message SELECT * FROM source.message + WHERE (time_created >= ? AND time_created < ? AND session_id IN ( + SELECT id FROM source.session WHERE parent_id IS NULL + )) OR session_id IN (${placeholders})`, + options.windowStart, + options.windowEnd, + ...selected, + ) + continue + } + if (table === "part") { + output.run("INSERT INTO main.part SELECT * FROM source.part WHERE message_id IN (SELECT id FROM main.message)") + continue + } + if (["session_context_epoch", "session_input", "session_message", "session_share", "todo"].includes(table)) { + output.run( + `INSERT INTO main."${table}" SELECT * FROM source."${table}" WHERE session_id IN (${placeholders})`, + ...selected, + ) + continue + } + output.run(`INSERT INTO main."${table}" SELECT * FROM source."${table}"`) + } + output.run("DETACH DATABASE source") + schema.filter((item) => item.type !== "table").forEach((item) => output.run(item.sql)) + output.close() +} + +export async function fingerprint(file: string) { + const input = Bun.file(file) + const hasher = new Bun.CryptoHasher("sha256") + for await (const chunk of input.stream()) hasher.update(chunk) + return { bytes: input.size, sha256: hasher.digest("hex") } +} + +export function loadCorpus(options: Options) { + const database = new Database(options.database, { readonly: true }) + database.run("PRAGMA query_only = ON") + const sessions = database + .query( + `SELECT id, project_id AS projectID, 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(options.windowStart, options.windowEnd) as { id: string; projectID: 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, options.windowStart, options.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)) + 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)]!, + }) + const targets = [select("p50", 0.5), select("p95", 0.95), select("max", 1)] satisfies Target[] + const typingText = loadTypingText(database, partRows, messageRows, targets[2]!, options) + const projectIDs = [...new Set(ranked.map((session) => session.projectID))] + database.close() + return { targets, typingText, projectIDs } +} + +function loadTypingText( + database: Database, + partRows: ReturnType, + messageRows: ReturnType, + target: Target, + options: Options, +) { + const messages = messageRows.all(target.id, options.windowStart, options.windowEnd) as { id: string; data: string }[] + const text = messages + .filter((message) => JSON.parse(message.data).role === "user") + .flatMap((message) => + (partRows.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] + if (!text) throw new Error("No real user prompt found for composer profiling") + return text +} diff --git a/packages/app/e2e/performance/desktop-profile/desktop-state.ts b/packages/app/e2e/performance/desktop-profile/desktop-state.ts new file mode 100644 index 00000000000..a9df97b1860 --- /dev/null +++ b/packages/app/e2e/performance/desktop-profile/desktop-state.ts @@ -0,0 +1,116 @@ +import { Database } from "bun:sqlite" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import type { Options } from "./types" + +export async function prepareDesktopState( + options: Options, + databasePath: string, + userData: string, + run: number, + projectIDs: string[], +) { + const database = new Database(databasePath) + const projects = database.query("SELECT id, worktree, sandboxes FROM project ORDER BY id").all() as { + id: string + worktree: string + sandboxes: string + }[] + const selected = new Set(projectIDs) + const profileProjects = projects.filter((project) => selected.has(project.id)) + const worktrees = + options.mode === "partial-snapshot" + ? await remapDirectories(database, profileProjects, path.join(options.output, "workspaces", String(run))) + : profileProjects.map((project) => project.worktree) + database.close() + + await Bun.write( + path.join(userData, "opencode.settings"), + JSON.stringify({ firstLaunchOnboardingComplete: true, oldLayoutEligible: true, tauriMigrated: true }), + ) + await Bun.write( + path.join(userData, "opencode.global.dat"), + JSON.stringify({ + server: JSON.stringify({ + list: [], + projects: { local: worktrees.map((worktree) => ({ worktree, expanded: true })) }, + lastProject: worktrees[0] ? { local: worktrees[0] } : {}, + recentlyClosed: {}, + }), + }), + ) +} + +async function remapDirectories( + database: Database, + projects: { id: string; worktree: string; sandboxes: string }[], + root: string, +) { + await mkdir(root, { recursive: true }) + const mappings = new Map() + const worktrees = await Promise.all( + projects.map(async (project, index) => { + const worktree = path.join(root, `project-${String(index + 1).padStart(3, "0")}`) + await mkdir(worktree, { recursive: true }) + mappings.set(project.worktree, worktree) + const sandboxes = JSON.parse(project.sandboxes) as string[] + const nextSandboxes = await Promise.all( + sandboxes.map(async (sandbox, sandboxIndex) => { + const next = path.join(worktree, `sandbox-${sandboxIndex + 1}`) + await mkdir(next, { recursive: true }) + mappings.set(sandbox, next) + return next + }), + ) + database.run("UPDATE project SET worktree = ?, sandboxes = ? WHERE id = ?", worktree, JSON.stringify(nextSandboxes), project.id) + return worktree + }), + ) + const byProject = new Map(projects.map((project, index) => [project.id, worktrees[index]!])) + const sessions = database.query("SELECT id, project_id, directory FROM session").all() as { + id: string + project_id: string + directory: string + }[] + const directories = database.query("SELECT * FROM project_directory").all() as { + project_id: string + directory: string + type: string | null + strategy: string | null + time_created: number + }[] + const selected = new Set(projects.map((project) => project.id)) + const nextDirectories = await Promise.all( + directories.filter((item) => selected.has(item.project_id)).map(async (item, index) => { + const directory = + mappings.get(item.directory) ?? path.join(byProject.get(item.project_id) ?? root, `directory-${index + 1}`) + await mkdir(directory, { recursive: true }) + return { ...item, directory } + }), + ) + database.transaction(() => { + sessions.filter((session) => selected.has(session.project_id)).forEach((session) => + database.run( + "UPDATE session SET directory = ? WHERE id = ?", + mappings.get(session.directory) ?? byProject.get(session.project_id) ?? worktrees[0]!, + session.id, + ), + ) + database.run( + `DELETE FROM project_directory WHERE project_id IN (${projects.map(() => "?").join(",")})`, + ...projects.map((project) => project.id), + ) + nextDirectories.forEach((item) => + database.run( + `INSERT INTO project_directory (project_id, directory, type, strategy, time_created) + VALUES (?, ?, ?, ?, ?)`, + item.project_id, + item.directory, + item.type, + item.strategy, + item.time_created, + ), + ) + })() + return worktrees +} diff --git a/packages/app/e2e/performance/desktop-profile/options.test.ts b/packages/app/e2e/performance/desktop-profile/options.test.ts new file mode 100644 index 00000000000..dda79da1636 --- /dev/null +++ b/packages/app/e2e/performance/desktop-profile/options.test.ts @@ -0,0 +1,54 @@ +import { Database } from "bun:sqlite" +import { afterAll, expect, test } from "bun:test" +import { mkdir, rm } from "node:fs/promises" +import path from "node:path" +import { createPartialSnapshot, fingerprint } from "./corpus" +import { parseOptions } from "./options" + +const directory = path.join(import.meta.dir, `.tmp-${process.pid}`) +const source = path.join(directory, "source.db") +const partialSnapshot = path.join(directory, "partial-snapshot.db") +await mkdir(directory, { recursive: true }) +const database = new Database(source, { create: true }) +database.run("CREATE TABLE sample (value TEXT NOT NULL)") +database.run("INSERT INTO sample VALUES ('repeatable')") +database.close() + +afterAll(() => rm(directory, { recursive: true, force: true })) + +test("parses a portable fixed-window partial snapshot invocation", () => { + const options = parseOptions([ + "--mode", + "partial-snapshot", + "--db", + source, + "--window-end", + "2026-08-04T06:14:26.878Z", + "--window-hours", + "24", + "--scenarios", + "home,calibration", + "--runs", + "3", + "--skip-build", + ])! + + expect(options.database).toBe(source) + expect(options.windowEnd).toBe(1_785_824_066_878) + expect(options.windowStart).toBe(1_785_737_666_878) + expect(options.scenarios).toEqual(["home", "calibration"]) + expect(options.runs).toBe(3) + expect(options.build).toBe(false) +}) + +test("creates a consistent private partial database snapshot", async () => { + const options = parseOptions(["--db", source, "--window-end", "2026-08-04T06:14:26.878Z"])! + await createPartialSnapshot(source, partialSnapshot, options, []) + const copy = new Database(partialSnapshot, { readonly: true }) + expect(copy.query("SELECT value FROM sample").get()).toEqual({ value: "repeatable" }) + copy.close() + expect(await fingerprint(partialSnapshot)).toEqual({ + bytes: expect.any(Number), + sha256: expect.stringMatching(/^[a-f0-9]{64}$/), + }) +}) diff --git a/packages/app/e2e/performance/desktop-profile/options.ts b/packages/app/e2e/performance/desktop-profile/options.ts new file mode 100644 index 00000000000..fd5d6496759 --- /dev/null +++ b/packages/app/e2e/performance/desktop-profile/options.ts @@ -0,0 +1,88 @@ +import { Global } from "@opencode-ai/core/global" +import { existsSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { scenarios, type Options, type Scenario } from "./types" + +const help = `Desktop renderer profiler + +Usage: + bun run profile:desktop [options] + +Options: + --mode local|partial-snapshot + Local corpus or fixed partial snapshot (default: local) + --db SQLite database (default: opencode data directory) + --partial-snapshot-out + Copy the benchmark corpus to a private partial snapshot + --output Report directory (default: OS temp directory) + --window-end End of corpus window (default: now; required for partial snapshot) + --window-hours Corpus window size (default: 24) + --scenarios Comma list: ${scenarios.join(",")} (default: all) + --runs Restart Electron and repeat (default: 1) + --skip-build Use the existing desktop production build + --diagnostics Capture Chrome traces + --cpu Capture sampled CPU summaries + --response-urls Attribute Response.text durations by URL + --help Show this message + +Partial snapshots contain private application data. Do not commit or share them. +` + +export function parseOptions(args: string[], now = Date.now()): Options | undefined { + if (args.includes("--help")) { + console.log(help) + return + } + + const value = (name: string) => { + const index = args.indexOf(name) + if (index === -1) return + const result = args[index + 1] + if (!result || result.startsWith("--")) throw new Error(`${name} requires a value`) + return result + } + const mode = value("--mode") ?? "local" + if (mode !== "local" && mode !== "partial-snapshot") throw new Error(`Unsupported mode: ${mode}`) + const endValue = value("--window-end") + if (mode === "partial-snapshot" && !endValue) + throw new Error("--window-end is required in partial-snapshot mode") + const windowEnd = endValue ? parseTime(endValue) : now + const windowHours = number(value("--window-hours") ?? "24", "--window-hours") + const selected = (value("--scenarios")?.split(",") ?? [...scenarios]).map((item) => item.trim()) + if (selected.some((item) => !scenarios.includes(item as Scenario))) + throw new Error(`--scenarios must contain only: ${scenarios.join(", ")}`) + const database = path.resolve(value("--db") ?? path.join(Global.Path.data, "opencode.db")) + if (!existsSync(database)) throw new Error(`Database does not exist: ${database}`) + + return { + mode, + database, + output: path.resolve( + value("--output") ?? path.join(tmpdir(), "opencode-performance", new Date(windowEnd).toISOString().replace(/[:.]/g, "-")), + ), + windowStart: windowEnd - windowHours * 60 * 60 * 1_000, + windowEnd, + scenarios: selected as Scenario[], + runs: number(value("--runs") ?? "1", "--runs"), + build: !args.includes("--skip-build"), + diagnostics: args.includes("--diagnostics"), + cpu: args.includes("--cpu"), + responseURLs: args.includes("--response-urls"), + partialSnapshotOut: value("--partial-snapshot-out") + ? path.resolve(value("--partial-snapshot-out")!) + : undefined, + } +} + +function parseTime(value: string) { + const result = /^\d+$/.test(value) ? Number(value) : Date.parse(value) + if (!Number.isFinite(result)) throw new Error(`Invalid --window-end: ${value}`) + return result +} + +function number(value: string, option: string) { + const result = Number(value) + if (!Number.isFinite(result) || result <= 0) throw new Error(`${option} must be greater than zero`) + return result +} diff --git a/packages/app/e2e/performance/desktop-profile/probe.ts b/packages/app/e2e/performance/desktop-profile/probe.ts new file mode 100644 index 00000000000..70b907c6227 --- /dev/null +++ b/packages/app/e2e/performance/desktop-profile/probe.ts @@ -0,0 +1,192 @@ +import type { Page } from "@playwright/test" +import type { Options, ProbeResult } from "./types" + +export async function installProbe(page: Page, options: Options) { + 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) return + 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 + }) + } + }, options.responseURLs) +} + +export 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 + }) +} + +export async function collectProbe(page: Page) { + return page.evaluate( + () => (window as Window & { __opencodeRendererProfile?: ProbeResult }).__opencodeRendererProfile!, + ) +} + +export 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: responsePath(item.url), durationMs: item.duration })) + .sort((a, b) => b.durationMs - a.durationMs), + } +} + +export async function startCPUProfile(page: Page, enabled: boolean) { + if (!enabled) 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) + }, + } +} + +function responsePath(value: string) { + try { + return new URL(value).pathname + } catch { + return value + } +} + +function sourceName(value: string) { + if (!value) return "(native)" + try { + return new URL(value).pathname.split("/").at(-1) || "(document)" + } catch { + return value.split(/[\\/]/).at(-1) || value + } +} + +function sum(values: number[]) { + return values.reduce((total, value) => total + value, 0) +} diff --git a/packages/app/e2e/performance/desktop-profile/progress.ts b/packages/app/e2e/performance/desktop-profile/progress.ts new file mode 100644 index 00000000000..4799e5b14b5 --- /dev/null +++ b/packages/app/e2e/performance/desktop-profile/progress.ts @@ -0,0 +1,7 @@ +const started = performance.now() + +export function progress(message: string, details?: Record) { + const elapsed = ((performance.now() - started) / 1_000).toFixed(1) + const suffix = details ? ` ${JSON.stringify(details)}` : "" + console.error(`[desktop-profile +${elapsed}s] ${message}${suffix}`) +} diff --git a/packages/app/e2e/performance/desktop-profile/runtime.ts b/packages/app/e2e/performance/desktop-profile/runtime.ts new file mode 100644 index 00000000000..d6379696eb3 --- /dev/null +++ b/packages/app/e2e/performance/desktop-profile/runtime.ts @@ -0,0 +1,156 @@ +import { chromium, type Page } from "@playwright/test" +import { copyFile, mkdir, rm } from "node:fs/promises" +import path from "node:path" +import { prepareDesktopState } from "./desktop-state" +import { progress } from "./progress" +import type { Options } from "./types" + +export async function withDesktop( + options: Options, + desktop: string, + run: number, + projectIDs: string[], + use: (page: Page) => Promise, +) { + const port = availablePort() + const endpoint = `http://127.0.0.1:${port}` + const userData = path.join(options.output, `user-data-${run}`) + const database = + options.mode === "partial-snapshot" ? path.join(options.output, `working-database-${run}.db`) : options.database + await rm(userData, { recursive: true, force: true }) + await mkdir(userData, { recursive: true }) + if (database !== options.database) await copyFile(options.database, database) + await prepareDesktopState(options, database, userData, run, projectIDs) + const electron = path.join( + desktop, + "node_modules", + "electron", + "dist", + (await Bun.file(path.join(desktop, "node_modules", "electron", "path.txt")).text()).trim(), + ) + progress("launching Electron", { run, port }) + const child = Bun.spawn([electron, "."], { + cwd: desktop, + env: { + ...process.env, + OPENCODE_DB: database, + OPENCODE_CHANNEL: "dev", + OPENCODE_PROFILE_LOAF: "1", + OPENCODE_PROFILE_CDP_PORT: String(port), + OPENCODE_PROFILE_USER_DATA: userData, + OPENCODE_PERFORMANCE_TRACE_DIR: options.diagnostics ? path.join(options.output, "traces", String(run)) : "", + OPENCODE_PERFORMANCE_RUN_ID: `desktop-${run}`, + }, + stdout: "pipe", + stderr: "pipe", + }) + const stdout = drain(child.stdout, "stdout") + const stderr = drain(child.stderr, "stderr") + let browser: Awaited> | undefined + + try { + progress("waiting for CDP", { run }) + await waitForCDP(endpoint, child, stdout, stderr) + progress("connecting Playwright", { run }) + browser = await chromium.connectOverCDP(endpoint) + progress("waiting for renderer", { run }) + const page = await waitForRenderer(browser) + progress("waiting for desktop API", { run }) + await page.waitForFunction(() => typeof window.api === "object", undefined, { timeout: 60_000 }) + progress("desktop ready", { run }) + return await use(page) + } finally { + progress("stopping Electron", { run }) + await browser?.close().catch(() => {}) + await killTree(child.pid) + await Promise.allSettled([stdout, stderr]) + if (database !== options.database) { + await Bun.sleep(500) + await rm(database, { force: true }).catch(() => undefined) + } + } +} + +export async function run(command: string[], cwd: string, database: string) { + const child = Bun.spawn(command, { + cwd, + env: { ...process.env, OPENCODE_DB: database, OPENCODE_CHANNEL: "dev" }, + stdout: "inherit", + stderr: "inherit", + }) + const code = await child.exited + if (code !== 0) throw new Error(`${command.join(" ")} exited with ${code}`) +} + +function availablePort() { + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() }) + const port = server.port + server.stop(true) + return port +} + +async function waitForCDP( + endpoint: string, + child: ReturnType, + stdout: Promise, + stderr: Promise, +) { + const timeout = Date.now() + 5 * 60_000 + let heartbeat = Date.now() + 10_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}`) + if (Date.now() >= heartbeat) { + progress("still waiting for CDP") + heartbeat = Date.now() + 10_000 + } + await Bun.sleep(250) + } + throw new Error("Timed out waiting for desktop CDP") +} + +async function waitForRenderer(browser: Awaited>) { + const timeout = Date.now() + 60_000 + let heartbeat = Date.now() + 10_000 + while (Date.now() < timeout) { + const page = browser + .contexts() + .flatMap((context) => context.pages()) + .find((candidate) => candidate.url().startsWith("oc://renderer")) + if (page) return page + if (Date.now() >= heartbeat) { + progress("still waiting for renderer") + heartbeat = Date.now() + 10_000 + } + await Bun.sleep(100) + } + throw new Error("Desktop renderer target was not found") +} + +async function drain(stream: ReadableStream, label: string) { + const decoder = new TextDecoder() + let output = "" + let pending = "" + for await (const chunk of stream) { + const text = decoder.decode(chunk, { stream: true }) + output = (output + text).slice(-50_000) + const lines = (pending + text).split(/\r?\n/) + pending = lines.pop() ?? "" + lines.filter(Boolean).forEach((line) => progress(`Electron ${label}`, { line: line.slice(0, 500) })) + } + if (pending) progress(`Electron ${label}`, { line: pending.slice(0, 500) }) + 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 +} diff --git a/packages/app/e2e/performance/desktop-profile/scenario-utils.ts b/packages/app/e2e/performance/desktop-profile/scenario-utils.ts new file mode 100644 index 00000000000..9285b8ee8ed --- /dev/null +++ b/packages/app/e2e/performance/desktop-profile/scenario-utils.ts @@ -0,0 +1,75 @@ +import type { Page } from "@playwright/test" +import { progress } from "./progress" + +export 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) +} + +export async function waitForQuietDOM(page: Page) { + progress("waiting for DOM to settle") + await page.evaluate( + () => + new Promise((resolve) => { + let settled = false + let timer = setTimeout(done, 750) + const deadline = setTimeout(done, 30_000) + const observer = new MutationObserver(() => { + clearTimeout(timer) + timer = setTimeout(done, 750) + }) + observer.observe(document.body, { childList: true, subtree: true, characterData: true }) + function done() { + if (settled) return + settled = true + clearTimeout(deadline) + observer.disconnect() + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + } + }), + ) + progress("DOM settled") +} + +export async function waitForSelector(page: Page, selector: string, label: string) { + progress("waiting for UI", { label }) + try { + await page.waitForSelector(selector, { timeout: 30_000 }) + } catch (error) { + progress("UI wait failed", { + label, + url: page.url(), + body: (await page.locator("body").innerText().catch(() => "")).replace(/\s+/g, " ").slice(0, 500), + }) + throw error + } + progress("UI ready", { label }) +} + +export async function domCounts(page: Page, review = false) { + return page.evaluate((review) => ({ + elements: document.getElementsByTagName("*").length, + ...(review + ? { + diffViewers: document.querySelectorAll('[data-component="file"][data-mode="diff"]').length, + diffLines: document.querySelectorAll("[data-line]").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, + }), + }), review) +} + +export function sum(values: number[]) { + return values.reduce((total, value) => total + value, 0) +} + +export 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/e2e/performance/desktop-profile/scenarios.ts b/packages/app/e2e/performance/desktop-profile/scenarios.ts new file mode 100644 index 00000000000..3f05fe0d06b --- /dev/null +++ b/packages/app/e2e/performance/desktop-profile/scenarios.ts @@ -0,0 +1,178 @@ +import type { Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { startChromeTrace } from "../chrome-trace" +import { collectProbe, resetProbe, startCPUProfile, summarizeProbe } from "./probe" +import { progress } from "./progress" +import { domCounts, percentile, setDesktopRoute, sum, waitForQuietDOM, waitForSelector } from "./scenario-utils" +import type { Options, Target } from "./types" + +export async function runScenarios(page: Page, options: Options, targets: Target[], typingText: string) { + const results: unknown[] = [] + if (options.scenarios.includes("home")) results.push(await profileHome(page, options)) + if (options.scenarios.includes("calibration")) results.push(await profileCalibration(page)) + if (options.scenarios.includes("session")) { + for (const target of targets) results.push(await profileSession(page, options, target)) + } + if (options.scenarios.some((scenario) => ["composer", "history", "review"].includes(scenario))) { + await openSession(page, targets[2]!) + } + if (options.scenarios.includes("composer")) results.push(await profileComposer(page, options, typingText)) + if (options.scenarios.includes("history")) results.push(await profileHistory(page, options, targets[2]!)) + if (options.scenarios.includes("review")) { + const review = await profileReview(page, options) + if (review) results.push(review) + } + return results +} + +async function profileHome(page: Page, options: Options) { + const measured = await measure(page, options, "home", async () => { + await setDesktopRoute(page, "/") + await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 }) + await waitForSelector(page, '[data-component="home-session-row"]', "Home session rows") + await waitForQuietDOM(page) + }) + return { ...measured, dom: await domCounts(page) } +} + +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)) + }) + }), + ) + return { name: "attribution-calibration", ...summarizeProbe(await collectProbe(page)) } +} + +async function profileSession(page: Page, options: Options, target: Target) { + await prepareHome(page) + const measured = await measure(page, options, `session-${target.label}`, async () => { + await navigateSession(page, target) + await waitForSelector(page, '[data-component="prompt-input"]', "session composer") + await waitForQuietDOM(page) + }) + return { ...measured, context: targetContext(target), dom: await domCounts(page) } +} + +async function profileComposer(page: Page, options: Options, typingText: string) { + 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) => !["\r", "\n", "\t"].includes(character)) + const measuredText = printable.slice(-120).join("") + const prefix = printable.slice(0, -measuredText.length).join("") + if (prefix) await page.keyboard.insertText(prefix) + await waitForQuietDOM(page) + const durations: number[] = [] + const measured = await measure(page, options, "composer-typing", async () => { + for (const character of measuredText) { + const started = performance.now() + await page.keyboard.type(character) + durations.push(performance.now() - started) + } + await waitForQuietDOM(page) + }) + await page.keyboard.press("Control+A") + await page.keyboard.press("Backspace") + return { + ...measured, + context: { promptCharacters: printable.length, measuredCharacters: measuredText.length }, + typing: { + totalMs: sum(durations), + meanMs: sum(durations) / durations.length, + p50Ms: percentile(durations, 0.5), + p95Ms: percentile(durations, 0.95), + maxMs: Math.max(...durations), + }, + } +} + +async function profileHistory(page: Page, options: Options, target: Target) { + await waitForSelector(page, '[data-component="prompt-input"]', "history session composer") + await waitForQuietDOM(page) + let requests = 0 + const onResponse = (response: { url(): string }) => { + if (/\/session\/[^/]+\/message(?:\?|$)/.test(response.url())) requests++ + } + page.on("response", onResponse) + const measured = await measure(page, options, "session-max-history-boundary", async () => { + const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }).first() + 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) + return { ...measured, context: targetContext(target), messageRequests: requests } +} + +async function profileReview(page: Page, options: Options) { + 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) + } + const measured = await measure(page, options, "review-open", async () => { + await button.click() + await panel.waitFor({ state: "visible", timeout: 60_000 }) + await waitForQuietDOM(page) + }) + return { ...measured, dom: await domCounts(page, true) } +} + +async function measure(page: Page, options: Options, name: string, action: () => Promise) { + progress("scenario started", { name }) + await resetProbe(page) + const stopTrace = options.diagnostics ? await startChromeTrace(page, name) : undefined + const cpu = await startCPUProfile(page, options.cpu) + const started = performance.now() + await action() + const result = { + name, + elapsedMs: performance.now() - started, + ...summarizeProbe(await collectProbe(page)), + cpu: await cpu.stop(), + trace: await stopTrace?.(), + } + progress("scenario completed", { name, elapsedMs: Math.round(result.elapsedMs), longTasks: result.longTasks.count }) + return result +} + +async function openSession(page: Page, target: Target) { + await navigateSession(page, target) + await waitForSelector(page, '[data-component="prompt-input"]', "session composer") + await waitForQuietDOM(page) +} + +async function prepareHome(page: Page) { + await setDesktopRoute(page, "/") + await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 }) + await waitForSelector(page, '[data-component="home-session-row"]', "Home session rows") + await waitForQuietDOM(page) +} + +async function navigateSession(page: Page, target: Target) { + await setDesktopRoute(page, `/server/${base64Encode("sidecar")}/session/${target.id}`) + await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 }) +} + +function targetContext(target: Target) { + return { serializedBytes: target.bytes, messages: target.messages, parts: target.parts, userTurns: target.userTurns } +} diff --git a/packages/app/e2e/performance/desktop-profile/types.ts b/packages/app/e2e/performance/desktop-profile/types.ts new file mode 100644 index 00000000000..b3d728b2887 --- /dev/null +++ b/packages/app/e2e/performance/desktop-profile/types.ts @@ -0,0 +1,50 @@ +export const scenarios = ["home", "calibration", "session", "composer", "history", "review"] as const + +export type Scenario = (typeof scenarios)[number] + +export type Options = { + mode: "local" | "partial-snapshot" + database: string + output: string + windowStart: number + windowEnd: number + scenarios: Scenario[] + runs: number + build: boolean + diagnostics: boolean + cpu: boolean + responseURLs: boolean + partialSnapshotOut?: string +} + +export type Target = { + label: "p50" | "p95" | "max" + id: string + projectID: string + directory: string + title: string + bytes: number + messages: number + parts: number + userTurns: number +} + +export 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 }[] +} diff --git a/packages/app/e2e/performance/profile-desktop.ts b/packages/app/e2e/performance/profile-desktop.ts new file mode 100644 index 00000000000..896c6c76f9a --- /dev/null +++ b/packages/app/e2e/performance/profile-desktop.ts @@ -0,0 +1,107 @@ +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { createPartialSnapshot, fingerprint, loadCorpus } from "./desktop-profile/corpus" +import { parseOptions } from "./desktop-profile/options" +import { installProbe } from "./desktop-profile/probe" +import { progress } from "./desktop-profile/progress" +import { withDesktop, run } from "./desktop-profile/runtime" +import { runScenarios } from "./desktop-profile/scenarios" + +const root = path.resolve(import.meta.dir, "../../../..") +const desktop = path.join(root, "packages/desktop") +const options = parseOptions(process.argv.slice(2)) +if (!options) process.exit(0) + +await mkdir(options.output, { recursive: true }) +progress("loading corpus", { mode: options.mode }) +let corpus = loadCorpus(options) +if (options.partialSnapshotOut) { + progress("creating partial snapshot") + await createPartialSnapshot(options.database, options.partialSnapshotOut, options, corpus.targets) + options.database = options.partialSnapshotOut + options.mode = "partial-snapshot" + corpus = loadCorpus(options) +} +if (options.build) { + progress("building desktop production bundle") + await run(["bun", "run", "build"], desktop, options.database) +} + +progress("corpus ready", { targets: corpus.targets.map((target) => target.label), runs: options.runs }) +const runs = [] +for (let index = 1; index <= options.runs; index++) { + runs.push( + await withDesktop(options, desktop, index, corpus.projectIDs, async (page) => { + await installProbe(page, options) + await page.evaluate(() => { + const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}") + localStorage.setItem( + "settings.v3", + JSON.stringify({ ...settings, general: { ...settings.general, newLayoutDesigns: true } }), + ) + }) + return runScenarios(page, options, corpus.targets, corpus.typingText) + }), + ) +} + +const report = { + schemaVersion: 2, + source: options.mode === "partial-snapshot" ? "partial-database-snapshot" : "local-opencode-db", + command: process.argv.slice(2), + diagnostics: options.diagnostics, + profileCPU: options.cpu, + database: await fingerprint(options.database), + window: { + start: new Date(options.windowStart).toISOString(), + end: new Date(options.windowEnd).toISOString(), + }, + revision: (await Bun.$`git rev-parse HEAD`.cwd(root).text()).trim(), + targets: corpus.targets.map(({ id: _, projectID: __, directory: ___, title: ____, ...target }) => target), + summary: summarize(runs), + runs: runs.map((results, index) => ({ index: index + 1, results })), +} +const file = path.join(options.output, "renderer-profile.json") +await Bun.write(file, JSON.stringify(report, null, 2)) +console.log(`PROFILE_REPORT ${file}`) +console.log(`PROFILE_SUMMARY ${JSON.stringify(report.summary)}`) +console.log(JSON.stringify(report, null, 2)) + +function summarize(runs: unknown[][]) { + type Result = { + name: string + elapsedMs?: number + longTasks: { count: number; totalMs: number; maxMs: number } + longAnimationFrames: { totalBlockingMs: number } + typing?: { p50Ms: number; p95Ms: number; maxMs: number } + } + return Object.fromEntries( + [...Map.groupBy(runs.flat() as Result[], (result) => result.name)].map(([name, samples]) => [ + name, + { + samples: samples.length, + elapsedMedianMs: median(samples.flatMap((sample) => sample.elapsedMs ?? [])), + longTasks: { + maxCount: Math.max(...samples.map((sample) => sample.longTasks.count)), + maxTotalMs: Math.max(...samples.map((sample) => sample.longTasks.totalMs)), + maxTaskMs: Math.max(...samples.map((sample) => sample.longTasks.maxMs)), + }, + maxBlockingMs: Math.max(...samples.map((sample) => sample.longAnimationFrames.totalBlockingMs)), + ...(samples[0]?.typing + ? { + typingMedianMs: { + p50: median(samples.flatMap((sample) => sample.typing?.p50Ms ?? [])), + p95: median(samples.flatMap((sample) => sample.typing?.p95Ms ?? [])), + max: median(samples.flatMap((sample) => sample.typing?.maxMs ?? [])), + }, + } + : {}), + }, + ]), + ) +} + +function median(values: number[]) { + if (values.length === 0) return + return values.toSorted((a, b) => a - b)[Math.floor(values.length / 2)] +} diff --git a/packages/app/e2e/performance/real-desktop-profile.ts b/packages/app/e2e/performance/real-desktop-profile.ts index 2e3dbaaab7f..cd7a85fd2b2 100644 --- a/packages/app/e2e/performance/real-desktop-profile.ts +++ b/packages/app/e2e/performance/real-desktop-profile.ts @@ -1,675 +1 @@ -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 -} +import "./profile-desktop" diff --git a/packages/app/package.json b/packages/app/package.json index 7eb870b5854..a51b178c4b0 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -28,7 +28,8 @@ "test:e2e:ui": "playwright test --ui", "test:e2e:report": "playwright show-report e2e/playwright-report", "test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts", - "test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts" + "test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts", + "profile:desktop": "bun run e2e/performance/profile-desktop.ts" }, "license": "MIT", "devDependencies": { From 842f1dcfdb01abea5be119cb1d6b8cad2c34fcde Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:42:41 +1000 Subject: [PATCH 12/13] fix(app): lazy load decoder workers --- .../src/context/session-message-decoder.ts | 54 +++++++++++-------- packages/app/src/utils/vcs-diff-decoder.ts | 54 +++++++++++-------- 2 files changed, 66 insertions(+), 42 deletions(-) diff --git a/packages/app/src/context/session-message-decoder.ts b/packages/app/src/context/session-message-decoder.ts index 37a2f94aee8..a4502b353b1 100644 --- a/packages/app/src/context/session-message-decoder.ts +++ b/packages/app/src/context/session-message-decoder.ts @@ -1,4 +1,3 @@ -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" @@ -6,6 +5,7 @@ import type { Session } from "@opencode-ai/sdk/v2/client" type Response = { id: number; data?: unknown; error?: string } let worker: Worker | undefined +let workerLoad: Promise | undefined let nextID = 0 const pending = new Map void; reject: (error: Error) => void }>() @@ -29,29 +29,41 @@ function decode( const id = ++nextID return new Promise((resolve, reject) => { pending.set(id, { resolve: (value) => resolve(value as T), reject }) - getWorker().postMessage({ id, type, buffer, options }, [buffer]) + void getWorker() + .then((worker) => worker.postMessage({ id, type, buffer, options }, [buffer])) + .catch((error) => { + pending.delete(id) + reject(error instanceof Error ? error : new Error(String(error))) + }) }) } function getWorker() { - if (worker) return worker - worker = new Worker(SessionMessageDecoderWorkerUrl, { type: "module" }) - worker.onmessage = (event: MessageEvent) => { - const request = pending.get(event.data.id) - if (!request) return - pending.delete(event.data.id) - if (event.data.error) { - request.reject(new Error(event.data.error)) - return + if (worker) return Promise.resolve(worker) + if (workerLoad) return workerLoad + workerLoad = (async () => { + const { default: workerUrl } = await import("./session-message-decoder.worker.ts?worker&url") + const next = new Worker(workerUrl, { type: "module" }) + next.onmessage = (event: MessageEvent) => { + const request = pending.get(event.data.id) + if (!request) return + pending.delete(event.data.id) + if (event.data.error) { + request.reject(new Error(event.data.error)) + return + } + request.resolve(event.data.data) } - request.resolve(event.data.data) - } - worker.onerror = (event) => { - const error = new Error(event.message) - pending.forEach((request) => request.reject(error)) - pending.clear() - worker?.terminate() - worker = undefined - } - return worker + next.onerror = (event) => { + const error = new Error(event.message) + pending.forEach((request) => request.reject(error)) + pending.clear() + next.terminate() + worker = undefined + workerLoad = undefined + } + worker = next + return next + })() + return workerLoad } diff --git a/packages/app/src/utils/vcs-diff-decoder.ts b/packages/app/src/utils/vcs-diff-decoder.ts index be4abedaf03..ac577be5005 100644 --- a/packages/app/src/utils/vcs-diff-decoder.ts +++ b/packages/app/src/utils/vcs-diff-decoder.ts @@ -1,9 +1,9 @@ import type { FileDiffInfo } from "@opencode-ai/client/promise" -import VcsDiffDecoderWorkerUrl from "./vcs-diff-decoder.worker.ts?worker&url" type Response = { id: number; data?: FileDiffInfo[]; error?: string } let worker: Worker | undefined +let workerLoad: Promise | undefined let nextID = 0 const pending = new Map void; reject: (error: Error) => void }>() let lastInput = 0 @@ -19,31 +19,43 @@ export function decodeVcsDiff(buffer: ArrayBuffer) { const id = ++nextID return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }) - getWorker().postMessage({ id, buffer }, [buffer]) + void getWorker() + .then((worker) => worker.postMessage({ id, buffer }, [buffer])) + .catch((error) => { + pending.delete(id) + reject(error instanceof Error ? error : new Error(String(error))) + }) }) } function getWorker() { - if (worker) return worker - worker = new Worker(VcsDiffDecoderWorkerUrl, { type: "module" }) - worker.onmessage = (event: MessageEvent) => { - const request = pending.get(event.data.id) - if (!request) return - pending.delete(event.data.id) - if (event.data.error) { - request.reject(new Error(event.data.error)) - return + if (worker) return Promise.resolve(worker) + if (workerLoad) return workerLoad + workerLoad = (async () => { + const { default: workerUrl } = await import("./vcs-diff-decoder.worker.ts?worker&url") + const next = new Worker(workerUrl, { type: "module" }) + next.onmessage = (event: MessageEvent) => { + const request = pending.get(event.data.id) + if (!request) return + pending.delete(event.data.id) + if (event.data.error) { + request.reject(new Error(event.data.error)) + return + } + resolveWhenInputIdle(request.resolve, event.data.data ?? []) } - resolveWhenInputIdle(request.resolve, event.data.data ?? []) - } - worker.onerror = (event) => { - const error = new Error(event.message) - pending.forEach((request) => request.reject(error)) - pending.clear() - worker?.terminate() - worker = undefined - } - return worker + next.onerror = (event) => { + const error = new Error(event.message) + pending.forEach((request) => request.reject(error)) + pending.clear() + next.terminate() + worker = undefined + workerLoad = undefined + } + worker = next + return next + })() + return workerLoad } function resolveWhenInputIdle(resolve: (value: FileDiffInfo[]) => void, value: FileDiffInfo[], initial = true) { From 1b0e4e4610472e47e9407d273857fcb58bb17d96 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:48:57 +1000 Subject: [PATCH 13/13] refactor(app): use standard worker URLs --- .../src/context/session-message-decoder.ts | 53 +++++++------------ packages/app/src/utils/vcs-diff-decoder.ts | 53 +++++++------------ 2 files changed, 40 insertions(+), 66 deletions(-) diff --git a/packages/app/src/context/session-message-decoder.ts b/packages/app/src/context/session-message-decoder.ts index a4502b353b1..30e7c27747b 100644 --- a/packages/app/src/context/session-message-decoder.ts +++ b/packages/app/src/context/session-message-decoder.ts @@ -5,7 +5,6 @@ import type { Session } from "@opencode-ai/sdk/v2/client" type Response = { id: number; data?: unknown; error?: string } let worker: Worker | undefined -let workerLoad: Promise | undefined let nextID = 0 const pending = new Map void; reject: (error: Error) => void }>() @@ -29,41 +28,29 @@ function decode( const id = ++nextID return new Promise((resolve, reject) => { pending.set(id, { resolve: (value) => resolve(value as T), reject }) - void getWorker() - .then((worker) => worker.postMessage({ id, type, buffer, options }, [buffer])) - .catch((error) => { - pending.delete(id) - reject(error instanceof Error ? error : new Error(String(error))) - }) + getWorker().postMessage({ id, type, buffer, options }, [buffer]) }) } function getWorker() { - if (worker) return Promise.resolve(worker) - if (workerLoad) return workerLoad - workerLoad = (async () => { - const { default: workerUrl } = await import("./session-message-decoder.worker.ts?worker&url") - const next = new Worker(workerUrl, { type: "module" }) - next.onmessage = (event: MessageEvent) => { - const request = pending.get(event.data.id) - if (!request) return - pending.delete(event.data.id) - if (event.data.error) { - request.reject(new Error(event.data.error)) - return - } - request.resolve(event.data.data) + if (worker) return worker + worker = new Worker(new URL("./session-message-decoder.worker.ts", import.meta.url), { type: "module" }) + worker.onmessage = (event: MessageEvent) => { + const request = pending.get(event.data.id) + if (!request) return + pending.delete(event.data.id) + if (event.data.error) { + request.reject(new Error(event.data.error)) + return } - next.onerror = (event) => { - const error = new Error(event.message) - pending.forEach((request) => request.reject(error)) - pending.clear() - next.terminate() - worker = undefined - workerLoad = undefined - } - worker = next - return next - })() - return workerLoad + request.resolve(event.data.data) + } + worker.onerror = (event) => { + const error = new Error(event.message) + pending.forEach((request) => request.reject(error)) + pending.clear() + worker?.terminate() + worker = undefined + } + return worker } diff --git a/packages/app/src/utils/vcs-diff-decoder.ts b/packages/app/src/utils/vcs-diff-decoder.ts index ac577be5005..5e65abe2f93 100644 --- a/packages/app/src/utils/vcs-diff-decoder.ts +++ b/packages/app/src/utils/vcs-diff-decoder.ts @@ -3,7 +3,6 @@ import type { FileDiffInfo } from "@opencode-ai/client/promise" type Response = { id: number; data?: FileDiffInfo[]; error?: string } let worker: Worker | undefined -let workerLoad: Promise | undefined let nextID = 0 const pending = new Map void; reject: (error: Error) => void }>() let lastInput = 0 @@ -19,43 +18,31 @@ export function decodeVcsDiff(buffer: ArrayBuffer) { const id = ++nextID return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }) - void getWorker() - .then((worker) => worker.postMessage({ id, buffer }, [buffer])) - .catch((error) => { - pending.delete(id) - reject(error instanceof Error ? error : new Error(String(error))) - }) + getWorker().postMessage({ id, buffer }, [buffer]) }) } function getWorker() { - if (worker) return Promise.resolve(worker) - if (workerLoad) return workerLoad - workerLoad = (async () => { - const { default: workerUrl } = await import("./vcs-diff-decoder.worker.ts?worker&url") - const next = new Worker(workerUrl, { type: "module" }) - next.onmessage = (event: MessageEvent) => { - const request = pending.get(event.data.id) - if (!request) return - pending.delete(event.data.id) - if (event.data.error) { - request.reject(new Error(event.data.error)) - return - } - resolveWhenInputIdle(request.resolve, event.data.data ?? []) + if (worker) return worker + worker = new Worker(new URL("./vcs-diff-decoder.worker.ts", import.meta.url), { type: "module" }) + worker.onmessage = (event: MessageEvent) => { + const request = pending.get(event.data.id) + if (!request) return + pending.delete(event.data.id) + if (event.data.error) { + request.reject(new Error(event.data.error)) + return } - next.onerror = (event) => { - const error = new Error(event.message) - pending.forEach((request) => request.reject(error)) - pending.clear() - next.terminate() - worker = undefined - workerLoad = undefined - } - worker = next - return next - })() - return workerLoad + resolveWhenInputIdle(request.resolve, event.data.data ?? []) + } + worker.onerror = (event) => { + const error = new Error(event.message) + pending.forEach((request) => request.reject(error)) + pending.clear() + worker?.terminate() + worker = undefined + } + return worker } function resolveWhenInputIdle(resolve: (value: FileDiffInfo[]) => void, value: FileDiffInfo[], initial = true) {