返回 DeepSeek-Reasonix
composer-goal-toggle.test.tsx
根目录 / desktop / frontend / src / __tests__ / composer-goal-toggle.test.tsx
1 import { JSDOM } from "jsdom";
2 import React from "react";
3 import { act } from "react";
4 import { createRoot } from "react-dom/client";
5 import { Composer, composerPickFileEntry } from "../components/Composer";
6 import { InvocationMetadataContext, UserMessage } from "../components/Message";
7 import { selectionFromDom } from "../components/RichComposerInput";
8 import { LocaleProvider } from "../lib/i18n";
9 import { ToastProvider } from "../lib/toast";
10 import type { AppBindings } from "../lib/bridge";
11 import type { ComposerInvocation, StructuredInvocationSubmit } from "../lib/invocationDisplay";
12 import type { CollaborationMode, CommandInfo, DirEntry, ToolApprovalMode } from "../lib/types";
13 import { dispatchNativeFileDrop, installDesktopHostStub, type DesktopHostStubOptions } from "./desktopHostStub";
14
15 let passed = 0;
16 let failed = 0;
17
18 function ok(value: boolean, label: string) {
19 if (value) {
20 process.stdout.write(` PASS ${label}\n`);
21 passed += 1;
22 } else {
23 process.stdout.write(` FAIL ${label}\n`);
24 failed += 1;
25 }
26 }
27
28 function eq(actual: unknown, expected: unknown, label: string) {
29 if (actual === expected) ok(true, label);
30 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
31 }
32
33 function flushTimers(ms = 0): Promise<void> {
34 return new Promise((resolve) => setTimeout(resolve, ms));
35 }
36
37 class TestResizeObserver {
38 observe() {}
39 unobserve() {}
40 disconnect() {}
41 }
42
43 function installDom() {
44 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
45 pretendToBeVisual: true,
46 url: "http://localhost/",
47 });
48 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
49 globalThis.window = dom.window as unknown as Window & typeof globalThis;
50 globalThis.document = dom.window.document;
51 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
52 globalThis.Node = dom.window.Node;
53 globalThis.HTMLElement = dom.window.HTMLElement;
54 globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
55 globalThis.Event = dom.window.Event;
56 globalThis.CustomEvent = dom.window.CustomEvent;
57 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
58 globalThis.InputEvent = dom.window.InputEvent;
59 globalThis.MouseEvent = dom.window.MouseEvent;
60 globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
61 globalThis.MutationObserver = dom.window.MutationObserver;
62 globalThis.File = dom.window.File;
63 globalThis.FileReader = dom.window.FileReader;
64 globalThis.localStorage = dom.window.localStorage;
65 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
66 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
67 globalThis.ResizeObserver = TestResizeObserver;
68 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
69 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
70 Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
71 Object.defineProperty(window, "matchMedia", {
72 configurable: true,
73 value: () => ({
74 matches: true,
75 media: "(prefers-reduced-motion: reduce)",
76 onchange: null,
77 addEventListener() {},
78 removeEventListener() {},
79 addListener() {},
80 removeListener() {},
81 dispatchEvent: () => false,
82 }),
83 });
84 return dom;
85 }
86
87 async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) {
88 const rootEl = document.getElementById("root");
89 if (!rootEl) throw new Error("missing root");
90 const root = createRoot(rootEl);
91 const calls: {
92 send: string[];
93 submit: (string | undefined)[];
94 structured: (StructuredInvocationSubmit | undefined)[];
95 cancel: number;
96 clearGoal: number;
97 setCollaborationMode: CollaborationMode[];
98 } = {
99 send: [],
100 submit: [],
101 structured: [],
102 cancel: 0,
103 clearGoal: 0,
104 setCollaborationMode: [],
105 };
106 let currentProps: Parameters<typeof Composer>[0] = {
107 running: false,
108 collaborationMode: "normal",
109 toolApprovalMode: "ask" as ToolApprovalMode,
110
111 goal: "",
112 cwd: "/repo",
113 tabId: "tab-a",
114 modelLabel: "DeepSeek-R1",
115 onSend: (displayText, submitText, _tabId, structured) => {
116 calls.send.push(displayText);
117 calls.submit.push(submitText);
118 calls.structured.push(structured);
119 },
120 onCancel: async () => {
121 calls.cancel += 1;
122 return { discardedItemIds: [] };
123 },
124 onCycleMode: () => {},
125 onSetMode: () => {},
126 onSetCollaborationMode: (mode) => calls.setCollaborationMode.push(mode),
127 onSetToolApprovalMode: () => {},
128 onClearGoal: () => { calls.clearGoal += 1; },
129 onEditGoal: () => {}, onPauseGoal: () => {}, onResumeGoal: () => {},
130 onSwitchModel: () => {},
131 onSetEffort: () => {},
132
133 ready: true,
134 ...props,
135 };
136 const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
137 currentProps = { ...currentProps, ...nextProps };
138 await act(async () => {
139 root.render(
140 <LocaleProvider>
141 <ToastProvider>
142 <Composer {...currentProps} />
143 </ToastProvider>
144 </LocaleProvider>,
145 );
146 await flushTimers();
147 });
148 };
149 await paint();
150 return { root, calls, rerender: paint };
151 }
152
153 function mockApp(methods: Partial<AppBindings>, stubOptions?: DesktopHostStubOptions) {
154 installDesktopHostStub(({
155 main: {
156 App: {
157 Commands: async () => [],
158 Models: async () => [],
159 ModelsForTab: async () => [],
160 SlashArgs: async () => ({ items: [], from: 0 }),
161 CaptureAttachmentTarget: async () => ({ token: "goal-attachment-target", capabilities: ["attachments-v2"] }), ReleaseAttachmentTarget: async () => {},
162 ...methods,
163 } as Partial<AppBindings> as AppBindings,
164 },
165 }).main.App, stubOptions ?? { getPathForFile: (file) => file.name });
166 }
167
168 function dispatchPasteFile(textarea: HTMLTextAreaElement, file: File) {
169 const event = new Event("paste", { bubbles: true, cancelable: true });
170 Object.defineProperty(event, "clipboardData", {
171 configurable: true,
172 value: {
173 files: [file],
174 items: [],
175 types: ["Files"],
176 getData: () => "",
177 },
178 });
179 textarea.dispatchEvent(event);
180 }
181
182 function dispatchPasteText(input: HTMLElement, text: string) {
183 const event = new Event("paste", { bubbles: true, cancelable: true });
184 Object.defineProperty(event, "clipboardData", {
185 configurable: true,
186 value: {
187 files: [],
188 items: [],
189 types: ["text/plain"],
190 getData: (kind: string) => (kind === "text" || kind === "text/plain" ? text : ""),
191 },
192 });
193 input.dispatchEvent(event);
194 }
195
196 function nativeFileDropEvent(): Event {
197 const drop = new window.Event("drop", { bubbles: true, cancelable: true });
198 Object.defineProperty(drop, "dataTransfer", {
199 configurable: true,
200 value: {
201 types: ["Files"],
202 files: [{}],
203 items: [
204 {
205 kind: "file",
206 webkitGetAsEntry: () => ({ isFile: true }),
207 },
208 ],
209 },
210 });
211 return drop;
212 }
213
214 async function waitFor(label: string, predicate: () => boolean) {
215 for (let attempt = 0; attempt < 20; attempt += 1) {
216 await act(async () => {
217 await flushTimers();
218 });
219 if (predicate()) return;
220 }
221 throw new Error(`timed out waiting for ${label}`);
222 }
223
224 type RenderedComposer = Awaited<ReturnType<typeof renderComposer>>;
225
226 function fileEntry(name: string): DirEntry {
227 return { name, isDir: false };
228 }
229
230 function richComposerTaskText(input: HTMLElement): string {
231 const clone = input.cloneNode(true) as HTMLElement;
232 clone.querySelectorAll("[data-invocation-id], [data-composer-caret-anchor]").forEach((node) => node.remove());
233 return clone.textContent ?? "";
234 }
235
236 function richTextBeforeInvocation(input: HTMLElement, invocation: Element): string {
237 const range = document.createRange();
238 range.setStart(input, 0);
239 range.setEndBefore(invocation);
240 const shell = document.createElement("div");
241 shell.appendChild(range.cloneContents());
242 return richComposerTaskText(shell);
243 }
244
245 async function appendRichComposerInput(input: HTMLElement, text: string, composing = false) {
246 await act(async () => {
247 if (composing) input.dispatchEvent(new Event("compositionstart", { bubbles: true }));
248 input.appendChild(document.createTextNode(text));
249 input.dispatchEvent(new window.InputEvent("input", {
250 bubbles: true,
251 data: text,
252 inputType: composing ? "insertCompositionText" : "insertText",
253 isComposing: composing,
254 }));
255 if (composing) input.dispatchEvent(new Event("compositionend", { bubbles: true }));
256 await flushTimers();
257 });
258 }
259
260 async function replaceComposerDraft(rerender: RenderedComposer["rerender"], id: number, text: string) {
261 await rerender({ insertRequest: { id, text, mode: "replace" } });
262 }
263
264 console.log("\ncomposer goal toggle");
265
266 {
267 const dom = installDom();
268 const { root, calls, rerender } = await renderComposer();
269 let textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
270 if (!textarea) throw new Error("composer textarea did not render");
271 eq(["spellcheck", "autocorrect", "autocapitalize"].map((name) => textarea.getAttribute(name)).join("/"), "false/off/off", "plain composer disables browser text assistance");
272 await rerender({ insertRequest: { id: 1, text: "ship the release notes", mode: "replace" } });
273 eq(textarea.value, "ship the release notes", "insert request populates the composer draft");
274 // The insert queues a rAF that refocuses the textarea; drain that frame
275 // before focusing the trigger, or the late refocus blurs the tooltip away.
276 await act(async () => {
277 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
278 await flushTimers();
279 });
280
281 await rerender({ insertRequest: { id: 2, text: "/reviewer ", mode: "prefix" } });
282 eq(textarea.value, "/reviewer ship the release notes", "prefix insert preserves the draft as a subagent task");
283 eq(calls.send.length, 0, "prefix insert does not send the subagent task");
284
285 eq(document.querySelector(".composer-task-mode-trigger"), null, "default execution has no mode chip");
286 const intentButton = document.querySelector(".composer-content-trigger") as HTMLButtonElement;
287 await act(async () => {
288 intentButton.click();
289 await flushTimers();
290 });
291
292 const taskModeItems = document.querySelectorAll(".composer-intent-menu__item");
293 eq(taskModeItems.length, 2, "task method menu exposes only Plan and Goal");
294 eq(document.querySelectorAll(".composer-intent-switch").length, 0, "task method menu does not present independent switches");
295 const planButton = taskModeItems[0] as HTMLButtonElement | undefined;
296 if (!planButton) throw new Error("composer Plan menu item did not render");
297 eq(planButton.querySelector(".composer-access-menu__desc"), null, "Plan menu keeps a single-line label");
298 ok(planButton.textContent?.toLowerCase().includes("read-only") === false, "Plan menu does not present Plan as a read-only permission mode");
299 const goalButton = taskModeItems[1] as HTMLButtonElement | undefined;
300 if (!goalButton) throw new Error("composer goal menu item did not render");
301
302 await act(async () => {
303 goalButton.click();
304 await flushTimers();
305 });
306
307 eq(calls.send.length, 0, "enabling goal mode with a draft does not send");
308 eq(calls.setCollaborationMode.join(","), "goal", "enabling goal mode switches only the collaboration axis");
309 eq(textarea.value, "/reviewer ship the release notes", "enabling goal mode preserves the prefixed draft text");
310
311 await rerender({ collaborationMode: "plan" });
312 ok(document.querySelector(".composer-task-mode-trigger") !== null, "Plan exposes its active mode chip");
313 await act(async () => {
314 document.querySelector<HTMLButtonElement>(".composer-content-trigger")?.click();
315 await flushTimers();
316 });
317 await act(async () => {
318 document.querySelector<HTMLButtonElement>(".composer-intent-menu__item")?.click();
319 await flushTimers();
320 });
321 eq(calls.setCollaborationMode.at(-1), "normal", "selecting active Plan exits to the implicit default");
322 eq(textarea.value, "/reviewer ship the release notes", "exiting Plan preserves the draft");
323 await act(async () => {
324 document.querySelector<HTMLButtonElement>(".composer-task-mode-trigger")?.click();
325 await flushTimers();
326 });
327 eq(calls.setCollaborationMode.at(-1), "normal", "clicking the mode chip exits Plan directly");
328 eq(textarea.value, "/reviewer ship the release notes", "dismissing the chip preserves the draft");
329
330 await act(async () => {
331 root.unmount();
332 });
333 dom.window.close();
334 }
335
336 {
337 const dom = installDom();
338 mockApp({
339 Commands: async () => [
340 { name: "ui-ux-pro-max", description: "Review the interface", kind: "skill" },
341 ],
342 ListDirForTarget: async () => [],
343 SearchFileRefsForTarget: async () => [],
344 });
345 const { root, calls, rerender } = await renderComposer({ collaborationMode: "goal", goal: "" });
346 await replaceComposerDraft(rerender, 4199, "/ui-ux-pro-max");
347 await waitFor("skill menu for the initial goal", () => Boolean(document.querySelector(".slashmenu")));
348 let textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
349 if (!textarea) throw new Error("composer textarea did not render for the initial goal skill");
350 await act(async () => {
351 textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
352 await flushTimers();
353 });
354
355 let sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
356 if (!sendButton) throw new Error("composer send button did not render for the initial goal skill");
357 await act(async () => {
358 sendButton.click();
359 await flushTimers();
360 });
361 eq(calls.send.length, 0, "a skill alone cannot become the initial goal");
362 ok(document.body.textContent?.includes("Enter a goal") === true, "a skill-only initial goal asks for task text");
363
364 await replaceComposerDraft(rerender, 4200, "List the existing notes");
365 sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
366 if (!sendButton) throw new Error("composer send button disappeared after entering the goal");
367 await act(async () => {
368 sendButton.click();
369 await flushTimers();
370 });
371 eq(calls.send[0], "List the existing notes", "the initial goal keeps its visible task text");
372 eq(calls.submit[0], "/ui-ux-pro-max List the existing notes", "the initial goal preserves the selected skill");
373 eq(calls.structured[0]?.input, "List the existing notes", "the initial goal sends structured skill input");
374 eq(calls.structured[0]?.invocations[0]?.name, "ui-ux-pro-max", "the initial goal submits the selected skill entity");
375
376 await replaceComposerDraft(rerender, 4201, "/ui-ux-pro-max List the notes again");
377 sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
378 if (!sendButton) throw new Error("composer send button did not render for a pasted skill invocation");
379 await act(async () => {
380 sendButton.click();
381 await flushTimers();
382 });
383 eq(calls.send[1], "List the notes again", "a pasted skill invocation keeps its task as the visible goal");
384 eq(calls.submit[1], "/ui-ux-pro-max List the notes again", "a pasted skill invocation keeps its slash display");
385 eq(calls.structured[1]?.input, "List the notes again", "a pasted skill invocation uses structured input");
386 eq(calls.structured[1]?.invocations[0]?.name, "ui-ux-pro-max", "a pasted skill invocation resolves the selected command");
387
388 await act(async () => {
389 root.unmount();
390 });
391 dom.window.close();
392 }
393
394 {
395 // Attachment-only first Goal: no text, no skill — attachment refs are valid task context.
396 const dom = installDom();
397 mockApp({
398 SavePastedFileForTarget: async () => ".reasonix/attachments/notes.txt",
399 });
400 const { root, calls } = await renderComposer({ collaborationMode: "goal", goal: "" });
401 const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
402 if (!textarea) throw new Error("composer textarea did not render for attachment-only goal");
403 await act(async () => {
404 dispatchPasteFile(textarea, new File(["hello"], "notes.txt", { type: "text/plain" }));
405 await flushTimers();
406 });
407 await waitFor("attachment-only initial goal card", () => document.body.textContent?.includes("notes.txt") === true);
408
409 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
410 if (!sendButton) throw new Error("send button missing for attachment-only initial goal");
411 await act(async () => {
412 sendButton.click();
413 await flushTimers();
414 });
415 eq(calls.send.length, 1, "attachment-only input can become the initial Goal");
416 ok(
417 calls.submit[0]?.includes("@.reasonix/attachments/notes.txt") === true,
418 "attachment-only initial Goal submits the attachment ref",
419 );
420 eq(calls.structured[0], undefined, "attachment-only initial Goal is not a structured skill submit");
421
422 await act(async () => {
423 root.unmount();
424 });
425 dom.window.close();
426 }
427
428 {
429 // Workspace-ref-only first Goal: no text, no skill — workspace refs remain valid task context.
430 const dom = installDom();
431 mockApp({
432 AttachDroppedForTarget: async () => ({
433 kind: "workspace",
434 path: "src/App.tsx",
435 isDir: false,
436 displayPath: "src/App.tsx",
437 }),
438 });
439 const { root, calls } = await renderComposer({ collaborationMode: "goal", goal: "" });
440 const wrap = document.querySelector(".composer-wrap");
441 if (!wrap) throw new Error("composer drop target did not render for workspace-ref goal");
442 await act(async () => {
443 dispatchNativeFileDrop(wrap, [new File([""], "/repo/src/App.tsx")]);
444 await flushTimers();
445 });
446 await waitFor("workspace-ref-only initial goal card", () => document.body.textContent?.includes("App.tsx") === true);
447
448 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
449 if (!sendButton) throw new Error("send button missing for workspace-ref-only initial goal");
450 await act(async () => {
451 sendButton.click();
452 await flushTimers();
453 });
454 eq(calls.send.length, 1, "workspace-ref-only input can become the initial Goal");
455 eq(calls.submit[0], "@src/App.tsx", "workspace-ref-only initial Goal submits the workspace ref");
456 eq(calls.structured[0], undefined, "workspace-ref-only initial Goal is not a structured skill submit");
457
458 await act(async () => {
459 root.unmount();
460 });
461 dom.window.close();
462 }
463
464 {
465 const dom = installDom();
466 mockApp({
467 Commands: async () => [
468 { name: "writing-plans", description: "Write a plan", kind: "skill" },
469 { name: "review", description: "Review the result", kind: "skill" },
470 ],
471 ListDirForTarget: async () => [],
472 SearchFileRefsForTarget: async () => [],
473 });
474 const { root, calls, rerender } = await renderComposer();
475 await replaceComposerDraft(rerender, 4200, "/writing-plans");
476 await waitFor("skill menu for pasted-block offsets", () => Boolean(document.querySelector(".slashmenu")));
477 let textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
478 if (!textarea) throw new Error("composer textarea did not render for pasted-block offsets");
479 await act(async () => {
480 textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
481 await flushTimers();
482 });
483
484 let richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
485 let firstToken = richInput?.querySelector(".composer-invocation-token");
486 if (!richInput || !firstToken) throw new Error("initial rich invocation did not render for pasted-block offsets");
487 const afterFirst = document.createRange();
488 afterFirst.setStartAfter(firstToken);
489 afterFirst.collapse(true);
490 document.getSelection()?.removeAllRanges();
491 document.getSelection()?.addRange(afterFirst);
492 const expandedText = Array.from({ length: 20 }, (_, index) => `expanded line ${index + 1}`).join("\n");
493 await act(async () => {
494 dispatchPasteText(richInput!, expandedText);
495 await flushTimers();
496 });
497 const firstLabel = document.querySelector(".composer__pasted-label")?.textContent ?? "";
498 ok(firstLabel !== "", "long rich-composer paste folds into a pasted block");
499
500 richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
501 if (!richInput) throw new Error("rich input disappeared after folded paste");
502 await appendRichComposerInput(richInput, " /review");
503 const afterReviewQuery = document.createRange();
504 afterReviewQuery.selectNodeContents(richInput);
505 afterReviewQuery.collapse(false);
506 document.getSelection()?.removeAllRanges();
507 document.getSelection()?.addRange(afterReviewQuery);
508 await act(async () => {
509 richInput!.dispatchEvent(new window.KeyboardEvent("keyup", { key: "w", bubbles: true }));
510 await flushTimers();
511 });
512 await waitFor("second skill menu after folded paste", () => Boolean(document.querySelector(".slashmenu")));
513 await act(async () => {
514 richInput!.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
515 await flushTimers();
516 });
517
518 const expandButton = document.querySelectorAll<HTMLButtonElement>(".composer__pasted-actions button")[1];
519 if (!expandButton) throw new Error("pasted-block expand button did not render");
520 await act(async () => {
521 expandButton.click();
522 await flushTimers();
523 });
524 ok(document.querySelector(".composer__pasted-block") === null, "expanding a pasted block removes its folded control");
525
526 richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
527 const tokensAfterExpand = richInput?.querySelectorAll(".composer-invocation-token");
528 const secondToken = tokensAfterExpand?.[1];
529 if (!richInput || !secondToken) throw new Error("second rich invocation disappeared after pasted-block expansion");
530 eq(richTextBeforeInvocation(richInput, secondToken), `${expandedText} `, "expanding folded text shifts the following invocation to the end of the expanded content");
531 const beforeSecond = document.createRange();
532 beforeSecond.setStartBefore(secondToken);
533 beforeSecond.collapse(true);
534 document.getSelection()?.removeAllRanges();
535 document.getSelection()?.addRange(beforeSecond);
536 const removedText = Array.from({ length: 20 }, (_, index) => `removed line ${index + 1}`).join("\n");
537 await act(async () => {
538 dispatchPasteText(richInput!, removedText);
539 await flushTimers();
540 });
541 const removeButton = document.querySelectorAll<HTMLButtonElement>(".composer__pasted-actions button")[2];
542 if (!removeButton) throw new Error("pasted-block remove button did not render");
543 await act(async () => {
544 removeButton.click();
545 await flushTimers();
546 });
547
548 richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
549 const secondTokenAfterRemove = richInput?.querySelectorAll(".composer-invocation-token")[1];
550 if (!richInput || !secondTokenAfterRemove) throw new Error("second rich invocation disappeared after pasted-block removal");
551 eq(richTextBeforeInvocation(richInput, secondTokenAfterRemove), `${expandedText} `, "removing folded text restores the following invocation offset");
552
553 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
554 if (!sendButton) throw new Error("send button did not render after pasted-block replacement");
555 await act(async () => {
556 sendButton.click();
557 await flushTimers();
558 });
559 eq(calls.structured[0]?.invocations[1]?.offset, expandedText.length, "trimmed structured submission keeps the normalized following invocation offset");
560
561 await act(async () => {
562 root.unmount();
563 });
564 dom.window.close();
565 }
566
567 {
568 const dom = installDom();
569 const command: CommandInfo = {
570 name: "writing-plans",
571 description: "Write a plan",
572 kind: "skill",
573 };
574 mockApp({
575 Commands: async () => [command],
576 ListDirForTarget: async () => [],
577 SearchFileRefsForTarget: async () => [],
578 });
579 const { root, rerender } = await renderComposer();
580 await replaceComposerDraft(rerender, 4201, "/writing-plans");
581 await waitFor("skill menu for paste undo selection", () => Boolean(document.querySelector(".slashmenu")));
582 const initialTextarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
583 if (!initialTextarea) throw new Error("composer textarea did not render for paste undo selection");
584 await act(async () => {
585 initialTextarea.dispatchEvent(new window.KeyboardEvent("keydown", {
586 key: "Enter",
587 bubbles: true,
588 cancelable: true,
589 }));
590 await flushTimers();
591 });
592
593 let richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
594 let token = richInput?.querySelector<HTMLElement>(".composer-invocation-token");
595 const invocationId = token?.dataset.invocationId;
596 if (!richInput || !token || !invocationId) throw new Error("rich invocation did not render for paste undo selection");
597 eq(["spellcheck", "autocorrect", "autocapitalize"].map((name) => richInput.getAttribute(name)).join("/"), "false/off/off", "rich composer disables browser text assistance");
598 const afterToken = document.createRange();
599 afterToken.setStartAfter(token);
600 afterToken.collapse(true);
601 document.getSelection()?.removeAllRanges();
602 document.getSelection()?.addRange(afterToken);
603 await act(async () => {
604 dispatchPasteText(richInput!, "pasted");
605 await flushTimers();
606 });
607 richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
608 if (!richInput) throw new Error("rich input disappeared after paste");
609 eq(richComposerTaskText(richInput), "pasted", "paste after an invocation inserts on the token's right side");
610
611 await act(async () => {
612 richInput!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
613 await flushTimers();
614 });
615 let richMenuItems = Array.from(document.querySelectorAll<HTMLButtonElement>(".context-menu__item"));
616 eq(richMenuItems.length, 6, "rich composer exposes the shared edit context menu");
617 ok(richMenuItems[0]?.disabled === false, "rich composer context-menu undo is enabled after paste");
618 ok(richMenuItems[1]?.disabled === true, "rich composer context-menu redo is disabled before undo");
619 await act(async () => {
620 richMenuItems[0]?.click();
621 await flushTimers();
622 });
623 richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
624 if (!richInput) throw new Error("rich input disappeared after context-menu undo");
625 eq(richComposerTaskText(richInput), "", "rich composer context-menu undo removes the pasted text");
626
627 await act(async () => {
628 richInput!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
629 await flushTimers();
630 });
631 richMenuItems = Array.from(document.querySelectorAll<HTMLButtonElement>(".context-menu__item"));
632 ok(richMenuItems[1]?.disabled === false, "rich composer context-menu redo is enabled after undo");
633 await act(async () => {
634 richMenuItems[1]?.click();
635 await flushTimers();
636 });
637 richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
638 if (!richInput) throw new Error("rich input disappeared after context-menu redo");
639 eq(richComposerTaskText(richInput), "pasted", "rich composer context-menu redo restores the pasted text");
640
641 const undoPaste = new window.KeyboardEvent("keydown", {
642 key: "z",
643 ctrlKey: true,
644 bubbles: true,
645 cancelable: true,
646 });
647 await act(async () => {
648 richInput!.dispatchEvent(undoPaste);
649 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
650 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
651 await flushTimers();
652 });
653 eq(undoPaste.defaultPrevented, true, "Ctrl+Z restores the rich-composer paste transaction");
654
655 richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
656 token = richInput?.querySelector<HTMLElement>(".composer-invocation-token");
657 if (!richInput || !token) throw new Error("rich invocation disappeared after paste undo");
658 const restoredInvocation: ComposerInvocation = { id: invocationId, offset: 0, command };
659 const restoredSelection = selectionFromDom(
660 richInput,
661 new Map([[invocationId, restoredInvocation]]),
662 );
663 eq(
664 restoredSelection.ok ? restoredSelection.selection.afterInvocationId : undefined,
665 invocationId,
666 "paste undo restores the caret after the invocation token",
667 );
668
669 await act(async () => {
670 richInput!.dispatchEvent(new window.KeyboardEvent("keydown", {
671 key: "Backspace",
672 bubbles: true,
673 cancelable: true,
674 }));
675 await flushTimers();
676 });
677 ok(
678 document.querySelector(".composer-invocation-token") === null,
679 "Backspace after paste undo removes the invocation on the caret's left",
680 );
681
682 await act(async () => {
683 root.unmount();
684 });
685 dom.window.close();
686 }
687
688 {
689 const dom = installDom();
690 const { root, calls } = await renderComposer({
691 collaborationMode: "goal",
692 goal: "finish the migration",
693 });
694
695 const intentButton = document.querySelector(".composer-task-mode-trigger") as HTMLButtonElement | null;
696 if (!intentButton) throw new Error("active goal task method trigger did not render");
697 ok(intentButton.textContent?.includes("Goal") === true, "task method trigger exposes an active goal");
698
699 await act(async () => {
700 (document.querySelector(".composer-content-trigger") as HTMLButtonElement).click();
701 await flushTimers();
702 });
703
704 const goalActions = Array.from(document.querySelectorAll(".composer-intent-menu__stop")) as HTMLButtonElement[];
705 const stopGoal = goalActions.find((b) => b.textContent === "End goal");
706 if (!stopGoal) throw new Error("explicit end-goal action did not render");
707 await act(async () => {
708 stopGoal.click();
709 await flushTimers();
710 });
711 eq(calls.clearGoal, 1, "explicit stop action clears the active goal");
712 eq(calls.setCollaborationMode.length, 0, "stopping a goal does not race a second mode update");
713 await act(async () => {
714 intentButton.click();
715 await flushTimers();
716 });
717 eq(calls.clearGoal, 2, "dismissing an active goal chip uses the existing clear-goal action");
718
719 await act(async () => {
720 root.unmount();
721 });
722 dom.window.close();
723 }
724
725 {
726 const dom = installDom();
727 const { root, calls } = await renderComposer({
728 running: true,
729 collaborationMode: "goal",
730 goal: "finish the migration",
731 turnStartAt: Date.now(),
732 });
733
734 const stopButton = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
735 if (!stopButton) throw new Error("composer stop button did not render");
736
737 await act(async () => {
738 stopButton.click();
739 await flushTimers();
740 });
741
742 eq(calls.cancel, 1, "goal-mode stop cancels the running turn");
743 eq(calls.clearGoal, 1, "goal-mode stop clears the active goal");
744
745 await act(async () => {
746 root.unmount();
747 });
748 dom.window.close();
749 }
750
751 {
752 const dom = installDom();
753 mockApp({
754 SavePastedFileForTarget: async () => {
755 throw new Error("/Users/example/private.pdf: permission denied");
756 },
757 });
758 const { root, rerender } = await renderComposer();
759 await rerender({ insertRequest: { id: 2, text: "keep this draft", mode: "replace" } });
760
761 let textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
762 if (!textarea) throw new Error("composer textarea did not render");
763 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
764 if (!sendButton) throw new Error("composer send button did not render");
765
766 await act(async () => {
767 dispatchPasteFile(textarea, new File(["hello"], "notes.txt", { type: "text/plain" }));
768 await flushTimers();
769 });
770 await waitFor("pasted file failure toast", () => document.body.textContent?.includes("File attach failed") === true);
771
772 ok(document.body.textContent?.includes("File attach failed") === true, "SavePastedFile rejection shows a visible error");
773 eq(textarea.value, "keep this draft", "failed pasted file attach preserves composer text");
774 ok(sendButton.disabled === false, "failed pasted file attach clears the pending state");
775 ok(document.body.textContent?.includes("/Users/example") === false, "pasted file failure toast does not expose the local path");
776
777 await act(async () => {
778 root.unmount();
779 });
780 dom.window.close();
781 }
782
783 {
784 const dom = installDom();
785 mockApp({
786 SavePastedFileForTarget: async () => ".reasonix/attachments/notes.txt",
787 });
788 const { root } = await renderComposer();
789
790 const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
791 if (!textarea) throw new Error("composer textarea did not render");
792
793 await act(async () => {
794 dispatchPasteFile(textarea, new File(["hello"], "notes.txt", { type: "text/plain" }));
795 await flushTimers();
796 });
797 await waitFor("pasted file attachment", () => document.body.textContent?.includes("notes.txt") === true);
798
799 ok(document.body.textContent?.includes("notes.txt") === true, "successful pasted file attach still renders the attachment");
800
801 await act(async () => {
802 root.unmount();
803 });
804 dom.window.close();
805 }
806
807 {
808 const dom = installDom();
809 mockApp({
810 AttachDroppedForTarget: async () => {
811 throw new Error("/Users/example/secret.pdf: permission denied");
812 },
813 });
814 const { root, rerender } = await renderComposer();
815 await rerender({ insertRequest: { id: 3, text: "drop draft", mode: "replace" } });
816
817 const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
818 if (!textarea) throw new Error("composer textarea did not render");
819 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
820 if (!sendButton) throw new Error("composer send button did not render");
821 const wrapSecret = document.querySelector(".composer-wrap");
822 if (!wrapSecret) throw new Error("composer drop target did not render");
823
824 await act(async () => {
825 dispatchNativeFileDrop(wrapSecret, [new File([""], "/Users/example/secret.pdf")]);
826 await flushTimers();
827 });
828 await waitFor("dropped file failure toast", () => document.body.textContent?.includes("Dropped file attach failed") === true);
829
830 ok(document.body.textContent?.includes("Dropped file attach failed") === true, "AttachDropped rejection shows a visible error");
831 eq(textarea.value, "drop draft", "failed dropped file attach preserves composer text");
832 ok(sendButton.disabled === false, "failed dropped file attach clears the pending state");
833 ok(document.body.textContent?.includes("/Users/example") === false, "dropped file failure toast does not expose the local path");
834
835 await act(async () => {
836 root.unmount();
837 });
838 dom.window.close();
839 }
840
841 {
842 const dom = installDom();
843 mockApp({
844 AttachDroppedForTarget: async () => ({
845 kind: "attachment",
846 path: ".reasonix/attachments/report.pdf",
847 }),
848 });
849 const { root } = await renderComposer();
850 const wrapReport = document.querySelector(".composer-wrap");
851 if (!wrapReport) throw new Error("composer drop target did not render");
852
853 await act(async () => {
854 dispatchNativeFileDrop(wrapReport, [new File([""], "/Users/example/report.pdf")]);
855 await flushTimers();
856 });
857 await waitFor("dropped file attachment", () => document.body.textContent?.includes("report.pdf") === true);
858
859 ok(document.body.textContent?.includes("report.pdf") === true, "successful dropped file attach still renders the attachment");
860
861 await act(async () => {
862 root.unmount();
863 });
864 dom.window.close();
865 }
866
867 {
868 const dom = installDom();
869 mockApp({
870 AttachDroppedForTarget: async () => ({
871 kind: "workspace",
872 path: "__reasonix_external_folder/mock/Folder-With-Spaces",
873 isDir: true,
874 displayPath: "/Users/example/Folder With Spaces",
875 }),
876 });
877 const { root, calls, rerender } = await renderComposer();
878 await rerender({ insertRequest: { id: 4, text: "inspect", mode: "replace" } });
879 const wrapFolderwithspaces = document.querySelector(".composer-wrap");
880 if (!wrapFolderwithspaces) throw new Error("composer drop target did not render");
881
882 await act(async () => {
883 dispatchNativeFileDrop(wrapFolderwithspaces, [new File([""], "/Users/example/Folder With Spaces")]);
884 await flushTimers();
885 });
886 await waitFor("dropped external folder chip", () => document.body.textContent?.includes("Folder With Spaces/") === true);
887
888 ok(document.body.textContent?.includes("Folder With Spaces/") === true, "dropped external folder renders as a folder context chip");
889
890 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
891 if (!sendButton) throw new Error("composer send button did not render");
892 await act(async () => {
893 sendButton.click();
894 await flushTimers();
895 });
896
897 eq(calls.send.join(","), "inspect @/Users/example/Folder With Spaces/", "external folder display text uses the real folder path");
898 eq(calls.submit.join(","), "inspect @__reasonix_external_folder/mock/Folder-With-Spaces/", "external folder submit text uses the session ref token");
899
900 await act(async () => {
901 root.unmount();
902 });
903 dom.window.close();
904 }
905
906 {
907 const externalToken = "__reasonix_external_folder/mock/Folder-With-Spaces/src/outside.txt";
908 const externalDisplayPath = "/Users/example/Folder With Spaces/src/outside.txt";
909 const picked = composerPickFileEntry("ask @outside", "outside", "", {
910 name: "src/outside.txt",
911 path: externalToken,
912 isDir: false,
913 displayName: "Folder With Spaces/src/outside.txt",
914 displayPath: externalDisplayPath,
915 });
916 eq(picked.text, "ask ", "external search selection removes the token fragment from the draft");
917 eq(picked.workspaceRef?.path, externalToken, "external search selection submits the session ref token");
918 eq(picked.workspaceRef?.displayPath, externalDisplayPath, "external search selection keeps the real display path");
919
920 const localFile = composerPickFileEntry("ask @src/mai", "src/mai", "src/", { name: "main.go", isDir: false });
921 eq(localFile.text, "ask @src/main.go ", "local file selection still completes inline text");
922
923 const localDir = composerPickFileEntry("ask @sr", "sr", "", { name: "src", isDir: true });
924 eq(localDir.text, "ask @src/", "local dir selection still keeps the menu-open slash");
925
926 const trailingNewline = composerPickFileEntry("ask @src/mai\n", "src/mai", "src/", { name: "main.go", isDir: false });
927 eq(trailingNewline.text, "ask @src/main.go ", "file selection ignores an invisible trailing newline");
928 }
929
930 {
931 const dom = installDom();
932 const { root: dropNavRoot } = await renderComposer();
933 const composer = document.querySelector(".composer") as HTMLElement | null;
934 if (!composer) throw new Error("composer did not render");
935
936 const drop = nativeFileDropEvent();
937 await act(async () => {
938 composer.dispatchEvent(drop);
939 await flushTimers();
940 });
941 ok(drop.defaultPrevented, "native file drop prevents browser image navigation");
942
943 await act(async () => {
944 dropNavRoot.unmount();
945 });
946 dom.window.close();
947 }
948
949 {
950 const dom = installDom();
951 const { root: dropWrapRoot } = await renderComposer();
952 const wrap = document.querySelector(".composer-wrap") as HTMLElement | null;
953 if (!wrap) throw new Error("composer wrap did not render");
954
955 const drop = nativeFileDropEvent();
956 await act(async () => {
957 wrap.dispatchEvent(drop);
958 await flushTimers();
959 });
960 ok(drop.defaultPrevented, "outer native file drop target prevents browser image navigation");
961
962 await act(async () => {
963 dropWrapRoot.unmount();
964 });
965 dom.window.close();
966 }
967
968 {
969 const dom = installDom();
970 let rejectSubmit: (err: Error) => void = () => {};
971 const rejectedSubmit = new Promise<void>((_, reject) => {
972 rejectSubmit = reject;
973 });
974 rejectedSubmit.catch(() => {});
975 const { root, calls, rerender } = await renderComposer({
976 onSend: (displayText) => {
977 calls.send.push(displayText);
978 return rejectedSubmit;
979 },
980 });
981
982 const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
983 if (!textarea) throw new Error("composer textarea did not render");
984
985 await rerender({ insertRequest: { id: 2, text: "keep this draft", mode: "replace" } });
986 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
987 if (!sendButton) throw new Error("composer send button did not render");
988
989 await act(async () => {
990 sendButton.click();
991 rejectSubmit(new Error("workspace is still starting"));
992 await flushTimers();
993 });
994
995 eq(calls.send.join(","), "keep this draft", "rejected submit attempts the send once");
996 eq(textarea.value, "keep this draft", "rejected submit preserves the composer draft");
997
998 await act(async () => {
999 root.unmount();
1000 });
1001 dom.window.close();
1002 }
1003
1004 {
1005 const dom = installDom();
1006 const { root, calls, rerender } = await renderComposer({
1007 onSend: (displayText) => {
1008 calls.send.push(displayText);
1009 return Promise.resolve();
1010 },
1011 });
1012
1013 const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
1014 if (!textarea) throw new Error("composer textarea did not render");
1015
1016 await rerender({ insertRequest: { id: 3, text: "send this draft", mode: "replace" } });
1017 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
1018 if (!sendButton) throw new Error("composer send button did not render");
1019
1020 await act(async () => {
1021 sendButton.click();
1022 await flushTimers();
1023 });
1024
1025 eq(calls.send.join(","), "send this draft", "successful submit attempts the send once");
1026 eq(textarea.value, "", "successful submit clears the composer draft");
1027
1028 await act(async () => {
1029 root.unmount();
1030 });
1031 dom.window.close();
1032 }
1033
1034 {
1035 const dom = installDom();
1036 const { root, rerender } = await renderComposer({
1037 running: true,
1038 guidanceQueuePreviewItems: ["confirm the send lifecycle", "keep steer protocol unchanged", "add a hanging submit regression"],
1039 });
1040
1041 let guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
1042 eq(guidanceItems.length, 2, "running guidance preview shows a compact queue preview");
1043 ok(guidanceItems[0]?.textContent?.includes("confirm the send lifecycle") === true, "guidance preview shows the first seeded item");
1044 ok(guidanceItems[1]?.textContent?.includes("keep steer protocol unchanged") === true, "guidance preview shows the second seeded item");
1045 eq(document.querySelectorAll(".composer-guidance-item__guide").length, 2, "guidance preview exposes a guide action for each visible item");
1046 let guidanceMore = document.querySelector(".composer-guidance-more") as HTMLButtonElement | null;
1047 ok(guidanceMore?.textContent?.includes("1 more queued") === true, "guidance preview summarizes overflow items");
1048 eq(guidanceMore?.getAttribute("aria-expanded"), "false", "guidance overflow starts collapsed");
1049
1050 if (!guidanceMore) throw new Error("guidance overflow button did not render");
1051 await act(async () => {
1052 guidanceMore.click();
1053 await flushTimers();
1054 });
1055 guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
1056 eq(guidanceItems.length, 3, "guidance overflow expands the remaining queued items");
1057 ok(guidanceItems[2]?.textContent?.includes("add a hanging submit regression") === true, "expanded guidance preview shows the hidden item");
1058 guidanceMore = document.querySelector(".composer-guidance-more") as HTMLButtonElement | null;
1059 ok(guidanceMore?.textContent?.includes("Collapse") === true, "expanded guidance overflow can be collapsed");
1060 eq(guidanceMore?.getAttribute("aria-expanded"), "true", "guidance overflow reports expanded state");
1061
1062 if (!guidanceMore) throw new Error("guidance collapse button did not render");
1063 await act(async () => {
1064 guidanceMore.click();
1065 await flushTimers();
1066 });
1067 guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
1068 eq(guidanceItems.length, 2, "guidance overflow collapses back to the compact preview");
1069
1070 await rerender({ guidanceQueuePreviewItems: ["only the latest preview seed"] });
1071 guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
1072 eq(guidanceItems.length, 1, "guidance preview refreshes when the seed changes");
1073 ok(guidanceItems[0]?.textContent?.includes("only the latest preview seed") === true, "guidance preview renders the refreshed seed");
1074
1075 await rerender({ running: false });
1076 ok(document.querySelector(".composer-guidance-item") === null, "guidance preview clears when the mock turn stops");
1077
1078 await act(async () => {
1079 root.unmount();
1080 });
1081 dom.window.close();
1082 }
1083
1084 {
1085 const dom = installDom();
1086 let nextInboxID = 0, revision = 0;
1087 let queued: { id: string; preview: string; state: string; intent: string; byteSize: number; position: number }[] = [];
1088 const steerItemIDs: string[] = [], deletedItemIDs: string[] = [];
1089 mockApp({
1090 InboxSnapshot: async () => ({ revision, paused: false, recovered: false, items: queued,
1091 itemsCount: queued.length, bytes: 0, maxItems: 64, maxBytes: 64 * 1024 * 1024 }),
1092 EnqueueInboxFollowup: async (_tabID, display) => {
1093 const id = `durable-${++nextInboxID}`;
1094 queued.push({ id, preview: display, state: "queued", intent: "followup", byteSize: display.length, position: queued.length + 1 }); revision++;
1095 return { itemId: id, disposition: "queued_followup", position: queued.length, paused: false };
1096 },
1097 SteerInboxItem: async (_tabID, itemID) => {
1098 steerItemIDs.push(itemID);
1099 queued = queued.filter(item => item.id !== itemID); revision++;
1100 return { itemId: itemID, disposition: "steer_accepted", position: 1, paused: false };
1101 },
1102 DeleteInboxItem: async (_tabID, itemID) => {
1103 deletedItemIDs.push(itemID);
1104 queued = queued.filter(item => item.id !== itemID); revision++;
1105 },
1106 });
1107 const { root, calls, rerender } = await renderComposer({
1108 running: true, inboxSessionPath: "/repo/session.jsonl",
1109 onSend: (displayText, submitText) => {
1110 calls.send.push(displayText);
1111 calls.submit.push(submitText);
1112 },
1113 });
1114
1115 const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
1116 if (!textarea) throw new Error("composer textarea did not render");
1117 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
1118 if (!sendButton) throw new Error("running composer send button did not render");
1119
1120 eq(textarea.placeholder, "Running — type guidance, Enter adds it to the queue", "running composer explains queued guidance input");
1121 ok(sendButton.classList.contains("composer__btn--steer"), "running composer marks send button as steer");
1122 ok(sendButton.disabled === true, "running steer button stays disabled without input");
1123
1124 await rerender({ insertRequest: { id: 4, text: "keep the files small", mode: "replace" } });
1125 ok(sendButton.disabled === false, "running steer button enables after text input");
1126
1127 await act(async () => {
1128 sendButton.click();
1129 await flushTimers();
1130 });
1131
1132 eq(calls.send.join(","), "", "running composer queues guidance without sending it immediately");
1133 eq(textarea.value, "", "queued running guidance clears the composer draft");
1134 const guidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
1135 if (!guidanceItem) throw new Error("running guidance chip did not render");
1136 ok(guidanceItem.textContent?.includes("keep the files small") === true, "running guidance chip shows queued text");
1137 ok(document.querySelector(".composer-guidance-head")?.textContent?.includes("Queued guidance 1") === true, "running guidance shelf shows queued count");
1138
1139 const guideButton = guidanceItem.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
1140 if (!guideButton) throw new Error("running guidance guide button did not render");
1141 await act(async () => {
1142 guideButton.click();
1143 await flushTimers();
1144 });
1145 eq(steerItemIDs.join(","), "durable-1", "guide attempts admission with the existing durable item ID");
1146 eq(calls.send.join(","), "", "guide never opens a duplicate frontend submit");
1147 ok(document.querySelector(".composer-guidance-item") === null, "accepted durable steer clears after backend admission");
1148
1149 await rerender({ insertRequest: { id: 5, text: "prefer the smaller diff", mode: "replace" } });
1150 await act(async () => {
1151 sendButton.click();
1152 await flushTimers();
1153 });
1154 const dismissibleGuidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
1155 if (!dismissibleGuidanceItem) throw new Error("dismissible guidance chip did not render");
1156 const dismissButton = Array.from(dismissibleGuidanceItem.querySelectorAll<HTMLButtonElement>(".composer-guidance-item__action")).at(-1) ?? null;
1157 if (!dismissButton) throw new Error("running guidance dismiss button did not render");
1158 await act(async () => {
1159 dismissButton.click();
1160 await flushTimers();
1161 });
1162 eq(deletedItemIDs.join(","), "durable-2", "dismiss deletes the durable backend item before clearing the shelf");
1163 ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip can be dismissed");
1164
1165 await rerender({ insertRequest: { id: 6, text: "prefer the smaller diff", mode: "replace" } });
1166 await act(async () => {
1167 sendButton.click();
1168 await flushTimers();
1169 });
1170 ok(document.querySelector(".composer-guidance-item") !== null, "running guidance chip renders again after another queued item");
1171
1172 queued = []; revision++; // The controller consumed and acknowledged the item.
1173 await rerender({ guidanceConsumedKey: "s1", guidanceConsumedText: "prefer the smaller diff" });
1174 ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip clears when steer is consumed");
1175
1176 await rerender({ insertRequest: { id: 7, text: "then stop showing the chip", mode: "replace" } });
1177 await act(async () => {
1178 sendButton.click();
1179 await flushTimers();
1180 });
1181 ok(document.querySelector(".composer-guidance-item") !== null, "running guidance chip renders before turn stop");
1182
1183 await rerender({ running: false });
1184 ok(document.querySelector(".composer-guidance-item") !== null, "pending guidance remains after the current turn stops");
1185
1186 await act(async () => {
1187 root.unmount();
1188 });
1189 dom.window.close();
1190 }
1191
1192 {
1193 const dom = installDom();
1194 const steerItemIDs: string[] = [];
1195 let queued = false, revision = 0;
1196 mockApp({
1197 InboxSnapshot: async () => ({ revision, paused: false, recovered: false,
1198 items: queued ? [{ id: "durable-activating", preview: "steer while activating", state: "queued", intent: "followup", byteSize: 22, position: 1 }] : [], itemsCount: queued ? 1 : 0,
1199 bytes: 0, maxItems: 64, maxBytes: 64 * 1024 * 1024,
1200 }),
1201 EnqueueInboxFollowup: async () => { queued = true; revision++; return { itemId: "durable-activating", disposition: "queued_followup", position: 1, paused: false }; },
1202 SteerInboxItem: async (_tabID, itemID) => {
1203 steerItemIDs.push(itemID);
1204 queued = false; revision++;
1205 return { itemId: itemID, disposition: "steer_accepted", position: 1, paused: false };
1206 },
1207 });
1208 const { root, calls, rerender } = await renderComposer({
1209 running: true, inboxSessionPath: "/repo/activating.jsonl",
1210 submitDisabled: true,
1211 onSend: (displayText) => {
1212 calls.send.push(displayText);
1213 },
1214 });
1215
1216 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
1217 if (!sendButton) throw new Error("running composer send button did not render");
1218
1219 await rerender({ insertRequest: { id: 7, text: "steer while activating", mode: "replace" } });
1220 ok(sendButton.disabled === false, "running guidance queue ignores controller submitDisabled");
1221
1222 await act(async () => {
1223 sendButton.click();
1224 await flushTimers();
1225 });
1226
1227 eq(calls.send.join(","), "", "running guidance queues while controllerReady is false");
1228 const guideButton = document.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
1229 if (!guideButton) throw new Error("running guidance guide button did not render");
1230 await act(async () => {
1231 guideButton.click();
1232 await flushTimers();
1233 });
1234
1235 eq(steerItemIDs.join(","), "durable-activating", "queued durable item can be guided while controllerReady is false");
1236 eq(calls.send.join(","), "", "controllerReady gap does not create a duplicate submit");
1237
1238 await act(async () => {
1239 root.unmount();
1240 });
1241 dom.window.close();
1242 }
1243
1244 {
1245 // A backend steer rejection means the turn crossed its final admission
1246 // boundary. The same durable item becomes a follow-up; the Controller, not
1247 // the browser, owns its later FIFO dispatch and ack.
1248 const dom = installDom();
1249 let steerAttempts = 0;
1250 let backendQueued = false, backendRevision = 0;
1251 mockApp({
1252 InboxSnapshot: async () => ({
1253 revision: backendRevision,
1254 paused: false,
1255 recovered: false,
1256 items: backendQueued ? [{
1257 id: "durable-late", intent: "followup", state: "queued", preview: "preserve this late guidance", byteSize: 27, position: 1,
1258 }] : [],
1259 itemsCount: backendQueued ? 1 : 0,
1260 bytes: backendQueued ? 27 : 0,
1261 maxItems: 64,
1262 maxBytes: 64 * 1024 * 1024,
1263 }),
1264 EnqueueInboxFollowup: async () => { backendQueued = true; backendRevision++;
1265 return { itemId: "durable-late", disposition: "queued_followup", position: 1, paused: false }; },
1266 SteerInboxItem: async (_tabID, itemID) => {
1267 steerAttempts += 1;
1268 return { itemId: itemID, disposition: "queued_followup", position: 1, paused: false };
1269 },
1270 });
1271 const { root, calls, rerender } = await renderComposer({
1272 running: true, inboxSessionPath: "/repo/late.jsonl",
1273 onSend: (displayText, submitText) => {
1274 calls.send.push(displayText);
1275 calls.submit.push(submitText);
1276 return Promise.resolve();
1277 },
1278 });
1279
1280 await rerender({ insertRequest: { id: 71, text: "preserve this late guidance", mode: "replace" } });
1281 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
1282 if (!sendButton) throw new Error("running composer send button did not render");
1283 await act(async () => {
1284 sendButton.click();
1285 await flushTimers();
1286 });
1287 const guidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
1288 const guideButton = guidanceItem?.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
1289 if (!guideButton) throw new Error("late guidance guide button did not render");
1290 await act(async () => {
1291 guideButton.click();
1292 await flushTimers();
1293 });
1294
1295 eq(steerAttempts, 1, "late guidance attempts one strict steer admission");
1296 eq(calls.send.length, 0, "rejected steer does not open a provider turn");
1297 ok(document.querySelector(".composer-guidance-item") !== null, "rejected steer remains queued");
1298
1299 backendQueued = false; backendRevision++; // Controller dispatched and durably acked after TurnDone.
1300 await rerender({ running: false });
1301 await waitFor("acked durable follow-up removed from shelf", () => document.querySelector(".composer-guidance-item") === null);
1302 eq(calls.send.length, 0, "late durable follow-up is never resubmitted by the frontend");
1303
1304 await act(async () => {
1305 root.unmount();
1306 });
1307 dom.window.close();
1308 }
1309
1310 {
1311 // A message queued while a turn is running remains durable. Natural
1312 // completion is dispatched by the Controller, so the frontend must only
1313 // reconcile the eventual durable ack and never call onSend itself.
1314 const dom = installDom();
1315 let backendQueued = false, backendRevision = 0;
1316 mockApp({
1317 InboxSnapshot: async () => ({
1318 revision: backendRevision,
1319 paused: false,
1320 recovered: false,
1321 items: backendQueued ? [{
1322 id: "durable-natural", intent: "followup", state: "queued", preview: "keep going after this finishes", byteSize: 30, position: 1,
1323 }] : [],
1324 itemsCount: backendQueued ? 1 : 0,
1325 bytes: backendQueued ? 30 : 0,
1326 maxItems: 64,
1327 maxBytes: 64 * 1024 * 1024,
1328 }),
1329 EnqueueInboxFollowup: async () => { backendQueued = true; backendRevision++;
1330 return { itemId: "durable-natural", disposition: "queued_followup", position: 1, paused: false }; },
1331 });
1332 const { root, calls, rerender } = await renderComposer({
1333 running: true, inboxSessionPath: "/repo/natural.jsonl",
1334 onSend: (displayText, submitText) => {
1335 calls.send.push(displayText);
1336 calls.submit.push(submitText);
1337 return Promise.resolve();
1338 },
1339 });
1340
1341 await rerender({ insertRequest: { id: 8, text: "keep going after this finishes", mode: "replace" } });
1342 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
1343 if (!sendButton) throw new Error("running composer send button did not render");
1344
1345 await act(async () => {
1346 sendButton.click();
1347 await flushTimers();
1348 });
1349
1350 eq(calls.send.length, 0, "queuing while running does not send immediately");
1351 ok(document.querySelector(".composer-guidance-item") !== null, "queued message shows in the guidance shelf");
1352
1353 backendQueued = false; backendRevision++; // Controller completed and acked the next FIFO turn.
1354 await rerender({ running: false });
1355 await waitFor("durable guidance ack reconciled", () => document.querySelector(".composer-guidance-item") === null);
1356
1357 eq(calls.send.length, 0, "natural completion never triggers a duplicate frontend submit");
1358 eq(calls.submit.length, 0, "durable body remains backend-owned through dispatch");
1359
1360 await act(async () => {
1361 root.unmount();
1362 });
1363 dom.window.close();
1364 }
1365
1366 {
1367 // Stop passes only this Composer's durable IDs to the controller, then folds
1368 // their visible text back into the draft instead of leaving a hidden turn
1369 // that could run after cancellation.
1370 const dom = installDom();
1371 let cancelledItemIDs: string[] = [];
1372 let queued = false;
1373 mockApp({
1374 InboxSnapshot: async () => ({
1375 revision: queued ? 1 : 0, paused: false, recovered: false,
1376 items: queued ? [{ id: "durable-cancel", preview: "keep cancelled follow-up", state: "queued", intent: "followup", source: "desktop", byteSize: 24, position: 1 }] : [], itemsCount: queued ? 1 : 0,
1377 bytes: 0, maxItems: 64, maxBytes: 64 * 1024 * 1024,
1378 }),
1379 EnqueueInboxFollowup: async () => { queued = true; return { itemId: "durable-cancel", disposition: "queued_followup", position: 1, paused: false }; },
1380 });
1381 const { root, rerender } = await renderComposer({
1382 running: true, inboxSessionPath: "/repo/cancel.jsonl",
1383 onCancel: async (itemIDs = []) => {
1384 cancelledItemIDs = itemIDs;
1385 return { discardedItemIds: [...itemIDs] };
1386 },
1387 });
1388
1389 await rerender({ insertRequest: { id: 81, text: "keep cancelled follow-up", mode: "replace" } });
1390 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
1391 if (!sendButton) throw new Error("running composer send button did not render");
1392 await act(async () => {
1393 sendButton.click();
1394 await flushTimers();
1395 });
1396
1397 const stopButton = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
1398 if (!stopButton) throw new Error("running composer stop button did not render");
1399 await act(async () => {
1400 stopButton.click();
1401 await flushTimers();
1402 });
1403 eq(cancelledItemIDs.join(","), "durable-cancel", "stop scopes backend discard to the Composer-owned durable ID");
1404 ok(document.querySelector(".composer-guidance-item") === null, "stop clears the local shelf after handing off durable IDs");
1405 const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
1406 ok(textarea?.value.includes("keep cancelled follow-up") === true, "stop restores queued guidance to the editable draft");
1407
1408 await act(async () => {
1409 root.unmount();
1410 });
1411 dom.window.close();
1412 }
1413
1414 {
1415 // Controller activation state is irrelevant to browser-side dispatch now:
1416 // the durable item remains visible until an authoritative consume/ack event.
1417 const dom = installDom();
1418 let backendReadyQueued = false, backendRevision = 0;
1419 mockApp({
1420 InboxSnapshot: async () => ({
1421 revision: backendRevision,
1422 paused: false,
1423 recovered: false,
1424 items: backendReadyQueued ? [{
1425 id: "durable-ready", intent: "followup", state: "queued", preview: "keep going once ready", byteSize: 21, position: 1,
1426 }] : [],
1427 itemsCount: backendReadyQueued ? 1 : 0,
1428 bytes: backendReadyQueued ? 21 : 0,
1429 maxItems: 64,
1430 maxBytes: 64 * 1024 * 1024,
1431 }),
1432 EnqueueInboxFollowup: async () => { backendReadyQueued = true; backendRevision++;
1433 return { itemId: "durable-ready", disposition: "queued_followup", position: 1, paused: false }; },
1434 });
1435 const { root, calls, rerender } = await renderComposer({
1436 running: true, inboxSessionPath: "/repo/ready.jsonl",
1437 submitDisabled: false,
1438 onSend: (displayText, submitText) => {
1439 calls.send.push(displayText);
1440 calls.submit.push(submitText);
1441 return Promise.resolve();
1442 },
1443 });
1444
1445 await rerender({ insertRequest: { id: 9, text: "keep going once ready", mode: "replace" } });
1446 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
1447 if (!sendButton) throw new Error("running composer send button did not render");
1448
1449 await act(async () => {
1450 sendButton.click();
1451 await flushTimers();
1452 });
1453 ok(document.querySelector(".composer-guidance-item") !== null, "queued message shows in the guidance shelf");
1454
1455 // Turn ends, but the controller is still not ready to accept a submit —
1456 // matches a rebuild/hydration window right after the turn finishes.
1457 await rerender({ running: false, submitDisabled: true });
1458 await act(async () => {
1459 await flushTimers();
1460 });
1461 eq(calls.send.length, 0, "auto-send does not fire while the controller is still activating");
1462 ok(document.querySelector(".composer-guidance-item") !== null, "queued message stays on the shelf while not ready");
1463
1464 await rerender({ submitDisabled: false });
1465 await act(async () => {
1466 await flushTimers();
1467 });
1468 eq(calls.send.length, 0, "controller readiness never triggers frontend auto-submit");
1469 ok(document.querySelector(".composer-guidance-item") !== null, "durable item remains until backend consume/ack");
1470
1471 backendReadyQueued = false; backendRevision++;
1472 await rerender({ guidanceConsumedKey: "durable-ready-acked", guidanceConsumedText: "keep going once ready" });
1473 ok(document.querySelector(".composer-guidance-item") === null, "backend consume event clears the acknowledged item");
1474
1475 await act(async () => {
1476 root.unmount();
1477 });
1478 dom.window.close();
1479 }
1480
1481 {
1482 const dom = installDom();
1483 let listDirCalls = 0;
1484 const listDirTabs: string[] = [];
1485 mockApp({
1486 ListDirForTarget: async (target) => {
1487 listDirTabs.push(target.tabId);
1488 listDirCalls += 1;
1489 return listDirCalls === 1 ? [fileEntry("cached-dir.txt")] : [fileEntry("fresh-dir.txt")];
1490 },
1491 SearchFileRefsForTarget: async () => [],
1492 });
1493 const { root, rerender } = await renderComposer();
1494
1495 await replaceComposerDraft(rerender, 101, "@");
1496 await waitFor("initial @ directory load", () => listDirCalls === 1);
1497
1498 await replaceComposerDraft(rerender, 102, "");
1499 await replaceComposerDraft(rerender, 103, "@");
1500 await waitFor("@ directory revalidation call", () => listDirCalls === 2);
1501
1502 eq(listDirCalls, 2, "@ directory cache hit still revalidates ListDir");
1503 ok(listDirTabs.every((tabId) => tabId === "tab-a"), "@ directory requests stay scoped to the composer tab");
1504
1505 await act(async () => {
1506 root.unmount();
1507 });
1508 dom.window.close();
1509 }
1510
1511 {
1512 const dom = installDom();
1513 let listDirCalls = 0;
1514 mockApp({
1515 ListDirForTarget: async () => {
1516 listDirCalls += 1;
1517 return listDirCalls === 1 ? [fileEntry("manual-refresh-stale.txt")] : [fileEntry("manual-refresh-fresh.txt")];
1518 },
1519 SearchFileRefsForTarget: async () => [],
1520 });
1521 const { root, rerender } = await renderComposer({ fileRefRefreshKey: "0" });
1522
1523 await replaceComposerDraft(rerender, 201, "@");
1524 await waitFor("initial @ directory load before refresh key", () => listDirCalls === 1);
1525
1526 await rerender({ fileRefRefreshKey: "1" });
1527 await waitFor("@ directory reload after refresh key", () => listDirCalls === 2);
1528
1529 eq(listDirCalls, 2, "fileRefRefreshKey refreshes @ directory cache while the menu is open");
1530
1531 await act(async () => {
1532 root.unmount();
1533 });
1534 dom.window.close();
1535 }
1536
1537 {
1538 const dom = installDom();
1539 const realDateNow = Date.now;
1540 let now = 1000;
1541 let searchCalls = 0;
1542 Date.now = () => now;
1543 mockApp({
1544 ListDirForTarget: async () => [],
1545 SearchFileRefsForTarget: async () => {
1546 searchCalls += 1;
1547 return searchCalls === 1 ? [fileEntry("alpha-old.ts")] : [fileEntry("alpha-new.ts")];
1548 },
1549 });
1550 const { root, rerender } = await renderComposer();
1551
1552 try {
1553 await replaceComposerDraft(rerender, 301, "@alpha");
1554 await waitFor("initial @ search request", () => searchCalls === 1);
1555 eq(searchCalls, 1, "@ search fetches the first query");
1556
1557 await replaceComposerDraft(rerender, 302, "");
1558 now = 2000;
1559 await replaceComposerDraft(rerender, 303, "@alpha");
1560 await act(async () => {
1561 await flushTimers();
1562 });
1563 eq(searchCalls, 1, "@ search cache is reused inside the TTL");
1564
1565 await replaceComposerDraft(rerender, 304, "");
1566 now = 7001;
1567 await replaceComposerDraft(rerender, 305, "@alpha");
1568 await waitFor("expired @ search cache refresh", () => searchCalls === 2);
1569 eq(searchCalls, 2, "@ search cache revalidates after the TTL");
1570 } finally {
1571 Date.now = realDateNow;
1572 }
1573
1574 await act(async () => {
1575 root.unmount();
1576 });
1577 dom.window.close();
1578 }
1579
1580 {
1581 const dom = installDom();
1582 let staleListDirResolve: ((entries: DirEntry[]) => void) | undefined;
1583 let thirdListDirResolve: ((entries: DirEntry[]) => void) | undefined;
1584 let listDirCalls = 0;
1585 mockApp({
1586 ListDirForTarget: async () => {
1587 listDirCalls += 1;
1588 if (listDirCalls === 1) {
1589 return new Promise<DirEntry[]>((resolve) => {
1590 staleListDirResolve = resolve;
1591 });
1592 }
1593 if (listDirCalls === 2) return [fileEntry("cache-live.txt")];
1594 return new Promise<DirEntry[]>((resolve) => {
1595 thirdListDirResolve = resolve;
1596 });
1597 },
1598 SearchFileRefsForTarget: async () => [],
1599 });
1600 const { root, rerender } = await renderComposer({ fileRefRefreshKey: "0" });
1601
1602 const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
1603 if (!textarea) throw new Error("composer textarea did not render");
1604
1605 await replaceComposerDraft(rerender, 401, "@cache");
1606 await waitFor("initial stale @ directory request", () => listDirCalls === 1);
1607
1608 await rerender({ fileRefRefreshKey: "1" });
1609 await waitFor("fresh @ directory request after refresh key", () => listDirCalls === 2);
1610 await act(async () => {
1611 await flushTimers();
1612 });
1613
1614 staleListDirResolve?.([fileEntry("cache-stale.txt")]);
1615 await act(async () => {
1616 await flushTimers();
1617 });
1618
1619 await replaceComposerDraft(rerender, 402, "");
1620 await replaceComposerDraft(rerender, 403, "@cache");
1621 await waitFor("second fresh @ directory request", () => listDirCalls === 3);
1622 await act(async () => {
1623 textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
1624 await flushTimers();
1625 });
1626 eq(textarea.value, "@cache-live.txt ", "stale @ directory request cannot repopulate cache after refresh");
1627 thirdListDirResolve?.([fileEntry("cache-later.txt")]);
1628
1629 await act(async () => {
1630 root.unmount();
1631 });
1632 dom.window.close();
1633 }
1634
1635 {
1636 const dom = installDom();
1637 const pending: Array<(entries: DirEntry[]) => void> = [];
1638 mockApp({
1639 ListDirForTarget: async () => [],
1640 SearchFileRefsForTarget: async () => new Promise<DirEntry[]>((resolve) => pending.push(resolve)),
1641 });
1642 const { root, rerender } = await renderComposer({ workspaceScopeKey: "session-a" });
1643 const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
1644 if (!textarea) throw new Error("composer textarea did not render");
1645
1646 await replaceComposerDraft(rerender, 501, "@current");
1647 await waitFor("initial composer session scope request", () => pending.length === 1);
1648 await rerender({ workspaceScopeKey: "session-b" });
1649 await waitFor("next composer session scope request", () => pending.length === 2);
1650 await rerender({ workspaceScopeKey: "session-a" });
1651 await waitFor("revisited composer session scope request", () => pending.length === 3);
1652
1653 await act(async () => {
1654 pending[2]([fileEntry("current-session-a.txt")]);
1655 await flushTimers();
1656 });
1657
1658 await act(async () => {
1659 pending[0]([fileEntry("stale-initial-a.txt")]);
1660 pending[1]([fileEntry("stale-session-b.txt")]);
1661 await flushTimers();
1662 textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
1663 await flushTimers();
1664 });
1665
1666 eq(textarea.value, "@current-session-a.txt ", "same-tab A→B→A keeps the current composer file-ref search cache");
1667
1668 await act(async () => {
1669 root.unmount();
1670 });
1671 dom.window.close();
1672 }
1673
1674 {
1675 const dom = installDom();
1676 mockApp({
1677 Commands: async () => [
1678 { name: "writing-plans", description: "Write a plan", kind: "skill", color: "amber" },
1679 { name: "review", description: "Review the result", kind: "skill" },
1680 { name: "mcp", description: "Manage MCP servers", kind: "builtin", group: "integrations" },
1681 ],
1682 ListDirForTarget: async () => [],
1683 SearchFileRefsForTarget: async () => [],
1684 });
1685 const { root, calls, rerender } = await renderComposer();
1686
1687 const initialText = "请用/writing-plans检查";
1688 await replaceComposerDraft(rerender, 1900, initialText);
1689 let textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
1690 if (!textarea) throw new Error("composer textarea did not render for middle slash completion");
1691 const slashCaret = "请用/writ".length;
1692 await act(async () => {
1693 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
1694 await flushTimers();
1695 });
1696 await act(async () => {
1697 textarea!.focus();
1698 textarea!.setSelectionRange(slashCaret, slashCaret);
1699 textarea!.dispatchEvent(new window.Event("select", { bubbles: true }));
1700 textarea!.dispatchEvent(new window.KeyboardEvent("keyup", { key: "/", bubbles: true }));
1701 await flushTimers();
1702 });
1703 await waitFor("middle slash command menu", () => Boolean(document.querySelector(".slashmenu")));
1704
1705 await act(async () => {
1706 textarea!.dispatchEvent(new window.KeyboardEvent("keydown", {
1707 key: "Enter",
1708 bubbles: true,
1709 cancelable: true,
1710 }));
1711 await flushTimers();
1712 });
1713
1714 let richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
1715 let tokens = richInput?.querySelectorAll<HTMLElement>(".composer-invocation-token");
1716 if (!richInput || !tokens?.[0]) throw new Error("middle skill invocation did not render");
1717 eq(richComposerTaskText(richInput), "请用检查", "first middle skill selection preserves surrounding text");
1718 eq(
1719 document.querySelector<HTMLElement>(".invocation-display--composer")?.style.getPropertyValue("--invocation-color"),
1720 "#d59a2f",
1721 "middle skill selection keeps its configured color",
1722 );
1723
1724 const afterFirstToken = document.createRange();
1725 afterFirstToken.setStartAfter(tokens[0]);
1726 afterFirstToken.collapse(true);
1727 document.getSelection()?.removeAllRanges();
1728 document.getSelection()?.addRange(afterFirstToken);
1729 await act(async () => {
1730 dispatchPasteText(richInput!, "更多");
1731 await flushTimers();
1732 });
1733 richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
1734 if (!richInput) throw new Error("rich input disappeared after middle-skill paste");
1735 eq(richComposerTaskText(richInput), "请用更多检查", "paste after a middle skill preserves the entity and suffix");
1736 eq(
1737 richInput.querySelectorAll(".composer-invocation-token").length,
1738 1,
1739 "paste after a middle skill keeps the selected entity",
1740 );
1741
1742 await appendRichComposerInput(richInput, " /review");
1743 richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
1744 if (!richInput) throw new Error("rich input disappeared before the second skill selection");
1745 const queryAtEnd = document.createRange();
1746 queryAtEnd.selectNodeContents(richInput);
1747 queryAtEnd.collapse(false);
1748 document.getSelection()?.removeAllRanges();
1749 document.getSelection()?.addRange(queryAtEnd);
1750 await act(async () => {
1751 richInput!.dispatchEvent(new window.KeyboardEvent("keyup", { key: "w", bubbles: true }));
1752 await flushTimers();
1753 });
1754 await waitFor("second skill menu at the end", () => Boolean(document.querySelector(".slashmenu")));
1755 await act(async () => {
1756 richInput!.dispatchEvent(new window.KeyboardEvent("keydown", {
1757 key: "Enter",
1758 bubbles: true,
1759 cancelable: true,
1760 }));
1761 await flushTimers();
1762 });
1763
1764 richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
1765 tokens = richInput?.querySelectorAll<HTMLElement>(".composer-invocation-token");
1766 eq(tokens?.length, 2, "a second skill can be inserted after existing text and an entity");
1767 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
1768 if (!sendButton) throw new Error("send button did not render for middle skill submission");
1769 await act(async () => {
1770 sendButton.click();
1771 await flushTimers();
1772 });
1773 eq(calls.structured[0]?.input, "请用更多检查", "middle skills submit the surrounding task text without slash tokens");
1774 eq(
1775 calls.structured[0]?.invocations.map((item) => item.name).join(","),
1776 "writing-plans,review",
1777 "multiple middle/end skills submit in visual order",
1778 );
1779 eq(calls.structured[0]?.invocations[0]?.offset, 2, "first middle skill keeps its text offset");
1780
1781 await act(async () => {
1782 root.unmount();
1783 });
1784 dom.window.close();
1785 }
1786
1787 {
1788 const dom = installDom();
1789 let commandsCalls = 0;
1790 const slashArgInputs: string[] = [];
1791 let availableCommands: CommandInfo[] = [
1792 { name: "mcp", description: "Manage MCP servers", kind: "builtin", group: "integrations" },
1793 { name: "explore", description: "Investigate the codebase", kind: "subagent" },
1794 { name: "superpowers:writing-plans", description: "Write a plan", kind: "skill", plugin: "superpowers" },
1795 { name: "toolbox:writing-plans", description: "Write another plan", kind: "skill", plugin: "toolbox" },
1796 { name: "superpowers:brainstorming", description: "Explore an idea", kind: "skill", plugin: "superpowers" },
1797 ];
1798 mockApp({
1799 Commands: async () => {
1800 commandsCalls += 1;
1801 return availableCommands;
1802 },
1803 ListDirForTarget: async () => [],
1804 SearchFileRefsForTarget: async () => [],
1805 SlashArgs: async (input) => {
1806 slashArgInputs.push(input);
1807 return input === "/mcp "
1808 ? { items: [{ label: "show", insert: "show", hint: "Show an MCP server", descend: false }], from: 5 }
1809 : { items: [], from: 0 };
1810 },
1811 });
1812 const { root, calls, rerender } = await renderComposer({ workspaceScopeKey: "runtime-0" });
1813
1814 await waitFor("plugin commands loaded", () => commandsCalls > 0);
1815 await replaceComposerDraft(rerender, 1999, "/\n");
1816 await waitFor("slash menu before trailing newline", () => Boolean(document.querySelector(".slashmenu")));
1817 ok(document.querySelector(".slashmenu") !== null, "slash menu ignores an invisible trailing newline");
1818
1819 await replaceComposerDraft(rerender, 1998, "@\n");
1820 await waitFor("file menu before trailing newline", () => Boolean(document.querySelector(".slashmenu")));
1821 ok(document.querySelector(".slashmenu") !== null, "file menu ignores an invisible trailing newline");
1822
1823 await replaceComposerDraft(rerender, 1997, "/mcp \n");
1824 await act(async () => {
1825 await flushTimers(150);
1826 });
1827 await waitFor("slash argument menu before trailing newline", () => document.querySelector(".slashmenu")?.textContent?.includes("show") === true);
1828 ok(slashArgInputs.includes("/mcp "), "slash argument completion removes an invisible trailing newline before lookup");
1829
1830 await replaceComposerDraft(rerender, 2000, "/m");
1831 await waitFor("initial skill command menu", () => Boolean(document.querySelector(".slashmenu")));
1832 ok(
1833 document.querySelector(".slashmenu")?.textContent?.includes("/my-formatter") === false,
1834 "new subagent command is absent before runtime refresh",
1835 );
1836
1837 availableCommands = [
1838 ...availableCommands,
1839 { name: "my-formatter", description: "Formats code the way I like it", kind: "subagent", color: "amber" },
1840 ];
1841 const initialCommandsCalls = commandsCalls;
1842 await rerender({ workspaceScopeKey: "runtime-1" });
1843 await waitFor("commands refreshed after runtime rebuild", () => commandsCalls > initialCommandsCalls);
1844 ok(commandsCalls > initialCommandsCalls, "runtime rebuild refetches subagent slash commands");
1845
1846 await replaceComposerDraft(rerender, 2001, "/writing-plans");
1847 await waitFor("qualified plugin skill menu", () => Boolean(document.querySelector(".slashmenu")));
1848
1849 const menuSizer = document.querySelector<HTMLElement>(".slashmenu__sizer");
1850 eq(menuSizer?.style.height, "94px", "short skill query keeps one group heading and both matching plugin names");
1851 let textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
1852 if (!textarea) throw new Error("composer textarea did not render");
1853 await act(async () => {
1854 textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
1855 await flushTimers();
1856 });
1857 ok(document.querySelector(".composer__rich-input") !== null, "selecting a plugin skill switches to the rich task input");
1858 ok(document.querySelector(".invocation-display--composer")?.textContent?.includes("Writing Plans") === true, "selected skill renders as composer context");
1859 ok(document.querySelector(".invocation-display--composer")?.textContent?.includes("superpowers") === true, "selected plugin skill keeps its source visible");
1860 ok(document.querySelector(".composer__rich-input .composer-invocation-token") !== null, "selected skill is an inline task entity");
1861 ok(document.querySelector(".composer__rich-input .composer-invocation-caret-anchor")?.textContent === "\u00A0", "selected skill keeps a caret anchor after the inline entity");
1862
1863 const richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
1864 if (!richInput) throw new Error("rich composer did not render");
1865 const richContent = document.querySelector(".composer__content") as HTMLDivElement | null;
1866 if (!richContent) throw new Error("rich composer content area did not render");
1867 richInput.blur();
1868 await act(async () => {
1869 richContent.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
1870 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
1871 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
1872 await flushTimers();
1873 });
1874 ok(document.activeElement === richInput, "clicking blank rich-composer space focuses the editable task input");
1875
1876 const invocationToken = richInput.querySelector(".composer-invocation-token");
1877 if (!invocationToken) throw new Error("rich invocation token did not render");
1878 const richRange = document.createRange();
1879 richRange.setStartAfter(invocationToken);
1880 richRange.collapse(true);
1881 document.getSelection()?.removeAllRanges();
1882 document.getSelection()?.addRange(richRange);
1883 await act(async () => {
1884 richInput.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Backspace", bubbles: true, cancelable: true }));
1885 await flushTimers();
1886 });
1887 ok(document.querySelector(".invocation-display--composer") === null, "Backspace removes a selected skill from an empty task input");
1888 await act(async () => {
1889 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
1890 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
1891 await flushTimers();
1892 });
1893 const textareaAfterEntityRemoval = document.querySelector("textarea") as HTMLTextAreaElement | null;
1894 if (!textareaAfterEntityRemoval) throw new Error("textarea did not return after removing the last entity");
1895 ok(
1896 document.activeElement === textareaAfterEntityRemoval,
1897 "removing the last entity hands focus to the textarea that replaces the rich input",
1898 );
1899 const undoEntityRemoval = new window.KeyboardEvent("keydown", {
1900 key: "z",
1901 ctrlKey: true,
1902 bubbles: true,
1903 cancelable: true,
1904 });
1905 await act(async () => {
1906 textareaAfterEntityRemoval.dispatchEvent(undoEntityRemoval);
1907 await flushTimers();
1908 });
1909 eq(undoEntityRemoval.defaultPrevented, true, "Ctrl+Z restores a token removed by the rich composer");
1910 ok(
1911 document.querySelector(".invocation-display--composer") !== null,
1912 "undoing the programmatic Backspace restores the selected skill",
1913 );
1914 const restoredRichInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
1915 if (!restoredRichInput) throw new Error("rich composer did not return after undoing token removal");
1916 const redoEntityRemoval = new window.KeyboardEvent("keydown", {
1917 key: "Z",
1918 ctrlKey: true,
1919 shiftKey: true,
1920 bubbles: true,
1921 cancelable: true,
1922 });
1923 await act(async () => {
1924 restoredRichInput.dispatchEvent(redoEntityRemoval);
1925 await flushTimers();
1926 });
1927 eq(redoEntityRemoval.defaultPrevented, true, "Ctrl+Shift+Z redoes rich token removal");
1928 ok(
1929 document.querySelector(".invocation-display--composer") === null,
1930 "redoing the programmatic Backspace removes the selected skill again",
1931 );
1932
1933 await replaceComposerDraft(rerender, 2002, "/writing-plans");
1934 await waitFor("plain composer after replacing the restored skill", () => Boolean(document.querySelector("textarea")));
1935 await waitFor("skill menu after removal", () => Boolean(document.querySelector(".slashmenu")));
1936 textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
1937 if (!textarea) throw new Error("composer textarea did not return after removing the skill");
1938 await act(async () => {
1939 textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
1940 await flushTimers();
1941 });
1942
1943 let sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
1944 if (!sendButton) throw new Error("composer send button did not render for skill-only invocation");
1945 ok(sendButton.disabled === false, "inline skill-only invocation enables submit");
1946 await act(async () => {
1947 sendButton?.click();
1948 await flushTimers();
1949 });
1950 eq(calls.submit[0], "/superpowers:writing-plans", "inline skill-only submission retains display metadata");
1951 eq(calls.structured[0]?.input, "", "inline skill-only submission sends an empty explicit task");
1952 eq(calls.structured[0]?.display, "/superpowers:writing-plans", "inline skill-only submission preserves reloadable invocation display metadata");
1953 eq(calls.structured[0]?.invocations[0]?.name, "superpowers:writing-plans", "inline skill-only submission sends a structured skill entity");
1954
1955 await replaceComposerDraft(rerender, 20021, "/writing-plans");
1956 await waitFor("skill menu for task submission", () => Boolean(document.querySelector(".slashmenu")));
1957 textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
1958 if (!textarea) throw new Error("composer textarea did not return after skill-only send");
1959 await act(async () => {
1960 textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
1961 await flushTimers();
1962 });
1963
1964 await replaceComposerDraft(rerender, 2003, "Draft the release plan");
1965 sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
1966 if (!sendButton) throw new Error("composer send button did not render");
1967 await act(async () => {
1968 sendButton.click();
1969 await flushTimers();
1970 });
1971 eq(calls.send[1], "Draft the release plan", "selected skill keeps the visible transcript text clean");
1972 eq(calls.submit[1], "/superpowers:writing-plans Draft the release plan", "selected skill preserves invocation display metadata");
1973 eq(calls.structured[1]?.input, "Draft the release plan", "selected skill sends task text separately from invocation metadata");
1974 ok(document.querySelector(".invocation-display--composer") === null, "selected skill clears after send");
1975
1976 await replaceComposerDraft(rerender, 2004, "/mcp");
1977 await waitFor("builtin command menu", () => Boolean(document.querySelector(".slashmenu")));
1978 textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
1979 if (!textarea) throw new Error("composer textarea did not render for management command");
1980 await act(async () => {
1981 textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
1982 await flushTimers();
1983 });
1984 eq(textarea.value, "/mcp ", "management commands keep the existing inline argument flow");
1985 ok(document.querySelector(".invocation-display--composer") === null, "management commands do not become selected abilities");
1986
1987 await replaceComposerDraft(rerender, 2005, "/my-formatter");
1988 await waitFor("colored subagent command menu", () => Boolean(document.querySelector(".slashmenu")));
1989 textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
1990 if (!textarea) throw new Error("composer textarea did not render for colored subagent");
1991 await act(async () => {
1992 textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
1993 await flushTimers();
1994 });
1995 ok(document.querySelector<HTMLElement>(".invocation-display--composer")?.style.getPropertyValue("--invocation-color") === "#d59a2f", "selected custom subagent uses its configured color");
1996 sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
1997 ok(sendButton?.disabled === true, "subagent-only invocation remains blocked until a task is entered");
1998
1999 const subagentInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
2000 if (!subagentInput) throw new Error("rich composer did not render for colored subagent");
2001 await appendRichComposerInput(subagentInput, "Inspect ");
2002 eq(richComposerTaskText(subagentInput), "Inspect ", "rich composer does not duplicate ordinary browser input");
2003 await appendRichComposerInput(subagentInput, "仓库做了什么?", true);
2004 eq(richComposerTaskText(subagentInput), "Inspect 仓库做了什么?", "rich composer does not duplicate committed IME input");
2005
2006 await replaceComposerDraft(rerender, 2006, "");
2007 const resetSubagentInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
2008 if (!resetSubagentInput) throw new Error("rich composer disappeared after external text replacement");
2009 eq(richComposerTaskText(resetSubagentInput), "", "external replacement can restore the initially rendered rich-composer text");
2010 await appendRichComposerInput(resetSubagentInput, "Inspect ");
2011 await appendRichComposerInput(resetSubagentInput, "仓库做了什么?", true);
2012
2013 sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
2014 if (!sendButton) throw new Error("composer send button did not render after subagent task input");
2015 await act(async () => {
2016 sendButton.click();
2017 await flushTimers();
2018 });
2019 eq(calls.send[2], "Inspect 仓库做了什么?", "subagent task is sent exactly once after rich input");
2020 eq(calls.structured[2]?.input, "Inspect 仓库做了什么?", "structured subagent input contains one task copy");
2021
2022 await act(async () => {
2023 root.unmount();
2024 });
2025 dom.window.close();
2026 }
2027
2028 {
2029 const dom = installDom();
2030 const rootEl = document.getElementById("root");
2031 if (!rootEl) throw new Error("missing root");
2032 const root = createRoot(rootEl);
2033 await act(async () => {
2034 root.render(
2035 <LocaleProvider>
2036 <UserMessage
2037 id="h1"
2038 text="Draft the release plan"
2039 submitText="/superpowers:writing-plans Draft the release plan"
2040 />
2041 </LocaleProvider>,
2042 );
2043 await flushTimers();
2044 });
2045 ok(document.querySelector(".invocation-display--message")?.textContent?.includes("Writing Plans") === true, "restored history renders the selected skill header");
2046 ok(document.querySelector(".invocation-display--message")?.textContent?.includes("superpowers") === true, "restored history retains plugin source from the qualified command");
2047 ok(document.querySelector(".msg__rich-text")?.textContent?.endsWith("Draft the release plan") === true, "history message keeps slash syntax out of the task body");
2048
2049 await act(async () => {
2050 root.render(
2051 <LocaleProvider>
2052 <InvocationMetadataContext.Provider value={{ "my-formatter": { kind: "subagent", color: "amber" } }}>
2053 <UserMessage
2054 id="h2"
2055 text="Format this file"
2056 submitText={"以下是用户引用的历史会话上下文:\n\n[会话:Earlier]\n...\n\n---\n\n当前用户问题:\n/my-formatter Format this file"}
2057 />
2058 </InvocationMetadataContext.Provider>
2059 </LocaleProvider>,
2060 );
2061 await flushTimers();
2062 });
2063 ok(document.querySelector(".invocation-display--message")?.textContent?.includes("My Formatter") === true, "history and trash previews recover selected abilities after referenced-session context");
2064 ok(document.querySelector(".invocation-display--subagent") !== null, "restored custom subagents keep their command type styling");
2065 ok(document.querySelector<HTMLElement>(".invocation-display--subagent")?.style.getPropertyValue("--invocation-color") === "#d59a2f", "restored custom subagents keep their configured color");
2066
2067 await act(async () => {
2068 root.render(
2069 <LocaleProvider>
2070 <UserMessage
2071 id="h3"
2072 text={"Compare these commands\n/other-command"}
2073 submitText={"/reasonix-develop Compare these commands\n/other-command"}
2074 />
2075 </LocaleProvider>,
2076 );
2077 await flushTimers();
2078 });
2079 ok(document.querySelector(".invocation-display--message")?.textContent?.includes("Reasonix Develop") === true, "history recovery ignores slash-prefixed lines inside the task body");
2080
2081 await act(async () => {
2082 root.render(
2083 <LocaleProvider>
2084 <UserMessage
2085 id="h4"
2086 text={"Compare these commands\n/other-command"}
2087 submitText={"以下是用户引用的历史会话上下文:\n\n[会话:Earlier]\n...\n\n---\n\n当前用户问题:\nCompare these commands\n/other-command"}
2088 />
2089 </LocaleProvider>,
2090 );
2091 await flushTimers();
2092 });
2093 ok(document.querySelector(".invocation-display--message") === null, "ordinary referenced-session text does not turn task slash lines into a skill header");
2094
2095 await act(async () => root.unmount());
2096 dom.window.close();
2097 }
2098
2099 {
2100 const dom = installDom();
2101 let savedFiles = 0;
2102 mockApp({
2103 Commands: async () => [{ name: "skill", description: "Manage skills", kind: "builtin" }],
2104 ListDirForTarget: async () => [fileEntry("README.md")],
2105 SearchFileRefsForTarget: async () => [],
2106 ListSessions: async () => [{ path: "/sessions/recent.jsonl", title: "Recent session", current: false }],
2107 SavePastedFileForTarget: async () => {
2108 savedFiles += 1;
2109 return ".reasonix/attachments/notes.txt";
2110 },
2111 });
2112 const { root, rerender } = await renderComposer();
2113 const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
2114 if (!textarea) throw new Error("composer textarea did not render");
2115
2116 await replaceComposerDraft(rerender, 3000, "Follow up #recent\n");
2117 await waitFor("typed hash recent-session picker", () => Boolean(document.querySelector(".slashmenu__search")));
2118 const typedSessionSearch = document.querySelector(".slashmenu__search") as HTMLInputElement | null;
2119 eq(typedSessionSearch?.value, "recent", "typing # opens recent sessions and carries the query across an invisible trailing newline");
2120 ok(document.activeElement !== typedSessionSearch, "the typed # flow leaves focus in the composer instead of the panel search box");
2121 await act(async () => {
2122 typedSessionSearch?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
2123 await flushTimers();
2124 });
2125 eq(textarea.value, "Follow up #recent\n", "Escape closes the typed recent-session picker and keeps the literal # text");
2126 ok(!document.querySelector(".slashmenu__search"), "Escape dismisses the typed recent-session panel until the query changes");
2127
2128 await replaceComposerDraft(rerender, 3005, "issue#6310");
2129 await act(async () => {
2130 await flushTimers();
2131 });
2132 ok(!document.querySelector(".slashmenu__search"), "an embedded hash remains ordinary composer text");
2133
2134 await replaceComposerDraft(rerender, 3006, "#\n");
2135 await waitFor("typed hash picker before session selection", () => Boolean(document.querySelector(".slashmenu__search")));
2136 const typedSessionButton = Array.from(document.querySelectorAll<HTMLButtonElement>(".slashmenu button"))
2137 .find((button) => button.textContent?.includes("Recent session"));
2138 if (!typedSessionButton) throw new Error("typed recent-session option did not render");
2139 await act(async () => {
2140 typedSessionButton.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
2141 await flushTimers();
2142 });
2143 eq(textarea.value, "", "selecting a typed recent-session reference removes the # token");
2144 ok(document.querySelector(".composer-context__item--session")?.textContent?.includes("Recent session") === true, "selecting a typed recent-session reference adds its context card");
2145 const removeTypedSession = document.querySelector<HTMLButtonElement>(".composer-context__item--session button");
2146 await act(async () => {
2147 removeTypedSession?.click();
2148 await flushTimers();
2149 });
2150
2151 const contentTrigger = document.querySelector(".composer-content-trigger") as HTMLButtonElement | null;
2152 if (!contentTrigger) throw new Error("content menu trigger did not render");
2153 await act(async () => {
2154 contentTrigger.click();
2155 await flushTimers();
2156 });
2157 ok(Boolean(document.querySelector(".composer-content-menu")), "plus trigger opens the add-content menu");
2158 const initialContentItems = Array.from(document.querySelectorAll<HTMLButtonElement>(".composer-content-menu__item"));
2159 eq(initialContentItems.length, 4, "add-content menu exposes four focused actions");
2160 const contentItemIcons = initialContentItems.map((item) => item.querySelector("svg")?.getAttribute("class") ?? "");
2161 ok(contentItemIcons[0]?.includes("lucide-file-plus"), "attachment action uses the file attachment icon");
2162 ok(contentItemIcons[1]?.includes("lucide-at-sign"), "workspace action uses the mention icon");
2163 ok(contentItemIcons[2]?.includes("lucide-hash"), "recent-session action uses the history reference icon");
2164 eq(initialContentItems[3]?.querySelector(".composer-content-menu__trigger-icon")?.textContent, "/", "command action uses the literal slash trigger icon");
2165 ok(!document.querySelector(".composer-content-menu__divider"), "add-content actions remain one unified group without a divider");
2166 ok(initialContentItems.every((item) => !item.querySelector("kbd")), "add-content actions do not duplicate their trigger icons on the right");
2167
2168 const attachmentButton = initialContentItems[0];
2169 const fileInput = document.querySelector(".composer-content-file-input") as HTMLInputElement | null;
2170 if (!attachmentButton || !fileInput) throw new Error("attachment picker controls did not render");
2171 await act(async () => {
2172 attachmentButton.click();
2173 Object.defineProperty(fileInput, "files", { configurable: true, value: [new File(["notes"], "notes.txt", { type: "text/plain" })] });
2174 fileInput.dispatchEvent(new Event("change", { bubbles: true }));
2175 await flushTimers();
2176 });
2177 await waitFor("attachment chosen from add-content menu", () => savedFiles === 1);
2178 eq(savedFiles, 1, "attachment action reuses the existing file-save path");
2179
2180 await replaceComposerDraft(rerender, 3001, "@");
2181 await waitFor("workspace menu before plus toggle", () => Boolean(document.querySelector(".slashmenu")));
2182 await act(async () => {
2183 contentTrigger.click();
2184 await flushTimers();
2185 });
2186 ok(!document.querySelector(".slashmenu"), "opening add-content closes the active suggestion panel");
2187 ok(Boolean(document.querySelector(".composer-content-menu")), "add-content remains the only open composer surface");
2188
2189 const sessionButton = document.querySelectorAll<HTMLButtonElement>(".composer-content-menu__item")[2];
2190 if (!sessionButton) throw new Error("recent-session action did not render");
2191 await act(async () => {
2192 sessionButton.click();
2193 await flushTimers();
2194 });
2195 await waitFor("direct recent-session picker", () => Boolean(document.querySelector(".slashmenu__search")));
2196 eq(textarea.value, "@ #", "recent-session action inserts # at the remembered caret");
2197 ok(!document.querySelector(".composer-content-menu"), "recent-session picker replaces the add-content menu");
2198 const sessionSearch = document.querySelector(".slashmenu__search") as HTMLInputElement | null;
2199 if (!sessionSearch) throw new Error("recent-session search did not render");
2200 await act(async () => {
2201 sessionSearch.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
2202 await flushTimers();
2203 });
2204 eq(textarea.value, "@ #", "Escape closes the direct recent-session picker and keeps the inserted # trigger");
2205
2206 await replaceComposerDraft(rerender, 3002, "");
2207 await act(async () => {
2208 contentTrigger.click();
2209 await flushTimers();
2210 });
2211 const commandButton = document.querySelectorAll<HTMLButtonElement>(".composer-content-menu__item")[3];
2212 if (!commandButton) throw new Error("command action did not render");
2213 await act(async () => {
2214 commandButton.click();
2215 await flushTimers();
2216 });
2217 eq(textarea.value, "/", "command action inserts / at the caret");
2218 await waitFor("slash menu from add-content action", () => Boolean(document.querySelector(".slashmenu")));
2219
2220 await replaceComposerDraft(rerender, 3003, "existing text");
2221 await act(async () => {
2222 contentTrigger.click();
2223 await flushTimers();
2224 });
2225 const disabledCommandButton = document.querySelectorAll<HTMLButtonElement>(".composer-content-menu__item")[3];
2226 if (!disabledCommandButton) throw new Error("command action did not render for non-empty input");
2227 ok(disabledCommandButton.disabled, "command action is disabled while the composer has text");
2228 await act(async () => {
2229 disabledCommandButton.click();
2230 await flushTimers();
2231 });
2232 eq(textarea.value, "existing text", "disabled command action does not insert / into existing text");
2233
2234 await rerender({ running: true });
2235 await waitFor("content menu closes when a run starts", () => !document.querySelector(".composer-content-menu"));
2236 await rerender({ running: false });
2237 ok(!document.querySelector(".composer-content-menu"), "content menu stays closed after the run ends");
2238
2239 await replaceComposerDraft(rerender, 3004, "");
2240 await act(async () => {
2241 contentTrigger.click();
2242 await flushTimers();
2243 });
2244 const runningSessionButton = document.querySelectorAll<HTMLButtonElement>(".composer-content-menu__item")[2];
2245 if (!runningSessionButton) throw new Error("recent-session action did not render before running");
2246 await act(async () => {
2247 runningSessionButton.click();
2248 await flushTimers();
2249 });
2250 await waitFor("recent-session picker before running", () => Boolean(document.querySelector(".slashmenu__search")));
2251 await rerender({ running: true });
2252 await waitFor("recent-session picker closes when a run starts", () => !document.querySelector(".slashmenu__search"));
2253 await rerender({ running: false });
2254 ok(!document.querySelector(".slashmenu__search"), "recent-session picker stays closed after the run ends");
2255
2256 await act(async () => {
2257 root.unmount();
2258 });
2259 dom.window.close();
2260 }
2261
2262 {
2263 // Entity-only input must remain structured while queued during a run.
2264 const dom = installDom();
2265 let queued: { submit?: string; invocations?: StructuredInvocationSubmit["invocations"] } = {};
2266 let queuedDisplay = "";
2267 mockApp({
2268 Commands: async () => [
2269 { name: "superpowers:writing-plans", description: "Write a plan", kind: "skill", plugin: "superpowers" },
2270 ],
2271 ListDirForTarget: async () => [],
2272 SearchFileRefsForTarget: async () => [],
2273 InboxSnapshot: async () => ({
2274 revision: queuedDisplay ? 1 : 0, paused: false, recovered: false,
2275 items: queuedDisplay ? [{ id: "durable-entity", preview: queuedDisplay, state: "queued", intent: "followup", source: "desktop", byteSize: queuedDisplay.length, position: 1 }] : [], itemsCount: queuedDisplay ? 1 : 0,
2276 bytes: 0, maxItems: 64, maxBytes: 64 * 1024 * 1024,
2277 }),
2278 EnqueueInboxFollowupWithInvocations: async (_tabId, display, input, invocations) => {
2279 queued = { submit: input, invocations };
2280 queuedDisplay = display;
2281 return { itemId: "durable-entity", disposition: "queued_followup", position: 1, paused: false };
2282 },
2283 });
2284 const { root, calls, rerender } = await renderComposer({ inboxSessionPath: "/repo/entity.jsonl" });
2285 await replaceComposerDraft(rerender, 4000, "/writing-plans");
2286 await waitFor("skill menu for the running-queue entity", () => Boolean(document.querySelector(".slashmenu")));
2287 const queueTextarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
2288 if (!queueTextarea) throw new Error("composer textarea did not render");
2289 await act(async () => {
2290 queueTextarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
2291 await flushTimers();
2292 });
2293 const queueRichInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
2294 if (!queueRichInput) throw new Error("rich composer did not render for the running-queue entity");
2295 await rerender({ running: true });
2296 await act(async () => {
2297 queueRichInput.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
2298 await flushTimers();
2299 });
2300 eq(calls.send.length, 0, "an entity-only submit while running queues instead of sending");
2301 ok(
2302 document.querySelector(".composer-guidance-item__text")?.textContent?.includes("/superpowers:writing-plans") === true,
2303 "the queued guidance shows the entity's slash form instead of dropping it silently",
2304 );
2305 eq(`${queued.invocations?.[0]?.name}:${queued.invocations?.[0]?.kind}`, "superpowers:writing-plans:skill", "queued guidance preserves the selected skill invocation");
2306 eq(queued.submit, "", "entity-only guidance keeps an empty explicit task instead of degrading to slash text");
2307 ok(document.querySelector(".composer__rich-input") === null, "queueing an entity-only submit clears the draft");
2308 await act(async () => {
2309 root.unmount();
2310 });
2311 dom.window.close();
2312 }
2313
2314 {
2315 // While an IME is composing, the rich input must neither resync the model
2316 // nor restore the DOM selection (removeAllRanges cancels or commits an
2317 // in-progress composition); compositionend performs the one authoritative
2318 // sync.
2319 const dom = installDom();
2320 mockApp({
2321 Commands: async () => [
2322 { name: "superpowers:writing-plans", description: "Write a plan", kind: "skill", plugin: "superpowers" },
2323 ],
2324 ListDirForTarget: async () => [],
2325 SearchFileRefsForTarget: async () => [],
2326 });
2327 const { root, calls, rerender } = await renderComposer();
2328 await replaceComposerDraft(rerender, 4100, "/writing-plans");
2329 await waitFor("skill menu for the composition guard", () => Boolean(document.querySelector(".slashmenu")));
2330 const compositionTextarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
2331 if (!compositionTextarea) throw new Error("composer textarea did not render");
2332 await act(async () => {
2333 compositionTextarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
2334 await flushTimers();
2335 });
2336 const compositionRichInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
2337 if (!compositionRichInput) throw new Error("rich composer did not render for the composition guard");
2338
2339 // Drain the entity-pick flow's pending animation frames (imperative caret
2340 // restore) so the spy below counts only composition-window work.
2341 await act(async () => {
2342 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
2343 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
2344 await flushTimers();
2345 });
2346 const domSelection = document.getSelection();
2347 if (!domSelection) throw new Error("document selection unavailable");
2348 let selectionStomps = 0;
2349 const originalRemoveAllRanges = domSelection.removeAllRanges.bind(domSelection);
2350 (domSelection as { removeAllRanges: () => void }).removeAllRanges = () => {
2351 selectionStomps += 1;
2352 originalRemoveAllRanges();
2353 };
2354 await act(async () => {
2355 compositionRichInput.dispatchEvent(new window.Event("compositionstart", { bubbles: true }));
2356 compositionRichInput.appendChild(document.createTextNode("拼"));
2357 compositionRichInput.dispatchEvent(new window.Event("input", { bubbles: true }));
2358 await flushTimers();
2359 });
2360 eq(selectionStomps, 0, "composition input neither resyncs the model nor restores the selection");
2361 await act(async () => {
2362 compositionRichInput.dispatchEvent(new window.Event("compositionend", { bubbles: true }));
2363 await flushTimers();
2364 });
2365 (domSelection as { removeAllRanges: () => void }).removeAllRanges = originalRemoveAllRanges;
2366
2367 const compositionSendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
2368 if (!compositionSendButton) throw new Error("send button did not render after composition");
2369 await act(async () => {
2370 compositionSendButton.click();
2371 await flushTimers();
2372 });
2373 eq(calls.structured[0]?.input, "拼", "compositionend commits the composed text to the model exactly once");
2374
2375 await act(async () => {
2376 root.unmount();
2377 });
2378 dom.window.close();
2379 }
2380
2381 {
2382 const dom = installDom();
2383 mockApp({
2384 Commands: async () => [
2385 { name: "review", description: "Review the current task", kind: "skill" },
2386 ],
2387 ListDirForTarget: async () => [],
2388 SearchFileRefsForTarget: async () => [],
2389 });
2390 const sessionA = "session:project:/repo:topic-a:session-a";
2391 const sessionB = "session:project:/repo:topic-b:session-b";
2392 const { root, rerender } = await renderComposer({ sessionKey: sessionA });
2393
2394 await replaceComposerDraft(rerender, 5000, "x/review");
2395 await waitFor("session A slash menu", () => Boolean(document.querySelector(".slashmenu")));
2396
2397 await rerender({ sessionKey: sessionB, insertRequest: null });
2398 await replaceComposerDraft(rerender, 5001, "b");
2399 const sessionBInput = document.querySelector("textarea") as HTMLTextAreaElement | null;
2400 if (!sessionBInput) throw new Error("session B textarea did not render");
2401 await act(async () => {
2402 sessionBInput.focus();
2403 sessionBInput.setSelectionRange(1, 1);
2404 sessionBInput.dispatchEvent(new window.KeyboardEvent("keyup", { key: "b", bubbles: true }));
2405 await flushTimers();
2406 });
2407
2408 await rerender({ sessionKey: sessionA, insertRequest: null });
2409 eq(
2410 (document.querySelector("textarea") as HTMLTextAreaElement | null)?.value,
2411 "x/review",
2412 "switching back restores session A slash draft",
2413 );
2414 await waitFor(
2415 "restored session A slash menu",
2416 () => Boolean(document.querySelector(".slashmenu")),
2417 );
2418 ok(
2419 document.querySelector(".slashmenu") !== null,
2420 "restoring a draft recomputes slash completion from its end caret",
2421 );
2422
2423 await act(async () => {
2424 root.unmount();
2425 });
2426 dom.window.close();
2427 }
2428
2429 {
2430 const dom = installDom();
2431 mockApp({
2432 Commands: async () => [
2433 { name: "review", description: "Review the current task", kind: "skill" },
2434 ],
2435 ListDirForTarget: async () => [],
2436 SearchFileRefsForTarget: async () => [],
2437 });
2438 const sessionA = "session:project:/repo:rich-topic-a:rich-session-a";
2439 const sessionB = "session:project:/repo:rich-topic-b:rich-session-b";
2440 const realRequestAnimationFrame = globalThis.requestAnimationFrame;
2441 const queuedComposerFrames: FrameRequestCallback[] = [];
2442 globalThis.requestAnimationFrame = (callback) => {
2443 queuedComposerFrames.push(callback);
2444 return queuedComposerFrames.length;
2445 };
2446 const { root, rerender } = await renderComposer({ sessionKey: sessionA });
2447
2448 await replaceComposerDraft(rerender, 6000, "/review");
2449 await waitFor("session A first skill menu", () => Boolean(document.querySelector(".slashmenu")));
2450 const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
2451 if (!textarea) throw new Error("session A textarea did not render");
2452 await act(async () => {
2453 textarea.dispatchEvent(new window.KeyboardEvent("keydown", {
2454 key: "Enter",
2455 bubbles: true,
2456 cancelable: true,
2457 }));
2458 await flushTimers();
2459 });
2460
2461 let richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
2462 if (!richInput) throw new Error("session A rich input did not render");
2463 await appendRichComposerInput(richInput, " /review");
2464 richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
2465 if (!richInput) throw new Error("session A rich input disappeared");
2466 const queryAtEnd = document.createRange();
2467 queryAtEnd.selectNodeContents(richInput);
2468 queryAtEnd.collapse(false);
2469 document.getSelection()?.removeAllRanges();
2470 document.getSelection()?.addRange(queryAtEnd);
2471 await act(async () => {
2472 richInput.dispatchEvent(new window.KeyboardEvent("keyup", { key: "w", bubbles: true }));
2473 await flushTimers();
2474 });
2475 await waitFor("session A second skill menu", () => Boolean(document.querySelector(".slashmenu")));
2476
2477 await rerender({ sessionKey: sessionB, insertRequest: null });
2478 await replaceComposerDraft(rerender, 6001, "b");
2479 await rerender({ sessionKey: sessionA, insertRequest: null });
2480 await act(async () => {
2481 let frameTime = 0;
2482 while (queuedComposerFrames.length > 0) {
2483 queuedComposerFrames.shift()?.(frameTime += 16);
2484 }
2485 await flushTimers();
2486 });
2487 richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
2488 if (!richInput) throw new Error("session A rich input was not restored");
2489 eq(richComposerTaskText(richInput), " /review", "switching back restores the rich invocation draft");
2490 await waitFor(
2491 "restored session A rich slash menu",
2492 () => Boolean(document.querySelector(".slashmenu")),
2493 );
2494 ok(
2495 document.querySelector(".slashmenu") !== null,
2496 "restoring a rich invocation draft recomputes slash completion from its end caret",
2497 );
2498
2499 await act(async () => {
2500 richInput.dispatchEvent(new window.KeyboardEvent("keydown", {
2501 key: "Enter",
2502 bubbles: true,
2503 cancelable: true,
2504 }));
2505 await flushTimers();
2506 });
2507 richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
2508 eq(
2509 richInput?.querySelectorAll(".composer-invocation-token").length,
2510 2,
2511 "the restored rich slash query can select a second skill",
2512 );
2513 eq(richInput ? richComposerTaskText(richInput) : "", " ", "selecting the restored query replaces its slash token");
2514
2515 await rerender({ sessionKey: sessionB, insertRequest: null });
2516 eq(
2517 (document.querySelector("textarea") as HTMLTextAreaElement | null)?.value,
2518 "b",
2519 "switching away again preserves the other session draft",
2520 );
2521
2522 await act(async () => {
2523 root.unmount();
2524 });
2525 globalThis.requestAnimationFrame = realRequestAnimationFrame;
2526 dom.window.close();
2527 }
2528
2529 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
2530 if (failed > 0) process.exit(1);
2531
2531 lines Plain Text