Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions packages/app/src/context/prompt-state.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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<PromptStore>) {
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 })
},
}
}
Expand Down Expand Up @@ -236,7 +237,10 @@ function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<P
}

function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt) {
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)))
const [store, setStore, _, ready] = persisted(
{ ...target, debounceMs: PROMPT_PERSIST_DEBOUNCE_MS },
createStore<PromptStore>(promptStore(initial)),
)
return { ready, ...createPromptStateValue(store, setStore) }
}

Expand Down
65 changes: 64 additions & 1 deletion packages/app/src/utils/persist.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<string, string>()
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()
})
})
})
74 changes: 72 additions & 2 deletions packages/app/src/utils/persist.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -469,6 +471,71 @@ export function draftPersistedKeys() {
return DRAFT_PERSISTED_KEYS
}

function debounceWrites<T extends SyncStorage | AsyncStorage>(storage: T, debounceMs: number): T {
type Pending = { value: string; timer: ReturnType<typeof setTimeout> }
const pending = new Map<string, Pending>()
const last = new Map<string, string>()

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,
Expand All @@ -477,6 +544,7 @@ export const PersistTesting = {
resolveTarget,
windowStorage,
workspaceStorage,
debounceWrites,
}

export const Persist = {
Expand Down Expand Up @@ -638,7 +706,9 @@ export function persisted<T>(
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(
Expand Down
37 changes: 19 additions & 18 deletions packages/session-ui/src/v2/components/prompt-input/store.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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])
Expand Down
Loading