diff --git a/packages/overlay/src/app.stop.test.ts b/packages/overlay/src/app.stop.test.ts new file mode 100644 index 0000000..02710b3 --- /dev/null +++ b/packages/overlay/src/app.stop.test.ts @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AirshipApp, type Stage } from "./app"; +import { ChromeLayer } from "./chrome-layer"; +import { cls } from "./dom"; +import { keys } from "./keys/registry"; +import { InlineResolver } from "./surface"; + +/* + * Send, while a turn is running, has to become Stop. + * + * The daemon has supported cancelling a job since `{ type: "cancel", jobId }` landed in the + * protocol (`server/src/index.ts`'s `case "cancel": jobs.get(parsed.jobId)?.abort?.abort()`), and + * `keepVisual` even explains that a cancelled turn keeps its pending deltas queued "so Send tries + * again rather than starting from nothing" — cancelling was designed in from the start. What never + * existed was a way to *ask* for it: the composer's Send button just disabled itself the moment a + * turn began (`beginJob`) and stayed that way until `job:done`. This file pins the fix — the + * button now stays live and flips to Stop, wired to that same cancel message, instead of going + * inert for however long the agent takes. + */ + +function stubStage(): Stage { + const layer = new ChromeLayer(); + return { + destroy() { + // Nothing to release beyond what `AirshipApp.destroy` already does. + }, + layer, + mount() { + layer.mount(document.body); + }, + onLayoutChange() { + // Nothing moves under a stage with no content. + }, + resolver: new InlineResolver(), + swallowPresses: true, + }; +} + +function stubStorage(): void { + const store = new Map(); + Object.defineProperty(window, "localStorage", { + configurable: true, + value: { + clear: () => store.clear(), + getItem: (k: string) => store.get(k) ?? null, + key: () => null, + length: 0, + removeItem: (k: string) => store.delete(k), + setItem: (k: string, v: string) => store.set(k, v), + }, + }); +} + +/** A WebSocket stand-in that is "open" from the moment it is constructed (so `AirshipSocket.send` + * actually forwards), records every message sent through it, and lets a test push a server event + * straight at the app the way a real frame would. */ +class FakeSocket { + static readonly OPEN = 1; + readyState = FakeSocket.OPEN; + sent: unknown[] = []; + onmessage: ((ev: { data: string }) => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + constructor() { + sockets.push(this); + } + addEventListener( + kind: string, + handler: (ev: { data: string }) => void + ): void { + if (kind === "message") { + this.onmessage = handler; + } + } + close(): void { + this.onclose?.(); + } + send(data: string): void { + this.sent.push(JSON.parse(data)); + } + emit(event: unknown): void { + this.onmessage?.({ data: JSON.stringify(event) }); + } +} + +const apps: AirshipApp[] = []; +let sockets: FakeSocket[] = []; + +function mount(): { app: AirshipApp; socket: FakeSocket } { + const stage = stubStage(); + const app = new AirshipApp( + { mode: "inline", wsPath: "/__airship/ws" }, + stage + ); + app.mount(); + apps.push(app); + return { app, socket: sockets.at(-1) as FakeSocket }; +} + +function sendBtn(): HTMLButtonElement { + const btn = document.querySelector(`.${cls("send")}`); + if (!btn) { + throw new Error("sendBtn: composer has not been mounted"); + } + return btn as HTMLButtonElement; +} + +function typeAndSubmit(text: string): void { + const input = document.querySelector( + `.${cls("input")}` + ) as HTMLTextAreaElement; + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + sendBtn().dispatchEvent(new Event("click", { bubbles: true })); +} + +beforeEach(() => { + stubStorage(); + sockets = []; + vi.stubGlobal("WebSocket", FakeSocket); +}); + +afterEach(() => { + for (const app of apps.splice(0)) { + app.destroy(); + } + keys.destroy(); + document.body.replaceChildren(); + vi.unstubAllGlobals(); +}); + +describe("stopping an in-progress chat response", () => { + it("turns Send into Stop once a turn is running, and Stop cancels the running job by id", () => { + const { socket } = mount(); + + typeAndSubmit("add a footer"); + expect(socket.sent).toEqual([{ request: expect.anything(), type: "edit" }]); + + // The turn is running but the daemon has not answered `job:created` yet — there is nothing + // to cancel, so the button must not pretend otherwise. + expect(sendBtn().disabled).toBe(false); + expect(sendBtn().getAttribute("aria-label")).toBe("Stop"); + + socket.emit({ + job: { createdAt: Date.now(), jobId: "job-1", status: "running" }, + type: "job:created", + }); + + sendBtn().dispatchEvent(new Event("click", { bubbles: true })); + + // Exactly one more message went out, and it is the cancel — not a second edit. + expect(socket.sent).toEqual([ + { request: expect.anything(), type: "edit" }, + { jobId: "job-1", type: "cancel" }, + ]); + }); + + it("reverts to Send once the job ends, ready to submit the next turn", () => { + const { socket } = mount(); + + typeAndSubmit("add a footer"); + socket.emit({ + job: { createdAt: Date.now(), jobId: "job-2", status: "running" }, + type: "job:created", + }); + expect(sendBtn().getAttribute("aria-label")).toBe("Stop"); + + socket.emit({ + bundle: { + basedOn: null, + jobId: "job-2", + parentJobId: null, + status: "done", + summary: "", + }, + jobId: "job-2", + type: "job:done", + }); + + expect(sendBtn().disabled).toBe(false); + expect(sendBtn().getAttribute("aria-label")).toBe("Send"); + + typeAndSubmit("one more change"); + expect(socket.sent.at(-1)).toEqual({ + request: expect.anything(), + type: "edit", + }); + }); +}); diff --git a/packages/overlay/src/app.ts b/packages/overlay/src/app.ts index 2a1bfdf..11fdff6 100644 --- a/packages/overlay/src/app.ts +++ b/packages/overlay/src/app.ts @@ -2675,7 +2675,7 @@ export class AirshipApp { "aria-label": "Send", class: `${cls("action")} ${cls("action-icon")} ${cls("primary")} ${cls("send")}`, ...tip("Send", "chat.send"), - onClick: () => this.submit(), + onClick: () => this.onSendClick(), type: "button", }, [icon("chev-up", "sm")] @@ -3646,6 +3646,36 @@ export class AirshipApp { // -- Submit — the single outbox -------------------------------------------- + /** + * What Send does — submit a new turn, or stop the one already running. + * + * The button used to just disable itself for `awaiting`'s whole span, which + * left no way back from a turn that was taking too long or was typed wrong: + * the daemon has supported `{ type: "cancel", jobId }` since the protocol + * added it (`server/src/index.ts`'s `case "cancel"`), but nothing in the + * composer ever sent one. `syncSendBtn` is what repaints the button between + * the two meanings; this is what a click means once it has. + */ + private onSendClick(): void { + if (this.awaiting) { + this.cancelActive(); + return; + } + this.submit(); + } + + /** Stop the running turn so its composer state can be edited and resent — + * `keepVisual` is what leaves the pending deltas queued for that resend. */ + private cancelActive(): void { + if (!this.activeJobId) { + // `job:created` has not landed yet; there is nothing on the daemon to + // cancel. The button still reads "Stop" (see `syncSendBtn`), but a + // click this early is a no-op rather than a request with no target. + return; + } + this.socket.send({ jobId: this.activeJobId, type: "cancel" }); + } + /** * Ship one edit turn from the left composer: the typed instruction plus any * pasted images and any pending direct-manipulation deltas (style tweaks + @@ -3907,7 +3937,7 @@ export class AirshipApp { private beginJob(): void { this.awaiting = true; this.activeJobId = null; - this.sendBtn.disabled = true; + this.syncSendBtn(); this.syncApplyGroup(); this.syncJobChip(); const turn = assistantTurn(); @@ -3915,6 +3945,30 @@ export class AirshipApp { this.pushBubble(turn.root); } + /** + * Repaint Send between its two meanings. + * + * Never disabled: disabling it during `awaiting` is what this replaces — + * the button stays live throughout a turn, it just means something else + * while one is running. + */ + private syncSendBtn(): void { + this.sendBtn.disabled = false; + this.sendBtn.replaceChildren( + icon(this.awaiting ? "square" : "chev-up", "sm") + ); + const label = this.awaiting ? "Stop" : "Send"; + this.sendBtn.setAttribute("aria-label", label); + this.sendBtn.setAttribute("data-tip", label); + // `chat.send`'s ⌘⏎ hint belongs on Send, not on Stop — Enter never cancels + // a turn, only `submit`'s own early return while `awaiting` is set. + if (this.awaiting) { + this.sendBtn.removeAttribute("data-key"); + } else { + this.sendBtn.setAttribute("data-key", "chat.send"); + } + } + /** * Reconcile a latched `awaiting` against what the daemon actually has. * @@ -3960,7 +4014,7 @@ export class AirshipApp { this.awaiting = false; this.activeJobId = null; this.applyingVisual = false; - this.sendBtn.disabled = false; + this.syncSendBtn(); this.syncApplyGroup(); this.syncJobChip(); @@ -4169,7 +4223,7 @@ export class AirshipApp { private onDone(bundle: JobDiffBundle): void { this.awaiting = false; - this.sendBtn.disabled = false; + this.syncSendBtn(); this.syncApplyGroup(); this.syncJobChip(); this.reportOffscreenTurn(bundle);