Skip to content

Commit 0ea9121

Browse files
committed
fix(queue): save queued edits on enter
1 parent 38b0614 commit 0ea9121

15 files changed

Lines changed: 296 additions & 299 deletions

File tree

‎packages/app/e2e/regression/session-queue-controls.spec.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,62 @@ test("sends queued follow-ups with an explicit JSON body", async ({ page }) => {
120120
body: null,
121121
})
122122
})
123+
124+
test("Enter saves a queued message edit without sending it", async ({ page }) => {
125+
const updates: Array<{ sessionID: string; queueID: string; raw: string | null; body: unknown }> = []
126+
const sends: unknown[] = []
127+
const prompts: unknown[] = []
128+
129+
await mockOpenCodeServer(page, {
130+
directory,
131+
project: {
132+
id: projectID,
133+
worktree: directory,
134+
vcs: "git",
135+
name: "session-queue-controls-regression",
136+
time: { created: 1700000000000, updated: 1700000000000 },
137+
sandboxes: [],
138+
},
139+
provider: {
140+
all: [
141+
{
142+
id: "opencode",
143+
name: "OpenCode",
144+
models: { "test-model": { id: "test-model", name: "Test Model", limit: { context: 200_000 } } },
145+
},
146+
],
147+
connected: ["opencode"],
148+
default: model,
149+
},
150+
sessions: [session],
151+
status: { [sessionID]: { type: "busy" } },
152+
queue: { [sessionID]: [{ id: "pqu_edit", text: "queued draft" }] },
153+
pageMessages: () => ({ items: [] }),
154+
onQueueUpdate: (input) => updates.push(input),
155+
onQueueSend: (input) => sends.push(input),
156+
onPromptAsync: (input) => prompts.push(input),
157+
})
158+
159+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
160+
await expectSessionTitle(page, session.title)
161+
162+
const followupDock = page.locator('[data-component="session-followup-dock"]')
163+
await expectAppVisible(followupDock)
164+
await followupDock.getByRole("button", { name: "Edit" }).click()
165+
166+
const composer = page.locator('[data-component="session-composer"]')
167+
const input = composer.locator('[data-component="prompt-input"]')
168+
await expect(input).toContainText("queued draft")
169+
await expect(followupDock.getByRole("button", { name: "Send now" })).toHaveCount(0)
170+
await expect(composer.getByRole("button", { name: "Queue" })).toHaveCount(0)
171+
await expect(composer.getByRole("button", { name: "Save" })).toBeVisible()
172+
173+
await input.fill("edited queued draft")
174+
await input.press("Enter")
175+
176+
await expect.poll(() => updates.length).toBe(1)
177+
expect(updates[0]).toMatchObject({ sessionID, queueID: "pqu_edit" })
178+
expect(updates[0]?.raw).toContain("edited queued draft")
179+
expect(sends).toHaveLength(0)
180+
expect(prompts).toHaveLength(0)
181+
})

‎packages/app/e2e/utils/mock-server.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ export interface MockServerConfig {
1111
createSession?: () => { id: string } & Record<string, unknown>
1212
onPromptAsync?: (input: { sessionID: string; body: unknown }) => void
1313
onQueueSend?: (input: { sessionID: string; queueID: string; raw: string | null; body: unknown }) => void
14+
onQueueUpdate?: (input: { sessionID: string; queueID: string; raw: string | null; body: unknown }) => void
1415
agents?: unknown[]
1516
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
1617
status?: Record<string, unknown>
1718
queue?: Record<string, { id: string; text: string }[]>
19+
queueDetails?: Record<string, Record<string, unknown>>
1820
vcsDiff?: unknown[]
1921
messageDelay?: number
2022
beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
@@ -96,7 +98,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
9698
})
9799
if (emptyObject.has(path)) return json(route, {})
98100
if (emptyList.has(path)) return json(route, [])
99-
if (path === "/provider") return json(route, typeof config.provider === "function" ? config.provider() : config.provider)
101+
if (path === "/provider")
102+
return json(route, typeof config.provider === "function" ? config.provider() : config.provider)
100103

