Skip to content

Commit 1a3735b

Browse files
authored
fix(app): better optimistic prompt submit (#17337)
1 parent d4ae13f commit 1a3735b

4 files changed

Lines changed: 254 additions & 9 deletions

File tree

packages/app/src/components/prompt-input/submit.test.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@ const createdClients: string[] = []
77
const createdSessions: string[] = []
88
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
99
const optimistic: Array<{
10+
directory?: string
11+
sessionID?: string
1012
message: {
1113
agent: string
1214
model: { providerID: string; modelID: string }
1315
variant?: string
1416
}
1517
}> = []
18+
const optimisticSeeded: boolean[] = []
19+
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
1620
const sentShell: string[] = []
1721
const syncedDirectories: string[] = []
1822

@@ -28,7 +32,12 @@ const clientFor = (directory: string) => {
2832
session: {
2933
create: async () => {
3034
createdSessions.push(directory)
31-
return { data: { id: `session-${createdSessions.length}` } }
35+
return {
36+
data: {
37+
id: `session-${createdSessions.length}`,
38+
title: `New session ${createdSessions.length}`,
39+
},
40+
}
3241
},
3342
shell: async () => {
3443
sentShell.push(directory)
@@ -129,9 +138,16 @@ beforeAll(async () => {
129138
session: {
130139
optimistic: {
131140
add: (value: {
141+
directory?: string
142+
sessionID?: string
132143
message: { agent: string; model: { providerID: string; modelID: string }; variant?: string }
133144
}) => {
134145
optimistic.push(value)
146+
optimisticSeeded.push(
147+
!!value.directory &&
148+
!!value.sessionID &&
149+
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
150+
)
135151
},
136152
remove: () => undefined,
137153
},
@@ -144,7 +160,21 @@ beforeAll(async () => {
144160
useGlobalSync: () => ({
145161
child: (directory: string) => {
146162
syncedDirectories.push(directory)
147-
return [{}, () => undefined]
163+
storedSessions[directory] ??= []
164+
return [
165+
{ session: storedSessions[directory] },
166+
(...args: unknown[]) => {
167+
if (args[0] !== "session") return
168+
const next = args[1]
169+
if (typeof next === "function") {
170+
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
171+
return
172+
}
173+
if (Array.isArray(next)) {
174+
storedSessions[directory] = next as Array<{ id: string; title?: string }>
175+
}
176+
},
177+
]
148178
},
149179
}),
150180
}))
@@ -170,11 +200,13 @@ beforeEach(() => {
170200
createdSessions.length = 0
171201
enabledAutoAccept.length = 0
172202
optimistic.length = 0
203+
optimisticSeeded.length = 0
173204
params = {}
174205
sentShell.length = 0
175206
syncedDirectories.length = 0
176207
selected = "/repo/worktree-a"
177208
variant = undefined
209+
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
178210
})
179211

180212
describe("prompt submit worktree selection", () => {
@@ -207,7 +239,7 @@ describe("prompt submit worktree selection", () => {
207239
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
208240
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
209241
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
210-
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
242+
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
211243
})
212244

213245
test("applies auto-accept to newly created sessions", async () => {
@@ -271,4 +303,32 @@ describe("prompt submit worktree selection", () => {
271303
},
272304
})
273305
})
306+
307+
test("seeds new sessions before optimistic prompts are added", async () => {
308+
const submit = createPromptSubmit({
309+
info: () => undefined,
310+
imageAttachments: () => [],
311+
commentCount: () => 0,
312+
autoAccept: () => false,
313+
mode: () => "normal",
314+
working: () => false,
315+
editor: () => undefined,
316+
queueScroll: () => undefined,
317+
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
318+
addToHistory: () => undefined,
319+
resetHistoryNavigation: () => undefined,
320+
setMode: () => undefined,
321+
setPopover: () => undefined,
322+
newSessionWorktree: () => selected,
323+
onNewSessionWorktreeReset: () => undefined,
324+
onSubmit: () => undefined,
325+
})
326+
327+
const event = { preventDefault: () => undefined } as unknown as Event
328+
329+
await submit.handleSubmit(event)
330+
331+
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
332+
expect(optimisticSeeded).toEqual([true])
333+
})
274334
})

packages/app/src/components/prompt-input/submit.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import type { Message } from "@opencode-ai/sdk/v2/client"
1+
import type { Message, Session } from "@opencode-ai/sdk/v2/client"
22
import { showToast } from "@opencode-ai/ui/toast"
33
import { base64Encode } from "@opencode-ai/util/encode"
4+
import { Binary } from "@opencode-ai/util/binary"
45
import { useNavigate, useParams } from "@solidjs/router"
56
import type { Accessor } from "solid-js"
67
import type { FileSelection } from "@/context/file"
@@ -266,6 +267,20 @@ export function createPromptSubmit(input: PromptSubmitInput) {
266267
}
267268
}
268269

270+
const seed = (dir: string, info: Session) => {
271+
const [, setStore] = globalSync.child(dir)
272+
setStore("session", (list: Session[]) => {
273+
const result = Binary.search(list, info.id, (item) => item.id)
274+
const next = [...list]
275+
if (result.found) {
276+
next[result.index] = info
277+
return next
278+
}
279+
next.splice(result.index, 0, info)
280+
return next
281+
})
282+
}
283+
269284
const handleSubmit = async (event: Event) => {
270285
event.preventDefault()
271286

@@ -341,7 +356,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
341356

342357
let session = input.info()
343358
if (!session && isNewSession) {
344-
session = await client.session
359+
const created = await client.session
345360
.create()
346361
.then((x) => x.data ?? undefined)
347362
.catch((err) => {
@@ -351,7 +366,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
351366
})
352367
return undefined
353368
})
354-
if (session) {
369+
if (created) {
370+
seed(sessionDirectory, created)
371+
session = created
355372
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
356373
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
357374
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)

packages/app/src/context/sync-optimistic.test.ts

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { describe, expect, test } from "bun:test"
22
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
3-
import { applyOptimisticAdd, applyOptimisticRemove } from "./sync"
3+
import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync"
4+
5+
type Text = Extract<Part, { type: "text" }>
46

57
const userMessage = (id: string, sessionID: string): Message => ({
68
id,
@@ -11,7 +13,7 @@ const userMessage = (id: string, sessionID: string): Message => ({
1113
model: { providerID: "openai", modelID: "gpt" },
1214
})
1315

14-
const textPart = (id: string, sessionID: string, messageID: string): Part => ({
16+
const textPart = (id: string, sessionID: string, messageID: string): Text => ({
1517
id,
1618
sessionID,
1719
messageID,
@@ -53,4 +55,69 @@ describe("sync optimistic reducers", () => {
5355
expect(draft.part.msg_1).toBeUndefined()
5456
expect(draft.part.msg_2).toHaveLength(1)
5557
})
58+
59+
test("mergeOptimisticPage keeps pending messages in fetched timelines", () => {
60+
const sessionID = "ses_1"
61+
const page = mergeOptimisticPage(
62+
{
63+
session: [userMessage("msg_1", sessionID)],
64+
part: [{ id: "msg_1", part: [textPart("prt_1", sessionID, "msg_1")] }],
65+
complete: true,
66+
},
67+
[{ message: userMessage("msg_2", sessionID), parts: [textPart("prt_2", sessionID, "msg_2")] }],
68+
)
69+
70+
expect(page.session.map((x) => x.id)).toEqual(["msg_1", "msg_2"])
71+
expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_2"])
72+
expect(page.confirmed).toEqual([])
73+
expect(page.complete).toBe(true)
74+
})
75+
76+
test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => {
77+
const sessionID = "ses_1"
78+
const page = mergeOptimisticPage(
79+
{
80+
session: [userMessage("msg_2", sessionID)],
81+
part: [{ id: "msg_2", part: [textPart("prt_2", sessionID, "msg_2")] }],
82+
complete: true,
83+
},
84+
[
85+
{
86+
message: userMessage("msg_2", sessionID),
87+
parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
88+
},
89+
],
90+
)
91+
92+
expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
93+
expect(page.confirmed).toEqual([])
94+
})
95+
96+
test("mergeOptimisticPage confirms echoed messages once all parts arrive", () => {
97+
const sessionID = "ses_1"
98+
const page = mergeOptimisticPage(
99+
{
100+
session: [userMessage("msg_2", sessionID)],
101+
part: [
102+
{
103+
id: "msg_2",
104+
part: [{ ...textPart("prt_1", sessionID, "msg_2"), text: "server" }, textPart("prt_2", sessionID, "msg_2")],
105+
},
106+
],
107+
complete: true,
108+
},
109+
[
110+
{
111+
message: userMessage("msg_2", sessionID),
112+
parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
113+
},
114+
],
115+
)
116+
117+
expect(page.confirmed).toEqual(["msg_2"])
118+
expect(page.part.find((x) => x.id === "msg_2")?.part).toMatchObject([
119+
{ id: "prt_1", type: "text", text: "server" },
120+
{ id: "prt_2", type: "text", text: "prt_2" },
121+
])
122+
})
56123
})

0 commit comments

Comments
 (0)