diff --git a/packages/app/e2e/regression/session-queue.spec.ts b/packages/app/e2e/regression/session-queue.spec.ts new file mode 100644 index 00000000000..f4bc001fc95 --- /dev/null +++ b/packages/app/e2e/regression/session-queue.spec.ts @@ -0,0 +1,226 @@ +import { expect, test, type Page } from "@playwright/test" +import type { OpenCodeEvent } from "@opencode-ai/client/promise" +import { base64Encode } from "@opencode-ai/util/encode" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +const directory = "C:/OpenCode/SessionQueueRegression" +const projectID = "proj_session_queue_regression" +const sessionID = "ses_session_queue_regression" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + +type InboxRow = { + id: string + sessionID: string + timeCreated: number + type: "user" + payload: { text: string; metadata?: Record } + delivery: "steer" | "queue" +} + +function createQueueMock(seed: string[]) { + const rows: InboxRow[] = seed.map((text, index) => ({ + id: `inb_seed_${index + 1}`, + sessionID, + timeCreated: 1700000000000 + index, + type: "user", + payload: { text }, + delivery: "queue", + })) + const events: OpenCodeEvent[] = [] + const prompts: Record[] = [] + const changes: { inboxID: string; action: "cancel" | "steer" }[] = [] + const log: string[] = [] + let sequence = 0 + const emit = (type: OpenCodeEvent["type"], data: OpenCodeEvent["data"]) => { + sequence += 1 + events.push({ + id: `evt_queue_${sequence}`, + type, + created: Date.now(), + durable: { aggregateID: sessionID, seq: sequence, version: 1 }, + data, + } as OpenCodeEvent) + } + return { + rows, + prompts, + changes, + log, + events: () => events.splice(0), + onPrompt: (input: { sessionID: string; body: Record }) => { + prompts.push(input.body) + log.push(`prompt:${String(input.body.delivery ?? "steer")}`) + const row: InboxRow = { + id: typeof input.body.id === "string" ? input.body.id : `inb_mock_${sequence}`, + sessionID: input.sessionID, + timeCreated: Date.now(), + type: "user", + payload: { + text: typeof input.body.text === "string" ? input.body.text : "", + ...(input.body.metadata === undefined ? {} : { metadata: input.body.metadata as Record }), + }, + delivery: input.body.delivery === "queue" ? "queue" : "steer", + } + rows.push(row) + emit("session.inbox.enqueued", { + sessionID: input.sessionID, + inboxID: row.id, + item: { type: "user", payload: row.payload, delivery: row.delivery }, + }) + }, + onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => { + changes.push({ inboxID: input.inboxID, action: input.action }) + log.push(`${input.action}:${input.inboxID}`) + const index = rows.findIndex((row) => row.id === input.inboxID) + const row = rows[index] + if (!row) return + if (input.action === "cancel") { + rows.splice(index, 1) + emit("session.inbox.cancelled", { sessionID: input.sessionID, inboxID: input.inboxID }) + return + } + row.delivery = "steer" + emit("session.inbox.delivery.changed", { + sessionID: input.sessionID, + inboxID: input.inboxID, + delivery: "steer", + }) + }, + } +} + +async function openSession(page: Page, mock: ReturnType, followUpBehavior?: "queue" | "steer") { + if (followUpBehavior) { + await page.addInitScript( + (behavior) => localStorage.setItem("settings.v3", JSON.stringify({ general: { followUpBehavior: behavior } })), + followUpBehavior, + ) + } + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "session-queue-regression", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { "queue-model": { id: "queue-model", name: "Queue Model", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "queue-model" }, + }, + sessions: [ + { + id: sessionID, + slug: "session-queue-regression", + projectID, + directory, + title: "Session queue regression", + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + pageMessages: () => ({ items: [] }), + sessionStatus: () => ({ [sessionID]: { type: "running" } }), + inbox: () => mock.rows.map((row) => ({ ...row, payload: { ...row.payload } })), + onPrompt: mock.onPrompt, + onInboxChange: mock.onInboxChange, + events: mock.events, + }) + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + const composer = page.locator('[data-component="composer"]') + await expectAppVisible(composer) + return { + composer, + input: composer.locator('[data-component="composer-editor"]'), + rows: page.locator('[data-component="session-queue-row"]'), + } +} + +test("follow-up preference controls Enter while Mod+Enter uses the alternate delivery", async ({ page }) => { + const mock = createQueueMock([]) + const view = await openSession(page, mock, "queue") + + await view.input.fill("queue this follow-up") + await expect(view.composer.locator('[data-action="composer-alternate-delivery"]')).toContainText("Steer") + await view.input.press("Enter") + await expect(view.rows.getByText("queue this follow-up", { exact: true })).toBeVisible() + + await view.input.fill("steer this correction") + await view.input.press("ControlOrMeta+Enter") + await expect.poll(() => mock.prompts.map((prompt) => prompt.delivery)).toEqual(["queue", "steer"]) + await expect(view.input).toHaveText("") +}) + +test("dragging reorders queued prompts", async ({ page }) => { + const mock = createQueueMock(["first queued prompt", "second queued prompt", "third queued prompt"]) + const view = await openSession(page, mock) + await expect(view.rows).toHaveCount(3) + + const first = view.rows.filter({ hasText: "first queued prompt" }) + const third = view.rows.filter({ hasText: "third queued prompt" }) + await first.getByRole("button", { name: "Reorder queued prompt" }).hover() + await page.mouse.down() + const target = await third.boundingBox() + if (!target) throw new Error("The target queue row is not visible") + await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 10 }) + await page.mouse.up() + + await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([ + "second queued prompt", + "third queued prompt", + "first queued prompt", + ]) + expect(mock.prompts.map((prompt) => prompt.text)).toEqual([ + "second queued prompt", + "third queued prompt", + "first queued prompt", + ]) + expect(mock.changes).toEqual([ + { inboxID: "inb_seed_1", action: "cancel" }, + { inboxID: "inb_seed_2", action: "cancel" }, + { inboxID: "inb_seed_3", action: "cancel" }, + ]) +}) + +test("editing restores the existing draft and replaces only the original queue position", async ({ page }) => { + const mock = createQueueMock(["first queued prompt", "tighten the error copy", "third queued prompt"]) + const view = await openSession(page, mock) + const original = view.rows.getByText("tighten the error copy", { exact: true }) + await expect(original).toBeVisible() + + await view.input.fill("my in-progress draft") + await original.click() + await expect(view.input).toHaveText("tighten the error copy") + await view.input.press("Escape") + await expect(view.input).toHaveText("my in-progress draft") + + await original.click() + await expect(view.input).toHaveText("tighten the error copy") + await view.input.fill("tighten the error copy and add a retry hint") + await view.input.press("Enter") + + await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([ + "first queued prompt", + "tighten the error copy and add a retry hint", + "third queued prompt", + ]) + await expect(view.input).toHaveText("my in-progress draft") + expect(mock.prompts.map((prompt) => prompt.text)).toEqual([ + "tighten the error copy and add a retry hint", + "tighten the error copy and add a retry hint", + "third queued prompt", + ]) + expect(mock.prompts.every((prompt) => prompt.delivery === "queue" && prompt.resume === false)).toBe(true) + expect(mock.changes.map((change) => change.action)).toEqual(["cancel", "cancel", "cancel"]) + expect(mock.log[0]).toBe("prompt:queue") +}) diff --git a/packages/app/e2e/utils/mock-api.ts b/packages/app/e2e/utils/mock-api.ts index 80c928e266a..e48cd169985 100644 --- a/packages/app/e2e/utils/mock-api.ts +++ b/packages/app/e2e/utils/mock-api.ts @@ -174,6 +174,39 @@ const Group = HttpApiGroup.make("mock") success: Json, }), ) + .add( + HttpApiEndpoint.post("sessionPrompt", "/api/session/:sessionID/prompt", { + params: SessionParams, + payload: JsonPayload, + success: Json, + }), + ) + .add( + HttpApiEndpoint.post("sessionSwitchAgent", "/api/session/:sessionID/agent", { + params: SessionParams, + payload: JsonPayload, + success: NoContent, + }), + ) + .add( + HttpApiEndpoint.post("sessionSwitchModel", "/api/session/:sessionID/model", { + params: SessionParams, + payload: JsonPayload, + success: NoContent, + }), + ) + .add( + HttpApiEndpoint.delete("sessionInboxCancel", "/api/session/:sessionID/inbox/:inboxID", { + params: { ...SessionParams, inboxID: Schema.String }, + success: NoContent, + }), + ) + .add( + HttpApiEndpoint.post("sessionInboxSteer", "/api/session/:sessionID/inbox/:inboxID/steer", { + params: { ...SessionParams, inboxID: Schema.String }, + success: NoContent, + }), + ) .add( HttpApiEndpoint.get("sessionPermission", "/api/session/:sessionID/permission", { params: SessionParams, diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 11d3b3d6afb..0ebad912cf0 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -35,6 +35,9 @@ export interface MockServerConfig { fileContent?: (path: string) => unknown | Promise findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown sessionStatus?: Record | (() => Record) + inbox?: unknown[] | (() => unknown[]) + onPrompt?: (input: { sessionID: string; body: Record }) => void + onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => void } type MockStreamWindow = Window & { @@ -397,7 +400,39 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map noContent, sessionFormCancel: () => noContent, sessionBackground: () => noContent, - sessionInbox: () => Effect.succeed({ data: [] }), + sessionInbox: () => + Effect.sync(() => ({ data: typeof config.inbox === "function" ? config.inbox() : (config.inbox ?? []) })), + sessionPrompt: (ctx) => + Effect.sync(() => { + const body = record(ctx.payload) ? ctx.payload : {} + config.onPrompt?.({ sessionID: ctx.params.sessionID, body }) + return { + data: { + id: typeof body.id === "string" ? body.id : `inb_mock_${Date.now()}`, + sessionID: ctx.params.sessionID, + timeCreated: Date.now(), + type: "user", + payload: { + text: typeof body.text === "string" ? body.text : "", + ...(body.files === undefined ? {} : { files: body.files }), + ...(body.agents === undefined ? {} : { agents: body.agents }), + ...(body.skills === undefined ? {} : { skills: body.skills }), + ...(body.metadata === undefined ? {} : { metadata: body.metadata }), + }, + delivery: body.delivery === "queue" ? "queue" : "steer", + }, + } + }), + sessionInboxCancel: (ctx) => + Effect.sync(() => + config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "cancel" }), + ).pipe(Effect.andThen(noContent)), + sessionInboxSteer: (ctx) => + Effect.sync(() => + config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "steer" }), + ).pipe(Effect.andThen(noContent)), + sessionSwitchAgent: () => noContent, + sessionSwitchModel: () => noContent, sessionPermission: (ctx) => { const permissions = typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []) diff --git a/packages/app/src/composer/adapter.ts b/packages/app/src/composer/adapter.ts index a663eef3b4b..842a20247b7 100644 --- a/packages/app/src/composer/adapter.ts +++ b/packages/app/src/composer/adapter.ts @@ -39,6 +39,26 @@ export type ComposerSelection = { variant?: string } +export type ComposerDelivery = "steer" | "queue" + +// Contract between the composer and the session prompt queue. The session +// owns the queue (pending inbox items); the composer only asks which delivery +// a submit should use and delegates edit confirmation while a queued prompt +// is loaded in the editor. +export type ComposerQueue = { + count: Accessor + // Delivery a plain submit uses right now. + delivery: Accessor + // Delivery offered on Mod+Enter and the toolbar hint button; undefined hides the hint. + alternate: Accessor + // Inbox ID of the queued prompt currently loaded in the composer for editing. + editing: Accessor + confirmEdit: (delivery: ComposerDelivery) => void + cancelEdit: () => void + // Loads the first queued prompt into the composer. Returns false when the queue is empty. + editFirst: () => boolean +} + export type ComposerSession = { id: string directory: string diff --git a/packages/app/src/composer/composer.tsx b/packages/app/src/composer/composer.tsx index cd85ffda2d5..29bd69020e8 100644 --- a/packages/app/src/composer/composer.tsx +++ b/packages/app/src/composer/composer.tsx @@ -8,7 +8,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { ComposerEditor } from "./editor/editor" import { ModelSelectorPopover } from "@/providers/models/select-dialog" import { DialogSelectModelUnpaid } from "@/providers/models/unpaid" -import { useCommand } from "@/shell/commands/command" +import { formatKeybind, useCommand } from "@/shell/commands/command" import { useLanguage } from "@/runtime/i18n/language" import type { ComposerModel } from "./model" @@ -32,6 +32,7 @@ export function Composer(props: { modelControlsVisible={!props.model.model.loading} attachKeybind={command.keybindParts("file.attach")} attachShortcut={command.keybind("file.attach")} + alternateKeybind={[formatKeybind("mod", language.t), formatKeybind("enter", language.t)]} modelControl={ { if (props.controller.onKeyDown(event)) return + const mod = event.metaKey || event.ctrlKey + if (mod && event.key === "ArrowUp" && !event.shiftKey && !event.altKey) { + if (view.submit.queue?.editFirst()) event.preventDefault() + return + } if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault() if (event.repeat) return - props.controller.submit() + props.controller.submit(mod ? { alternate: true } : undefined) } }} onKeyUp={updateCursor} @@ -248,6 +254,12 @@ export function ComposerEditor(props: ComposerEditorProps) { + + + props.controller.submit()} onStop={props.controller.stop} /> @@ -691,6 +703,42 @@ export function ComposerEditorPopover(props: { ) } +// "Steer ⌘⏎" / "Queue ⌘⏎" hint next to the submit button: submits with the +// delivery opposite to what plain Enter does. Visible only while the queue +// exposes an alternate (turn running and composer holding a value), so it +// disappears on its own when the current turn ends. +function ComposerEditorAlternateDelivery(props: { controller: ComposerEditorModel; keybind: string[] }) { + const i18n = useI18n() + const view = props.controller.view + const action = createMemo(() => { + const queue = view.submit.queue + if (!queue || !props.controller.canSubmit()) return undefined + if (queue.editing()) return "steer" as const + return queue.alternate() + }) + return ( + + {(delivery) => ( + + + + )} + + ) +} + export function ComposerEditorSubmitButton(props: { mode: ComposerMode stopping: boolean diff --git a/packages/app/src/composer/editor/interaction.ts b/packages/app/src/composer/editor/interaction.ts index be27cf84329..bbe544d0413 100644 --- a/packages/app/src/composer/editor/interaction.ts +++ b/packages/app/src/composer/editor/interaction.ts @@ -19,6 +19,7 @@ import { type ComposerInteractionEvent, } from "../suggestions/machine" import { clonePrompt, promptLength } from "../prompt-parts" +import type { ComposerQueue } from "../adapter" export type ComposerSelectControl = { options: Accessor @@ -37,7 +38,8 @@ export type ComposerEditorView = { submit: { stopping: Accessor working?: Accessor - onSubmit: () => void + queue?: ComposerQueue + onSubmit: (options?: { alternate?: boolean }) => void onStop: () => void } shell?: { @@ -212,6 +214,11 @@ export function createComposerEditor(input: { ) } if (handled) return true + if (event.key === "Escape" && input.view.submit.queue?.editing()) { + event.preventDefault() + input.view.submit.queue.cancelEdit() + return true + } const stop = input.view.submit.working?.() && ((event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "g") || @@ -354,8 +361,8 @@ export function createComposerEditor(input: { openShell() { dispatch({ type: "mode.shell" }) }, - submit() { - input.view.submit.onSubmit() + submit(options?: { alternate?: boolean }) { + input.view.submit.onSubmit(options) dispatch({ type: "popover.close" }) }, stop() { diff --git a/packages/app/src/composer/model.ts b/packages/app/src/composer/model.ts index 2e6f6dc8167..5ec69fcbfaa 100644 --- a/packages/app/src/composer/model.ts +++ b/packages/app/src/composer/model.ts @@ -16,7 +16,7 @@ import { createSessionTabs } from "@/session/helpers" import { showToast } from "@/shell/notifications/toast" import { formatServerError } from "@/runtime/server/errors" import { Skill } from "@opencode-ai/schema/skill" -import type { ComposerAdapter, ComposerControls } from "./adapter" +import type { ComposerAdapter, ComposerControls, ComposerQueue } from "./adapter" import type { ImageAttachmentPart } from "./state" import type { PromptHistoryComment } from "./history/entry" import { createComposerHistory } from "./history/store" @@ -27,7 +27,7 @@ export type ComposerModel = ComposerEditorModel & { readonly model: ComposerControls["model"] } -export function createComposerModel(adapter: ComposerAdapter): ComposerModel { +export function createComposerModel(adapter: ComposerAdapter, options?: { queue?: ComposerQueue }): ComposerModel { const sdk = useWorkspaceLocation() const data = useData() const files = useFile() @@ -80,7 +80,11 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel { }) const stopping = createMemo(() => adapter.working() && blank()) const placeholder = () => - composerPlaceholder(mode(), (key, params) => language.t(key as Parameters[0], params as never)) + composerPlaceholder( + mode(), + (key, params) => language.t(key as Parameters[0], params as never), + adapter.working() || (options?.queue?.count() ?? 0) > 0, + ) const historyComments = () => { const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) @@ -253,6 +257,11 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel { resetHistory: () => controller.resetHistory(), setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }), closePopover: () => controller.dispatch({ type: "popover.close" }), + delivery: (alternate) => { + const queue = options?.queue + if (!queue) return "steer" + return (alternate ? queue.alternate() : queue.delivery()) ?? "steer" + }, notify: { missingSelection: () => showToast({ @@ -360,7 +369,18 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel { submit: { stopping, working: adapter.working, - onSubmit: () => void submission.submit(new Event("submit")), + queue: options?.queue, + onSubmit: (submitOptions) => { + const queue = options?.queue + // Confirming an edit re-admits the queued prompt instead of sending + // the composer value as a new prompt. Enter keeps it queued in + // place; the alternate action sends it as a steer. + if (queue?.editing()) { + queue.confirmEdit(submitOptions?.alternate ? "steer" : "queue") + return + } + void submission.submit(new Event("submit"), submitOptions) + }, onStop: () => void submission.stop(), }, }, diff --git a/packages/app/src/composer/placeholder.test.ts b/packages/app/src/composer/placeholder.test.ts index 7b326e6d00d..543f5d18356 100644 --- a/packages/app/src/composer/placeholder.test.ts +++ b/packages/app/src/composer/placeholder.test.ts @@ -12,4 +12,12 @@ describe("Composer placeholder", () => { test("uses the command and context hint in normal mode", () => { expect(composerPlaceholder("normal", t)).toBe("ui.promptInput.placeholder.normal/@") }) + + test("uses the follow-up copy while a turn runs or prompts are queued", () => { + expect(composerPlaceholder("normal", t, true)).toBe("ui.promptInput.placeholder.followUp/@") + }) + + test("keeps the shell placeholder while a turn runs", () => { + expect(composerPlaceholder("shell", t, true)).toBe("prompt.placeholder.shell:git status") + }) }) diff --git a/packages/app/src/composer/placeholder.ts b/packages/app/src/composer/placeholder.ts index 1a1e0f62542..64ca1673f50 100644 --- a/packages/app/src/composer/placeholder.ts +++ b/packages/app/src/composer/placeholder.ts @@ -1,7 +1,9 @@ export function composerPlaceholder( mode: "normal" | "shell", t: (key: string, params?: Record) => string, + followUp?: boolean, ) { if (mode === "shell") return t("prompt.placeholder.shell", { example: "git status" }) + if (followUp) return t("ui.promptInput.placeholder.followUp", { slash: "/", at: "@" }) return t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" }) } diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index 4cd9999b635..da255ce3c36 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -5,7 +5,7 @@ import type { Accessor } from "solid-js" import type { PromptHistoryComment } from "./history/entry" import type { ImageAttachmentPart, Prompt } from "./state" import { clonePrompt, promptLength } from "./prompt-parts" -import type { ComposerAdapter, ComposerSelection, ComposerSession } from "./adapter" +import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSession } from "./adapter" import { createComposerSubmission } from "./submission-state" import { buildPromptRequest } from "./request" import { setCursorPosition } from "./editor/dom" @@ -21,7 +21,7 @@ type ComposerSubmission = { text: string images: ImageAttachmentPart[] selection: ComposerSelection - delivery: "steer" + delivery: ComposerDelivery } type ComposerSubmitInput = { @@ -33,6 +33,7 @@ type ComposerSubmitInput = { resetHistory: () => void setMode: (mode: "normal" | "shell") => void closePopover: () => void + delivery?: (alternate: boolean) => ComposerDelivery notify: { missingSelection: () => void failed: (kind: "shell" | "command" | "prompt", error: unknown) => void @@ -45,7 +46,7 @@ type ComposerSubmitInput = { } export function createComposerSubmit(input: ComposerSubmitInput) { - const submit = async (event: globalThis.Event) => { + const submit = async (event: globalThis.Event, options?: { alternate?: boolean }) => { event.preventDefault() const submission = createComposerSubmission({ @@ -56,7 +57,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) { selection: item.selection ? { ...item.selection } : undefined, })), }) - const value = readSubmission(input, submission.prompt, submission.context) + const value = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false) if (!value) { if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt() return @@ -113,7 +114,10 @@ export function createComposerSubmit(input: ComposerSubmitInput) { if (command) { clearSubmission(input, submission) - void sendCommand(session, value, command).catch((error) => + // Commands always steer: the server applies a command's configured + // agent and model immediately at admission, so queueing one would + // reconfigure the turn it is supposed to wait behind. + void sendCommand(session, { ...value, delivery: "steer" }, command).catch((error) => failSubmission(input, session, "command", error, restore, value.id), ) return @@ -157,6 +161,7 @@ function readSubmission( input: ComposerSubmitInput, prompt: Prompt, context: ComposerSubmission["context"], + alternate: boolean, ): ComposerSubmission | undefined { const text = prompt.map((part) => ("content" in part ? part.content : "")).join("") const mode = input.mode() @@ -195,7 +200,7 @@ function readSubmission( model: { modelID: model.id, providerID: model.provider.id }, variant, }, - delivery: "steer", + delivery: input.delivery?.(alternate) ?? "steer", } } @@ -285,23 +290,30 @@ async function sendCommand( async function sendPrompt(session: ComposerSession, value: ComposerSubmission) { const request = await buildSubmissionRequest(session, value) - const current = session.current() - if (current?.agent !== value.selection.agent) { - await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent }) - } - if ( - current?.model?.providerID !== value.selection.model.providerID || - current.model.id !== value.selection.model.modelID || - (current.model.variant ?? "default") !== (value.selection.variant ?? "default") - ) { - await session.api.switchModel({ - sessionID: session.id, - model: { - id: value.selection.model.modelID, - providerID: value.selection.model.providerID, - variant: value.selection.variant, - }, - }) + // Switching agent or model reconfigures the session immediately, and with it + // the remainder of a running turn. A steer targets that turn, so its + // selection applies now; a queued follow-up must not reconfigure the turn it + // waits behind, so it runs with the session selection at delivery time (the + // intended selection stays recorded in its metadata). + if (value.delivery === "steer") { + const current = session.current() + if (current?.agent !== value.selection.agent) { + await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent }) + } + if ( + current?.model?.providerID !== value.selection.model.providerID || + current.model.id !== value.selection.model.modelID || + (current.model.variant ?? "default") !== (value.selection.variant ?? "default") + ) { + await session.api.switchModel({ + sessionID: session.id, + model: { + id: value.selection.model.modelID, + providerID: value.selection.model.providerID, + variant: value.selection.variant, + }, + }) + } } const admission = { diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 2ff367616db..24cbef99888 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -677,6 +677,14 @@ export const dict = { "session.background.subagent.one": "{{count}} subagent", "session.background.subagent.other": "{{count}} subagents", "command.session.background": "Move to background", + "session.queue.count.one": "{{count}} queued", + "session.queue.count.other": "{{count}} queued", + "session.queue.steer": "Steer", + "session.queue.send": "Send", + "session.queue.steerTooltip": "Send without interrupting", + "session.queue.remove": "Remove", + "session.queue.reorder": "Reorder queued prompt", + "session.queue.attachments": "+ attachments", "session.timeline.notice.finished": "{{actor}} finished", "session.timeline.notice.failed": "{{actor}} failed", "session.timeline.notice.cancelled": "{{actor}} cancelled", @@ -961,6 +969,11 @@ export const dict = { "settings.general.row.showCustomAgents.title": "Show agent", "settings.general.row.showCustomAgents.description": "Switch between agents in the composer. When hidden, defaults to Build agent.", + "settings.general.row.followUpBehavior.title": "Follow-up behavior", + "settings.general.row.followUpBehavior.description": + "Choose whether to queue follow-ups or steer the current turn. Use {{keybind}} to switch.", + "settings.general.row.followUpBehavior.queue": "Queue", + "settings.general.row.followUpBehavior.steer": "Steer", "settings.general.row.reasoningSummaries.title": "Show reasoning summaries", "settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline", "settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts", diff --git a/packages/app/src/session/composer/queue-panel.tsx b/packages/app/src/session/composer/queue-panel.tsx new file mode 100644 index 00000000000..8709db05d98 --- /dev/null +++ b/packages/app/src/session/composer/queue-panel.tsx @@ -0,0 +1,186 @@ +import { createMemo, For, Show } from "solid-js" +import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" +import { isSortable, useSortable } from "@dnd-kit/solid/sortable" +import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom" +import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers" +import { RestrictToElement } from "@dnd-kit/dom/modifiers" +import { arrayMove } from "@dnd-kit/helpers" +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { useLanguage } from "@/runtime/i18n/language" +import type { SessionQueueView } from "./queue" + +// Pullout above the composer listing the prompts queued behind the current +// turn. The panel slides under the composer card (negative margin, opaque +// composer background) so the two read as one attached surface. +export function SessionQueuePanel(props: { queue: SessionQueueView }) { + const language = useLanguage() + const count = () => props.queue.rows().length + let listRef!: HTMLDivElement + return ( + 0}> +
+ 3}> +
+ {language.plural("session.queue.count", count())} +
+
+ [ + ...defaults.filter((sensor) => sensor !== PointerSensor), + PointerSensor.configure({ + activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })], + }), + ]} + modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]} + plugins={(defaults) => [ + ...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback), + AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }), + Feedback.configure({ dropAnimation: null }), + ]} + onDragEnd={(event) => { + const source = event.operation.source + if (event.canceled || !isSortable(source)) return + if (source.initialIndex === source.index) return + void props.queue.reorder( + arrayMove( + props.queue.rows().map((row) => row.id), + source.initialIndex, + source.index, + ), + ) + }} + > + {/* Keyed on row IDs so store updates move row elements instead of + remounting them, which would kill an in-flight drag. */} +
3 }} + > + row.id)}> + {(id, index) => } + +
+
+
+
+ ) +} + +function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: number }) { + const language = useLanguage() + const row = createMemo(() => props.queue.rows().find((entry) => entry.id === props.id)) + const editing = () => props.queue.editing() === props.id + // While the turn is stopped the queue stays parked, so the first prompt + // shows its actions without hover and its label reads Send: that is how a + // parked queue resumes. + const active = () => !props.queue.working() && props.index === 0 + const sortable = useSortable({ + get id() { + return props.id + }, + get index() { + return props.index + }, + get disabled() { + return props.queue.busy() + }, + }) + return ( + + {(entry) => ( +
+
+ +
+ + + + {language.t("session.queue.attachments")} + + +
+
+
+ + + + + + + } + disabled={props.queue.busy()} + aria-label={language.t("session.queue.remove")} + onClick={() => void props.queue.remove(props.id)} + /> + +
+
+ )} +
+ ) +} diff --git a/packages/app/src/session/composer/queue.ts b/packages/app/src/session/composer/queue.ts new file mode 100644 index 00000000000..59bda26cb34 --- /dev/null +++ b/packages/app/src/session/composer/queue.ts @@ -0,0 +1,275 @@ +import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js" +import { createStore } from "solid-js/store" +import type { SessionInboxInfo } from "@opencode-ai/client/promise" +import type { ComposerDelivery } from "@/composer/adapter" +import type { ComposerModel } from "@/composer/model" +import type { ComposerStateTarget } from "@/composer/submission-state" +import type { ImageAttachmentPart, Prompt } from "@/composer/state" +import { clonePrompt, promptLength } from "@/composer/prompt-parts" +import { buildPromptRequest } from "@/composer/request" +import { blobDataUrl } from "@/runtime/persistence/drafts" +import { useData } from "@/runtime/server/current" +import { useServerSDK } from "@/runtime/server/client" +import { useWorkspaceLocation } from "@/workspaces/location" +import { useLanguage } from "@/runtime/i18n/language" +import { showToast } from "@/shell/notifications/toast" + +export type QueuedPrompt = Extract + +type EditStash = { + prompt: Prompt + cursor: number + mode: "normal" | "shell" + retry: ReturnType +} + +export function createSessionQueue(input: { + sessionID: string + draft: ComposerStateTarget + working: Accessor + behavior: Accessor + composer: Accessor +}) { + const data = useData() + const server = useServerSDK() + const location = useWorkspaceLocation() + const language = useLanguage() + const [state, setState] = createStore<{ editing?: { id: string; stash: EditStash }; busy: boolean }>({ busy: false }) + + const queued = createMemo(() => + data.session.pending + .list(input.sessionID) + .filter((item): item is QueuedPrompt => item.type === "user" && item.delivery === "queue"), + ) + const rows = createMemo(() => + queued().map((item) => ({ + id: item.id, + text: queuedPromptText(item), + attachments: (item.payload.files?.length ?? 0) > 0, + })), + ) + + createEffect(() => { + const editing = state.editing + if (!editing || state.busy || queued().some((item) => item.id === editing.id)) return + setState("editing", undefined) + }) + onCleanup(() => cancelEdit()) + + const notify = () => showToast({ title: language.t("common.requestFailed") }) + const run = (work: () => Promise) => { + setState("busy", true) + return work() + .catch(() => notify()) + .finally(async () => { + await data.session.pending.sync(input.sessionID).catch(() => undefined) + setState("busy", false) + }) + } + + const rewrite = async (inboxIDs: string[]) => { + const pending = await server.api.session.inbox.list({ sessionID: input.sessionID }) + if (pending.some((item) => item.delivery === "queue" && item.type !== "user")) + throw new Error("Queued control items block reordering") + const current = pending.filter((item): item is QueuedPrompt => item.type === "user" && item.delivery === "queue") + const ordered = inboxIDs.flatMap((id) => current.filter((item) => item.id === id)) + if (ordered.length !== current.length) throw new Error("Queued prompts changed before reordering") + const changed = ordered.findIndex((item, index) => item.id !== current[index]?.id) + if (changed < 0) return + + // Existing inbox APIs cannot reorder rows, so replace only the changed suffix. + for (const item of ordered.slice(changed)) { + await data.session.prompt({ + sessionID: input.sessionID, + text: item.payload.text, + files: item.payload.files?.map((file) => ({ + uri: `data:${file.mime};base64,${file.data}`, + name: file.name, + description: file.description, + mention: file.mention, + })), + agents: item.payload.agents, + skills: item.payload.skills, + metadata: item.payload.metadata, + delivery: "queue", + resume: false, + }) + } + for (const item of current.slice(changed)) { + await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: item.id }) + } + } + const steer = (id: string) => { + if (state.editing?.id === id) cancelEdit() + return server.api.session.inbox.steer({ sessionID: input.sessionID, inboxID: id }).catch(() => notify()) + } + const remove = (id: string) => { + if (state.editing?.id === id) cancelEdit() + return server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: id }).catch(() => notify()) + } + const reorder = (inboxIDs: string[]) => { + if (state.busy) return Promise.resolve() + return run(() => rewrite(inboxIDs)) + } + + const edit = (id: string) => { + if (state.busy) return false + if (state.editing?.id === id) return true + const item = queued().find((entry) => entry.id === id) + if (!item) return false + if (state.editing) cancelEdit() + const draft = input.draft.current() + setState("editing", { + id, + stash: { + prompt: clonePrompt(draft), + cursor: input.draft.cursor() ?? promptLength(draft), + mode: input.draft.mode.current(), + retry: input.draft.retry.current(), + }, + }) + const text = queuedPromptText(item) + input.composer()?.dispatch({ type: "mode.normal" }) + input.draft.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) + input.composer()?.restoreFocus(text.length) + return true + } + const cancelEdit = () => { + const editing = state.editing + if (!editing) return + setState("editing", undefined) + // Mode first, then prompt, then retry: mode and prompt writes both clear + // the retry marker. + input.composer()?.dispatch({ type: editing.stash.mode === "shell" ? "mode.shell" : "mode.normal" }) + input.draft.set(editing.stash.prompt, editing.stash.cursor) + if (editing.stash.retry) input.draft.retry.set(editing.stash.retry) + input.composer()?.restoreFocus(editing.stash.cursor) + } + const confirmEdit = (delivery: ComposerDelivery) => { + const editing = state.editing + if (!editing || state.busy) return + const prompt = clonePrompt(input.draft.current()) + const text = prompt.map((part) => ("content" in part ? part.content : "")).join("") + if (!text.trim() && !prompt.some((part) => part.type === "image")) return cancelEdit() + const item = queued().find((entry) => entry.id === editing.id) + const pristine = item && text.trim() === queuedPromptText(item) && !prompt.some((part) => part.type === "image") + if (pristine && delivery === "queue") return cancelEdit() + const inboxIDs = queued().map((entry) => entry.id) + void run(async () => { + const replacement = await editedPromptInput(input.sessionID, location().directory, item, prompt, text) + // Admit before cancelling so a failed replacement never discards the original. + const admitted = await data.session.prompt({ + ...replacement, + delivery, + ...(delivery === "queue" ? { resume: false } : {}), + }) + await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: editing.id }) + cancelEdit() + if (delivery === "queue") await rewrite(inboxIDs.map((id) => (id === editing.id ? admitted.id : id))) + }) + } + const editFirst = () => { + const first = queued()[0] + if (!first) return false + return edit(first.id) + } + + return { + count: () => queued().length, + delivery: () => (input.working() ? input.behavior() : "steer"), + alternate: () => { + if (state.editing) return "steer" + if (!input.working()) return undefined + return input.behavior() === "queue" ? "steer" : "queue" + }, + editing: () => state.editing?.id, + confirmEdit, + cancelEdit, + editFirst, + rows, + busy: () => state.busy, + working: input.working, + steer, + remove, + edit, + reorder, + } +} + +export type SessionQueue = ReturnType + +// The slice of the queue the panel renders and drives. +export type SessionQueueView = Pick< + SessionQueue, + "rows" | "editing" | "working" | "busy" | "steer" | "remove" | "edit" | "reorder" +> + +export function queuedPromptText(item: QueuedPrompt) { + const display = item.payload.metadata?.["displayText"] + return typeof display === "string" && display.length > 0 ? display : item.payload.text +} + +// Confirming an edit submits the current composer content as the replacement: +// mentions and images added during the edit are parsed like a normal +// submission, the original's stored attachments are preserved, and the +// review-comment notes appended to the original's model-visible text survive. +// Ambient composer context (open review comments) stays out: it belongs to +// the next fresh prompt, not to a queued edit. +async function editedPromptInput( + sessionID: string, + directory: string, + item: QueuedPrompt | undefined, + prompt: Prompt, + text: string, +) { + const images = await Promise.all( + prompt + .filter((part): part is ImageAttachmentPart => part.type === "image") + .map(async (part) => ({ ...part, dataUrl: await blobDataUrl(part.blob, part.mime) })), + ) + const request = buildPromptRequest({ prompt, context: [], images, text, sessionDirectory: directory }) + const payload = item?.payload + const display = item ? queuedPromptText(item) : "" + const notes = payload && display && payload.text.startsWith(display) ? payload.text.slice(display.length) : "" + const mention = (value: { start: number; end: number; text: string } | undefined) => { + if (!value) return undefined + const start = text.indexOf(value.text) + if (start < 0) return undefined + return { text: value.text, start, end: start + value.text.length } + } + // Structured mentions degrade to plain text in the editor, so an original + // agent or skill reference survives the edit as long as its mention text + // still appears; newly typed structured mentions come from the request. + const agents = [ + ...(payload?.agents?.filter( + (agent) => + agent.mention && + text.includes(agent.mention.text) && + !request.agents.some((entry) => entry.name === agent.name), + ) ?? []), + ...request.agents, + ] + const skills = [ + ...(payload?.skills?.filter( + (skill) => + skill.mention && text.includes(skill.mention.text) && !request.skills.some((entry) => entry.id === skill.id), + ) ?? []), + ...request.skills, + ] + return { + sessionID, + text: request.text + notes, + files: [ + ...(payload?.files?.map((file) => ({ + uri: `data:${file.mime};base64,${file.data}`, + name: file.name, + description: file.description, + mention: mention(file.mention), + })) ?? []), + ...request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })), + ], + agents: agents.map((agent) => ({ name: agent.name, mention: mention(agent.mention) })), + skills: skills.map((skill) => ({ id: skill.id, mention: mention(skill.mention) })), + metadata: { ...payload?.metadata, displayText: request.displayText }, + } +} diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index e25b8a213ca..e4db6540599 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -6,7 +6,7 @@ import { makeEventListener } from "@solid-primitives/event-listener" import { useNavigate } from "@solidjs/router" import { createEffect, on, onMount } from "solid-js" import { Composer } from "@/composer/composer" -import { createComposerModel } from "@/composer/model" +import { createComposerModel, type ComposerModel } from "@/composer/model" import { useComposerState } from "@/composer/persistence" import { createComposerControls } from "@/composer/selection" import { setCursorPosition } from "@/composer/editor/dom" @@ -27,8 +27,11 @@ import { createSessionRevert } from "../revert" import { SessionComposerRegion } from "./session-composer-region" import { createSessionComposerRegionController } from "./session-composer-region-controller" import { createActiveComposerAdapter } from "./adapter" +import { createSessionQueue } from "./queue" +import { SessionQueuePanel } from "./queue-panel" import { resolveSessionComposerSelection } from "./selection" import { createSessionRequestModel } from "../requests/model" +import { useSettings } from "@/settings/model" export function createActiveSessionRegion(input: { session: SessionModel @@ -216,6 +219,7 @@ export function ActiveSessionComposerRegion(props: { accentSubmit: boolean onResponseSubmit: () => void }) { + const settings = useSettings() const region = createSessionComposerRegionController({ state: props.model.region.state, parentID: props.session.data.parentID, @@ -231,11 +235,32 @@ export function ActiveSessionComposerRegion(props: { submitted: props.model.submitted, setEditor: props.model.input.setPromptRef, }) - const composer = createComposerModel(adapter) + let composer: ComposerModel | undefined + const queue = createSessionQueue({ + sessionID: requireSessionID(props.session), + draft: adapter.state, + working: adapter.working, + behavior: settings.general.followUpBehavior, + composer: () => composer, + }) + composer = createComposerModel(adapter, { queue }) return ( } + composer={ +
+ +
+ +
+
+ } /> ) } + +function requireSessionID(session: SessionModel) { + const id = session.identity.params.id + if (!id) throw new Error("Active Composer requires a Session ID") + return id +} diff --git a/packages/app/src/session/revert.ts b/packages/app/src/session/revert.ts index c988207b3ab..080e4cc2e77 100644 --- a/packages/app/src/session/revert.ts +++ b/packages/app/src/session/revert.ts @@ -55,6 +55,28 @@ export function createSessionRevert(input: { await server.api.session.interrupt({ sessionID }).catch(() => undefined) } if (!(await request(() => server.api.session.revert.stage({ sessionID, messageID: message.id })))) return + // Reverting to a previous prompt discards the pending queue (and pending + // steers): they were written against the history being rewound. Cancel + // the authoritative inbox merged with the local snapshot, fire-and-forget + // so a slow request cannot delay restoring the composer. The cutoff keeps + // the asynchronous sweep away from prompts admitted after the revert; an + // old admission still in flight when the list is fetched can survive it, + // and fully closing that race needs a server-side revert-discards-inbox + // rule. + const cutoff = Date.now() + const local = data.session.pending + .list(sessionID) + .filter((item) => item.type === "user") + .map((item) => item.id) + void server.api.session.inbox + .list({ sessionID }) + .then((rows) => rows.filter((row) => row.type === "user" && row.timeCreated <= cutoff).map((row) => row.id)) + .catch(() => []) + .then((authoritative) => { + new Set([...local, ...authoritative]).forEach( + (inboxID) => void server.api.session.inbox.cancel({ sessionID, inboxID }).catch(() => undefined), + ) + }) restore(target, message) owner.run(() => input.setActiveMessage(previous)) } diff --git a/packages/app/src/settings/general/general.tsx b/packages/app/src/settings/general/general.tsx index d14c14622e0..465a29b1065 100644 --- a/packages/app/src/settings/general/general.tsx +++ b/packages/app/src/settings/general/general.tsx @@ -7,7 +7,13 @@ import { TextInput } from "@opencode-ai/ui/text-input" import { useLanguage } from "@/runtime/i18n/language" import { usePlatform } from "@/runtime/platform/platform" import { useUpdaterAction } from "@/shell/updates/action" -import { type TerminalPlacement, type WorkspaceDefaultDestination, useSettings } from "@/settings/model" +import { + type FollowUpBehavior, + type TerminalPlacement, + type WorkspaceDefaultDestination, + useSettings, +} from "@/settings/model" +import { formatKeybind } from "@/shell/commands/command" import { ExternalLink } from "@/runtime/platform/external-link" import { SettingsList } from "@/settings/list" import { SettingsRow } from "@/settings/row" @@ -148,6 +154,35 @@ const TerminalPlacementSetting: Component = () => { ) } +const FollowUpBehaviorSetting: Component = () => { + const language = useLanguage() + const settings = useSettings() + const options = createMemo((): { value: FollowUpBehavior; label: string }[] => [ + { value: "queue", label: language.t("settings.general.row.followUpBehavior.queue") }, + { value: "steer", label: language.t("settings.general.row.followUpBehavior.steer") }, + ]) + + return ( + +