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