From b2bd7be7ac1b5c735fe89a4910adf8181efdd1a4 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:22:39 -0500 Subject: [PATCH] fix(app): perf --- packages/app/src/context/prompt.tsx | 207 +++++++++++++++--- packages/app/src/pages/layout.tsx | 10 +- .../app/src/pages/layout/sidebar-items.tsx | 9 +- packages/app/src/pages/session.tsx | 152 +++++++++---- .../src/pages/session/message-timeline.tsx | 2 +- 5 files changed, 295 insertions(+), 85 deletions(-) diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index fb82265591..2da78561e4 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -1,6 +1,6 @@ import { createStore, type SetStoreFunction } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" -import { batch, createMemo, createRoot, onCleanup } from "solid-js" +import { batch, createEffect, createMemo, createRoot, createSignal, onCleanup, untrack } from "solid-js" import { useParams } from "@solidjs/router" import type { FileSelection } from "@/context/file" import { Persist, persisted } from "@/utils/persist" @@ -50,8 +50,23 @@ export type FileContextItem = { export type ContextItem = FileContextItem +type State = { + prompt: Prompt + cursor?: number + context: { + items: (ContextItem & { key: string })[] + } +} + +type Pics = { + synced: boolean + items: ImageAttachmentPart[] +} + export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] +const PROMPT_SYNC_MS = 250 + function isSelectionEqual(a?: FileSelection, b?: FileSelection) { if (!a && !b) return true if (!a || !b) return false @@ -100,6 +115,76 @@ function clonePrompt(prompt: Prompt): Prompt { return prompt.map(clonePart) } +function cloneContextItem(item: ContextItem & { key: string }) { + return { + ...item, + selection: cloneSelection(item.selection), + } +} + +function cloneContextItems(items: (ContextItem & { key: string })[]) { + return items.map(cloneContextItem) +} + +function cloneImages(items: ImageAttachmentPart[]) { + return items.map((item) => ({ ...item })) +} + +function sameImages(a: ImageAttachmentPart[], b: ImageAttachmentPart[]) { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + const x = a[i] + const y = b[i] + if (x.id !== y.id) return false + if (x.filename !== y.filename) return false + if (x.mime !== y.mime) return false + if (x.dataUrl !== y.dataUrl) return false + } + return true +} + +function sameContext(a: (ContextItem & { key: string })[], b: (ContextItem & { key: string })[]) { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + const x = a[i] + const y = b[i] + if (x.key !== y.key) return false + if (x.type !== y.type) return false + if (x.path !== y.path) return false + if (x.comment !== y.comment) return false + if (x.commentID !== y.commentID) return false + if (x.commentOrigin !== y.commentOrigin) return false + if (x.preview !== y.preview) return false + if (!isSelectionEqual(x.selection, y.selection)) return false + } + return true +} + +function stripImages(prompt: Prompt): Prompt { + return prompt.filter((part) => part.type !== "image") +} + +function pickImages(prompt: Prompt): ImageAttachmentPart[] { + return prompt.filter((part): part is ImageAttachmentPart => part.type === "image") +} + +function state(): State { + return { + prompt: clonePrompt(DEFAULT_PROMPT), + cursor: undefined, + context: { + items: [], + }, + } +} + +function images(): Pics { + return { + synced: false, + items: [], + } +} + function contextItemKey(item: ContextItem) { if (item.type !== "file") return item.type const start = item.selection?.startLine @@ -120,15 +205,7 @@ function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { return item.type === "file" && !!item.comment?.trim() } -function createPromptActions( - setStore: SetStoreFunction<{ - prompt: Prompt - cursor?: number - context: { - items: (ContextItem & { key: string })[] - } - }>, -) { +function createPromptActions(setStore: SetStoreFunction) { return { set(prompt: Prompt, cursorPosition?: number) { const next = clonePrompt(prompt) @@ -159,22 +236,100 @@ type PromptCacheEntry = { function createPromptSession(dir: string, id: string | undefined) { const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2` - const [store, setStore, _, ready] = persisted( - Persist.scoped(dir, id, "prompt", [legacy]), - createStore<{ - prompt: Prompt - cursor?: number - context: { - items: (ContextItem & { key: string })[] - } - }>({ - prompt: clonePrompt(DEFAULT_PROMPT), - cursor: undefined, - context: { - items: [], - }, - }), - ) + const [draft, setDraft, , draftReady] = persisted(Persist.scoped(dir, id, "prompt", [legacy]), createStore(state())) + + const [pics, setPics, , picsReady] = persisted(Persist.scoped(dir, id, "prompt-image"), createStore(images())) + + const [store, setStore] = createStore(state()) + const [hydrated, setHydrated] = createSignal(false) + + const prompt = createMemo(() => stripImages(store.prompt), clonePrompt(DEFAULT_PROMPT), { + equals: isPromptEqual, + }) + const picsList = createMemo(() => pickImages(store.prompt), [] as ImageAttachmentPart[], { + equals: sameImages, + }) + const ready = createMemo(() => hydrated() && draftReady() && picsReady()) + + let promptt: ReturnType | undefined + let pict: ReturnType | undefined + + const clear = () => { + if (promptt !== undefined) clearTimeout(promptt) + if (pict !== undefined) clearTimeout(pict) + } + + onCleanup(clear) + + createEffect(() => { + if (!draftReady() || !picsReady()) return + if (hydrated()) return + + const base = stripImages(draft.prompt) + const legacyPics = pickImages(draft.prompt) + const nextPics = pics.synced || pics.items.length > 0 ? pics.items : legacyPics + + batch(() => { + setStore({ + prompt: clonePrompt([...base, ...nextPics]), + cursor: draft.cursor, + context: { + items: cloneContextItems(draft.context.items), + }, + }) + setHydrated(true) + }) + + if (!pics.synced && legacyPics.length > 0) { + setPics({ synced: true, items: cloneImages(legacyPics) }) + } + }) + + createEffect(() => { + if (!hydrated()) return + prompt() + store.cursor + store.context.items + if (promptt !== undefined) clearTimeout(promptt) + + promptt = setTimeout(() => { + promptt = undefined + const next = clonePrompt(prompt()) + const items = cloneContextItems(store.context.items) + const samePrompt = isPromptEqual(stripImages(untrack(() => draft.prompt)), next) + const sameCursor = untrack(() => draft.cursor) === store.cursor + const sameItems = sameContext( + untrack(() => draft.context.items), + items, + ) + if (samePrompt && sameCursor && sameItems) return + + batch(() => { + setDraft("prompt", next) + setDraft("cursor", store.cursor) + setDraft("context", "items", items) + }) + }, PROMPT_SYNC_MS) + }) + + createEffect(() => { + if (!hydrated()) return + picsList() + if (pict !== undefined) clearTimeout(pict) + + pict = setTimeout(() => { + pict = undefined + const next = cloneImages(picsList()) + if ( + sameImages( + untrack(() => pics.items), + next, + ) + ) + if (untrack(() => pics.synced)) return + setPics({ synced: true, items: next }) + }, PROMPT_SYNC_MS) + }) const actions = createPromptActions(setStore) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index ed22218464..65488f9bc5 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -670,14 +670,14 @@ export default function Layout(props: ParentProps) { running: number } - const prefetchChunk = 200 - const prefetchConcurrency = 2 - const prefetchPendingLimit = 10 - const span = 4 + const prefetchChunk = 80 + const prefetchConcurrency = 1 + const prefetchPendingLimit = 6 + const span = 1 const prefetchToken = { value: 0 } const prefetchQueues = new Map() - const PREFETCH_MAX_SESSIONS_PER_DIR = 10 + const PREFETCH_MAX_SESSIONS_PER_DIR = 6 const prefetchedByDir = new Map>() const lruFor = (directory: string) => { diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 17572923eb..4143613cb2 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -261,12 +261,11 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { hoverPrefetch.current = undefined } const scheduleHoverPrefetch = () => { - warm(1, "high") if (hoverPrefetch.current !== undefined) return hoverPrefetch.current = setTimeout(() => { hoverPrefetch.current = undefined - warm(2, "low") - }, 80) + warm(1, "low") + }, 160) } onCleanup(cancelHoverPrefetch) @@ -291,8 +290,8 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { clearHoverProjectSoon={props.clearHoverProjectSoon} sidebarOpened={layout.sidebar.opened} warmHover={scheduleHoverPrefetch} - warmPress={() => warm(2, "high")} - warmFocus={() => warm(2, "high")} + warmPress={() => warm(1, "high")} + warmFocus={() => warm(1, "high")} cancelHoverPrefetch={cancelHoverPrefetch} /> ) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 57ef1853d1..6cbd549585 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -526,11 +526,58 @@ export default function Page() { return key }, sessionKey()) + type Job = { + frame?: number + timer?: number + idle?: number + } + + const syncMs = SESSION_PREFETCH_TTL + const todoMs = 30_000 + const diffMs = 30_000 + const at = { + sync: new Map(), + todo: new Map(), + diff: new Map(), + } + + const key = (dir: string, id: string) => `${dir}\n${id}` + const due = (map: Map, id: string, ttl: number) => Date.now() - (map.get(id) ?? 0) >= ttl + const touch = (map: Map, id: string) => map.set(id, Date.now()) + + const clearJob = (job: Job) => { + if (job.frame !== undefined) cancelAnimationFrame(job.frame) + if (job.timer !== undefined) window.clearTimeout(job.timer) + if (job.idle !== undefined && typeof window.cancelIdleCallback === "function") { + window.cancelIdleCallback(job.idle) + } + job.frame = undefined + job.timer = undefined + job.idle = undefined + } + + const queueJob = (job: Job, run: VoidFunction, delay = 180) => { + clearJob(job) + job.frame = requestAnimationFrame(() => { + job.frame = undefined + job.timer = window.setTimeout(() => { + job.timer = undefined + const fire = () => { + job.idle = undefined + run() + } + if (typeof window.requestIdleCallback !== "function") { + fire() + return + } + job.idle = window.requestIdleCallback(fire, { timeout: 500 }) + }, delay) + }) + } + let reviewFrame: number | undefined - let refreshFrame: number | undefined - let refreshTimer: number | undefined - let diffFrame: number | undefined - let diffTimer: number | undefined + const refreshJob: Job = {} + const diffJob: Job = {} createComputed((prev) => { const open = desktopReviewOpen() @@ -690,17 +737,16 @@ export default function Page() { createEffect( on([() => sdk.directory, () => params.id] as const, ([, id]) => { - if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) - if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) - refreshFrame = undefined - refreshTimer = undefined + clearJob(refreshJob) if (!id) return + const dir = sdk.directory + const idKey = key(dir, id) const cached = untrack(() => sync.data.message[id] !== undefined) const stale = !cached ? false : (() => { - const info = getSessionPrefetch(sdk.directory, id) + const info = getSessionPrefetch(dir, id) if (!info) return true return Date.now() - info.at > SESSION_PREFETCH_TTL })() @@ -710,17 +756,31 @@ export default function Page() { void sync.session.sync(id) }) - refreshFrame = requestAnimationFrame(() => { - refreshFrame = undefined - refreshTimer = window.setTimeout(() => { - refreshTimer = undefined + queueJob( + refreshJob, + () => { if (params.id !== id) return + if (sdk.directory !== dir) return + untrack(() => { - if (stale) void sync.session.sync(id, { force: true }) - void sync.session.todo(id, todos ? { force: true } : undefined) + if (!todos) { + touch(at.todo, idKey) + void sync.session.todo(id) + } + + if (stale && due(at.sync, idKey, syncMs)) { + touch(at.sync, idKey) + void sync.session.sync(id, { force: true }) + } + + if (todos && due(at.todo, idKey, todoMs)) { + touch(at.todo, idKey) + void sync.session.todo(id, { force: true }) + } }) - }, 0) - }) + }, + cached ? 220 : 450, + ) }), ) @@ -1111,20 +1171,6 @@ export default function Page() { requestAnimationFrame(() => attempt(0)) }) - createEffect(() => { - const id = params.id - if (!id) return - - const wants = isDesktop() - ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") - : store.mobileTab === "changes" - if (!wants) return - if (sync.data.session_diff[id] !== undefined) return - if (sync.status === "loading") return - - void sync.session.diff(id) - }) - createEffect( on( () => @@ -1133,26 +1179,38 @@ export default function Page() { isDesktop() ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") : store.mobileTab === "changes", + sync.status, ] as const, - ([key, wants]) => { - if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) - if (diffTimer !== undefined) window.clearTimeout(diffTimer) - diffFrame = undefined - diffTimer = undefined + ([session, wants, status]) => { + clearJob(diffJob) if (!wants) return const id = params.id if (!id) return - if (!untrack(() => sync.data.session_diff[id] !== undefined)) return + if (status === "loading") return - diffFrame = requestAnimationFrame(() => { - diffFrame = undefined - diffTimer = window.setTimeout(() => { - diffTimer = undefined - if (sessionKey() !== key) return + const dir = sdk.directory + const idKey = key(dir, id) + const cached = untrack(() => sync.data.session_diff[id] !== undefined) + + queueJob( + diffJob, + () => { + if (sessionKey() !== session) return + if (sdk.directory !== dir) return + + if (!cached) { + touch(at.diff, idKey) + void sync.session.diff(id) + return + } + + if (!due(at.diff, idKey, diffMs)) return + touch(at.diff, idKey) void sync.session.diff(id, { force: true }) - }, 0) - }) + }, + cached ? 240 : 160, + ) }, { defer: true }, ), @@ -1640,10 +1698,8 @@ export default function Page() { onCleanup(() => { document.removeEventListener("keydown", handleKeyDown) if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) - if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) - if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) - if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) - if (diffTimer !== undefined) window.clearTimeout(diffTimer) + clearJob(refreshJob) + clearJob(diffJob) if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame) if (fillFrame !== undefined) cancelAnimationFrame(fillFrame) }) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index b4a740c60e..fb0f22420c 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -228,7 +228,6 @@ export function MessageTimeline(props: { const { params, sessionKey } = useSessionKey() const platform = usePlatform() - const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id)) const sessionID = createMemo(() => params.id) const sessionMessages = createMemo(() => { const id = sessionID() @@ -315,6 +314,7 @@ export function MessageTimeline(props: { messages: () => props.renderedUserMessages, config: stageCfg, }) + const rendered = createMemo(() => staging.messages().map((message) => message.id)) const [title, setTitle] = createStore({ draft: "",