101104
if (path === "/session" && route.request().method() === "POST") {
102105
const created = config.createSession?.()
@@ -143,6 +146,28 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
143146
return json(route, true)
144147
}
145148

149+
const queueItemMatch = path.match(/^\/session\/([^/]+)\/queue\/([^/]+)$/)
150+
if (queueItemMatch && route.request().method() === "GET") {
151+
const item = config.queue?.[queueItemMatch[1]!]?.find((entry) => entry.id === queueItemMatch[2])
152+
return json(
153+
route,
154+
config.queueDetails?.[queueItemMatch[1]!]?.[queueItemMatch[2]!] ?? {
155+
id: queueItemMatch[2],
156+
parts: item ? [{ type: "text", text: item.text }] : [],
157+
},
158+
)
159+
}
160+
if (queueItemMatch && route.request().method() === "PATCH") {
161+
const raw = route.request().postData()
162+
config.onQueueUpdate?.({
163+
sessionID: queueItemMatch[1]!,
164+
queueID: queueItemMatch[2]!,
165+
raw,
166+
body: raw ? JSON.parse(raw) : undefined,
167+
})
168+
return json(route, true)
169+
}
170+
146171
const promptAsyncMatch = path.match(/^\/session\/([^/]+)\/prompt_async$/)
147172
if (promptAsyncMatch && route.request().method() === "POST") {
148173
const raw = route.request().postData()

‎packages/app/src/components/prompt-input.tsx‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
385385
})
386386
const stopping = createMemo(() => working() && blank())
387387
const tip = () => {
388+
if (editingQueueID()) {
389+
return (
390+
<div class="flex items-center gap-2">
391+
<span>{language.t("common.save")}</span>
392+
<Icon name="enter" size="small" class="text-icon-base" />
393+
</div>
394+
)
395+
}
396+
388397
if (stopping()) {
389398
return (
390399
<div class="flex items-center gap-2">
@@ -403,7 +412,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
403412
}
404413

405414
const queueToggle = () => (
406-
<Show when={store.mode === "normal" && !!props.controls.session.id}>
415+
<Show when={store.mode === "normal" && !!props.controls.session.id && !editingQueueID()}>
407416
<TooltipV2
408417
placement="top"
409418
value={
@@ -1893,7 +1902,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
18931902
"background-image":
18941903
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
18951904
}}
1896-
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
1905+
aria-label={
1906+
stopping()
1907+
? language.t("prompt.action.stop")
1908+
: editingQueueID()
1909+
? language.t("common.save")
1910+
: language.t("prompt.action.send")
1911+
}
18971912
/>
18981913
</TooltipV2>
18991914
</div>
@@ -2036,7 +2051,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
20362051
}
20372052
variant="primary"
20382053
class="size-8"
2039-
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
2054+
aria-label={
2055+
stopping()
2056+
? language.t("prompt.action.stop")
2057+
: editingQueueID()
2058+
? language.t("common.save")
2059+
: language.t("prompt.action.send")
2060+
}
20402061
/>
20412062
</Tooltip>
20422063
</div>

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,42 @@ describe("prompt submit queue mode", () => {
653653
expect(queuedDrafts[0]).toMatchObject({ queueID: "pqu_edit" })
654654
})
655655

656+
test("editingQueueID commits through onQueue when queue mode is inactive", async () => {
657+
params = { id: "session-1" }
658+
659+
const submit = createPromptSubmit({
660+
prompt,
661+
info: () => ({ id: "session-1" }),
662+
imageAttachments: () => [],
663+
commentCount: () => 0,
664+
autoAccept: () => false,
665+
mode: () => "normal",
666+
working: () => false,
667+
editor: () => undefined,
668+
queueScroll: () => undefined,
669+
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
670+
addToHistory: () => undefined,
671+
resetHistoryNavigation: () => undefined,
672+
setMode: () => undefined,
673+
setPopover: () => undefined,
674+
shouldQueue: () => false,
675+
editingQueueID: () => "pqu_edit",
676+
resetEditingQueueID: () => undefined,
677+
onQueue: (draft) => {
678+
queuedDrafts.push(draft)
679+
},
680+
onSubmit: () => undefined,
681+
})
682+
683+
const event = { preventDefault: () => undefined } as unknown as Event
684+
685+
await submit.handleSubmit(event)
686+
687+
expect(queuedDrafts).toHaveLength(1)
688+
expect(queuedDrafts[0]).toMatchObject({ queueID: "pqu_edit" })
689+
expect(promptAsyncCalls).toHaveLength(0)
690+
})
691+
656692
test("shouldQueue routes existing sessions through onQueue", async () => {
657693
params = { id: "session-1" }
658694

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
455455
return true
456456
}
457457

458-
if (!isNewSession && mode === "normal" && (input.shouldQueue?.() || queueMode)) {
458+
if (!isNewSession && mode === "normal" && (draft.queueID || input.shouldQueue?.() || queueMode)) {
459459
input.onQueue?.(draft)
460460
input.resetQueueMode?.()
461461
if (draft.queueID) input.resetEditingQueueID?.()

‎packages/app/src/pages/session/composer/session-followup-dock.tsx‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -125,15 +125,17 @@ export function SessionFollowupDock(props: {
125125
when={active()}
126126
fallback={
127127
<>
128-
<Button
129-
size="small"
130-
variant="secondary"
131-
class="shrink-0"
132-
disabled={!!props.sending}
133-
onClick={() => props.onSend(item.id)}
134-
>
135-
{language.t("session.followupDock.sendNow")}
136-
</Button>
128+
<Show when={!editing()}>
129+
<Button
130+
size="small"
131+
variant="secondary"
132+
class="shrink-0"
133+
disabled={!!props.sending}
134+
onClick={() => props.onSend(item.id)}
135+
>
136+
{language.t("session.followupDock.sendNow")}
137+
</Button>
138+
</Show>
137139
<Button
138140
size="small"
139141
variant="ghost"

‎packages/opencode/scripts/adhoc-queue-qa-smoke.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,10 @@ def send_queue_key(child: pexpect.spawn) -> None:
266266
child.send("\x1b[27;6;13~")
267267

268268

269+
def send_edit_queue_key(child: pexpect.spawn) -> None:
270+
child.send("\x1b[1;3A")
271+
272+
269273
def send_submit(child: pexpect.spawn) -> None:
270274
child.send("\r")
271275

@@ -657,6 +661,17 @@ def test_tui_attach_queue(
657661
)
658662
save_screen("tui-attach-queue-dock", snapshot(child))
659663
report.ok("TUI attach shows queued prompts in UI")
664+
665+
send_edit_queue_key(child)
666+
wait_pattern(child, re.compile(r"Editing queued message|save edit", re.I), 15, "TUI queued edit mode")
667+
child.send("-edited")
668+
send_submit(child)
669+
time.sleep(0.8)
670+
671+
edited = curl_json("GET", f"{base_url}/session/{sid}/queue", str(ws))
672+
if not isinstance(edited, list) or len(edited) != 3 or "-edited" not in edited[0].get("text", ""):
673+
raise RuntimeError(f"Enter did not save queued edit in place: {edited!r}")
674+
report.ok("TUI Enter saves queued edit without sending it")
660675
except Exception as exc:
661676
save_screen("tui-attach-queue-dock-error", snapshot(child))
662677
if "timed out" in str(exc).lower():

‎packages/opencode/test/cli/tui/prompt-queue-tui-smoke.cli.test.ts‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ describe("prompt queue TUI smoke (serve + script)", () => {
7979

8080
yield* Effect.sleep("150 millis")
8181

82-
const script = yield* Effect.tryPromise(() =>
83-
Bun.spawn(["bash", scriptPath], {
82+
const script = yield* Effect.tryPromise(async () => {
83+
const child = Bun.spawn(["bash", scriptPath], {
8484
cwd: home,
8585
env: {
8686
...process.env,
@@ -89,13 +89,21 @@ describe("prompt queue TUI smoke (serve + script)", () => {
8989
OPENCODE_SESSION_ID: sessionID,
9090
OPENCODE_ARTIFACT_DIR: artifactDir,
9191
OPENCODE_QUEUE_ONLY: "1",
92+
OPENCODE_EDIT_FIRST: "1",
9293
OPENCODE_CLI_ENTRY: path.join(opencodeRoot, "src/index.ts"),
9394
},
9495
stdout: "pipe",
9596
stderr: "pipe",
96-
}).exited,
97-
)
98-
expect(script).toBe(0)
97+
})
98+
const [exitCode, stdout, stderr] = await Promise.all([
99+
child.exited,
100+
new Response(child.stdout).text(),
101+
new Response(child.stderr).text(),
102+
])
103+
return { exitCode, stdout, stderr }
104+
})
105+
if (script.exitCode !== 0) throw new Error(`queue smoke failed\n${script.stderr}\n${script.stdout}`)
106+
expect(script.exitCode).toBe(0)
99107

100108
yield* Deferred.succeed(gate, void 0)
101109

@@ -107,7 +115,7 @@ describe("prompt queue TUI smoke (serve + script)", () => {
107115

108116
const inputs = yield* llm.inputs
109117
expect(inputs).toHaveLength(4)
110-
expect(JSON.stringify(inputs[1]?.messages)).toContain("queue-one")
118+
expect(JSON.stringify(inputs[1]?.messages)).toContain("queue-one-edited")
111119
expect(JSON.stringify(inputs[1]?.messages)).not.toContain("queue-two")
112120
expect(JSON.stringify(inputs[2]?.messages)).toContain("queue-two")
113121
expect(JSON.stringify(inputs[2]?.messages)).not.toContain("queue-three")
@@ -116,6 +124,9 @@ describe("prompt queue TUI smoke (serve + script)", () => {
116124
const queued = yield* Effect.tryPromise(() => readFile(path.join(artifactDir, "queued.txt"), "utf8"))
117125
expect(queued.trim()).toBe("queued")
118126

127+
const edited = yield* Effect.tryPromise(() => readFile(path.join(artifactDir, "edited.txt"), "utf8"))
128+
expect(edited.trim()).toBe("queue-one-edited")
129+
119130
const attach = yield* Effect.tryPromise(() =>
120131
readFile(path.join(artifactDir, "tui-attach.txt"), "utf8").catch(() => "attach skipped"),
121132
)

‎packages/opencode/test/lib/cli-process.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -319,9 +319,8 @@ export function withCliFixture<A, E>(
319319
if (opts?.hostname) argv.push("--hostname", opts.hostname)
320320
if (opts?.extraArgs) argv.push(...opts.extraArgs)
321321

322-
// Acquire the subprocess; release sends SIGTERM and awaits exit on
323-
// scope close. Wrapped in Effect.ignore so a flaky kill doesn't surface
324-
// as a finalizer error during test teardown.
322+
// Acquire the subprocess; release sends SIGTERM and escalates to SIGKILL
323+
// if an active server drain prevents graceful shutdown.
325324
const proc = yield* Effect.acquireRelease(
326325
Effect.sync(() =>
327326
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], {
@@ -332,9 +331,15 @@ export function withCliFixture<A, E>(
332331
}),
333332
),
334333
(p) =>
335-
Effect.promise(() => {
336-
p.kill()
337-
return p.exited
334+
Effect.gen(function* () {
335+
yield* Effect.sync(() => p.kill())
336+
yield* Effect.promise(() => p.exited).pipe(
337+
Effect.timeoutOrElse({
338+
duration: Duration.seconds(2),
339+
orElse: () => Effect.sync(() => p.kill("SIGKILL")),
340+
}),
341+
)
342+
yield* Effect.promise(() => p.exited)
338343
}).pipe(Effect.ignore),
339344
)
340345

0 commit comments

Comments
 (0)