From f008270e1b6b25d2bba93ebcbcf745ba7a6a37a3 Mon Sep 17 00:00:00 2001 From: DGGua Date: Sun, 2 Aug 2026 20:12:16 +0800 Subject: [PATCH 1/2] fix(app): debounce prompt draft persistence Typing in the desktop chat input was lagging because every keystroke JSON-serialized the prompt store and wrote it through IPC to electron-store. Debounce those writes and update prompt+cursor in one store set so desktop input stays responsive. --- packages/app/src/context/prompt-state.ts | 22 ++++--- packages/app/src/utils/persist.test.ts | 30 +++++++++ packages/app/src/utils/persist.ts | 65 ++++++++++++++++++- .../src/v2/components/prompt-input/store.ts | 37 ++++++----- 4 files changed, 125 insertions(+), 29 deletions(-) diff --git a/packages/app/src/context/prompt-state.ts b/packages/app/src/context/prompt-state.ts index 73196289e2f3..a50dcd36f6c7 100644 --- a/packages/app/src/context/prompt-state.ts +++ b/packages/app/src/context/prompt-state.ts @@ -1,6 +1,6 @@ import { checksum } from "@opencode-ai/core/util/encode" import type { FilePartSource } from "@opencode-ai/sdk/v2/client" -import { batch, createMemo, type Accessor } from "solid-js" +import { createMemo, type Accessor } from "solid-js" import { createStore, type SetStoreFunction } from "solid-js/store" import type { FileSelection } from "@/context/file" import { Persist, persisted } from "@/utils/persist" @@ -149,20 +149,21 @@ export function isCommentItem(item: ContextItem | (ContextItem & { key: string } return item.type === "file" && !!item.comment?.trim() } +const PROMPT_PERSIST_DEBOUNCE_MS = 300 + function createPromptActions(setStore: SetStoreFunction) { return { set(prompt: Prompt, cursorPosition?: number) { const next = clonePrompt(prompt) - batch(() => { + // Single store write so makePersisted serializes once per keystroke. + if (cursorPosition === undefined) { setStore("prompt", next) - if (cursorPosition !== undefined) setStore("cursor", cursorPosition) - }) + return + } + setStore({ prompt: next, cursor: cursorPosition }) }, reset() { - batch(() => { - setStore("prompt", clonePrompt(DEFAULT_PROMPT)) - setStore("cursor", 0) - }) + setStore({ prompt: clonePrompt(DEFAULT_PROMPT), cursor: 0 }) }, } } @@ -236,7 +237,10 @@ function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction

, initial?: InitialPrompt) { - const [store, setStore, _, ready] = persisted(target, createStore(promptStore(initial))) + const [store, setStore, _, ready] = persisted( + { ...target, debounceMs: PROMPT_PERSIST_DEBOUNCE_MS }, + createStore(promptStore(initial)), + ) return { ready, ...createPromptStateValue(store, setStore) } } diff --git a/packages/app/src/utils/persist.test.ts b/packages/app/src/utils/persist.test.ts index d8b822d856bb..c581db3a7e20 100644 --- a/packages/app/src/utils/persist.test.ts +++ b/packages/app/src/utils/persist.test.ts @@ -1,4 +1,5 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" +import { createRoot } from "solid-js" import { ServerScope } from "./server-scope" type PersistTestingType = typeof import("./persist").PersistTesting @@ -208,4 +209,33 @@ describe("persist localStorage resilience", () => { test("server global target cannot collide when scope and key contain colons", () => { expect(Persist.serverGlobal("a:b" as ServerScope, "c")).not.toEqual(Persist.serverGlobal("a" as ServerScope, "b:c")) }) + + test("debounceWrites coalesces rapid setItem calls into one flush", async () => { + await new Promise((resolve, reject) => { + createRoot((dispose) => { + try { + const api = persistTesting.debounceWrites(persistTesting.localStorageDirect(), 20) + api.setItem("draft", '{"v":1}') + api.setItem("draft", '{"v":2}') + api.setItem("draft", '{"v":3}') + expect(storage.calls.set).toBe(0) + + setTimeout(() => { + try { + expect(storage.calls.set).toBe(1) + expect(storage.getItem("draft")).toBe('{"v":3}') + dispose() + resolve() + } catch (error) { + dispose() + reject(error) + } + }, 40) + } catch (error) { + dispose() + reject(error) + } + }) + }) + }) }) diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index a2daae4866bc..915f22f0a5a3 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -1,7 +1,7 @@ import { Platform, usePlatform } from "@/context/platform" import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage" import { checksum } from "@opencode-ai/core/util/encode" -import { createResource, type Accessor } from "solid-js" +import { createResource, getOwner, onCleanup, type Accessor } from "solid-js" import type { SetStoreFunction, Store } from "solid-js/store" import { pathKey } from "@/utils/path-key" import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope" @@ -21,6 +21,8 @@ type PersistTarget = { key: string legacy?: string[] migrate?: (value: unknown) => unknown + /** Delay storage writes so high-frequency updates (e.g. prompt typing) do not hit disk/IPC every keystroke. */ + debounceMs?: number } const LEGACY_STORAGE = "default.dat" @@ -469,6 +471,62 @@ export function draftPersistedKeys() { return DRAFT_PERSISTED_KEYS } +function debounceWrites(storage: T, debounceMs: number): T { + type Pending = { value: string; timer: ReturnType } + const pending = new Map() + const last = new Map() + + const flush = (key: string) => { + const item = pending.get(key) + if (!item) return + pending.delete(key) + clearTimeout(item.timer) + if (last.get(key) === item.value) return + last.set(key, item.value) + void storage.setItem(key, item.value) + } + + const flushAll = () => { + for (const key of Array.from(pending.keys())) flush(key) + } + + if (typeof window !== "undefined") { + window.addEventListener("pagehide", flushAll) + window.addEventListener("beforeunload", flushAll) + } + + if (getOwner()) { + onCleanup(() => { + if (typeof window !== "undefined") { + window.removeEventListener("pagehide", flushAll) + window.removeEventListener("beforeunload", flushAll) + } + flushAll() + }) + } + + return { + ...storage, + setItem: (key: string, value: string) => { + const prev = pending.get(key) + if (prev) clearTimeout(prev.timer) + pending.set(key, { + value, + timer: setTimeout(() => flush(key), debounceMs), + }) + }, + removeItem: (key: string) => { + const prev = pending.get(key) + if (prev) { + clearTimeout(prev.timer) + pending.delete(key) + } + last.delete(key) + return storage.removeItem(key) + }, + } as T +} + export const PersistTesting = { localStorageDirect, localStorageWithPrefix, @@ -477,6 +535,7 @@ export const PersistTesting = { resolveTarget, windowStorage, workspaceStorage, + debounceWrites, } export const Persist = { @@ -638,7 +697,9 @@ export function persisted( return api })() - const [state, setState, init] = makePersisted(store, { name: config.key, storage }) + const writeStorage = config.debounceMs && config.debounceMs > 0 ? debounceWrites(storage, config.debounceMs) : storage + + const [state, setState, init] = makePersisted(store, { name: config.key, storage: writeStorage }) const isAsync = init instanceof Promise const [ready] = createResource( diff --git a/packages/session-ui/src/v2/components/prompt-input/store.ts b/packages/session-ui/src/v2/components/prompt-input/store.ts index 5a27de6c4de3..b21345b5180c 100644 --- a/packages/session-ui/src/v2/components/prompt-input/store.ts +++ b/packages/session-ui/src/v2/components/prompt-input/store.ts @@ -1,4 +1,4 @@ -import { batch, type Accessor } from "solid-js" +import type { Accessor } from "solid-js" import type { SetStoreFunction, Store } from "solid-js/store" import type { PromptInputV2AgentPart, @@ -30,35 +30,33 @@ export function createPromptInputV2Store(input: PromptInputV2StoreInput) { return store() }, setPrompt(prompt: PromptInputV2Prompt, cursor?: number) { - batch(() => { + if (cursor === undefined) { setStore()("prompt", prompt) - if (cursor !== undefined) setStore()("cursor", cursor) - }) + return + } + setStore()({ prompt, cursor }) }, setCursor(cursor: number) { setStore()("cursor", cursor) }, setText(content: string) { - batch(() => { - setStore()("prompt", (prompt) => [ + setStore()({ + prompt: [ { type: "text", content, start: 0, end: content.length }, - ...prompt.filter((part) => part.type !== "text"), - ]) - setStore()("cursor", content.length) + ...store().prompt.filter((part) => part.type !== "text"), + ], + cursor: content.length, }) }, addText(content: string) { const cursor = store().cursor ?? promptLength(store().prompt) - batch(() => { - setStore()("prompt", (prompt) => insertText(prompt, cursor, content)) - setStore()("cursor", cursor + content.length) + setStore()({ + prompt: insertText(store().prompt, cursor, content), + cursor: cursor + content.length, }) }, reset() { - batch(() => { - setStore()("prompt", [{ type: "text", content: "", start: 0, end: 0 }]) - setStore()("cursor", 0) - }) + setStore()({ prompt: [{ type: "text", content: "", start: 0, end: 0 }], cursor: 0 }) }, setModel(model: PromptInputV2Model | undefined) { setStore()("model", model) @@ -79,8 +77,11 @@ export function createPromptInputV2Store(input: PromptInputV2StoreInput) { .join("") const end = store().cursor ?? text.length const start = text.slice(0, end).lastIndexOf("@") - setStore()("prompt", insertMention(store().prompt, start < 0 ? end : start, end, mention)) - setStore()("cursor", (start < 0 ? end : start) + mention.content.length + 1) + const at = start < 0 ? end : start + setStore()({ + prompt: insertMention(store().prompt, at, end, mention), + cursor: at + mention.content.length + 1, + }) }, addAttachment(attachment: PromptInputV2Attachment) { setStore()("prompt", (prompt) => [...prompt, attachment]) From cf0143c718d207428a93f673a12737cd5f56e408 Mon Sep 17 00:00:00 2001 From: DGGua Date: Sun, 2 Aug 2026 20:21:20 +0800 Subject: [PATCH 2/2] fix(app): address debounce persist review feedback Preserve live AsyncStorage getters with a Proxy wrapper, and drive debounce tests with fake timers plus dispose-flush coverage. --- packages/app/src/utils/persist.test.ts | 83 ++++++++++++++++++-------- packages/app/src/utils/persist.ts | 47 +++++++++------ 2 files changed, 86 insertions(+), 44 deletions(-) diff --git a/packages/app/src/utils/persist.test.ts b/packages/app/src/utils/persist.test.ts index c581db3a7e20..5ade33106282 100644 --- a/packages/app/src/utils/persist.test.ts +++ b/packages/app/src/utils/persist.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" +import { beforeAll, beforeEach, describe, expect, mock, test, vi } from "bun:test" import { createRoot } from "solid-js" import { ServerScope } from "./server-scope" @@ -210,32 +210,65 @@ describe("persist localStorage resilience", () => { expect(Persist.serverGlobal("a:b" as ServerScope, "c")).not.toEqual(Persist.serverGlobal("a" as ServerScope, "b:c")) }) - test("debounceWrites coalesces rapid setItem calls into one flush", async () => { - await new Promise((resolve, reject) => { + test("debounceWrites coalesces rapid setItem calls into one flush", () => { + vi.useFakeTimers() + try { createRoot((dispose) => { - try { - const api = persistTesting.debounceWrites(persistTesting.localStorageDirect(), 20) - api.setItem("draft", '{"v":1}') - api.setItem("draft", '{"v":2}') - api.setItem("draft", '{"v":3}') - expect(storage.calls.set).toBe(0) - - setTimeout(() => { - try { - expect(storage.calls.set).toBe(1) - expect(storage.getItem("draft")).toBe('{"v":3}') - dispose() - resolve() - } catch (error) { - dispose() - reject(error) - } - }, 40) - } catch (error) { - dispose() - reject(error) - } + const api = persistTesting.debounceWrites(persistTesting.localStorageDirect(), 20) + api.setItem("draft", '{"v":1}') + api.setItem("draft", '{"v":2}') + api.setItem("draft", '{"v":3}') + expect(storage.calls.set).toBe(0) + + vi.advanceTimersByTime(19) + expect(storage.calls.set).toBe(0) + + vi.advanceTimersByTime(1) + expect(storage.calls.set).toBe(1) + expect(storage.getItem("draft")).toBe('{"v":3}') + dispose() }) + } finally { + vi.useRealTimers() + } + }) + + test("debounceWrites flushes pending writes on dispose", () => { + vi.useFakeTimers() + try { + createRoot((dispose) => { + const api = persistTesting.debounceWrites(persistTesting.localStorageDirect(), 50) + api.setItem("draft", '{"v":1}') + expect(storage.calls.set).toBe(0) + dispose() + expect(storage.calls.set).toBe(1) + expect(storage.getItem("draft")).toBe('{"v":1}') + }) + } finally { + vi.useRealTimers() + } + }) + + test("debounceWrites preserves live storage length getters", () => { + createRoot((dispose) => { + const values = new Map() + const base = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value) + }, + removeItem: (key: string) => { + values.delete(key) + }, + get length() { + return values.size + }, + } + const api = persistTesting.debounceWrites(base, 20) + expect(api.length).toBe(0) + base.setItem("a", "1") + expect(api.length).toBe(1) + dispose() }) }) }) diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 915f22f0a5a3..29ee322a3d2c 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -505,26 +505,35 @@ function debounceWrites(storage: T, deboun }) } - return { - ...storage, - setItem: (key: string, value: string) => { - const prev = pending.get(key) - if (prev) clearTimeout(prev.timer) - pending.set(key, { - value, - timer: setTimeout(() => flush(key), debounceMs), - }) - }, - removeItem: (key: string) => { - const prev = pending.get(key) - if (prev) { - clearTimeout(prev.timer) - pending.delete(key) - } - last.delete(key) - return storage.removeItem(key) + const setItem = (key: string, value: string) => { + const prev = pending.get(key) + if (prev) clearTimeout(prev.timer) + pending.set(key, { + value, + timer: setTimeout(() => flush(key), debounceMs), + }) + } + + const removeItem = (key: string) => { + const prev = pending.get(key) + if (prev) { + clearTimeout(prev.timer) + pending.delete(key) + } + last.delete(key) + return storage.removeItem(key) + } + + // Proxy instead of object spread so getter-based fields like AsyncStorage.length stay live. + return new Proxy(storage, { + get(target, prop) { + if (prop === "setItem") return setItem + if (prop === "removeItem") return removeItem + const value = Reflect.get(target, prop, target) + if (typeof value === "function") return value.bind(target) + return value }, - } as T + }) } export const PersistTesting = {