summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/context
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-03-13 07:38:03 -0500
committerGitHub <[email protected]>2026-03-13 07:38:03 -0500
commit1a3735b6197a192be10a2576ed21edee93f9da21 (patch)
treebc55b2d672b378b8838411718fc1e9522d041121 /packages/app/src/context
parentd4ae13f2a0e7748dd8f3a94ec21ee05050ec2cf7 (diff)
downloadopencode-1a3735b6197a192be10a2576ed21edee93f9da21.tar.gz
opencode-1a3735b6197a192be10a2576ed21edee93f9da21.zip
fix(app): better optimistic prompt submit (#17337)
Diffstat (limited to 'packages/app/src/context')
-rw-r--r--packages/app/src/context/sync-optimistic.test.ts71
-rw-r--r--packages/app/src/context/sync.tsx103
2 files changed, 171 insertions, 3 deletions
diff --git a/packages/app/src/context/sync-optimistic.test.ts b/packages/app/src/context/sync-optimistic.test.ts
index 7deeddd6e..94324f8a0 100644
--- a/packages/app/src/context/sync-optimistic.test.ts
+++ b/packages/app/src/context/sync-optimistic.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
-import { applyOptimisticAdd, applyOptimisticRemove } from "./sync"
+import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync"
+
+type Text = Extract<Part, { type: "text" }>
const userMessage = (id: string, sessionID: string): Message => ({
id,
@@ -11,7 +13,7 @@ const userMessage = (id: string, sessionID: string): Message => ({
model: { providerID: "openai", modelID: "gpt" },
})
-const textPart = (id: string, sessionID: string, messageID: string): Part => ({
+const textPart = (id: string, sessionID: string, messageID: string): Text => ({
id,
sessionID,
messageID,
@@ -53,4 +55,69 @@ describe("sync optimistic reducers", () => {
expect(draft.part.msg_1).toBeUndefined()
expect(draft.part.msg_2).toHaveLength(1)
})
+
+ test("mergeOptimisticPage keeps pending messages in fetched timelines", () => {
+ const sessionID = "ses_1"
+ const page = mergeOptimisticPage(
+ {
+ session: [userMessage("msg_1", sessionID)],
+ part: [{ id: "msg_1", part: [textPart("prt_1", sessionID, "msg_1")] }],
+ complete: true,
+ },
+ [{ message: userMessage("msg_2", sessionID), parts: [textPart("prt_2", sessionID, "msg_2")] }],
+ )
+
+ expect(page.session.map((x) => x.id)).toEqual(["msg_1", "msg_2"])
+ expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_2"])
+ expect(page.confirmed).toEqual([])
+ expect(page.complete).toBe(true)
+ })
+
+ test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => {
+ const sessionID = "ses_1"
+ const page = mergeOptimisticPage(
+ {
+ session: [userMessage("msg_2", sessionID)],
+ part: [{ id: "msg_2", part: [textPart("prt_2", sessionID, "msg_2")] }],
+ complete: true,
+ },
+ [
+ {
+ message: userMessage("msg_2", sessionID),
+ parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
+ },
+ ],
+ )
+
+ expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
+ expect(page.confirmed).toEqual([])
+ })
+
+ test("mergeOptimisticPage confirms echoed messages once all parts arrive", () => {
+ const sessionID = "ses_1"
+ const page = mergeOptimisticPage(
+ {
+ session: [userMessage("msg_2", sessionID)],
+ part: [
+ {
+ id: "msg_2",
+ part: [{ ...textPart("prt_1", sessionID, "msg_2"), text: "server" }, textPart("prt_2", sessionID, "msg_2")],
+ },
+ ],
+ complete: true,
+ },
+ [
+ {
+ message: userMessage("msg_2", sessionID),
+ parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
+ },
+ ],
+ )
+
+ expect(page.confirmed).toEqual(["msg_2"])
+ expect(page.part.find((x) => x.id === "msg_2")?.part).toMatchObject([
+ { id: "prt_1", type: "text", text: "server" },
+ { id: "prt_2", type: "text", text: "prt_2" },
+ ])
+ })
})
diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx
index 9dc6623a7..0f2008723 100644
--- a/packages/app/src/context/sync.tsx
+++ b/packages/app/src/context/sync.tsx
@@ -54,6 +54,67 @@ type OptimisticRemoveInput = {
messageID: string
}
+type OptimisticItem = {
+ message: Message
+ parts: Part[]
+}
+
+type MessagePage = {
+ session: Message[]
+ part: { id: string; part: Part[] }[]
+ cursor?: string
+ complete: boolean
+}
+
+const hasParts = (parts: Part[] | undefined, want: Part[]) => {
+ if (!parts) return want.length === 0
+ return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
+}
+
+const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
+ if (!parts) return sortParts(want)
+ const next = [...parts]
+ let changed = false
+ for (const part of want) {
+ const result = Binary.search(next, part.id, (item) => item.id)
+ if (result.found) continue
+ next.splice(result.index, 0, part)
+ changed = true
+ }
+ if (!changed) return parts
+ return next
+}
+
+export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
+ if (items.length === 0) return { ...page, confirmed: [] as string[] }
+
+ const session = [...page.session]
+ const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
+ const confirmed: string[] = []
+
+ for (const item of items) {
+ const result = Binary.search(session, item.message.id, (message) => message.id)
+ const found = result.found
+ if (!found) session.splice(result.index, 0, item.message)
+
+ const current = part.get(item.message.id)
+ if (found && hasParts(current, item.parts)) {
+ confirmed.push(item.message.id)
+ continue
+ }
+
+ part.set(item.message.id, mergeParts(current, item.parts))
+ }
+
+ return {
+ cursor: page.cursor,
+ complete: page.complete,
+ session,
+ part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
+ confirmed,
+ }
+}
+
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID]
if (messages) {
@@ -121,6 +182,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
+ const optimistic = new Map<string, Map<string, OptimisticItem>>()
const maxDirs = 30
const seen = new Map<string, Set<string>>()
const [meta, setMeta] = createStore({
@@ -137,6 +199,33 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return undefined
}
+ const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
+ const key = keyFor(directory, sessionID)
+ const list = optimistic.get(key)
+ if (list) {
+ list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
+ return
+ }
+ optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
+ }
+
+ const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
+ const key = keyFor(directory, sessionID)
+ if (!messageID) {
+ optimistic.delete(key)
+ return
+ }
+
+ const list = optimistic.get(key)
+ if (!list) return
+ list.delete(messageID)
+ if (list.size === 0) optimistic.delete(key)
+ }
+
+ const getOptimistic = (directory: string, sessionID: string) => [
+ ...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
+ ]
+
const seenFor = (directory: string) => {
const existing = seen.get(directory)
if (existing) {
@@ -159,6 +248,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const clearMeta = (directory: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
+ for (const sessionID of sessionIDs) {
+ clearOptimistic(directory, sessionID)
+ }
setMeta(
produce((draft) => {
for (const sessionID of sessionIDs) {
@@ -232,8 +324,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
setMeta("loading", key, true)
await fetchMessages(input)
- .then((next) => {
+ .then((page) => {
if (!tracked(input.directory, input.sessionID)) return
+ const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID))
+ for (const messageID of next.confirmed) {
+ clearOptimistic(input.directory, input.sessionID, messageID)
+ }
const [store] = globalSync.child(input.directory, { bootstrap: false })
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
@@ -290,11 +386,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
get: getSession,
optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
+ const directory = input.directory ?? sdk.directory
const [, setStore] = target(input.directory)
+ setOptimistic(directory, input.sessionID, { message: input.message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
},
remove(input: { directory?: string; sessionID: string; messageID: string }) {
+ const directory = input.directory ?? sdk.directory
const [, setStore] = target(input.directory)
+ clearOptimistic(directory, input.sessionID, input.messageID)
setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
},
},
@@ -316,6 +416,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
variant: input.variant,
}
const [, setStore] = target()
+ setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
sessionID: input.sessionID,
message,