返回 DeepSeek-Reasonix
composer-session-draft.test.tsx
根目录 / desktop / frontend / src / __tests__ / composer-session-draft.test.tsx
1 import { JSDOM } from "jsdom";
2 import React from "react";
3 import { act } from "react";
4 import { createRoot } from "react-dom/client";
5 import { Composer } from "../components/Composer";
6 import { invalidateCache } from "../lib/composerHistory";
7 import { composerDraftKeyForTab } from "../lib/composerDraftKey";
8 import { LocaleProvider } from "../lib/i18n";
9 import { resetCustomShortcuts, saveCustomShortcut } from "../lib/keyboardShortcuts";
10 import { ToastProvider } from "../lib/toast";
11 import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
12 import { installDesktopHostStub } from "./desktopHostStub";
13
14 let passed = 0;
15 let failed = 0;
16
17 function ok(value: boolean, label: string) {
18 if (value) {
19 process.stdout.write(` PASS ${label}\n`);
20 passed += 1;
21 } else {
22 process.stdout.write(` FAIL ${label}\n`);
23 failed += 1;
24 }
25 }
26
27 function eq(actual: unknown, expected: unknown, label: string) {
28 if (actual === expected) ok(true, label);
29 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
30 }
31
32 function flushTimers(): Promise<void> {
33 return new Promise((resolve) => setTimeout(resolve, 0));
34 }
35
36 function deferred<T>() {
37 let resolve!: (value: T) => void;
38 const promise = new Promise<T>((next) => {
39 resolve = next;
40 });
41 return { promise, resolve };
42 }
43
44 class TestResizeObserver {
45 observe() {}
46 unobserve() {}
47 disconnect() {}
48 }
49
50 function installDom() {
51 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
52 pretendToBeVisual: true,
53 url: "http://localhost/",
54 });
55 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
56 globalThis.window = dom.window as unknown as Window & typeof globalThis;
57 globalThis.document = dom.window.document;
58 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
59 globalThis.Node = dom.window.Node;
60 globalThis.HTMLElement = dom.window.HTMLElement;
61 globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
62 globalThis.Event = dom.window.Event;
63 globalThis.CustomEvent = dom.window.CustomEvent;
64 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
65 globalThis.InputEvent = dom.window.InputEvent;
66 globalThis.MouseEvent = dom.window.MouseEvent;
67 globalThis.File = dom.window.File;
68 globalThis.FileReader = dom.window.FileReader;
69 globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
70 globalThis.MutationObserver = dom.window.MutationObserver;
71 globalThis.localStorage = dom.window.localStorage;
72 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
73 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
74 globalThis.ResizeObserver = TestResizeObserver;
75 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
76 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
77 Object.defineProperty(window, "matchMedia", {
78 configurable: true,
79 value: () => ({
80 matches: true,
81 media: "(prefers-reduced-motion: reduce)",
82 onchange: null,
83 addEventListener() {},
84 removeEventListener() {},
85 addListener() {},
86 removeListener() {},
87 dispatchEvent: () => false,
88 }),
89 });
90 return dom;
91 }
92
93 function installBridgeApp(methods: Record<string, unknown>) {
94 installDesktopHostStub({
95 Commands: async () => [],
96 Models: async () => [],
97 ModelsForTab: async () => [],
98 ListDir: async () => [],
99 ListDirForTab: async () => [],
100 ListDirForTarget: async () => [],
101 SearchFileRefs: async () => [],
102 SearchFileRefsForTab: async () => [],
103 SearchFileRefsForTarget: async () => [],
104 CaptureAttachmentTarget: async () => ({ token: "test-attachment-target", capabilities: ["attachments-v2"] }), ReleaseAttachmentTarget: async () => {},
105 AttachmentDataURLForTarget: async (_target: unknown, path: string) => path,
106 ...methods,
107 });
108 }
109
110 async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) {
111 const rootEl = document.getElementById("root");
112 if (!rootEl) throw new Error("missing root");
113 const root = createRoot(rootEl);
114 let currentProps: Parameters<typeof Composer>[0] = {
115 running: false,
116 collaborationMode: "normal",
117 toolApprovalMode: "ask" as ToolApprovalMode,
118
119 goal: "",
120 cwd: "/repo",
121 modelLabel: "DeepSeek-R1",
122 tabId: "single-surface-tab",
123 sessionKey: "session:project:/repo:topic-a:session-a",
124 onSend: () => {},
125 onCancel: async () => ({ discardedItemIds: [] }),
126 onCycleMode: () => {},
127 onSetMode: () => {},
128 onSetCollaborationMode: (_mode: CollaborationMode) => {},
129 onSetToolApprovalMode: () => {},
130 onClearGoal: () => {},
131 onSwitchModel: () => {},
132 onSetEffort: () => {},
133
134 ready: true,
135 ...props,
136 };
137 const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
138 const switchingDraft = nextProps.sessionKey !== undefined && nextProps.sessionKey !== currentProps.sessionKey;
139 currentProps = {
140 ...currentProps,
141 ...(switchingDraft ? { insertRequest: null, selectedTextRequest: null } : {}),
142 ...nextProps,
143 };
144 await act(async () => {
145 root.render(
146 <LocaleProvider>
147 <ToastProvider>
148 <Composer {...currentProps} />
149 </ToastProvider>
150 </LocaleProvider>,
151 );
152 await flushTimers();
153 });
154 };
155 await paint();
156 return { root, rerender: paint };
157 }
158
159 function textarea(): HTMLTextAreaElement {
160 const node = document.querySelector("textarea") as HTMLTextAreaElement | null;
161 if (!node) throw new Error("composer textarea did not render");
162 return node;
163 }
164
165 function updateTextareaFromUser(
166 value: string,
167 inputType: "insertText" | "deleteContentBackward" | "historyUndo" | "historyRedo",
168 ) {
169 const input = textarea();
170 input.focus();
171 const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
172 if (!setter) throw new Error("textarea value setter is unavailable");
173 setter.call(input, value);
174 input.dispatchEvent(new window.InputEvent("input", {
175 bubbles: true,
176 data: inputType === "insertText" ? value.slice(-1) : null,
177 inputType,
178 }));
179 input.dispatchEvent(new window.Event("change", { bubbles: true }));
180 input.dispatchEvent(new window.KeyboardEvent("keyup", { key: "x", bubbles: true }));
181 }
182
183 function sendButton(): HTMLButtonElement {
184 const node = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
185 if (!node) throw new Error("composer send button did not render");
186 return node;
187 }
188
189 function contextItemCount(): number {
190 return document.querySelectorAll(".composer-context__item").length;
191 }
192
193 async function openComposerInputMenu(): Promise<HTMLButtonElement[]> {
194 await act(async () => {
195 textarea().dispatchEvent(new window.MouseEvent("contextmenu", {
196 bubbles: true,
197 cancelable: true,
198 clientX: 20,
199 clientY: 20,
200 }));
201 await flushTimers();
202 });
203 return Array.from(document.querySelectorAll(".context-menu__item")) as HTMLButtonElement[];
204 }
205
206 function textPasteEvent(text: string): Event {
207 const event = new Event("paste", { bubbles: true, cancelable: true });
208 Object.defineProperty(event, "clipboardData", {
209 configurable: true,
210 value: {
211 files: [],
212 items: [],
213 types: ["text/plain"],
214 getData: (kind: string) => (kind === "text" || kind === "text/plain" ? text : ""),
215 },
216 });
217 return event;
218 }
219
220 async function drainAnimationFrame() {
221 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
222 await flushTimers();
223 }
224
225 console.log("\ncomposer session draft");
226
227 {
228 const dom = installDom();
229 const { root, rerender } = await renderComposer();
230 const content = document.querySelector(".composer__content") as HTMLDivElement | null;
231 if (!content) throw new Error("composer content area did not render");
232
233 textarea().blur();
234 await act(async () => {
235 content.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
236 await drainAnimationFrame();
237 });
238 eq(document.activeElement, textarea(), "clicking empty composer space focuses the text input");
239 eq(textarea().selectionStart, 0, "empty composer space click places the caret on the first line");
240
241 await rerender({ insertRequest: { id: 100, text: "existing draft", mode: "replace" } });
242 await act(async () => {
243 textarea().setSelectionRange(0, 0);
244 textarea().blur();
245 content.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
246 await drainAnimationFrame();
247 });
248 eq(textarea().selectionStart, textarea().value.length, "clicking composer space places an existing draft caret at the end");
249
250 await act(async () => {
251 root.unmount();
252 });
253 dom.window.close();
254 }
255
256 {
257 const withoutPath = composerDraftKeyForTab({
258 id: "tab-a",
259 scope: "project",
260 workspaceRoot: "/repo",
261 topicId: "topic-a",
262 sessionPath: "",
263 }, "tab-a");
264 const withPath = composerDraftKeyForTab({
265 id: "tab-a",
266 scope: "project",
267 workspaceRoot: "/repo",
268 topicId: "topic-a",
269 sessionPath: "/repo/.reasonix/sessions/topic-a.jsonl",
270 }, "tab-a");
271 eq(withPath, withoutPath, "topic draft key stays stable when session path appears");
272 }
273
274 {
275 const dom = installDom();
276 const { root, rerender } = await renderComposer();
277
278 await rerender({ insertRequest: { id: 1, text: "draft for A", mode: "replace" } });
279 await rerender({ insertRequest: { id: 2, text: "@/repo/src/app.ts", mode: "insert" } });
280 eq(textarea().value, "draft for A", "session A text is visible before switching");
281 eq(contextItemCount(), 1, "session A workspace ref is visible before switching");
282
283 await rerender({ sessionKey: "session:project:/repo:topic-b:session-b" });
284 eq(textarea().value, "", "session B does not inherit session A text");
285 eq(contextItemCount(), 0, "session B does not inherit session A context refs");
286
287 await rerender({ insertRequest: { id: 3, text: "draft for B", mode: "replace" } });
288 eq(textarea().value, "draft for B", "session B keeps its own text draft");
289
290 await rerender({ sessionKey: "session:project:/repo:topic-a:session-a" });
291 eq(textarea().value, "draft for A", "session A text is restored when switching back");
292 eq(contextItemCount(), 1, "session A context refs are restored when switching back");
293
294 await rerender({ sessionKey: "session:project:/repo:topic-b:session-b" });
295 eq(textarea().value, "draft for B", "session B text is restored independently");
296 eq(contextItemCount(), 0, "session B context refs stay isolated");
297
298 await act(async () => {
299 root.unmount();
300 });
301 dom.window.close();
302 }
303
304 {
305 // A queued follow-up belongs to the session where it was entered. Switching
306 // to an idle session must neither auto-send it there nor discard it from the
307 // running source session.
308 const dom = installDom();
309 const sent: Array<{ tab: string; text: string }> = [];
310 const inboxByTab = new Map<string, Array<{ id: string; preview: string }>>();
311 let inboxRevision = 0;
312 installBridgeApp({
313 InboxSnapshot: async (tabId: string) => {
314 const items = (inboxByTab.get(tabId) ?? []).map((item, position) => ({
315 ...item,
316 intent: "followup",
317 state: "queued",
318 byteSize: item.preview.length,
319 position: position + 1,
320 }));
321 return {
322 revision: inboxRevision,
323 paused: false,
324 recovered: false,
325 items,
326 itemsCount: items.length,
327 bytes: items.reduce((sum, item) => sum + item.byteSize, 0),
328 maxItems: 64,
329 maxBytes: 64 * 1024 * 1024,
330 };
331 },
332 EnqueueInboxFollowup: async (tabId: string, display: string) => {
333 const item = { id: "durable-tab-a", preview: display };
334 inboxByTab.set(tabId, [...(inboxByTab.get(tabId) ?? []), item]);
335 inboxRevision += 1;
336 return { itemId: item.id, disposition: "queued_followup", position: 1, paused: false };
337 },
338 });
339 const { root, rerender } = await renderComposer({
340 running: true,
341 tabId: "tab-a", inboxSessionPath: "/repo/session-a.jsonl",
342 sessionKey: "session:project:/repo:topic-a:session-a",
343 onSend: (text, _submit, targetTabId) => {
344 sent.push({ tab: targetTabId ?? "", text });
345 },
346 });
347 await rerender({ insertRequest: { id: 10, text: "follow up in A", mode: "replace" } });
348 await act(async () => {
349 sendButton().click();
350 await flushTimers();
351 });
352 ok(document.querySelector(".composer-guidance-item") !== null, "session A shows its queued guidance before switching");
353 await rerender({
354 running: false,
355 tabId: "tab-b", inboxSessionPath: "/repo/session-b.jsonl",
356 sessionKey: "session:project:/repo:topic-b:session-b",
357 onSend: (text, _submit, targetTabId) => {
358 sent.push({ tab: targetTabId ?? "", text });
359 },
360 });
361 eq(sent.length, 0, "switching to idle session B does not send session A guidance");
362 ok(document.querySelector(".composer-guidance-item") === null, "session B does not inherit session A guidance shelf");
363
364 await rerender({
365 running: true,
366 tabId: "tab-a", inboxSessionPath: "/repo/session-a.jsonl",
367 sessionKey: "session:project:/repo:topic-a:session-a",
368 onSend: (text, _submit, targetTabId) => {
369 sent.push({ tab: targetTabId ?? "", text });
370 },
371 });
372 ok(document.querySelector(".composer-guidance-item") !== null, "session A restores its queued guidance after switching back");
373
374 inboxByTab.delete("tab-a"); // Controller dispatched and durably acked it.
375 inboxRevision += 1;
376 await rerender({ running: false });
377 await act(async () => {
378 await flushTimers();
379 });
380 eq(sent.length, 0, "session A completion does not duplicate the Controller-owned follow-up");
381 ok(document.querySelector(".composer-guidance-item") === null, "session A clears the durable item after its backend ack");
382 await act(async () => {
383 root.unmount();
384 });
385 dom.window.close();
386 }
387
388 {
389 const dom = installDom();
390 const sent: Array<{ display: string; submit?: string }> = [];
391 const { root, rerender } = await renderComposer({
392 onSend: (display, submit) => {
393 sent.push({ display, submit });
394 },
395 });
396 const typedPrefix = "typed before paste: ";
397 await rerender({ insertRequest: { id: 100, text: typedPrefix, mode: "replace" } });
398 const rawPaste = "error: failed to compile\r\nat loader.ts:10\r\nat run.ts:22";
399 const normalizedPaste = "error: failed to compile\nat loader.ts:10\nat run.ts:22";
400 const event = textPasteEvent(rawPaste);
401
402 await act(async () => {
403 const input = textarea();
404 input.selectionStart = input.selectionEnd = typedPrefix.length;
405 input.dispatchEvent(event);
406 await flushTimers();
407 });
408
409 eq(event.defaultPrevented, true, "short text paste is handled by React state");
410 eq(textarea().value, typedPrefix + normalizedPaste, "short multiline paste is visible after existing text");
411
412 const undo = new window.KeyboardEvent("keydown", { key: "z", ctrlKey: true, bubbles: true, cancelable: true });
413 await act(async () => {
414 textarea().dispatchEvent(undo);
415 await flushTimers();
416 });
417 eq(undo.defaultPrevented, true, "Ctrl+Z handles the programmatic paste transaction");
418 eq(textarea().value, typedPrefix, "Ctrl+Z preserves the text typed before a short paste");
419
420 const redo = new window.KeyboardEvent("keydown", {
421 key: "Z",
422 ctrlKey: true,
423 shiftKey: true,
424 bubbles: true,
425 cancelable: true,
426 });
427 await act(async () => {
428 textarea().dispatchEvent(redo);
429 await flushTimers();
430 });
431 eq(redo.defaultPrevented, true, "Ctrl+Shift+Z redoes the programmatic paste transaction");
432 eq(textarea().value, typedPrefix + normalizedPaste, "redo restores the short paste after existing text");
433
434 const undoAgain = new window.KeyboardEvent("keydown", {
435 key: "z",
436 ctrlKey: true,
437 bubbles: true,
438 cancelable: true,
439 });
440 await act(async () => {
441 textarea().dispatchEvent(undoAgain);
442 await flushTimers();
443 });
444 eq(textarea().value, typedPrefix, "a second undo returns to the pre-paste boundary");
445
446 await act(async () => {
447 updateTextareaFromUser(`${typedPrefix}X`, "insertText");
448 await flushTimers();
449 });
450 await rerender();
451 eq(textarea().value, `${typedPrefix}X`, "ordinary typing after paste undo updates the draft");
452 await act(async () => {
453 updateTextareaFromUser(typedPrefix, "deleteContentBackward");
454 await flushTimers();
455 });
456 await rerender();
457
458 const staleRedo = new window.KeyboardEvent("keydown", {
459 key: "Z",
460 ctrlKey: true,
461 shiftKey: true,
462 bubbles: true,
463 cancelable: true,
464 });
465 await act(async () => {
466 textarea().dispatchEvent(staleRedo);
467 await flushTimers();
468 });
469 eq(staleRedo.defaultPrevented, false, "a new native edit invalidates the programmatic redo transaction");
470 eq(textarea().value, typedPrefix, "stale redo cannot resurrect an earlier paste");
471
472 await act(async () => {
473 const input = textarea();
474 input.selectionStart = input.selectionEnd = typedPrefix.length;
475 input.dispatchEvent(textPasteEvent(rawPaste));
476 await flushTimers();
477 });
478
479 await act(async () => {
480 sendButton().click();
481 await flushTimers();
482 });
483
484 eq(sent.length, 1, "short multiline paste submits once");
485 eq(sent[0]?.display, typedPrefix + normalizedPaste, "short multiline paste is preserved in display text");
486 eq(sent[0]?.submit, typedPrefix + normalizedPaste, "short multiline paste is preserved in submit text");
487
488 await act(async () => {
489 root.unmount();
490 });
491 dom.window.close();
492 }
493
494 {
495 const dom = installDom();
496 const { root, rerender } = await renderComposer();
497 const base = "base:";
498 const pasted = "paste";
499 await rerender({ insertRequest: { id: 101, text: base, mode: "replace" } });
500 await act(async () => {
501 const input = textarea();
502 input.selectionStart = input.selectionEnd = base.length;
503 input.dispatchEvent(textPasteEvent(pasted));
504 await flushTimers();
505 });
506 eq(textarea().value, base + pasted, "interleaved-history test starts at the paste boundary");
507
508 await act(async () => {
509 updateTextareaFromUser(`${base}${pasted}X`, "insertText");
510 await flushTimers();
511 });
512 await rerender();
513 await act(async () => {
514 updateTextareaFromUser(base + pasted, "deleteContentBackward");
515 await flushTimers();
516 });
517 await rerender();
518
519 const undoNativeDelete = new window.KeyboardEvent("keydown", {
520 key: "z",
521 ctrlKey: true,
522 bubbles: true,
523 cancelable: true,
524 });
525 await act(async () => {
526 textarea().dispatchEvent(undoNativeDelete);
527 await flushTimers();
528 });
529 eq(
530 undoNativeDelete.defaultPrevented,
531 false,
532 "Ctrl+Z leaves a native deletion above the paste transaction to the browser",
533 );
534 eq(
535 textarea().value,
536 base + pasted,
537 "matching net text does not make the older paste transaction look newest",
538 );
539
540 await act(async () => {
541 updateTextareaFromUser(`${base}${pasted}X`, "historyUndo");
542 await flushTimers();
543 });
544 await rerender();
545 const undoNativeInsert = new window.KeyboardEvent("keydown", {
546 key: "z",
547 ctrlKey: true,
548 bubbles: true,
549 cancelable: true,
550 });
551 await act(async () => {
552 textarea().dispatchEvent(undoNativeInsert);
553 await flushTimers();
554 });
555 eq(
556 undoNativeInsert.defaultPrevented,
557 false,
558 "Ctrl+Z leaves the remaining native insertion to the browser",
559 );
560
561 await act(async () => {
562 updateTextareaFromUser(base + pasted, "historyUndo");
563 await flushTimers();
564 });
565 await rerender();
566 const undoPasteAfterNative = new window.KeyboardEvent("keydown", {
567 key: "z",
568 ctrlKey: true,
569 bubbles: true,
570 cancelable: true,
571 });
572 await act(async () => {
573 textarea().dispatchEvent(undoPasteAfterNative);
574 await flushTimers();
575 });
576 eq(
577 undoPasteAfterNative.defaultPrevented,
578 true,
579 "the paste transaction becomes undoable only after newer native edits are exhausted",
580 );
581 eq(textarea().value, base, "ordered undo eventually restores the pre-paste text");
582
583 const redoPasteBeforeNative = new window.KeyboardEvent("keydown", {
584 key: "Z",
585 ctrlKey: true,
586 shiftKey: true,
587 bubbles: true,
588 cancelable: true,
589 });
590 await act(async () => {
591 textarea().dispatchEvent(redoPasteBeforeNative);
592 await flushTimers();
593 });
594 eq(
595 redoPasteBeforeNative.defaultPrevented,
596 true,
597 "ordered redo restores the custom paste before its newer native edits",
598 );
599 eq(textarea().value, base + pasted, "custom redo returns to the browser redo boundary");
600
601 const redoNativeAfterPaste = new window.KeyboardEvent("keydown", {
602 key: "Z",
603 ctrlKey: true,
604 shiftKey: true,
605 bubbles: true,
606 cancelable: true,
607 });
608 await act(async () => {
609 textarea().dispatchEvent(redoNativeAfterPaste);
610 await flushTimers();
611 });
612 eq(
613 redoNativeAfterPaste.defaultPrevented,
614 false,
615 "newer native redo remains browser-owned after the paste redo",
616 );
617
618 await act(async () => root.unmount());
619 dom.window.close();
620 }
621
622 {
623 const dom = installDom();
624 const { root, rerender } = await renderComposer();
625 await act(async () => {
626 textarea().dispatchEvent(textPasteEvent("first"));
627 await flushTimers();
628 });
629 await act(async () => {
630 updateTextareaFromUser("firstX", "insertText");
631 await flushTimers();
632 });
633 await rerender();
634 await act(async () => {
635 const input = textarea();
636 input.selectionStart = input.selectionEnd = input.value.length;
637 input.dispatchEvent(textPasteEvent("second"));
638 await flushTimers();
639 });
640 eq(textarea().value, "firstXsecond", "two custom pastes can surround a native edit");
641
642 const undoSecondPaste = new window.KeyboardEvent("keydown", {
643 key: "z",
644 ctrlKey: true,
645 bubbles: true,
646 cancelable: true,
647 });
648 await act(async () => {
649 textarea().dispatchEvent(undoSecondPaste);
650 await flushTimers();
651 });
652 eq(undoSecondPaste.defaultPrevented, true, "the newest custom paste undoes first");
653 eq(textarea().value, "firstX", "undoing the second paste reaches its native boundary");
654
655 const undoInterleavedNative = new window.KeyboardEvent("keydown", {
656 key: "z",
657 ctrlKey: true,
658 bubbles: true,
659 cancelable: true,
660 });
661 await act(async () => {
662 textarea().dispatchEvent(undoInterleavedNative);
663 await flushTimers();
664 });
665 eq(
666 undoInterleavedNative.defaultPrevented,
667 false,
668 "the native edit between two custom pastes remains browser-owned",
669 );
670 await act(async () => {
671 updateTextareaFromUser("first", "historyUndo");
672 await flushTimers();
673 });
674 await rerender();
675
676 const undoFirstPaste = new window.KeyboardEvent("keydown", {
677 key: "z",
678 ctrlKey: true,
679 bubbles: true,
680 cancelable: true,
681 });
682 await act(async () => {
683 textarea().dispatchEvent(undoFirstPaste);
684 await flushTimers();
685 });
686 eq(undoFirstPaste.defaultPrevented, true, "the older custom paste follows the native undo segment");
687 eq(textarea().value, "", "ordered undo reaches the start of the mixed timeline");
688
689 const redoFirstPaste = new window.KeyboardEvent("keydown", {
690 key: "Z",
691 ctrlKey: true,
692 shiftKey: true,
693 bubbles: true,
694 cancelable: true,
695 });
696 await act(async () => {
697 textarea().dispatchEvent(redoFirstPaste);
698 await flushTimers();
699 });
700 eq(redoFirstPaste.defaultPrevented, true, "ordered redo restores the first custom paste");
701 eq(textarea().value, "first", "redo reaches the native segment after the first paste");
702
703 const redoInterleavedNative = new window.KeyboardEvent("keydown", {
704 key: "Z",
705 ctrlKey: true,
706 shiftKey: true,
707 bubbles: true,
708 cancelable: true,
709 });
710 await act(async () => {
711 textarea().dispatchEvent(redoInterleavedNative);
712 await flushTimers();
713 });
714 eq(
715 redoInterleavedNative.defaultPrevented,
716 false,
717 "ordered redo returns the interleaved native edit to the browser",
718 );
719 await act(async () => {
720 updateTextareaFromUser("firstX", "historyRedo");
721 await flushTimers();
722 });
723 await rerender();
724
725 const redoSecondPaste = new window.KeyboardEvent("keydown", {
726 key: "Z",
727 ctrlKey: true,
728 shiftKey: true,
729 bubbles: true,
730 cancelable: true,
731 });
732 await act(async () => {
733 textarea().dispatchEvent(redoSecondPaste);
734 await flushTimers();
735 });
736 eq(redoSecondPaste.defaultPrevented, true, "the second custom paste redoes after the native segment");
737 eq(textarea().value, "firstXsecond", "mixed custom/native redo restores the final state");
738
739 await act(async () => root.unmount());
740 dom.window.close();
741 }
742
743 {
744 const dom = installDom();
745 await act(async () => {
746 resetCustomShortcuts();
747 await flushTimers();
748 });
749 saveCustomShortcut("toolApproval.yolo", { key: "z", ctrl: true });
750 const { root } = await renderComposer();
751 await act(async () => {
752 textarea().dispatchEvent(textPasteEvent("pasted"));
753 await flushTimers();
754 });
755 const undoPaste = new window.KeyboardEvent("keydown", {
756 key: "z",
757 ctrlKey: true,
758 bubbles: true,
759 cancelable: true,
760 });
761 await act(async () => {
762 textarea().dispatchEvent(undoPaste);
763 await flushTimers();
764 });
765 eq(undoPaste.defaultPrevented, true, "legacy Ctrl+Z YOLO binding yields to composer undo while editing");
766 eq(textarea().value, "", "legacy Ctrl+Z YOLO binding still undoes the paste");
767
768 await act(async () => {
769 saveCustomShortcut("toolApproval.yolo", { key: "z", ctrl: true, shift: true });
770 await flushTimers();
771 });
772 const legacyRedo = new window.KeyboardEvent("keydown", {
773 key: "z",
774 ctrlKey: true,
775 shiftKey: true,
776 bubbles: true,
777 cancelable: true,
778 });
779 await act(async () => {
780 textarea().dispatchEvent(legacyRedo);
781 await flushTimers();
782 });
783 eq(legacyRedo.defaultPrevented, true, "legacy Ctrl+Shift+Z YOLO binding yields to composer redo while editing");
784 eq(textarea().value, "pasted", "legacy Ctrl+Shift+Z YOLO binding still redoes the paste");
785
786 await act(async () => {
787 saveCustomShortcut("toolApproval.yolo", { key: "u", ctrl: true });
788 textarea().dispatchEvent(new window.KeyboardEvent("keydown", {
789 key: "z",
790 ctrlKey: true,
791 bubbles: true,
792 cancelable: true,
793 }));
794 await flushTimers();
795 });
796 eq(textarea().value, "", "Ctrl+Z prepares a custom redo transaction after YOLO is rebound");
797
798 const ctrlYRedo = new window.KeyboardEvent("keydown", {
799 key: "y",
800 ctrlKey: true,
801 bubbles: true,
802 cancelable: true,
803 });
804 await act(async () => {
805 textarea().dispatchEvent(ctrlYRedo);
806 await flushTimers();
807 });
808 eq(ctrlYRedo.defaultPrevented, true, "Ctrl+Y redoes the paste when the YOLO shortcut is rebound");
809 eq(textarea().value, "pasted", "Ctrl+Y restores the programmatic paste");
810
811 await act(async () => {
812 resetCustomShortcuts();
813 await flushTimers();
814 });
815 const defaultCtrlY = new window.KeyboardEvent("keydown", {
816 key: "y",
817 ctrlKey: true,
818 bubbles: true,
819 cancelable: true,
820 });
821 await act(async () => {
822 textarea().dispatchEvent(defaultCtrlY);
823 await flushTimers();
824 });
825 eq(defaultCtrlY.defaultPrevented, true, "default Ctrl+Y remains reserved while the legacy permission shortcut is retired");
826 eq(textarea().value, "pasted", "the retired shortcut does not mutate composer history");
827
828 await act(async () => root.unmount());
829 resetCustomShortcuts();
830 dom.window.close();
831 }
832
833 {
834 const dom = installDom();
835 const { root, rerender } = await renderComposer({
836 tabId: "tab-a",
837 sessionKey: "session:project:/repo:topic-a:session-a",
838 });
839 const longPaste = Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join("\n");
840 await act(async () => {
841 textarea().dispatchEvent(textPasteEvent(longPaste));
842 await flushTimers();
843 });
844 ok(document.querySelector(".composer__pasted-label") !== null, "long paste creates a folded block");
845 const previewButton = document.querySelector<HTMLButtonElement>(".composer__pasted-actions button");
846 if (!previewButton) throw new Error("long paste preview button did not render");
847 await act(async () => {
848 previewButton.click();
849 await flushTimers();
850 });
851 ok(document.querySelector(".composer__pasted-preview") !== null, "long paste preview opens without changing text history");
852
853 await rerender({
854 tabId: "tab-b",
855 sessionKey: "session:project:/repo:topic-b:session-b",
856 insertRequest: { id: 101, text: "draft B", mode: "replace" },
857 });
858 const unrelatedUndo = new window.KeyboardEvent("keydown", {
859 key: "z",
860 ctrlKey: true,
861 bubbles: true,
862 cancelable: true,
863 });
864 await act(async () => {
865 textarea().dispatchEvent(unrelatedUndo);
866 await flushTimers();
867 });
868 eq(unrelatedUndo.defaultPrevented, false, "another draft does not inherit the paste undo transaction");
869 eq(textarea().value, "draft B", "Ctrl+Z in another draft leaves its text unchanged");
870
871 await rerender({ tabId: "tab-a", sessionKey: "session:project:/repo:topic-a:session-a" });
872 ok(document.querySelector(".composer__pasted-preview") !== null, "source draft restores its open long-paste preview");
873 const foldedUndo = new window.KeyboardEvent("keydown", {
874 key: "z",
875 ctrlKey: true,
876 bubbles: true,
877 cancelable: true,
878 });
879 await act(async () => {
880 textarea().dispatchEvent(foldedUndo);
881 await flushTimers();
882 });
883 eq(foldedUndo.defaultPrevented, true, "source draft keeps its own folded-paste undo transaction");
884 eq(textarea().value, "", "Ctrl+Z removes the folded-paste label");
885 ok(document.querySelector(".composer__pasted-label") === null, "Ctrl+Z removes the folded block state");
886 ok(document.querySelector(".composer__pasted-preview") === null, "Ctrl+Z also clears preview state with the folded block");
887
888 await act(async () => root.unmount());
889 dom.window.close();
890 }
891
892 {
893 const dom = installDom();
894 const { root } = await renderComposer();
895 const longPaste = Array.from({ length: 20 }, (_, index) => `expanded ${index + 1}`).join("\n");
896 await act(async () => {
897 textarea().dispatchEvent(textPasteEvent(longPaste));
898 await flushTimers();
899 });
900 const expandButton = document.querySelectorAll<HTMLButtonElement>(".composer__pasted-actions button")[1];
901 if (!expandButton) throw new Error("long paste expand button did not render");
902 await act(async () => {
903 expandButton.click();
904 await flushTimers();
905 });
906 eq(textarea().value, longPaste, "expanding a folded paste restores its original text");
907 ok(document.querySelector(".composer__pasted-label") === null, "expanded paste no longer renders a folded label");
908
909 const undoExpand = new window.KeyboardEvent("keydown", {
910 key: "z",
911 ctrlKey: true,
912 bubbles: true,
913 cancelable: true,
914 });
915 await act(async () => {
916 textarea().dispatchEvent(undoExpand);
917 await flushTimers();
918 });
919 eq(undoExpand.defaultPrevented, true, "Ctrl+Z treats pasted-block expansion as a custom transaction");
920 ok(document.querySelector(".composer__pasted-label") !== null, "undoing expansion restores the folded block");
921
922 const undoLongPaste = new window.KeyboardEvent("keydown", {
923 key: "z",
924 ctrlKey: true,
925 bubbles: true,
926 cancelable: true,
927 });
928 await act(async () => {
929 textarea().dispatchEvent(undoLongPaste);
930 await flushTimers();
931 });
932 eq(undoLongPaste.defaultPrevented, true, "a second Ctrl+Z undoes the original folded paste");
933 eq(textarea().value, "", "undoing the original folded paste restores the empty draft");
934
935 await act(async () => root.unmount());
936 dom.window.close();
937 }
938
939 {
940 const dom = installDom();
941 const saveStarted = deferred<void>();
942 const savePastedFile = deferred<string>();
943 const sent: string[] = [];
944 installBridgeApp({
945 SavePastedFileForTarget: async () => {
946 saveStarted.resolve();
947 return savePastedFile.promise;
948 },
949 });
950 const { root, rerender } = await renderComposer({
951 onSend: (text) => {
952 sent.push(text);
953 },
954 });
955 const file = new File(["draft attachment"], "draft.txt", { type: "text/plain", lastModified: 1 });
956 const event = new Event("paste", { bubbles: true, cancelable: true });
957 Object.defineProperty(event, "clipboardData", {
958 configurable: true,
959 value: {
960 files: [file],
961 items: [],
962 types: [],
963 getData: () => "",
964 },
965 });
966
967 await act(async () => {
968 textarea().dispatchEvent(event);
969 await saveStarted.promise;
970 });
971 await rerender({ insertRequest: { id: 10, text: "session A waits for its attachment", mode: "replace" } });
972 ok(sendButton().disabled, "source session cannot submit while its actual attachment is pending");
973 await act(async () => { sendButton().click(); await flushTimers(); });
974 eq(sent.length, 0, "pending attachment prevents source-session dispatch");
975 await rerender({ sessionKey: "session:project:/repo:topic-b:session-b" });
976 await rerender({ insertRequest: { id: 11, text: "session B stays writable", mode: "replace" } });
977 ok(sendButton().disabled === false, "session B is not blocked by session A's pending attachment");
978 await act(async () => {
979 sendButton().click();
980 await flushTimers();
981 });
982 eq(sent.join(","), "session B stays writable", "session B can submit while session A attachment is pending");
983 await act(async () => {
984 savePastedFile.resolve("/tmp/reasonix/draft.txt");
985 await flushTimers();
986 });
987 eq(contextItemCount(), 0, "async attachment does not land in the switched-to session");
988
989 await rerender({ sessionKey: "session:project:/repo:topic-a:session-a" });
990 eq(contextItemCount(), 1, "async attachment returns to the source session draft");
991
992 await act(async () => {
993 root.unmount();
994 });
995 dom.window.close();
996 }
997
998 {
999 const dom = installDom();
1000 const submitStarted = deferred<void>();
1001 const releaseSubmit = deferred<void>();
1002 const sent: Array<{ tab: string; text: string }> = [];
1003 const { root, rerender } = await renderComposer({
1004 tabId: "tab-a",
1005 sessionKey: "session:project:/repo:topic-a:session-a",
1006 onSend: async (text, _submit, targetTabId) => {
1007 sent.push({ tab: targetTabId ?? "", text });
1008 submitStarted.resolve();
1009 await releaseSubmit.promise;
1010 },
1011 });
1012
1013 await rerender({ insertRequest: { id: 12, text: "slow submit in A", mode: "replace" } });
1014 await act(async () => {
1015 sendButton().click();
1016 await submitStarted.promise;
1017 });
1018
1019 await rerender({
1020 tabId: "tab-b",
1021 sessionKey: "session:project:/repo:topic-b:session-b",
1022 onSend: (text, _submit, targetTabId) => {
1023 sent.push({ tab: targetTabId ?? "", text });
1024 },
1025 });
1026 await rerender({ insertRequest: { id: 13, text: "fast submit in B", mode: "replace" } });
1027 ok(sendButton().disabled === false, "session B is not blocked by session A's in-flight submit");
1028 await act(async () => {
1029 sendButton().click();
1030 await flushTimers();
1031 });
1032 eq(sent[0]?.tab, "tab-a", "slow submit retains session A as its target");
1033 eq(sent[1]?.tab, "tab-b", "session B submit keeps its own target");
1034
1035 await act(async () => {
1036 releaseSubmit.resolve();
1037 await flushTimers();
1038 root.unmount();
1039 });
1040 dom.window.close();
1041 }
1042
1043 {
1044 // Clipboard menu actions await browser APIs. Their eventual mutation must
1045 // stay with the draft that owned the selection, even after a tab switch.
1046 const dom = installDom();
1047 const clipboardRead = deferred<string>();
1048 Object.defineProperty(window.navigator, "clipboard", {
1049 configurable: true,
1050 value: {
1051 read: async () => [],
1052 readText: () => clipboardRead.promise,
1053 writeText: async () => {},
1054 },
1055 });
1056 const { root, rerender } = await renderComposer({
1057 tabId: "tab-a",
1058 sessionKey: "session:project:/repo:topic-a:session-a",
1059 });
1060 await rerender({ insertRequest: { id: 20, text: "A:", mode: "replace" } });
1061 textarea().setSelectionRange(2, 2);
1062 const menuItems = await openComposerInputMenu();
1063 await act(async () => {
1064 menuItems[4]?.click();
1065 await flushTimers();
1066 });
1067 await rerender({
1068 tabId: "tab-b",
1069 sessionKey: "session:project:/repo:topic-b:session-b",
1070 insertRequest: { id: 21, text: "B stays clean", mode: "replace" },
1071 });
1072 await act(async () => {
1073 clipboardRead.resolve("pasted into A");
1074 await flushTimers();
1075 });
1076 eq(textarea().value, "B stays clean", "async clipboard paste does not mutate the switched-to session");
1077 await rerender({ tabId: "tab-a", sessionKey: "session:project:/repo:topic-a:session-a" });
1078 eq(textarea().value, "A:pasted into A", "async clipboard paste returns to its source draft");
1079
1080 await act(async () => root.unmount());
1081 dom.window.close();
1082 }
1083
1084 {
1085 const dom = installDom();
1086 const clipboardWrite = deferred<void>();
1087 const clipboardWriteStarted = deferred<void>();
1088 Object.defineProperty(window.navigator, "clipboard", {
1089 configurable: true,
1090 value: {
1091 read: async () => [],
1092 readText: async () => "",
1093 writeText: () => {
1094 clipboardWriteStarted.resolve();
1095 return clipboardWrite.promise;
1096 },
1097 },
1098 });
1099 const { root, rerender } = await renderComposer({
1100 tabId: "tab-a",
1101 sessionKey: "session:project:/repo:topic-a:session-a",
1102 });
1103 await rerender({ insertRequest: { id: 22, text: "abcdef", mode: "replace" } });
1104 await act(async () => {
1105 await new Promise((resolve) => window.requestAnimationFrame(() => resolve(null)));
1106 await flushTimers();
1107 });
1108 textarea().setSelectionRange(1, 4);
1109 const menuItems = await openComposerInputMenu();
1110 await act(async () => {
1111 menuItems[2]?.click();
1112 await clipboardWriteStarted.promise;
1113 });
1114 await rerender({
1115 tabId: "tab-b",
1116 sessionKey: "session:project:/repo:topic-b:session-b",
1117 insertRequest: { id: 23, text: "B is untouched", mode: "replace" },
1118 });
1119 await act(async () => {
1120 clipboardWrite.resolve();
1121 await flushTimers();
1122 await flushTimers();
1123 });
1124 eq(textarea().value, "B is untouched", "async cut does not delete text from the switched-to session");
1125 await rerender({ tabId: "tab-a", sessionKey: "session:project:/repo:topic-a:session-a" });
1126 eq(textarea().value, "aef", "async cut completes in its source draft");
1127
1128 await act(async () => root.unmount());
1129 dom.window.close();
1130 }
1131
1132 {
1133 const dom = installDom();
1134 invalidateCache();
1135 const historyPage = deferred<unknown>();
1136 installBridgeApp({ ScanPromptHistory: () => historyPage.promise });
1137 const { root, rerender } = await renderComposer({
1138 tabId: "tab-a",
1139 sessionKey: "session:project:/repo:topic-a:session-a",
1140 });
1141 await rerender({ insertRequest: { id: 24, text: "draft A", mode: "replace" } });
1142 textarea().setSelectionRange(0, 0);
1143 await act(async () => {
1144 textarea().dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowUp", code: "ArrowUp", bubbles: true }));
1145 await flushTimers();
1146 });
1147 await rerender({
1148 tabId: "tab-b",
1149 sessionKey: "session:project:/repo:topic-b:session-b",
1150 insertRequest: { id: 25, text: "draft B", mode: "replace" },
1151 });
1152 await act(async () => {
1153 historyPage.resolve({
1154 entries: [{ text: "older prompt for A", at: 1, sessionPath: "/a.jsonl", turn: 0 }],
1155 nonce: "history-test",
1156 hasOlder: false,
1157 });
1158 await flushTimers();
1159 });
1160 eq(textarea().value, "draft B", "async prompt history does not overwrite the switched-to session");
1161 await rerender({ tabId: "tab-a", sessionKey: "session:project:/repo:topic-a:session-a" });
1162 eq(textarea().value, "older prompt for A", "async prompt history result returns to its source draft");
1163
1164 await act(async () => root.unmount());
1165 dom.window.close();
1166 }
1167
1168 {
1169 // Session-reference expansion awaits PreviewSession. A tab switch during
1170 // that await must not make A expand B's identically-labelled folded paste.
1171 const dom = installDom();
1172 const previewResult = deferred<Array<{ role: string; content: string }>>();
1173 let previewCalls = 0;
1174 const sent: Array<{ tab: string; submit: string }> = [];
1175 installBridgeApp({
1176 ListSessions: async () => [{
1177 path: "/history.jsonl",
1178 preview: "history",
1179 title: "History",
1180 turns: 1,
1181 createdAt: 1,
1182 lastActivityAt: 1,
1183 modTime: 1,
1184 current: false,
1185 open: false,
1186 }],
1187 PreviewSession: async () => {
1188 previewCalls += 1;
1189 return previewResult.promise;
1190 },
1191 });
1192 const { root, rerender } = await renderComposer({
1193 tabId: "tab-a",
1194 sessionKey: "session:project:/repo:topic-a:session-a",
1195 onSend: (_display, submit, targetTabId) => {
1196 sent.push({ tab: targetTabId ?? "", submit: submit ?? "" });
1197 },
1198 });
1199 const longA = Array.from({ length: 20 }, (_, index) => `A line ${index}`).join("\n");
1200 const longB = Array.from({ length: 20 }, (_, index) => `B line ${index}`).join("\n");
1201 await act(async () => {
1202 textarea().dispatchEvent(textPasteEvent(longA));
1203 await flushTimers();
1204 });
1205 textarea().setSelectionRange(textarea().value.length, textarea().value.length);
1206 await act(async () => {
1207 textarea().dispatchEvent(textPasteEvent(" @past:chats"));
1208 await flushTimers();
1209 });
1210 await act(async () => {
1211 textarea().dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", code: "Enter", bubbles: true }));
1212 await flushTimers();
1213 });
1214 await act(async () => {
1215 textarea().dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", code: "Enter", bubbles: true }));
1216 await flushTimers();
1217 });
1218 await act(async () => {
1219 sendButton().click();
1220 await flushTimers();
1221 });
1222 eq(previewCalls, 1, "source session reference starts asynchronous expansion");
1223 await rerender({
1224 tabId: "tab-b",
1225 sessionKey: "session:project:/repo:topic-b:session-b",
1226 });
1227 await act(async () => {
1228 textarea().dispatchEvent(textPasteEvent(longB));
1229 await flushTimers();
1230 previewResult.resolve([{ role: "user", content: "history context" }]);
1231 await flushTimers();
1232 });
1233 eq(sent[0]?.tab, "tab-a", "session-context submit retains its source tab");
1234 ok(sent[0]?.submit.includes("A line 19") === true, "session-context submit expands source folded paste");
1235 ok(sent[0]?.submit.includes("B line 19") === false, "session-context submit excludes switched-to folded paste");
1236
1237 await act(async () => root.unmount());
1238 dom.window.close();
1239 }
1240
1241 {
1242 const dom = installDom();
1243 const sent: Array<{ display: string; submit: string }> = [];
1244 const { root, rerender } = await renderComposer({
1245 onSend: (display, submit) => {
1246 sent.push({ display, submit: submit ?? "" });
1247 },
1248 });
1249
1250 await rerender({ insertRequest: { id: 30, text: "Explain the selected behavior", mode: "replace" } });
1251 await rerender({ selectedTextRequest: { id: 1, text: " selected assistant response " } });
1252 await act(async () => drainAnimationFrame());
1253
1254 const selectionCard = document.querySelector(".composer-context__item--selection");
1255 ok(selectionCard != null, "Add to Chat renders a dedicated composer selection card");
1256 eq(selectionCard?.textContent?.includes("selected assistant response"), true, "selection card previews the selected text");
1257 eq(selectionCard?.querySelector("button")?.getAttribute("aria-label"), "Remove selected text", "selection card remove action has an accessible name");
1258 eq(textarea().value, "Explain the selected behavior", "adding selected text preserves the existing draft");
1259 eq(document.activeElement, textarea(), "adding selected text returns focus to the composer");
1260
1261 await rerender({ sessionKey: "session:project:/repo:topic-b:session-b" });
1262 eq(document.querySelector(".composer-context__item--selection"), null, "another session does not inherit the selection");
1263 await rerender({ sessionKey: "session:project:/repo:topic-a:session-a" });
1264 ok(document.querySelector(".composer-context__item--selection") != null, "the source session restores its selection draft");
1265
1266 await rerender({ selectedTextRequest: { id: 2, text: "const value = 1;\n", path: "src/lib/util.ts" } });
1267 await act(async () => drainAnimationFrame());
1268 await rerender({ selectedTextRequest: { id: 3, text: "Error: boom\n at main.ts:1", source: "terminal" } });
1269 await act(async () => drainAnimationFrame());
1270 const selectionCards = document.querySelectorAll(".composer-context__item--selection");
1271 eq(selectionCards.length, 3, "chat, code, and terminal selections each get their own selection card");
1272 eq(selectionCards[1]?.textContent?.includes("util.ts"), true, "the code selection card shows the file basename");
1273 eq(selectionCards[1]?.textContent?.includes("Code selection"), true, "the code selection card is labeled as a code selection");
1274 eq(selectionCards[2]?.textContent?.includes("Terminal selection"), true, "the terminal selection card is labeled as terminal context");
1275 eq(selectionCards[2]?.textContent?.includes("Error: boom"), true, "the terminal selection card previews selected output");
1276
1277 await act(async () => {
1278 sendButton().click();
1279 await flushTimers();
1280 });
1281 // Selection labels show a snippet of the selected text
1282 ok(sent[0]?.display.includes("[Chat:") && sent[0]?.display.includes("[Code: util.ts →") && sent[0]?.display.includes("[Terminal:"), "display includes typed selection labels with text snippets");
1283 ok(sent[0]?.submit.includes("<reasonix-selected-chat-context>") === true, "submit appends the selected text context block");
1284 ok(sent[0]?.submit.includes("\"source\":\"terminal\"") === true, "submit marks terminal selections in the JSON context");
1285 eq(sent[0]?.submit.includes("--- Begin [Chat:"), false, "submit does not duplicate selected text in display-only marker blocks");
1286 eq(sent[0]?.submit.split("selected assistant response").length - 1, 1, "selected chat text appears once in provider-visible submit bytes");
1287 ok(
1288 sent[0]?.submit.includes('[{"text":"selected assistant response"},{"path":"src/lib/util.ts","text":"const value = 1;"},{"source":"terminal","text":"Error: boom\\n at main.ts:1"}]') === true,
1289 "submit serializes chat, code, and terminal selections deterministically",
1290 );
1291 eq(document.querySelector(".composer-context__item--selection"), null, "a completed submit clears the selection card");
1292
1293 await act(async () => root.unmount());
1294 dom.window.close();
1295 }
1296
1297 {
1298 const dom = installDom();
1299 const changes: string[] = [];
1300 const empty = {
1301 text: "restored draft",
1302 invocations: [], attachments: [], workspaceRefs: [], pastedBlocks: [], openPastedLabels: [], sessionRefs: [], selectedTextRefs: [],
1303 };
1304 const onChange = (_draftId: string, _generation: number, value: typeof empty) => changes.push(value.text);
1305 const { root, rerender } = await renderComposer({
1306 sessionKey: "draft:draft-a",
1307 tabId: undefined,
1308 persistentDraft: { draftId: "draft-a", generation: 1, initial: empty, revision: 1, onChange },
1309 composerTarget: { kind: "draft", draftId: "draft-a" },
1310 });
1311 eq(textarea().value, "restored draft", "persistent draft restores backend-confirmed text");
1312 await act(async () => { await new Promise((resolve) => setTimeout(resolve, 300)); });
1313 eq(changes.length, 0, "restoring a persistent draft does not write an unchanged snapshot");
1314
1315 await act(async () => {
1316 updateTextareaFromUser("edited draft", "insertText");
1317 await new Promise((resolve) => setTimeout(resolve, 300));
1318 });
1319 eq(changes.join(","), "edited draft", "editing a persistent draft emits one debounced snapshot");
1320
1321 const saved = { ...empty, text: "saved in another window" };
1322 await rerender({ persistentDraft: { draftId: "draft-a", generation: 1, initial: saved, revision: 2, onChange } });
1323 eq(textarea().value, "edited draft", "a save acknowledgement revision does not replace newer local edits");
1324 await act(async () => { await new Promise((resolve) => setTimeout(resolve, 300)); });
1325 eq(changes.length, 1, "a save acknowledgement does not echo or restore content");
1326
1327 await rerender({ persistentDraft: { draftId: "draft-a", generation: 2, initial: saved, revision: 2, onChange } });
1328 eq(textarea().value, "saved in another window", "an explicit replacement generation installs the saved version");
1329
1330 await act(async () => root.unmount());
1331 dom.window.close();
1332 }
1333
1334 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
1335 if (failed > 0) process.exit(1);
1336
1336 lines Plain Text