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..5ade33106282 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 { beforeAll, beforeEach, describe, expect, mock, test, vi } from "bun:test" +import { createRoot } from "solid-js" import { ServerScope } from "./server-scope" type PersistTestingType = typeof import("./persist").PersistTesting @@ -208,4 +209,66 @@ 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", () => { + vi.useFakeTimers() + try { + createRoot((dispose) => { + 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 a2daae4866bc..29ee322a3d2c 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,71 @@ 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() + }) + } + + 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 + }, + }) +} + export const PersistTesting = { localStorageDirect, localStorageWithPrefix, @@ -477,6 +544,7 @@ export const PersistTesting = { resolveTarget, windowStorage, workspaceStorage, + debounceWrites, } export const Persist = { @@ -638,7 +706,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])