返回 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",
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",
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",
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({ sessionKey: "session:project:/repo:topic-b:session-b" });
972 await rerender({ insertRequest: { id: 11, text: "session B stays writable", mode: "replace" } });
973 ok(sendButton().disabled === false, "session B is not blocked by session A's pending attachment");
974 await act(async () => {
975 sendButton().click();
976 await flushTimers();
977 });
978 eq(sent.join(","), "session B stays writable", "session B can submit while session A attachment is pending");
979 await act(async () => {
980 savePastedFile.resolve("/tmp/reasonix/draft.txt");
981 await flushTimers();
982 });
983 eq(contextItemCount(), 0, "async attachment does not land in the switched-to session");
984
985 await rerender({ sessionKey: "session:project:/repo:topic-a:session-a" });
986 eq(contextItemCount(), 1, "async attachment returns to the source session draft");
987
988 await act(async () => {
989 root.unmount();
990 });
991 dom.window.close();
992 }
993
994 {
995 const dom = installDom();
996 const submitStarted = deferred<void>();
997 const releaseSubmit = deferred<void>();
998 const sent: Array<{ tab: string; text: string }> = [];
999 const { root, rerender } = await renderComposer({
1000 tabId: "tab-a",
1001 sessionKey: "session:project:/repo:topic-a:session-a",
1002 onSend: async (text, _submit, targetTabId) => {
1003 sent.push({ tab: targetTabId ?? "", text });
1004 submitStarted.resolve();
1005 await releaseSubmit.promise;
1006 },
1007 });
1008
1009 await rerender({ insertRequest: { id: 12, text: "slow submit in A", mode: "replace" } });
1010 await act(async () => {
1011 sendButton().click();
1012 await submitStarted.promise;
1013 });
1014
1015 await rerender({
1016 tabId: "tab-b",
1017 sessionKey: "session:project:/repo:topic-b:session-b",
1018 onSend: (text, _submit, targetTabId) => {
1019 sent.push({ tab: targetTabId ?? "", text });
1020 },
1021 });
1022 await rerender({ insertRequest: { id: 13, text: "fast submit in B", mode: "replace" } });
1023 ok(sendButton().disabled === false, "session B is not blocked by session A's in-flight submit");
1024 await act(async () => {
1025 sendButton().click();
1026 await flushTimers();
1027 });
1028 eq(sent[0]?.tab, "tab-a", "slow submit retains session A as its target");
1029 eq(sent[1]?.tab, "tab-b", "session B submit keeps its own target");
1030
1031 await act(async () => {
1032 releaseSubmit.resolve();
1033 await flushTimers();
1034 root.unmount();
1035 });
1036 dom.window.close();
1037 }
1038
1039 {
1040 // Clipboard menu actions await browser APIs. Their eventual mutation must
1041 // stay with the draft that owned the selection, even after a tab switch.
1042 const dom = installDom();
1043 const clipboardRead = deferred<string>();
1044 Object.defineProperty(window.navigator, "clipboard", {
1045 configurable: true,
1046 value: {
1047 read: async () => [],
1048 readText: () => clipboardRead.promise,
1049 writeText: async () => {},
1050 },
1051 });
1052 const { root, rerender } = await renderComposer({
1053 tabId: "tab-a",
1054 sessionKey: "session:project:/repo:topic-a:session-a",
1055 });
1056 await rerender({ insertRequest: { id: 20, text: "A:", mode: "replace" } });
1057 textarea().setSelectionRange(2, 2);
1058 const menuItems = await openComposerInputMenu();
1059 await act(async () => {
1060 menuItems[4]?.click();
1061 await flushTimers();
1062 });
1063 await rerender({
1064 tabId: "tab-b",
1065 sessionKey: "session:project:/repo:topic-b:session-b",
1066 insertRequest: { id: 21, text: "B stays clean", mode: "replace" },
1067 });
1068 await act(async () => {
1069 clipboardRead.resolve("pasted into A");
1070 await flushTimers();
1071 });
1072 eq(textarea().value, "B stays clean", "async clipboard paste does not mutate the switched-to session");
1073 await rerender({ tabId: "tab-a", sessionKey: "session:project:/repo:topic-a:session-a" });
1074 eq(textarea().value, "A:pasted into A", "async clipboard paste returns to its source draft");
1075
1076 await act(async () => root.unmount());
1077 dom.window.close();
1078 }
1079
1080 {
1081 const dom = installDom();
1082 const clipboardWrite = deferred<void>();
1083 const clipboardWriteStarted = deferred<void>();
1084 Object.defineProperty(window.navigator, "clipboard", {
1085 configurable: true,
1086 value: {
1087 read: async () => [],
1088 readText: async () => "",
1089 writeText: () => {
1090 clipboardWriteStarted.resolve();
1091 return clipboardWrite.promise;
1092 },
1093 },
1094 });
1095 const { root, rerender } = await renderComposer({
1096 tabId: "tab-a",
1097 sessionKey: "session:project:/repo:topic-a:session-a",
1098 });
1099 await rerender({ insertRequest: { id: 22, text: "abcdef", mode: "replace" } });
1100 await act(async () => {
1101 await new Promise((resolve) => window.requestAnimationFrame(() => resolve(null)));
1102 await flushTimers();
1103 });
1104 textarea().setSelectionRange(1, 4);
1105 const menuItems = await openComposerInputMenu();
1106 await act(async () => {
1107 menuItems[2]?.click();
1108 await clipboardWriteStarted.promise;
1109 });
1110 await rerender({
1111 tabId: "tab-b",
1112 sessionKey: "session:project:/repo:topic-b:session-b",
1113 insertRequest: { id: 23, text: "B is untouched", mode: "replace" },
1114 });
1115 await act(async () => {
1116 clipboardWrite.resolve();
1117 await flushTimers();
1118 await flushTimers();
1119 });
1120 eq(textarea().value, "B is untouched", "async cut does not delete text from the switched-to session");
1121 await rerender({ tabId: "tab-a", sessionKey: "session:project:/repo:topic-a:session-a" });
1122 eq(textarea().value, "aef", "async cut completes in its source draft");
1123
1124 await act(async () => root.unmount());
1125 dom.window.close();
1126 }
1127
1128 {
1129 const dom = installDom();
1130 invalidateCache();
1131 const historyPage = deferred<unknown>();
1132 installBridgeApp({ ScanPromptHistory: () => historyPage.promise });
1133 const { root, rerender } = await renderComposer({
1134 tabId: "tab-a",
1135 sessionKey: "session:project:/repo:topic-a:session-a",
1136 });
1137 await rerender({ insertRequest: { id: 24, text: "draft A", mode: "replace" } });
1138 textarea().setSelectionRange(0, 0);
1139 await act(async () => {
1140 textarea().dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowUp", code: "ArrowUp", bubbles: true }));
1141 await flushTimers();
1142 });
1143 await rerender({
1144 tabId: "tab-b",
1145 sessionKey: "session:project:/repo:topic-b:session-b",
1146 insertRequest: { id: 25, text: "draft B", mode: "replace" },
1147 });
1148 await act(async () => {
1149 historyPage.resolve({
1150 entries: [{ text: "older prompt for A", at: 1, sessionPath: "/a.jsonl", turn: 0 }],
1151 nonce: "history-test",
1152 hasOlder: false,
1153 });
1154 await flushTimers();
1155 });
1156 eq(textarea().value, "draft B", "async prompt history does not overwrite the switched-to session");
1157 await rerender({ tabId: "tab-a", sessionKey: "session:project:/repo:topic-a:session-a" });
1158 eq(textarea().value, "older prompt for A", "async prompt history result returns to its source draft");
1159
1160 await act(async () => root.unmount());
1161 dom.window.close();
1162 }
1163
1164 {
1165 // Session-reference expansion awaits PreviewSession. A tab switch during
1166 // that await must not make A expand B's identically-labelled folded paste.
1167 const dom = installDom();
1168 const previewResult = deferred<Array<{ role: string; content: string }>>();
1169 let previewCalls = 0;
1170 const sent: Array<{ tab: string; submit: string }> = [];
1171 installBridgeApp({
1172 ListSessions: async () => [{
1173 path: "/history.jsonl",
1174 preview: "history",
1175 title: "History",
1176 turns: 1,
1177 createdAt: 1,
1178 lastActivityAt: 1,
1179 modTime: 1,
1180 current: false,
1181 open: false,
1182 }],
1183 PreviewSession: async () => {
1184 previewCalls += 1;
1185 return previewResult.promise;
1186 },
1187 });
1188 const { root, rerender } = await renderComposer({
1189 tabId: "tab-a",
1190 sessionKey: "session:project:/repo:topic-a:session-a",
1191 onSend: (_display, submit, targetTabId) => {
1192 sent.push({ tab: targetTabId ?? "", submit: submit ?? "" });
1193 },
1194 });
1195 const longA = Array.from({ length: 20 }, (_, index) => `A line ${index}`).join("\n");
1196 const longB = Array.from({ length: 20 }, (_, index) => `B line ${index}`).join("\n");
1197 await act(async () => {
1198 textarea().dispatchEvent(textPasteEvent(longA));
1199 await flushTimers();
1200 });
1201 textarea().setSelectionRange(textarea().value.length, textarea().value.length);
1202 await act(async () => {
1203 textarea().dispatchEvent(textPasteEvent(" @past:chats"));
1204 await flushTimers();
1205 });
1206 await act(async () => {
1207 textarea().dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", code: "Enter", bubbles: true }));
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 sendButton().click();
1216 await flushTimers();
1217 });
1218 eq(previewCalls, 1, "source session reference starts asynchronous expansion");
1219 await rerender({
1220 tabId: "tab-b",
1221 sessionKey: "session:project:/repo:topic-b:session-b",
1222 });
1223 await act(async () => {
1224 textarea().dispatchEvent(textPasteEvent(longB));
1225 await flushTimers();
1226 previewResult.resolve([{ role: "user", content: "history context" }]);
1227 await flushTimers();
1228 });
1229 eq(sent[0]?.tab, "tab-a", "session-context submit retains its source tab");
1230 ok(sent[0]?.submit.includes("A line 19") === true, "session-context submit expands source folded paste");
1231 ok(sent[0]?.submit.includes("B line 19") === false, "session-context submit excludes switched-to folded paste");
1232
1233 await act(async () => root.unmount());
1234 dom.window.close();
1235 }
1236
1237 {
1238 const dom = installDom();
1239 const sent: Array<{ display: string; submit: string }> = [];
1240 const { root, rerender } = await renderComposer({
1241 onSend: (display, submit) => {
1242 sent.push({ display, submit: submit ?? "" });
1243 },
1244 });
1245
1246 await rerender({ insertRequest: { id: 30, text: "Explain the selected behavior", mode: "replace" } });
1247 await rerender({ selectedTextRequest: { id: 1, text: " selected assistant response " } });
1248 await act(async () => drainAnimationFrame());
1249
1250 const selectionCard = document.querySelector(".composer-context__item--selection");
1251 ok(selectionCard != null, "Add to Chat renders a dedicated composer selection card");
1252 eq(selectionCard?.textContent?.includes("selected assistant response"), true, "selection card previews the selected text");
1253 eq(selectionCard?.querySelector("button")?.getAttribute("aria-label"), "Remove selected text", "selection card remove action has an accessible name");
1254 eq(textarea().value, "Explain the selected behavior", "adding selected text preserves the existing draft");
1255 eq(document.activeElement, textarea(), "adding selected text returns focus to the composer");
1256
1257 await rerender({ sessionKey: "session:project:/repo:topic-b:session-b" });
1258 eq(document.querySelector(".composer-context__item--selection"), null, "another session does not inherit the selection");
1259 await rerender({ sessionKey: "session:project:/repo:topic-a:session-a" });
1260 ok(document.querySelector(".composer-context__item--selection") != null, "the source session restores its selection draft");
1261
1262 await rerender({ selectedTextRequest: { id: 2, text: "const value = 1;\n", path: "src/lib/util.ts" } });
1263 await act(async () => drainAnimationFrame());
1264 await rerender({ selectedTextRequest: { id: 3, text: "Error: boom\n at main.ts:1", source: "terminal" } });
1265 await act(async () => drainAnimationFrame());
1266 const selectionCards = document.querySelectorAll(".composer-context__item--selection");
1267 eq(selectionCards.length, 3, "chat, code, and terminal selections each get their own selection card");
1268 eq(selectionCards[1]?.textContent?.includes("util.ts"), true, "the code selection card shows the file basename");
1269 eq(selectionCards[1]?.textContent?.includes("Code selection"), true, "the code selection card is labeled as a code selection");
1270 eq(selectionCards[2]?.textContent?.includes("Terminal selection"), true, "the terminal selection card is labeled as terminal context");
1271 eq(selectionCards[2]?.textContent?.includes("Error: boom"), true, "the terminal selection card previews selected output");
1272
1273 await act(async () => {
1274 sendButton().click();
1275 await flushTimers();
1276 });
1277 // Selection labels show a snippet of the selected text
1278 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");
1279 ok(sent[0]?.submit.includes("<reasonix-selected-chat-context>") === true, "submit appends the selected text context block");
1280 ok(sent[0]?.submit.includes("\"source\":\"terminal\"") === true, "submit marks terminal selections in the JSON context");
1281 eq(sent[0]?.submit.includes("--- Begin [Chat:"), false, "submit does not duplicate selected text in display-only marker blocks");
1282 eq(sent[0]?.submit.split("selected assistant response").length - 1, 1, "selected chat text appears once in provider-visible submit bytes");
1283 ok(
1284 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,
1285 "submit serializes chat, code, and terminal selections deterministically",
1286 );
1287 eq(document.querySelector(".composer-context__item--selection"), null, "a completed submit clears the selection card");
1288
1289 await act(async () => root.unmount());
1290 dom.window.close();
1291 }
1292
1293 {
1294 const dom = installDom();
1295 const changes: string[] = [];
1296 const empty = {
1297 text: "restored draft",
1298 invocations: [], attachments: [], workspaceRefs: [], pastedBlocks: [], openPastedLabels: [], sessionRefs: [], selectedTextRefs: [],
1299 };
1300 const onChange = (_draftId: string, _generation: number, value: typeof empty) => changes.push(value.text);
1301 const { root, rerender } = await renderComposer({
1302 sessionKey: "draft:draft-a",
1303 tabId: undefined,
1304 persistentDraft: { draftId: "draft-a", generation: 1, initial: empty, revision: 1, onChange },
1305 composerTarget: { kind: "draft", draftId: "draft-a" },
1306 });
1307 eq(textarea().value, "restored draft", "persistent draft restores backend-confirmed text");
1308 await act(async () => { await new Promise((resolve) => setTimeout(resolve, 300)); });
1309 eq(changes.length, 0, "restoring a persistent draft does not write an unchanged snapshot");
1310
1311 await act(async () => {
1312 updateTextareaFromUser("edited draft", "insertText");
1313 await new Promise((resolve) => setTimeout(resolve, 300));
1314 });
1315 eq(changes.join(","), "edited draft", "editing a persistent draft emits one debounced snapshot");
1316
1317 const saved = { ...empty, text: "saved in another window" };
1318 await rerender({ persistentDraft: { draftId: "draft-a", generation: 1, initial: saved, revision: 2, onChange } });
1319 eq(textarea().value, "edited draft", "a save acknowledgement revision does not replace newer local edits");
1320 await act(async () => { await new Promise((resolve) => setTimeout(resolve, 300)); });
1321 eq(changes.length, 1, "a save acknowledgement does not echo or restore content");
1322
1323 await rerender({ persistentDraft: { draftId: "draft-a", generation: 2, initial: saved, revision: 2, onChange } });
1324 eq(textarea().value, "saved in another window", "an explicit replacement generation installs the saved version");
1325
1326 await act(async () => root.unmount());
1327 dom.window.close();
1328 }
1329
1330 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
1331 if (failed > 0) process.exit(1);
1332
1332 lines Plain Text