返回 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;
1087 const steerItemIDs: string[] = [];
1088 const deletedItemIDs: string[] = [];
1089 mockApp({
1090 InboxSnapshot: async () => ({
1091 revision: 0, paused: false, recovered: false, items: [], itemsCount: 0,
1092 bytes: 0, maxItems: 64, maxBytes: 64 * 1024 * 1024,
1093 }),
1094 EnqueueInboxFollowup: async () => ({
1095 itemId: `durable-${++nextInboxID}`, disposition: "queued_followup", position: nextInboxID, paused: false,
1096 }),
1097 SteerInboxItem: async (_tabID, itemID) => {
1098 steerItemIDs.push(itemID);
1099 return { itemId: itemID, disposition: "steer_accepted", position: 1, paused: false };
1100 },
1101 DeleteInboxItem: async (_tabID, itemID) => {
1102 deletedItemIDs.push(itemID);
1103 },
1104 });
1105 const { root, calls, rerender } = await renderComposer({
1106 running: true,
1107 onSend: (displayText, submitText) => {
1108 calls.send.push(displayText);
1109 calls.submit.push(submitText);
1110 },
1111 });
1112
1113 const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
1114 if (!textarea) throw new Error("composer textarea did not render");
1115 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
1116 if (!sendButton) throw new Error("running composer send button did not render");
1117
1118 eq(textarea.placeholder, "Running — type guidance, Enter adds it to the queue", "running composer explains queued guidance input");
1119 ok(sendButton.classList.contains("composer__btn--steer"), "running composer marks send button as steer");
1120 ok(sendButton.disabled === true, "running steer button stays disabled without input");
1121
1122 await rerender({ insertRequest: { id: 4, text: "keep the files small", mode: "replace" } });
1123 ok(sendButton.disabled === false, "running steer button enables after text input");
1124
1125 await act(async () => {
1126 sendButton.click();
1127 await flushTimers();
1128 });
1129
1130 eq(calls.send.join(","), "", "running composer queues guidance without sending it immediately");
1131 eq(textarea.value, "", "queued running guidance clears the composer draft");
1132 const guidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
1133 if (!guidanceItem) throw new Error("running guidance chip did not render");
1134 ok(guidanceItem.textContent?.includes("keep the files small") === true, "running guidance chip shows queued text");
1135 ok(document.querySelector(".composer-guidance-head")?.textContent?.includes("Queued guidance 1") === true, "running guidance shelf shows queued count");
1136
1137 const guideButton = guidanceItem.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
1138 if (!guideButton) throw new Error("running guidance guide button did not render");
1139 await act(async () => {
1140 guideButton.click();
1141 await flushTimers();
1142 });
1143 eq(steerItemIDs.join(","), "durable-1", "guide attempts admission with the existing durable item ID");
1144 eq(calls.send.join(","), "", "guide never opens a duplicate frontend submit");
1145 ok(document.querySelector(".composer-guidance-item") === null, "accepted durable steer clears after backend admission");
1146
1147 await rerender({ insertRequest: { id: 5, text: "prefer the smaller diff", mode: "replace" } });
1148 await act(async () => {
1149 sendButton.click();
1150 await flushTimers();
1151 });
1152 const dismissibleGuidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
1153 if (!dismissibleGuidanceItem) throw new Error("dismissible guidance chip did not render");
1154 const dismissButton = Array.from(dismissibleGuidanceItem.querySelectorAll<HTMLButtonElement>(".composer-guidance-item__action")).at(-1) ?? null;
1155 if (!dismissButton) throw new Error("running guidance dismiss button did not render");
1156 await act(async () => {
1157 dismissButton.click();
1158 await flushTimers();
1159 });
1160 eq(deletedItemIDs.join(","), "durable-2", "dismiss deletes the durable backend item before clearing the shelf");
1161 ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip can be dismissed");
1162
1163 await rerender({ insertRequest: { id: 6, text: "prefer the smaller diff", mode: "replace" } });
1164 await act(async () => {
1165 sendButton.click();
1166 await flushTimers();
1167 });
1168 ok(document.querySelector(".composer-guidance-item") !== null, "running guidance chip renders again after another queued item");
1169
1170 await rerender({ guidanceConsumedKey: "s1", guidanceConsumedText: "prefer the smaller diff" });
1171 ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip clears when steer is consumed");
1172
1173 await rerender({ insertRequest: { id: 7, text: "then stop showing the chip", mode: "replace" } });
1174 await act(async () => {
1175 sendButton.click();
1176 await flushTimers();
1177 });
1178 ok(document.querySelector(".composer-guidance-item") !== null, "running guidance chip renders before turn stop");
1179
1180 await rerender({ running: false });
1181 ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip clears when the turn stops");
1182
1183 await act(async () => {
1184 root.unmount();
1185 });
1186 dom.window.close();
1187 }
1188
1189 {
1190 const dom = installDom();
1191 const steerItemIDs: string[] = [];
1192 mockApp({
1193 InboxSnapshot: async () => ({
1194 revision: 0, paused: false, recovered: false, items: [], itemsCount: 0,
1195 bytes: 0, maxItems: 64, maxBytes: 64 * 1024 * 1024,
1196 }),
1197 EnqueueInboxFollowup: async () => ({
1198 itemId: "durable-activating", disposition: "queued_followup", position: 1, paused: false,
1199 }),
1200 SteerInboxItem: async (_tabID, itemID) => {
1201 steerItemIDs.push(itemID);
1202 return { itemId: itemID, disposition: "steer_accepted", position: 1, paused: false };
1203 },
1204 });
1205 const { root, calls, rerender } = await renderComposer({
1206 running: true,
1207 submitDisabled: true,
1208 onSend: (displayText) => {
1209 calls.send.push(displayText);
1210 },
1211 });
1212
1213 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
1214 if (!sendButton) throw new Error("running composer send button did not render");
1215
1216 await rerender({ insertRequest: { id: 7, text: "steer while activating", mode: "replace" } });
1217 ok(sendButton.disabled === false, "running guidance queue ignores controller submitDisabled");
1218
1219 await act(async () => {
1220 sendButton.click();
1221 await flushTimers();
1222 });
1223
1224 eq(calls.send.join(","), "", "running guidance queues while controllerReady is false");
1225 const guideButton = document.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
1226 if (!guideButton) throw new Error("running guidance guide button did not render");
1227 await act(async () => {
1228 guideButton.click();
1229 await flushTimers();
1230 });
1231
1232 eq(steerItemIDs.join(","), "durable-activating", "queued durable item can be guided while controllerReady is false");
1233 eq(calls.send.join(","), "", "controllerReady gap does not create a duplicate submit");
1234
1235 await act(async () => {
1236 root.unmount();
1237 });
1238 dom.window.close();
1239 }
1240
1241 {
1242 // A backend steer rejection means the turn crossed its final admission
1243 // boundary. The same durable item becomes a follow-up; the Controller, not
1244 // the browser, owns its later FIFO dispatch and ack.
1245 const dom = installDom();
1246 let steerAttempts = 0;
1247 let backendQueued = false;
1248 mockApp({
1249 InboxSnapshot: async () => ({
1250 revision: backendQueued ? 1 : 2,
1251 paused: false,
1252 recovered: false,
1253 items: backendQueued ? [{
1254 id: "durable-late", intent: "followup", state: "queued", preview: "preserve this late guidance", byteSize: 27, position: 1,
1255 }] : [],
1256 itemsCount: backendQueued ? 1 : 0,
1257 bytes: backendQueued ? 27 : 0,
1258 maxItems: 64,
1259 maxBytes: 64 * 1024 * 1024,
1260 }),
1261 EnqueueInboxFollowup: async () => {
1262 backendQueued = true;
1263 return { itemId: "durable-late", disposition: "queued_followup", position: 1, paused: false };
1264 },
1265 SteerInboxItem: async (_tabID, itemID) => {
1266 steerAttempts += 1;
1267 return { itemId: itemID, disposition: "queued_followup", position: 1, paused: false };
1268 },
1269 });
1270 const { root, calls, rerender } = await renderComposer({
1271 running: true,
1272 onSend: (displayText, submitText) => {
1273 calls.send.push(displayText);
1274 calls.submit.push(submitText);
1275 return Promise.resolve();
1276 },
1277 });
1278
1279 await rerender({ insertRequest: { id: 71, text: "preserve this late guidance", mode: "replace" } });
1280 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
1281 if (!sendButton) throw new Error("running composer send button did not render");
1282 await act(async () => {
1283 sendButton.click();
1284 await flushTimers();
1285 });
1286 const guidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
1287 const guideButton = guidanceItem?.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
1288 if (!guideButton) throw new Error("late guidance guide button did not render");
1289 await act(async () => {
1290 guideButton.click();
1291 await flushTimers();
1292 });
1293
1294 eq(steerAttempts, 1, "late guidance attempts one strict steer admission");
1295 eq(calls.send.length, 0, "rejected steer does not open a provider turn");
1296 ok(document.querySelector(".composer-guidance-item") !== null, "rejected steer remains queued");
1297
1298 backendQueued = false; // Controller dispatched and durably acked after TurnDone.
1299 await rerender({ running: false });
1300 await waitFor("acked durable follow-up removed from shelf", () => document.querySelector(".composer-guidance-item") === null);
1301 eq(calls.send.length, 0, "late durable follow-up is never resubmitted by the frontend");
1302
1303 await act(async () => {
1304 root.unmount();
1305 });
1306 dom.window.close();
1307 }
1308
1309 {
1310 // A message queued while a turn is running remains durable. Natural
1311 // completion is dispatched by the Controller, so the frontend must only
1312 // reconcile the eventual durable ack and never call onSend itself.
1313 const dom = installDom();
1314 let backendQueued = false;
1315 mockApp({
1316 InboxSnapshot: async () => ({
1317 revision: backendQueued ? 1 : 2,
1318 paused: false,
1319 recovered: false,
1320 items: backendQueued ? [{
1321 id: "durable-natural", intent: "followup", state: "queued", preview: "keep going after this finishes", byteSize: 30, position: 1,
1322 }] : [],
1323 itemsCount: backendQueued ? 1 : 0,
1324 bytes: backendQueued ? 30 : 0,
1325 maxItems: 64,
1326 maxBytes: 64 * 1024 * 1024,
1327 }),
1328 EnqueueInboxFollowup: async () => {
1329 backendQueued = true;
1330 return { itemId: "durable-natural", disposition: "queued_followup", position: 1, paused: false };
1331 },
1332 });
1333 const { root, calls, rerender } = await renderComposer({
1334 running: true,
1335 onSend: (displayText, submitText) => {
1336 calls.send.push(displayText);
1337 calls.submit.push(submitText);
1338 return Promise.resolve();
1339 },
1340 });
1341
1342 await rerender({ insertRequest: { id: 8, text: "keep going after this finishes", mode: "replace" } });
1343 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
1344 if (!sendButton) throw new Error("running composer send button did not render");
1345
1346 await act(async () => {
1347 sendButton.click();
1348 await flushTimers();
1349 });
1350
1351 eq(calls.send.length, 0, "queuing while running does not send immediately");
1352 ok(document.querySelector(".composer-guidance-item") !== null, "queued message shows in the guidance shelf");
1353
1354 backendQueued = false; // Controller completed and acked the next FIFO turn.
1355 await rerender({ running: false });
1356 await waitFor("durable guidance ack reconciled", () => document.querySelector(".composer-guidance-item") === null);
1357
1358 eq(calls.send.length, 0, "natural completion never triggers a duplicate frontend submit");
1359 eq(calls.submit.length, 0, "durable body remains backend-owned through dispatch");
1360
1361 await act(async () => {
1362 root.unmount();
1363 });
1364 dom.window.close();
1365 }
1366
1367 {
1368 // Stop passes only this Composer's durable IDs to the controller, then folds
1369 // their visible text back into the draft instead of leaving a hidden turn
1370 // that could run after cancellation.
1371 const dom = installDom();
1372 let cancelledItemIDs: string[] = [];
1373 mockApp({
1374 InboxSnapshot: async () => ({
1375 revision: 0, paused: false, recovered: false, items: [], itemsCount: 0,
1376 bytes: 0, maxItems: 64, maxBytes: 64 * 1024 * 1024,
1377 }),
1378 EnqueueInboxFollowup: async () => ({
1379 itemId: "durable-cancel", disposition: "queued_followup", position: 1, paused: false,
1380 }),
1381 });
1382 const { root, rerender } = await renderComposer({
1383 running: true,
1384 onCancel: async (itemIDs = []) => {
1385 cancelledItemIDs = itemIDs;
1386 return { discardedItemIds: [...itemIDs] };
1387 },
1388 });
1389
1390 await rerender({ insertRequest: { id: 81, text: "keep cancelled follow-up", mode: "replace" } });
1391 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
1392 if (!sendButton) throw new Error("running composer send button did not render");
1393 await act(async () => {
1394 sendButton.click();
1395 await flushTimers();
1396 });
1397
1398 const stopButton = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
1399 if (!stopButton) throw new Error("running composer stop button did not render");
1400 await act(async () => {
1401 stopButton.click();
1402 await flushTimers();
1403 });
1404 eq(cancelledItemIDs.join(","), "durable-cancel", "stop scopes backend discard to the Composer-owned durable ID");
1405 ok(document.querySelector(".composer-guidance-item") === null, "stop clears the local shelf after handing off durable IDs");
1406 const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
1407 ok(textarea?.value.includes("keep cancelled follow-up") === true, "stop restores queued guidance to the editable draft");
1408
1409 await act(async () => {
1410 root.unmount();
1411 });
1412 dom.window.close();
1413 }
1414
1415 {
1416 // Controller activation state is irrelevant to browser-side dispatch now:
1417 // the durable item remains visible until an authoritative consume/ack event.
1418 const dom = installDom();
1419 let backendReadyQueued = false;
1420 mockApp({
1421 InboxSnapshot: async () => ({
1422 revision: backendReadyQueued ? 1 : 0,
1423 paused: false,
1424 recovered: false,
1425 items: backendReadyQueued ? [{
1426 id: "durable-ready", intent: "followup", state: "queued", preview: "keep going once ready", byteSize: 21, position: 1,
1427 }] : [],
1428 itemsCount: backendReadyQueued ? 1 : 0,
1429 bytes: backendReadyQueued ? 21 : 0,
1430 maxItems: 64,
1431 maxBytes: 64 * 1024 * 1024,
1432 }),
1433 EnqueueInboxFollowup: async () => {
1434 backendReadyQueued = true;
1435 return { itemId: "durable-ready", disposition: "queued_followup", position: 1, paused: false };
1436 },
1437 });
1438 const { root, calls, rerender } = await renderComposer({
1439 running: true,
1440 submitDisabled: false,
1441 onSend: (displayText, submitText) => {
1442 calls.send.push(displayText);
1443 calls.submit.push(submitText);
1444 return Promise.resolve();
1445 },
1446 });
1447
1448 await rerender({ insertRequest: { id: 9, text: "keep going once ready", mode: "replace" } });
1449 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
1450 if (!sendButton) throw new Error("running composer send button did not render");
1451
1452 await act(async () => {
1453 sendButton.click();
1454 await flushTimers();
1455 });
1456 ok(document.querySelector(".composer-guidance-item") !== null, "queued message shows in the guidance shelf");
1457
1458 // Turn ends, but the controller is still not ready to accept a submit —
1459 // matches a rebuild/hydration window right after the turn finishes.
1460 await rerender({ running: false, submitDisabled: true });
1461 await act(async () => {
1462 await flushTimers();
1463 });
1464 eq(calls.send.length, 0, "auto-send does not fire while the controller is still activating");
1465 ok(document.querySelector(".composer-guidance-item") !== null, "queued message stays on the shelf while not ready");
1466
1467 await rerender({ submitDisabled: false });
1468 await act(async () => {
1469 await flushTimers();
1470 });
1471 eq(calls.send.length, 0, "controller readiness never triggers frontend auto-submit");
1472 ok(document.querySelector(".composer-guidance-item") !== null, "durable item remains until backend consume/ack");
1473
1474 await rerender({ guidanceConsumedKey: "durable-ready-acked", guidanceConsumedText: "keep going once ready" });
1475 ok(document.querySelector(".composer-guidance-item") === null, "backend consume event clears the acknowledged item");
1476
1477 await act(async () => {
1478 root.unmount();
1479 });
1480 dom.window.close();
1481 }
1482
1483 {
1484 const dom = installDom();
1485 let listDirCalls = 0;
1486 const listDirTabs: string[] = [];
1487 mockApp({
1488 ListDirForTarget: async (target) => {
1489 listDirTabs.push(target.tabId);
1490 listDirCalls += 1;
1491 return listDirCalls === 1 ? [fileEntry("cached-dir.txt")] : [fileEntry("fresh-dir.txt")];
1492 },
1493 SearchFileRefsForTarget: async () => [],
1494 });
1495 const { root, rerender } = await renderComposer();
1496
1497 await replaceComposerDraft(rerender, 101, "@");
1498 await waitFor("initial @ directory load", () => listDirCalls === 1);
1499
1500 await replaceComposerDraft(rerender, 102, "");
1501 await replaceComposerDraft(rerender, 103, "@");
1502 await waitFor("@ directory revalidation call", () => listDirCalls === 2);
1503
1504 eq(listDirCalls, 2, "@ directory cache hit still revalidates ListDir");
1505 ok(listDirTabs.every((tabId) => tabId === "tab-a"), "@ directory requests stay scoped to the composer tab");
1506
1507 await act(async () => {
1508 root.unmount();
1509 });
1510 dom.window.close();
1511 }
1512
1513 {
1514 const dom = installDom();
1515 let listDirCalls = 0;
1516 mockApp({
1517 ListDirForTarget: async () => {
1518 listDirCalls += 1;
1519 return listDirCalls === 1 ? [fileEntry("manual-refresh-stale.txt")] : [fileEntry("manual-refresh-fresh.txt")];
1520 },
1521 SearchFileRefsForTarget: async () => [],
1522 });
1523 const { root, rerender } = await renderComposer({ fileRefRefreshKey: "0" });
1524
1525 await replaceComposerDraft(rerender, 201, "@");
1526 await waitFor("initial @ directory load before refresh key", () => listDirCalls === 1);
1527
1528 await rerender({ fileRefRefreshKey: "1" });
1529 await waitFor("@ directory reload after refresh key", () => listDirCalls === 2);
1530
1531 eq(listDirCalls, 2, "fileRefRefreshKey refreshes @ directory cache while the menu is open");
1532
1533 await act(async () => {
1534 root.unmount();
1535 });
1536 dom.window.close();
1537 }
1538
1539 {
1540 const dom = installDom();
1541 const realDateNow = Date.now;
1542 let now = 1000;
1543 let searchCalls = 0;
1544 Date.now = () => now;
1545 mockApp({
1546 ListDirForTarget: async () => [],
1547 SearchFileRefsForTarget: async () => {
1548 searchCalls += 1;
1549 return searchCalls === 1 ? [fileEntry("alpha-old.ts")] : [fileEntry("alpha-new.ts")];
1550 },
1551 });
1552 const { root, rerender } = await renderComposer();
1553
1554 try {
1555 await replaceComposerDraft(rerender, 301, "@alpha");
1556 await waitFor("initial @ search request", () => searchCalls === 1);
1557 eq(searchCalls, 1, "@ search fetches the first query");
1558
1559 await replaceComposerDraft(rerender, 302, "");
1560 now = 2000;
1561 await replaceComposerDraft(rerender, 303, "@alpha");
1562 await act(async () => {
1563 await flushTimers();
1564 });
1565 eq(searchCalls, 1, "@ search cache is reused inside the TTL");
1566
1567 await replaceComposerDraft(rerender, 304, "");
1568 now = 7001;
1569 await replaceComposerDraft(rerender, 305, "@alpha");
1570 await waitFor("expired @ search cache refresh", () => searchCalls === 2);
1571 eq(searchCalls, 2, "@ search cache revalidates after the TTL");
1572 } finally {
1573 Date.now = realDateNow;
1574 }
1575
1576 await act(async () => {
1577 root.unmount();
1578 });
1579 dom.window.close();
1580 }
1581
1582 {
1583 const dom = installDom();
1584 let staleListDirResolve: ((entries: DirEntry[]) => void) | undefined;
1585 let thirdListDirResolve: ((entries: DirEntry[]) => void) | undefined;
1586 let listDirCalls = 0;
1587 mockApp({
1588 ListDirForTarget: async () => {
1589 listDirCalls += 1;
1590 if (listDirCalls === 1) {
1591 return new Promise<DirEntry[]>((resolve) => {
1592 staleListDirResolve = resolve;
1593 });
1594 }
1595 if (listDirCalls === 2) return [fileEntry("cache-live.txt")];
1596 return new Promise<DirEntry[]>((resolve) => {
1597 thirdListDirResolve = resolve;
1598 });
1599 },
1600 SearchFileRefsForTarget: async () => [],
1601 });
1602 const { root, rerender } = await renderComposer({ fileRefRefreshKey: "0" });
1603
1604 const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
1605 if (!textarea) throw new Error("composer textarea did not render");
1606
1607 await replaceComposerDraft(rerender, 401, "@cache");
1608 await waitFor("initial stale @ directory request", () => listDirCalls === 1);
1609
1610 await rerender({ fileRefRefreshKey: "1" });
1611 await waitFor("fresh @ directory request after refresh key", () => listDirCalls === 2);
1612 await act(async () => {
1613 await flushTimers();
1614 });
1615
1616 staleListDirResolve?.([fileEntry("cache-stale.txt")]);
1617 await act(async () => {
1618 await flushTimers();
1619 });
1620
1621 await replaceComposerDraft(rerender, 402, "");
1622 await replaceComposerDraft(rerender, 403, "@cache");
1623 await waitFor("second fresh @ directory request", () => listDirCalls === 3);
1624 await act(async () => {
1625 textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
1626 await flushTimers();
1627 });
1628 eq(textarea.value, "@cache-live.txt ", "stale @ directory request cannot repopulate cache after refresh");
1629 thirdListDirResolve?.([fileEntry("cache-later.txt")]);
1630
1631 await act(async () => {
1632 root.unmount();
1633 });
1634 dom.window.close();
1635 }
1636
1637 {
1638 const dom = installDom();
1639 const pending: Array<(entries: DirEntry[]) => void> = [];
1640 mockApp({
1641 ListDirForTarget: async () => [],
1642 SearchFileRefsForTarget: async () => new Promise<DirEntry[]>((resolve) => pending.push(resolve)),
1643 });
1644 const { root, rerender } = await renderComposer({ workspaceScopeKey: "session-a" });
1645 const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
1646 if (!textarea) throw new Error("composer textarea did not render");
1647
1648 await replaceComposerDraft(rerender, 501, "@current");
1649 await waitFor("initial composer session scope request", () => pending.length === 1);
1650 await rerender({ workspaceScopeKey: "session-b" });
1651 await waitFor("next composer session scope request", () => pending.length === 2);
1652 await rerender({ workspaceScopeKey: "session-a" });
1653 await waitFor("revisited composer session scope request", () => pending.length === 3);
1654
1655 await act(async () => {
1656 pending[2]([fileEntry("current-session-a.txt")]);
1657 await flushTimers();
1658 });
1659
1660 await act(async () => {
1661 pending[0]([fileEntry("stale-initial-a.txt")]);
1662 pending[1]([fileEntry("stale-session-b.txt")]);
1663 await flushTimers();
1664 textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
1665 await flushTimers();
1666 });
1667
1668 eq(textarea.value, "@current-session-a.txt ", "same-tab A→B→A keeps the current composer file-ref search cache");
1669
1670 await act(async () => {
1671 root.unmount();
1672 });
1673 dom.window.close();
1674 }
1675
1676 {
1677 const dom = installDom();
1678 mockApp({
1679 Commands: async () => [
1680 { name: "writing-plans", description: "Write a plan", kind: "skill", color: "amber" },
1681 { name: "review", description: "Review the result", kind: "skill" },
1682 { name: "mcp", description: "Manage MCP servers", kind: "builtin", group: "integrations" },
1683 ],
1684 ListDirForTarget: async () => [],
1685 SearchFileRefsForTarget: async () => [],
1686 });
1687 const { root, calls, rerender } = await renderComposer();
1688
1689 const initialText = "请用/writing-plans检查";
1690 await replaceComposerDraft(rerender, 1900, initialText);
1691 let textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
1692 if (!textarea) throw new Error("composer textarea did not render for middle slash completion");
1693 const slashCaret = "请用/writ".length;
1694 await act(async () => {
1695 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
1696 await flushTimers();
1697 });
1698 await act(async () => {
1699 textarea!.focus();
1700 textarea!.setSelectionRange(slashCaret, slashCaret);
1701 textarea!.dispatchEvent(new window.Event("select", { bubbles: true }));
1702 textarea!.dispatchEvent(new window.KeyboardEvent("keyup", { key: "/", bubbles: true }));
1703 await flushTimers();
1704 });
1705 await waitFor("middle slash command menu", () => Boolean(document.querySelector(".slashmenu")));
1706
1707 await act(async () => {
1708 textarea!.dispatchEvent(new window.KeyboardEvent("keydown", {
1709 key: "Enter",
1710 bubbles: true,
1711 cancelable: true,
1712 }));
1713 await flushTimers();
1714 });
1715
1716 let richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
1717 let tokens = richInput?.querySelectorAll<HTMLElement>(".composer-invocation-token");
1718 if (!richInput || !tokens?.[0]) throw new Error("middle skill invocation did not render");
1719 eq(richComposerTaskText(richInput), "请用检查", "first middle skill selection preserves surrounding text");
1720 eq(
1721 document.querySelector<HTMLElement>(".invocation-display--composer")?.style.getPropertyValue("--invocation-color"),
1722 "#d59a2f",
1723 "middle skill selection keeps its configured color",
1724 );
1725
1726 const afterFirstToken = document.createRange();
1727 afterFirstToken.setStartAfter(tokens[0]);
1728 afterFirstToken.collapse(true);
1729 document.getSelection()?.removeAllRanges();
1730 document.getSelection()?.addRange(afterFirstToken);
1731 await act(async () => {
1732 dispatchPasteText(richInput!, "更多");
1733 await flushTimers();
1734 });
1735 richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
1736 if (!richInput) throw new Error("rich input disappeared after middle-skill paste");
1737 eq(richComposerTaskText(richInput), "请用更多检查", "paste after a middle skill preserves the entity and suffix");
1738 eq(
1739 richInput.querySelectorAll(".composer-invocation-token").length,
1740 1,
1741 "paste after a middle skill keeps the selected entity",
1742 );
1743
1744 await appendRichComposerInput(richInput, " /review");
1745 richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
1746 if (!richInput) throw new Error("rich input disappeared before the second skill selection");
1747 const queryAtEnd = document.createRange();
1748 queryAtEnd.selectNodeContents(richInput);
1749 queryAtEnd.collapse(false);
1750 document.getSelection()?.removeAllRanges();
1751 document.getSelection()?.addRange(queryAtEnd);
1752 await act(async () => {
1753 richInput!.dispatchEvent(new window.KeyboardEvent("keyup", { key: "w", bubbles: true }));
1754 await flushTimers();
1755 });
1756 await waitFor("second skill menu at the end", () => Boolean(document.querySelector(".slashmenu")));
1757 await act(async () => {
1758 richInput!.dispatchEvent(new window.KeyboardEvent("keydown", {
1759 key: "Enter",
1760 bubbles: true,
1761 cancelable: true,
1762 }));
1763 await flushTimers();
1764 });
1765
1766 richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
1767 tokens = richInput?.querySelectorAll<HTMLElement>(".composer-invocation-token");
1768 eq(tokens?.length, 2, "a second skill can be inserted after existing text and an entity");
1769 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
1770 if (!sendButton) throw new Error("send button did not render for middle skill submission");
1771 await act(async () => {
1772 sendButton.click();
1773 await flushTimers();
1774 });
1775 eq(calls.structured[0]?.input, "请用更多检查", "middle skills submit the surrounding task text without slash tokens");
1776 eq(
1777 calls.structured[0]?.invocations.map((item) => item.name).join(","),
1778 "writing-plans,review",
1779 "multiple middle/end skills submit in visual order",
1780 );
1781 eq(calls.structured[0]?.invocations[0]?.offset, 2, "first middle skill keeps its text offset");
1782
1783 await act(async () => {
1784 root.unmount();
1785 });
1786 dom.window.close();
1787 }
1788
1789 {
1790 const dom = installDom();
1791 let commandsCalls = 0;
1792 const slashArgInputs: string[] = [];
1793 let availableCommands: CommandInfo[] = [
1794 { name: "mcp", description: "Manage MCP servers", kind: "builtin", group: "integrations" },
1795 { name: "explore", description: "Investigate the codebase", kind: "subagent" },
1796 { name: "superpowers:writing-plans", description: "Write a plan", kind: "skill", plugin: "superpowers" },
1797 { name: "toolbox:writing-plans", description: "Write another plan", kind: "skill", plugin: "toolbox" },
1798 { name: "superpowers:brainstorming", description: "Explore an idea", kind: "skill", plugin: "superpowers" },
1799 ];
1800 mockApp({
1801 Commands: async () => {
1802 commandsCalls += 1;
1803 return availableCommands;
1804 },
1805 ListDirForTarget: async () => [],
1806 SearchFileRefsForTarget: async () => [],
1807 SlashArgs: async (input) => {
1808 slashArgInputs.push(input);
1809 return input === "/mcp "
1810 ? { items: [{ label: "show", insert: "show", hint: "Show an MCP server", descend: false }], from: 5 }
1811 : { items: [], from: 0 };
1812 },
1813 });
1814 const { root, calls, rerender } = await renderComposer({ workspaceScopeKey: "runtime-0" });
1815
1816 await waitFor("plugin commands loaded", () => commandsCalls > 0);
1817 await replaceComposerDraft(rerender, 1999, "/\n");
1818 await waitFor("slash menu before trailing newline", () => Boolean(document.querySelector(".slashmenu")));
1819 ok(document.querySelector(".slashmenu") !== null, "slash menu ignores an invisible trailing newline");
1820
1821 await replaceComposerDraft(rerender, 1998, "@\n");
1822 await waitFor("file menu before trailing newline", () => Boolean(document.querySelector(".slashmenu")));
1823 ok(document.querySelector(".slashmenu") !== null, "file menu ignores an invisible trailing newline");
1824
1825 await replaceComposerDraft(rerender, 1997, "/mcp \n");
1826 await act(async () => {
1827 await flushTimers(150);
1828 });
1829 await waitFor("slash argument menu before trailing newline", () => document.querySelector(".slashmenu")?.textContent?.includes("show") === true);
1830 ok(slashArgInputs.includes("/mcp "), "slash argument completion removes an invisible trailing newline before lookup");
1831
1832 await replaceComposerDraft(rerender, 2000, "/m");
1833 await waitFor("initial skill command menu", () => Boolean(document.querySelector(".slashmenu")));
1834 ok(
1835 document.querySelector(".slashmenu")?.textContent?.includes("/my-formatter") === false,
1836 "new subagent command is absent before runtime refresh",
1837 );
1838
1839 availableCommands = [
1840 ...availableCommands,
1841 { name: "my-formatter", description: "Formats code the way I like it", kind: "subagent", color: "amber" },
1842 ];
1843 const initialCommandsCalls = commandsCalls;
1844 await rerender({ workspaceScopeKey: "runtime-1" });
1845 await waitFor("commands refreshed after runtime rebuild", () => commandsCalls > initialCommandsCalls);
1846 ok(commandsCalls > initialCommandsCalls, "runtime rebuild refetches subagent slash commands");
1847
1848 await replaceComposerDraft(rerender, 2001, "/writing-plans");
1849 await waitFor("qualified plugin skill menu", () => Boolean(document.querySelector(".slashmenu")));
1850
1851 const menuSizer = document.querySelector<HTMLElement>(".slashmenu__sizer");
1852 eq(menuSizer?.style.height, "94px", "short skill query keeps one group heading and both matching plugin names");
1853 let textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
1854 if (!textarea) throw new Error("composer textarea did not render");
1855 await act(async () => {
1856 textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
1857 await flushTimers();
1858 });
1859 ok(document.querySelector(".composer__rich-input") !== null, "selecting a plugin skill switches to the rich task input");
1860 ok(document.querySelector(".invocation-display--composer")?.textContent?.includes("Writing Plans") === true, "selected skill renders as composer context");
1861 ok(document.querySelector(".invocation-display--composer")?.textContent?.includes("superpowers") === true, "selected plugin skill keeps its source visible");
1862 ok(document.querySelector(".composer__rich-input .composer-invocation-token") !== null, "selected skill is an inline task entity");
1863 ok(document.querySelector(".composer__rich-input .composer-invocation-caret-anchor")?.textContent === "\u00A0", "selected skill keeps a caret anchor after the inline entity");
1864
1865 const richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
1866 if (!richInput) throw new Error("rich composer did not render");
1867 const richContent = document.querySelector(".composer__content") as HTMLDivElement | null;
1868 if (!richContent) throw new Error("rich composer content area did not render");
1869 richInput.blur();
1870 await act(async () => {
1871 richContent.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
1872 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
1873 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
1874 await flushTimers();
1875 });
1876 ok(document.activeElement === richInput, "clicking blank rich-composer space focuses the editable task input");
1877
1878 const invocationToken = richInput.querySelector(".composer-invocation-token");
1879 if (!invocationToken) throw new Error("rich invocation token did not render");
1880 const richRange = document.createRange();
1881 richRange.setStartAfter(invocationToken);
1882 richRange.collapse(true);
1883 document.getSelection()?.removeAllRanges();
1884 document.getSelection()?.addRange(richRange);
1885 await act(async () => {
1886 richInput.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Backspace", bubbles: true, cancelable: true }));
1887 await flushTimers();
1888 });
1889 ok(document.querySelector(".invocation-display--composer") === null, "Backspace removes a selected skill from an empty task input");
1890 await act(async () => {
1891 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
1892 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
1893 await flushTimers();
1894 });
1895 const textareaAfterEntityRemoval = document.querySelector("textarea") as HTMLTextAreaElement | null;
1896 if (!textareaAfterEntityRemoval) throw new Error("textarea did not return after removing the last entity");
1897 ok(
1898 document.activeElement === textareaAfterEntityRemoval,
1899 "removing the last entity hands focus to the textarea that replaces the rich input",
1900 );
1901 const undoEntityRemoval = new window.KeyboardEvent("keydown", {
1902 key: "z",
1903 ctrlKey: true,
1904 bubbles: true,
1905 cancelable: true,
1906 });
1907 await act(async () => {
1908 textareaAfterEntityRemoval.dispatchEvent(undoEntityRemoval);
1909 await flushTimers();
1910 });
1911 eq(undoEntityRemoval.defaultPrevented, true, "Ctrl+Z restores a token removed by the rich composer");
1912 ok(
1913 document.querySelector(".invocation-display--composer") !== null,
1914 "undoing the programmatic Backspace restores the selected skill",
1915 );
1916 const restoredRichInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
1917 if (!restoredRichInput) throw new Error("rich composer did not return after undoing token removal");
1918 const redoEntityRemoval = new window.KeyboardEvent("keydown", {
1919 key: "Z",
1920 ctrlKey: true,
1921 shiftKey: true,
1922 bubbles: true,
1923 cancelable: true,
1924 });
1925 await act(async () => {
1926 restoredRichInput.dispatchEvent(redoEntityRemoval);
1927 await flushTimers();
1928 });
1929 eq(redoEntityRemoval.defaultPrevented, true, "Ctrl+Shift+Z redoes rich token removal");
1930 ok(
1931 document.querySelector(".invocation-display--composer") === null,
1932 "redoing the programmatic Backspace removes the selected skill again",
1933 );
1934
1935 await replaceComposerDraft(rerender, 2002, "/writing-plans");
1936 await waitFor("plain composer after replacing the restored skill", () => Boolean(document.querySelector("textarea")));
1937 await waitFor("skill menu after removal", () => Boolean(document.querySelector(".slashmenu")));
1938 textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
1939 if (!textarea) throw new Error("composer textarea did not return after removing the skill");
1940 await act(async () => {
1941 textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
1942 await flushTimers();
1943 });
1944
1945 let sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
1946 if (!sendButton) throw new Error("composer send button did not render for skill-only invocation");
1947 ok(sendButton.disabled === false, "inline skill-only invocation enables submit");
1948 await act(async () => {
1949 sendButton?.click();
1950 await flushTimers();
1951 });
1952 eq(calls.submit[0], "/superpowers:writing-plans", "inline skill-only submission retains display metadata");
1953 eq(calls.structured[0]?.input, "", "inline skill-only submission sends an empty explicit task");
1954 eq(calls.structured[0]?.display, "/superpowers:writing-plans", "inline skill-only submission preserves reloadable invocation display metadata");
1955 eq(calls.structured[0]?.invocations[0]?.name, "superpowers:writing-plans", "inline skill-only submission sends a structured skill entity");
1956
1957 await replaceComposerDraft(rerender, 20021, "/writing-plans");
1958 await waitFor("skill menu for task submission", () => Boolean(document.querySelector(".slashmenu")));
1959 textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
1960 if (!textarea) throw new Error("composer textarea did not return after skill-only send");
1961 await act(async () => {
1962 textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
1963 await flushTimers();
1964 });
1965
1966 await replaceComposerDraft(rerender, 2003, "Draft the release plan");
1967 sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
1968 if (!sendButton) throw new Error("composer send button did not render");
1969 await act(async () => {
1970 sendButton.click();
1971 await flushTimers();
1972 });
1973 eq(calls.send[1], "Draft the release plan", "selected skill keeps the visible transcript text clean");
1974 eq(calls.submit[1], "/superpowers:writing-plans Draft the release plan", "selected skill preserves invocation display metadata");
1975 eq(calls.structured[1]?.input, "Draft the release plan", "selected skill sends task text separately from invocation metadata");
1976 ok(document.querySelector(".invocation-display--composer") === null, "selected skill clears after send");
1977
1978 await replaceComposerDraft(rerender, 2004, "/mcp");
1979 await waitFor("builtin command menu", () => Boolean(document.querySelector(".slashmenu")));
1980 textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
1981 if (!textarea) throw new Error("composer textarea did not render for management command");
1982 await act(async () => {
1983 textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
1984 await flushTimers();
1985 });
1986 eq(textarea.value, "/mcp ", "management commands keep the existing inline argument flow");
1987 ok(document.querySelector(".invocation-display--composer") === null, "management commands do not become selected abilities");
1988
1989 await replaceComposerDraft(rerender, 2005, "/my-formatter");
1990 await waitFor("colored subagent command menu", () => Boolean(document.querySelector(".slashmenu")));
1991 textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
1992 if (!textarea) throw new Error("composer textarea did not render for colored subagent");
1993 await act(async () => {
1994 textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
1995 await flushTimers();
1996 });
1997 ok(document.querySelector<HTMLElement>(".invocation-display--composer")?.style.getPropertyValue("--invocation-color") === "#d59a2f", "selected custom subagent uses its configured color");
1998 sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
1999 ok(sendButton?.disabled === true, "subagent-only invocation remains blocked until a task is entered");
2000
2001 const subagentInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
2002 if (!subagentInput) throw new Error("rich composer did not render for colored subagent");
2003 await appendRichComposerInput(subagentInput, "Inspect ");
2004 eq(richComposerTaskText(subagentInput), "Inspect ", "rich composer does not duplicate ordinary browser input");
2005 await appendRichComposerInput(subagentInput, "仓库做了什么?", true);
2006 eq(richComposerTaskText(subagentInput), "Inspect 仓库做了什么?", "rich composer does not duplicate committed IME input");
2007
2008 await replaceComposerDraft(rerender, 2006, "");
2009 const resetSubagentInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
2010 if (!resetSubagentInput) throw new Error("rich composer disappeared after external text replacement");
2011 eq(richComposerTaskText(resetSubagentInput), "", "external replacement can restore the initially rendered rich-composer text");
2012 await appendRichComposerInput(resetSubagentInput, "Inspect ");
2013 await appendRichComposerInput(resetSubagentInput, "仓库做了什么?", true);
2014
2015 sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
2016 if (!sendButton) throw new Error("composer send button did not render after subagent task input");
2017 await act(async () => {
2018 sendButton.click();
2019 await flushTimers();
2020 });
2021 eq(calls.send[2], "Inspect 仓库做了什么?", "subagent task is sent exactly once after rich input");
2022 eq(calls.structured[2]?.input, "Inspect 仓库做了什么?", "structured subagent input contains one task copy");
2023
2024 await act(async () => {
2025 root.unmount();
2026 });
2027 dom.window.close();
2028 }
2029
2030 {
2031 const dom = installDom();
2032 const rootEl = document.getElementById("root");
2033 if (!rootEl) throw new Error("missing root");
2034 const root = createRoot(rootEl);
2035 await act(async () => {
2036 root.render(
2037 <LocaleProvider>
2038 <UserMessage
2039 id="h1"
2040 text="Draft the release plan"
2041 submitText="/superpowers:writing-plans Draft the release plan"
2042 />
2043 </LocaleProvider>,
2044 );
2045 await flushTimers();
2046 });
2047 ok(document.querySelector(".invocation-display--message")?.textContent?.includes("Writing Plans") === true, "restored history renders the selected skill header");
2048 ok(document.querySelector(".invocation-display--message")?.textContent?.includes("superpowers") === true, "restored history retains plugin source from the qualified command");
2049 ok(document.querySelector(".msg__rich-text")?.textContent?.endsWith("Draft the release plan") === true, "history message keeps slash syntax out of the task body");
2050
2051 await act(async () => {
2052 root.render(
2053 <LocaleProvider>
2054 <InvocationMetadataContext.Provider value={{ "my-formatter": { kind: "subagent", color: "amber" } }}>
2055 <UserMessage
2056 id="h2"
2057 text="Format this file"
2058 submitText={"以下是用户引用的历史会话上下文:\n\n[会话:Earlier]\n...\n\n---\n\n当前用户问题:\n/my-formatter Format this file"}
2059 />
2060 </InvocationMetadataContext.Provider>
2061 </LocaleProvider>,
2062 );
2063 await flushTimers();
2064 });
2065 ok(document.querySelector(".invocation-display--message")?.textContent?.includes("My Formatter") === true, "history and trash previews recover selected abilities after referenced-session context");
2066 ok(document.querySelector(".invocation-display--subagent") !== null, "restored custom subagents keep their command type styling");
2067 ok(document.querySelector<HTMLElement>(".invocation-display--subagent")?.style.getPropertyValue("--invocation-color") === "#d59a2f", "restored custom subagents keep their configured color");
2068
2069 await act(async () => {
2070 root.render(
2071 <LocaleProvider>
2072 <UserMessage
2073 id="h3"
2074 text={"Compare these commands\n/other-command"}
2075 submitText={"/reasonix-develop Compare these commands\n/other-command"}
2076 />
2077 </LocaleProvider>,
2078 );
2079 await flushTimers();
2080 });
2081 ok(document.querySelector(".invocation-display--message")?.textContent?.includes("Reasonix Develop") === true, "history recovery ignores slash-prefixed lines inside the task body");
2082
2083 await act(async () => {
2084 root.render(
2085 <LocaleProvider>
2086 <UserMessage
2087 id="h4"
2088 text={"Compare these commands\n/other-command"}
2089 submitText={"以下是用户引用的历史会话上下文:\n\n[会话:Earlier]\n...\n\n---\n\n当前用户问题:\nCompare these commands\n/other-command"}
2090 />
2091 </LocaleProvider>,
2092 );
2093 await flushTimers();
2094 });
2095 ok(document.querySelector(".invocation-display--message") === null, "ordinary referenced-session text does not turn task slash lines into a skill header");
2096
2097 await act(async () => root.unmount());
2098 dom.window.close();
2099 }
2100
2101 {
2102 const dom = installDom();
2103 let savedFiles = 0;
2104 mockApp({
2105 Commands: async () => [{ name: "skill", description: "Manage skills", kind: "builtin" }],
2106 ListDirForTarget: async () => [fileEntry("README.md")],
2107 SearchFileRefsForTarget: async () => [],
2108 ListSessions: async () => [{ path: "/sessions/recent.jsonl", title: "Recent session", current: false }],
2109 SavePastedFileForTarget: async () => {
2110 savedFiles += 1;
2111 return ".reasonix/attachments/notes.txt";
2112 },
2113 });
2114 const { root, rerender } = await renderComposer();
2115 const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
2116 if (!textarea) throw new Error("composer textarea did not render");
2117
2118 await replaceComposerDraft(rerender, 3000, "Follow up #recent\n");
2119 await waitFor("typed hash recent-session picker", () => Boolean(document.querySelector(".slashmenu__search")));
2120 const typedSessionSearch = document.querySelector(".slashmenu__search") as HTMLInputElement | null;
2121 eq(typedSessionSearch?.value, "recent", "typing # opens recent sessions and carries the query across an invisible trailing newline");
2122 ok(document.activeElement !== typedSessionSearch, "the typed # flow leaves focus in the composer instead of the panel search box");
2123 await act(async () => {
2124 typedSessionSearch?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
2125 await flushTimers();
2126 });
2127 eq(textarea.value, "Follow up #recent\n", "Escape closes the typed recent-session picker and keeps the literal # text");
2128 ok(!document.querySelector(".slashmenu__search"), "Escape dismisses the typed recent-session panel until the query changes");
2129
2130 await replaceComposerDraft(rerender, 3005, "issue#6310");
2131 await act(async () => {
2132 await flushTimers();
2133 });
2134 ok(!document.querySelector(".slashmenu__search"), "an embedded hash remains ordinary composer text");
2135
2136 await replaceComposerDraft(rerender, 3006, "#\n");
2137 await waitFor("typed hash picker before session selection", () => Boolean(document.querySelector(".slashmenu__search")));
2138 const typedSessionButton = Array.from(document.querySelectorAll<HTMLButtonElement>(".slashmenu button"))
2139 .find((button) => button.textContent?.includes("Recent session"));
2140 if (!typedSessionButton) throw new Error("typed recent-session option did not render");
2141 await act(async () => {
2142 typedSessionButton.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
2143 await flushTimers();
2144 });
2145 eq(textarea.value, "", "selecting a typed recent-session reference removes the # token");
2146 ok(document.querySelector(".composer-context__item--session")?.textContent?.includes("Recent session") === true, "selecting a typed recent-session reference adds its context card");
2147 const removeTypedSession = document.querySelector<HTMLButtonElement>(".composer-context__item--session button");
2148 await act(async () => {
2149 removeTypedSession?.click();
2150 await flushTimers();
2151 });
2152
2153 const contentTrigger = document.querySelector(".composer-content-trigger") as HTMLButtonElement | null;
2154 if (!contentTrigger) throw new Error("content menu trigger did not render");
2155 await act(async () => {
2156 contentTrigger.click();
2157 await flushTimers();
2158 });
2159 ok(Boolean(document.querySelector(".composer-content-menu")), "plus trigger opens the add-content menu");
2160 const initialContentItems = Array.from(document.querySelectorAll<HTMLButtonElement>(".composer-content-menu__item"));
2161 eq(initialContentItems.length, 4, "add-content menu exposes four focused actions");
2162 const contentItemIcons = initialContentItems.map((item) => item.querySelector("svg")?.getAttribute("class") ?? "");
2163 ok(contentItemIcons[0]?.includes("lucide-file-plus"), "attachment action uses the file attachment icon");
2164 ok(contentItemIcons[1]?.includes("lucide-at-sign"), "workspace action uses the mention icon");
2165 ok(contentItemIcons[2]?.includes("lucide-hash"), "recent-session action uses the history reference icon");
2166 eq(initialContentItems[3]?.querySelector(".composer-content-menu__trigger-icon")?.textContent, "/", "command action uses the literal slash trigger icon");
2167 ok(!document.querySelector(".composer-content-menu__divider"), "add-content actions remain one unified group without a divider");
2168 ok(initialContentItems.every((item) => !item.querySelector("kbd")), "add-content actions do not duplicate their trigger icons on the right");
2169
2170 const attachmentButton = initialContentItems[0];
2171 const fileInput = document.querySelector(".composer-content-file-input") as HTMLInputElement | null;
2172 if (!attachmentButton || !fileInput) throw new Error("attachment picker controls did not render");
2173 await act(async () => {
2174 attachmentButton.click();
2175 Object.defineProperty(fileInput, "files", { configurable: true, value: [new File(["notes"], "notes.txt", { type: "text/plain" })] });
2176 fileInput.dispatchEvent(new Event("change", { bubbles: true }));
2177 await flushTimers();
2178 });
2179 await waitFor("attachment chosen from add-content menu", () => savedFiles === 1);
2180 eq(savedFiles, 1, "attachment action reuses the existing file-save path");
2181
2182 await replaceComposerDraft(rerender, 3001, "@");
2183 await waitFor("workspace menu before plus toggle", () => Boolean(document.querySelector(".slashmenu")));
2184 await act(async () => {
2185 contentTrigger.click();
2186 await flushTimers();
2187 });
2188 ok(!document.querySelector(".slashmenu"), "opening add-content closes the active suggestion panel");
2189 ok(Boolean(document.querySelector(".composer-content-menu")), "add-content remains the only open composer surface");
2190
2191 const sessionButton = document.querySelectorAll<HTMLButtonElement>(".composer-content-menu__item")[2];
2192 if (!sessionButton) throw new Error("recent-session action did not render");
2193 await act(async () => {
2194 sessionButton.click();
2195 await flushTimers();
2196 });
2197 await waitFor("direct recent-session picker", () => Boolean(document.querySelector(".slashmenu__search")));
2198 eq(textarea.value, "@ #", "recent-session action inserts # at the remembered caret");
2199 ok(!document.querySelector(".composer-content-menu"), "recent-session picker replaces the add-content menu");
2200 const sessionSearch = document.querySelector(".slashmenu__search") as HTMLInputElement | null;
2201 if (!sessionSearch) throw new Error("recent-session search did not render");
2202 await act(async () => {
2203 sessionSearch.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
2204 await flushTimers();
2205 });
2206 eq(textarea.value, "@ #", "Escape closes the direct recent-session picker and keeps the inserted # trigger");
2207
2208 await replaceComposerDraft(rerender, 3002, "");
2209 await act(async () => {
2210 contentTrigger.click();
2211 await flushTimers();
2212 });
2213 const commandButton = document.querySelectorAll<HTMLButtonElement>(".composer-content-menu__item")[3];
2214 if (!commandButton) throw new Error("command action did not render");
2215 await act(async () => {
2216 commandButton.click();
2217 await flushTimers();
2218 });
2219 eq(textarea.value, "/", "command action inserts / at the caret");
2220 await waitFor("slash menu from add-content action", () => Boolean(document.querySelector(".slashmenu")));
2221
2222 await replaceComposerDraft(rerender, 3003, "existing text");
2223 await act(async () => {
2224 contentTrigger.click();
2225 await flushTimers();
2226 });
2227 const disabledCommandButton = document.querySelectorAll<HTMLButtonElement>(".composer-content-menu__item")[3];
2228 if (!disabledCommandButton) throw new Error("command action did not render for non-empty input");
2229 ok(disabledCommandButton.disabled, "command action is disabled while the composer has text");
2230 await act(async () => {
2231 disabledCommandButton.click();
2232 await flushTimers();
2233 });
2234 eq(textarea.value, "existing text", "disabled command action does not insert / into existing text");
2235
2236 await rerender({ running: true });
2237 await waitFor("content menu closes when a run starts", () => !document.querySelector(".composer-content-menu"));
2238 await rerender({ running: false });
2239 ok(!document.querySelector(".composer-content-menu"), "content menu stays closed after the run ends");
2240
2241 await replaceComposerDraft(rerender, 3004, "");
2242 await act(async () => {
2243 contentTrigger.click();
2244 await flushTimers();
2245 });
2246 const runningSessionButton = document.querySelectorAll<HTMLButtonElement>(".composer-content-menu__item")[2];
2247 if (!runningSessionButton) throw new Error("recent-session action did not render before running");
2248 await act(async () => {
2249 runningSessionButton.click();
2250 await flushTimers();
2251 });
2252 await waitFor("recent-session picker before running", () => Boolean(document.querySelector(".slashmenu__search")));
2253 await rerender({ running: true });
2254 await waitFor("recent-session picker closes when a run starts", () => !document.querySelector(".slashmenu__search"));
2255 await rerender({ running: false });
2256 ok(!document.querySelector(".slashmenu__search"), "recent-session picker stays closed after the run ends");
2257
2258 await act(async () => {
2259 root.unmount();
2260 });
2261 dom.window.close();
2262 }
2263
2264 {
2265 // Entity-only input must remain structured while queued during a run.
2266 const dom = installDom();
2267 let queued: { submit?: string; invocations?: StructuredInvocationSubmit["invocations"] } = {};
2268 mockApp({
2269 Commands: async () => [
2270 { name: "superpowers:writing-plans", description: "Write a plan", kind: "skill", plugin: "superpowers" },
2271 ],
2272 ListDirForTarget: async () => [],
2273 SearchFileRefsForTarget: async () => [],
2274 InboxSnapshot: async () => ({
2275 revision: 0, paused: false, recovered: false, items: [], itemsCount: 0,
2276 bytes: 0, maxItems: 64, maxBytes: 64 * 1024 * 1024,
2277 }),
2278 EnqueueInboxFollowupWithInvocations: async (_tabId, _display, input, invocations) => {
2279 queued = { submit: input, invocations };
2280 return { itemId: "durable-entity", disposition: "queued_followup", position: 1, paused: false };
2281 },
2282 });
2283 const { root, calls, rerender } = await renderComposer();
2284 await replaceComposerDraft(rerender, 4000, "/writing-plans");
2285 await waitFor("skill menu for the running-queue entity", () => Boolean(document.querySelector(".slashmenu")));
2286 const queueTextarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
2287 if (!queueTextarea) throw new Error("composer textarea did not render");
2288 await act(async () => {
2289 queueTextarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
2290 await flushTimers();
2291 });
2292 const queueRichInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
2293 if (!queueRichInput) throw new Error("rich composer did not render for the running-queue entity");
2294 await rerender({ running: true });
2295 await act(async () => {
2296 queueRichInput.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
2297 await flushTimers();
2298 });
2299 eq(calls.send.length, 0, "an entity-only submit while running queues instead of sending");
2300 ok(
2301 document.querySelector(".composer-guidance-item__text")?.textContent?.includes("/superpowers:writing-plans") === true,
2302 "the queued guidance shows the entity's slash form instead of dropping it silently",
2303 );
2304 eq(`${queued.invocations?.[0]?.name}:${queued.invocations?.[0]?.kind}`, "superpowers:writing-plans:skill", "queued guidance preserves the selected skill invocation");
2305 eq(queued.submit, "", "entity-only guidance keeps an empty explicit task instead of degrading to slash text");
2306 ok(document.querySelector(".composer__rich-input") === null, "queueing an entity-only submit clears the draft");
2307 await act(async () => {
2308 root.unmount();
2309 });
2310 dom.window.close();
2311 }
2312
2313 {
2314 // While an IME is composing, the rich input must neither resync the model
2315 // nor restore the DOM selection (removeAllRanges cancels or commits an
2316 // in-progress composition); compositionend performs the one authoritative
2317 // sync.
2318 const dom = installDom();
2319 mockApp({
2320 Commands: async () => [
2321 { name: "superpowers:writing-plans", description: "Write a plan", kind: "skill", plugin: "superpowers" },
2322 ],
2323 ListDirForTarget: async () => [],
2324 SearchFileRefsForTarget: async () => [],
2325 });
2326 const { root, calls, rerender } = await renderComposer();
2327 await replaceComposerDraft(rerender, 4100, "/writing-plans");
2328 await waitFor("skill menu for the composition guard", () => Boolean(document.querySelector(".slashmenu")));
2329 const compositionTextarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
2330 if (!compositionTextarea) throw new Error("composer textarea did not render");
2331 await act(async () => {
2332 compositionTextarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
2333 await flushTimers();
2334 });
2335 const compositionRichInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
2336 if (!compositionRichInput) throw new Error("rich composer did not render for the composition guard");
2337
2338 // Drain the entity-pick flow's pending animation frames (imperative caret
2339 // restore) so the spy below counts only composition-window work.
2340 await act(async () => {
2341 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
2342 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
2343 await flushTimers();
2344 });
2345 const domSelection = document.getSelection();
2346 if (!domSelection) throw new Error("document selection unavailable");
2347 let selectionStomps = 0;
2348 const originalRemoveAllRanges = domSelection.removeAllRanges.bind(domSelection);
2349 (domSelection as { removeAllRanges: () => void }).removeAllRanges = () => {
2350 selectionStomps += 1;
2351 originalRemoveAllRanges();
2352 };
2353 await act(async () => {
2354 compositionRichInput.dispatchEvent(new window.Event("compositionstart", { bubbles: true }));
2355 compositionRichInput.appendChild(document.createTextNode("拼"));
2356 compositionRichInput.dispatchEvent(new window.Event("input", { bubbles: true }));
2357 await flushTimers();
2358 });
2359 eq(selectionStomps, 0, "composition input neither resyncs the model nor restores the selection");
2360 await act(async () => {
2361 compositionRichInput.dispatchEvent(new window.Event("compositionend", { bubbles: true }));
2362 await flushTimers();
2363 });
2364 (domSelection as { removeAllRanges: () => void }).removeAllRanges = originalRemoveAllRanges;
2365
2366 const compositionSendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
2367 if (!compositionSendButton) throw new Error("send button did not render after composition");
2368 await act(async () => {
2369 compositionSendButton.click();
2370 await flushTimers();
2371 });
2372 eq(calls.structured[0]?.input, "拼", "compositionend commits the composed text to the model exactly once");
2373
2374 await act(async () => {
2375 root.unmount();
2376 });
2377 dom.window.close();
2378 }
2379
2380 {
2381 const dom = installDom();
2382 mockApp({
2383 Commands: async () => [
2384 { name: "review", description: "Review the current task", kind: "skill" },
2385 ],
2386 ListDirForTarget: async () => [],
2387 SearchFileRefsForTarget: async () => [],
2388 });
2389 const sessionA = "session:project:/repo:topic-a:session-a";
2390 const sessionB = "session:project:/repo:topic-b:session-b";
2391 const { root, rerender } = await renderComposer({ sessionKey: sessionA });
2392
2393 await replaceComposerDraft(rerender, 5000, "x/review");
2394 await waitFor("session A slash menu", () => Boolean(document.querySelector(".slashmenu")));
2395
2396 await rerender({ sessionKey: sessionB, insertRequest: null });
2397 await replaceComposerDraft(rerender, 5001, "b");
2398 const sessionBInput = document.querySelector("textarea") as HTMLTextAreaElement | null;
2399 if (!sessionBInput) throw new Error("session B textarea did not render");
2400 await act(async () => {
2401 sessionBInput.focus();
2402 sessionBInput.setSelectionRange(1, 1);
2403 sessionBInput.dispatchEvent(new window.KeyboardEvent("keyup", { key: "b", bubbles: true }));
2404 await flushTimers();
2405 });
2406
2407 await rerender({ sessionKey: sessionA, insertRequest: null });
2408 eq(
2409 (document.querySelector("textarea") as HTMLTextAreaElement | null)?.value,
2410 "x/review",
2411 "switching back restores session A slash draft",
2412 );
2413 await waitFor(
2414 "restored session A slash menu",
2415 () => Boolean(document.querySelector(".slashmenu")),
2416 );
2417 ok(
2418 document.querySelector(".slashmenu") !== null,
2419 "restoring a draft recomputes slash completion from its end caret",
2420 );
2421
2422 await act(async () => {
2423 root.unmount();
2424 });
2425 dom.window.close();
2426 }
2427
2428 {
2429 const dom = installDom();
2430 mockApp({
2431 Commands: async () => [
2432 { name: "review", description: "Review the current task", kind: "skill" },
2433 ],
2434 ListDirForTarget: async () => [],
2435 SearchFileRefsForTarget: async () => [],
2436 });
2437 const sessionA = "session:project:/repo:rich-topic-a:rich-session-a";
2438 const sessionB = "session:project:/repo:rich-topic-b:rich-session-b";
2439 const realRequestAnimationFrame = globalThis.requestAnimationFrame;
2440 const queuedComposerFrames: FrameRequestCallback[] = [];
2441 globalThis.requestAnimationFrame = (callback) => {
2442 queuedComposerFrames.push(callback);
2443 return queuedComposerFrames.length;
2444 };
2445 const { root, rerender } = await renderComposer({ sessionKey: sessionA });
2446
2447 await replaceComposerDraft(rerender, 6000, "/review");
2448 await waitFor("session A first skill menu", () => Boolean(document.querySelector(".slashmenu")));
2449 const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
2450 if (!textarea) throw new Error("session A textarea did not render");
2451 await act(async () => {
2452 textarea.dispatchEvent(new window.KeyboardEvent("keydown", {
2453 key: "Enter",
2454 bubbles: true,
2455 cancelable: true,
2456 }));
2457 await flushTimers();
2458 });
2459
2460 let richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
2461 if (!richInput) throw new Error("session A rich input did not render");
2462 await appendRichComposerInput(richInput, " /review");
2463 richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
2464 if (!richInput) throw new Error("session A rich input disappeared");
2465 const queryAtEnd = document.createRange();
2466 queryAtEnd.selectNodeContents(richInput);
2467 queryAtEnd.collapse(false);
2468 document.getSelection()?.removeAllRanges();
2469 document.getSelection()?.addRange(queryAtEnd);
2470 await act(async () => {
2471 richInput.dispatchEvent(new window.KeyboardEvent("keyup", { key: "w", bubbles: true }));
2472 await flushTimers();
2473 });
2474 await waitFor("session A second skill menu", () => Boolean(document.querySelector(".slashmenu")));
2475
2476 await rerender({ sessionKey: sessionB, insertRequest: null });
2477 await replaceComposerDraft(rerender, 6001, "b");
2478 await rerender({ sessionKey: sessionA, insertRequest: null });
2479 await act(async () => {
2480 let frameTime = 0;
2481 while (queuedComposerFrames.length > 0) {
2482 queuedComposerFrames.shift()?.(frameTime += 16);
2483 }
2484 await flushTimers();
2485 });
2486 richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
2487 if (!richInput) throw new Error("session A rich input was not restored");
2488 eq(richComposerTaskText(richInput), " /review", "switching back restores the rich invocation draft");
2489 await waitFor(
2490 "restored session A rich slash menu",
2491 () => Boolean(document.querySelector(".slashmenu")),
2492 );
2493 ok(
2494 document.querySelector(".slashmenu") !== null,
2495 "restoring a rich invocation draft recomputes slash completion from its end caret",
2496 );
2497
2498 await act(async () => {
2499 richInput.dispatchEvent(new window.KeyboardEvent("keydown", {
2500 key: "Enter",
2501 bubbles: true,
2502 cancelable: true,
2503 }));
2504 await flushTimers();
2505 });
2506 richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
2507 eq(
2508 richInput?.querySelectorAll(".composer-invocation-token").length,
2509 2,
2510 "the restored rich slash query can select a second skill",
2511 );
2512 eq(richInput ? richComposerTaskText(richInput) : "", " ", "selecting the restored query replaces its slash token");
2513
2514 await rerender({ sessionKey: sessionB, insertRequest: null });
2515 eq(
2516 (document.querySelector("textarea") as HTMLTextAreaElement | null)?.value,
2517 "b",
2518 "switching away again preserves the other session draft",
2519 );
2520
2521 await act(async () => {
2522 root.unmount();
2523 });
2524 globalThis.requestAnimationFrame = realRequestAnimationFrame;
2525 dom.window.close();
2526 }
2527
2528 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
2529 if (failed > 0) process.exit(1);
2530
2530 lines Plain Text