Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions packages/overlay/src/app.stop.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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",
});
});
});
62 changes: 58 additions & 4 deletions packages/overlay/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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 +
Expand Down Expand Up @@ -3907,14 +3937,38 @@ 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();
this.activeTurn = turn;
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.
*
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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);
Expand Down