@plannotator/ui 0.55.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/HANDOFF.md +43 -1
  2. package/README.md +81 -4
  3. package/components/AnnotationPanel.tsx +2 -1
  4. package/components/AttachmentsButton.tsx +1 -1
  5. package/components/CodeFilePopout.tsx +6 -4
  6. package/components/ComposerTokenTextarea.tsx +43 -2
  7. package/components/DocBadges.tsx +47 -35
  8. package/components/ImageZoomStage.tsx +32 -4
  9. package/components/Settings.tsx +2 -1
  10. package/components/TableOfContents.tsx +2 -1
  11. package/components/Viewer.tsx +43 -17
  12. package/components/chat/ChatComposer.tsx +192 -0
  13. package/components/chat/ChatFrame.tsx +524 -0
  14. package/components/chat/ChatTranscript.tsx +422 -0
  15. package/components/chat/chatMarkdown.ts +436 -0
  16. package/components/chat/chatRecipes.tsx +39 -0
  17. package/components/chat/chatWindowStore.ts +196 -0
  18. package/components/chat/index.ts +72 -0
  19. package/components/diagram/DiagramCanvas.tsx +7 -0
  20. package/components/diagram/DiagramComposer.tsx +15 -3
  21. package/components/diagram/DiagramViewer.tsx +1 -0
  22. package/components/docBadgesWidth.ts +29 -0
  23. package/components/imageZoom.ts +10 -0
  24. package/components/inbox-thread/FileName.tsx +118 -0
  25. package/components/inbox-thread/GuideCard.tsx +39 -0
  26. package/components/inbox-thread/ReaskNote.tsx +25 -0
  27. package/components/inbox-thread/SkillsLine.tsx +40 -0
  28. package/components/inbox-thread/Tip.tsx +105 -0
  29. package/components/inbox-thread/icons.tsx +262 -0
  30. package/components/inbox-thread/reask.ts +222 -0
  31. package/hooks/useActiveSection.ts +46 -63
  32. package/hooks/useAnnotationHighlighter.ts +40 -1
  33. package/hooks/useFileBrowser.ts +4 -3
  34. package/inbox-thread.css +4542 -0
  35. package/package.json +10 -3
  36. package/styles.css +1 -1
  37. package/theme.css +1 -0
  38. package/themes/tokyo-night-moon.css +34 -0
  39. package/utils/aiChatFormat.ts +32 -11
  40. package/utils/headingScrollSpy.ts +19 -6
  41. package/utils/syntaxTheme.ts +1 -0
  42. package/utils/themeRegistry.ts +11 -0
@@ -0,0 +1,524 @@
1
+ /**
2
+ * The floating chat window: the box, drag-move by the header, double-click
3
+ * re-dock, top-left drag and keyboard resize, minimize, fullscreen (and the
4
+ * forced fullscreen of a frame too narrow to float in), the optional launcher
5
+ * slot, the opt-in keyboard map and the focus return on minimize.
6
+ *
7
+ * It renders from ChatWindowStore alone. The host passes what the header
8
+ * holds, what sits under it, and the body and composer. The window is
9
+ * `absolute`, so its parent must be a positioned element: that parent is the
10
+ * frame it floats in and is clamped to.
11
+ */
12
+
13
+ import { ChevronDown, Maximize2, Minimize2, X } from 'lucide-react';
14
+ import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
15
+ import type * as React from 'react';
16
+
17
+ import { cn } from '../../lib/utils';
18
+ import { isImeCompositionKey } from '../../utils/commentSubmitKey';
19
+ import { isMac } from '../../utils/platform';
20
+ import { Button } from '../ui/button';
21
+ import { CHAT_ICON_BUTTON, floatingDialogChrome } from './chatRecipes';
22
+ import {
23
+ CHAT_WINDOW_DEFAULT,
24
+ CHAT_WINDOW_FRAME_MARGIN,
25
+ CHAT_WINDOW_MIN,
26
+ fitChatWindow,
27
+ type ChatWindowGeometry,
28
+ type ChatWindowStore,
29
+ } from './chatWindowStore';
30
+
31
+ /** The mark a launcher wears: the focus return finds it by this attribute. */
32
+ export const CHAT_LAUNCHER_SELECTOR = '[data-chat-launcher]';
33
+
34
+ /** What the frame tells its slots on every render. */
35
+ export interface ChatFrameState {
36
+ /** Fullscreen, by the person's toggle or forced by a narrow frame. */
37
+ readonly fullscreen: boolean;
38
+ /** The floating width, for slots that slim down when narrow. */
39
+ readonly width: number;
40
+ }
41
+
42
+ /** The host's extras on the window's root: a drop target, and any `data-*`
43
+ * attribute of its own. */
44
+ export interface ChatFrameRootProps {
45
+ readonly [attribute: `data-${string}`]: string | undefined;
46
+ readonly onDragOver?: React.DragEventHandler<HTMLElement>;
47
+ readonly onDragLeave?: React.DragEventHandler<HTMLElement>;
48
+ readonly onDrop?: React.DragEventHandler<HTMLElement>;
49
+ }
50
+
51
+ /** The words the frame draws. */
52
+ export interface ChatFrameLabels {
53
+ readonly fullscreen: string;
54
+ readonly exitFullscreen: string;
55
+ readonly minimize: string;
56
+ readonly close: string;
57
+ readonly resize: string;
58
+ readonly dragHint: string;
59
+ }
60
+
61
+ const DEFAULT_LABELS: ChatFrameLabels = {
62
+ fullscreen: 'Fullscreen',
63
+ exitFullscreen: 'Exit fullscreen',
64
+ minimize: 'Minimize',
65
+ close: 'Close',
66
+ resize: 'Resize the chat window. Arrow keys resize; Home restores the default size.',
67
+ dragHint: 'Drag to move · double-click to re-dock',
68
+ };
69
+
70
+ export interface ChatFrameProps {
71
+ store: ChatWindowStore;
72
+ /** The window's accessible name. */
73
+ label: string;
74
+ /** The host's handle on the window's root (it is also the frame's own). */
75
+ windowRef?: React.RefObject<HTMLElement | null>;
76
+ /** Extra classes on the root, after the chrome. */
77
+ className?: string | false | undefined;
78
+ rootProps?: ChatFrameRootProps;
79
+ /** The header's left side, inside the drag row (a mark and a title, or tabs). */
80
+ header?: (frame: ChatFrameState) => React.ReactNode;
81
+ /** Under the drag row, still in the header (notices, a strip). */
82
+ headerBelow?: React.ReactNode;
83
+ /** The body and the composer. None: the host has nothing to show yet, so
84
+ * the open frame renders nothing. */
85
+ children?: (frame: ChatFrameState) => React.ReactNode;
86
+ /** Runs after the minimize button has minimized the store. */
87
+ onMinimize?: () => void;
88
+ /** Passing this draws the × button. What closing means is the host's. */
89
+ onClose?: () => void;
90
+ /** False: the header does not move the window and double-click does nothing. */
91
+ allowDrag?: boolean;
92
+ /** False: no toggle, and the store's fullscreen flag is ignored. A narrow
93
+ * frame still forces fullscreen, because the window cannot float there. */
94
+ allowFullscreen?: boolean;
95
+ labels?: Partial<ChatFrameLabels>;
96
+ }
97
+
98
+ /** A press that can start a gesture: the primary button of the first pointer.
99
+ * Ctrl+press is the context menu on a Mac, and that menu can eat the release. */
100
+ function startsGesture(event: React.PointerEvent): boolean {
101
+ return event.button === 0 && event.isPrimary && !event.ctrlKey;
102
+ }
103
+
104
+ /** Follow one pointer on an element until it lifts. A second finger is ignored.
105
+ * A gesture the system cancels runs `onCancel`: it never finished, so it undoes. */
106
+ function followPointer(
107
+ element: HTMLElement,
108
+ pointerId: number,
109
+ onMove: (move: PointerEvent) => void,
110
+ onCancel: () => void,
111
+ ): void {
112
+ element.setPointerCapture(pointerId);
113
+ const move = (event: PointerEvent): void => {
114
+ if (event.pointerId === pointerId) onMove(event);
115
+ };
116
+ const stop = (): void => {
117
+ element.removeEventListener('pointermove', move);
118
+ element.removeEventListener('pointerup', up);
119
+ element.removeEventListener('pointercancel', cancel);
120
+ };
121
+ const up = (event: PointerEvent): void => {
122
+ if (event.pointerId === pointerId) stop();
123
+ };
124
+ const cancel = (event: PointerEvent): void => {
125
+ if (event.pointerId !== pointerId) return;
126
+ stop();
127
+ onCancel();
128
+ };
129
+ element.addEventListener('pointermove', move);
130
+ element.addEventListener('pointerup', up);
131
+ element.addEventListener('pointercancel', cancel);
132
+ }
133
+
134
+ /** Did this event start on the title row itself? A menu opened from the header
135
+ * is a React portal: its rows sit under <body> in the DOM but inside this row in
136
+ * the React tree, so their events still bubble here, and acting on one would
137
+ * swallow its click. Buttons and links in the row keep their own presses. */
138
+ function isOnTitleRow(event: React.SyntheticEvent<HTMLElement>): boolean {
139
+ const target = event.target as HTMLElement | null;
140
+ if (target === null || !event.currentTarget.contains(target)) return false;
141
+ return target.closest('button, a') === null;
142
+ }
143
+
144
+ export function ChatFrame({
145
+ store,
146
+ label,
147
+ windowRef,
148
+ className,
149
+ rootProps,
150
+ header,
151
+ headerBelow,
152
+ children,
153
+ onMinimize,
154
+ onClose,
155
+ allowDrag = true,
156
+ allowFullscreen = true,
157
+ labels,
158
+ }: ChatFrameProps) {
159
+ 'use no memo'; // reads mutable store state; compiler memoization would go stale
160
+ useSyncExternalStore(store.subscribe, store.getVersion, store.getVersion);
161
+ const ownRef = useRef<HTMLElement | null>(null);
162
+ const rootRef = windowRef ?? ownRef;
163
+ const shown = !store.minimized && children !== undefined;
164
+ const words = { ...DEFAULT_LABELS, ...labels };
165
+
166
+ /** The frame the window floats in. An unmeasurable frame reads as unbounded:
167
+ * the min clamps still apply, and the real frame re-clamps on the next measure. */
168
+ const frameBox = (): { width: number; height: number } => {
169
+ const frame = rootRef.current?.parentElement;
170
+ const width = frame?.clientWidth ?? 0;
171
+ const height = frame?.clientHeight ?? 0;
172
+ return {
173
+ width: width > 0 ? width : Number.POSITIVE_INFINITY,
174
+ height: height > 0 ? height : Number.POSITIVE_INFINITY,
175
+ };
176
+ };
177
+
178
+ // A floating window cannot fit where the free width is under the minimum, so
179
+ // a narrow frame forces fullscreen. Any other frame draws the person's
180
+ // geometry fitted to it. Before paint, so a saved window never flashes off screen.
181
+ const [frameSize, setFrameSize] = useState<{ width: number; height: number } | null>(null);
182
+ useLayoutEffect(() => {
183
+ const frame = rootRef.current?.parentElement;
184
+ if (frame == null) return;
185
+ const measure = (): void => {
186
+ const { clientWidth: width } = frame;
187
+ // A display: none frame measures 0: unknown, not a reason to force fullscreen.
188
+ // A height of 0 alone is a parent with no size of its own: the width still counts.
189
+ const height = frame.clientHeight > 0 ? frame.clientHeight : Number.POSITIVE_INFINITY;
190
+ setFrameSize((last) => {
191
+ if (width <= 0) return null;
192
+ return last?.width === width && last.height === height ? last : { width, height };
193
+ });
194
+ };
195
+ measure();
196
+ if (typeof ResizeObserver !== 'function') return;
197
+ const observer = new ResizeObserver(measure);
198
+ observer.observe(frame);
199
+ return () => observer.disconnect();
200
+ }, [store, store.minimized, shown, rootRef]);
201
+ const forcedFullscreen =
202
+ frameSize !== null && frameSize.width - CHAT_WINDOW_FRAME_MARGIN * 2 < CHAT_WINDOW_MIN.width;
203
+ const fullscreen = (allowFullscreen && store.fullscreen) || forcedFullscreen;
204
+
205
+ // What is on screen. Gestures start from this and write the person's choice
206
+ // to the store. A value back where the gesture started is the choice from that
207
+ // moment, taken at the press: mid-gesture the store holds the last move, and a
208
+ // window drawn smaller than it was chosen must not get saved smaller.
209
+ const chosen = store.geometry();
210
+ const drawn: ChatWindowGeometry = frameSize === null ? chosen : fitChatWindow(chosen, frameSize);
211
+
212
+ const resizeFrom = (
213
+ from: ChatWindowGeometry,
214
+ start: ChatWindowGeometry,
215
+ width: number,
216
+ height: number,
217
+ ): void => {
218
+ store.setWindowSize(width === from.w ? start.w : width, height === from.h ? start.h : height);
219
+ };
220
+ const moveFrom = (
221
+ from: ChatWindowGeometry,
222
+ start: ChatWindowGeometry,
223
+ right: number,
224
+ bottom: number,
225
+ ): void => {
226
+ store.setWindowOffsets(
227
+ right === from.right ? start.right : right,
228
+ bottom === from.bottom ? start.bottom : bottom,
229
+ );
230
+ };
231
+
232
+ /** The largest size the frame allows from where the window is anchored. */
233
+ const maxSize = (): { width: number; height: number } => {
234
+ const frame = frameBox();
235
+ return {
236
+ width: Math.min(frame.width - CHAT_WINDOW_FRAME_MARGIN * 2, frame.width - drawn.right),
237
+ height: Math.min(frame.height - CHAT_WINDOW_FRAME_MARGIN * 2, frame.height - drawn.bottom),
238
+ };
239
+ };
240
+
241
+ // Drag-resize from the top-left grip: the window stays anchored by its
242
+ // bottom-right offsets, so growing pulls the free corner.
243
+ const onResizePointerDown = (event: React.PointerEvent<HTMLButtonElement>): void => {
244
+ if (fullscreen || !startsGesture(event)) return;
245
+ event.preventDefault();
246
+ const startX = event.clientX;
247
+ const startY = event.clientY;
248
+ const from = drawn;
249
+ const start = store.geometry();
250
+ const max = maxSize();
251
+ followPointer(
252
+ event.currentTarget,
253
+ event.pointerId,
254
+ (move) => {
255
+ resizeFrom(
256
+ from,
257
+ start,
258
+ Math.max(CHAT_WINDOW_MIN.width, Math.min(max.width, from.w + (startX - move.clientX))),
259
+ Math.max(CHAT_WINDOW_MIN.height, Math.min(max.height, from.h + (startY - move.clientY))),
260
+ );
261
+ },
262
+ () => store.setWindowSize(start.w, start.h),
263
+ );
264
+ };
265
+
266
+ // Keyboard resize on the focused grip: arrows resize, Shift for big steps,
267
+ // Home restores the default size. The grip sits top-left, so Left and Up
268
+ // grow the window and Right and Down shrink it.
269
+ const onResizeKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>): void => {
270
+ if (fullscreen) return;
271
+ if (event.key === 'Home') {
272
+ event.preventDefault();
273
+ store.setWindowSize(CHAT_WINDOW_DEFAULT.width, CHAT_WINDOW_DEFAULT.height);
274
+ return;
275
+ }
276
+ const step = event.shiftKey ? 64 : 16;
277
+ let deltaW = 0;
278
+ let deltaH = 0;
279
+ if (event.key === 'ArrowLeft') deltaW = step;
280
+ else if (event.key === 'ArrowRight') deltaW = -step;
281
+ else if (event.key === 'ArrowUp') deltaH = step;
282
+ else if (event.key === 'ArrowDown') deltaH = -step;
283
+ else return;
284
+ event.preventDefault();
285
+ const max = maxSize();
286
+ resizeFrom(
287
+ drawn,
288
+ chosen,
289
+ Math.max(CHAT_WINDOW_MIN.width, Math.min(max.width, drawn.w + deltaW)),
290
+ Math.max(CHAT_WINDOW_MIN.height, Math.min(max.height, drawn.h + deltaH)),
291
+ );
292
+ };
293
+
294
+ // Drag-move by the header, anywhere in the title row that is not a control.
295
+ const dragEnabled = allowDrag && !fullscreen;
296
+ const onHeaderPointerDown = (event: React.PointerEvent<HTMLDivElement>): void => {
297
+ if (!dragEnabled || !startsGesture(event) || !isOnTitleRow(event)) return;
298
+ event.preventDefault();
299
+ const startX = event.clientX;
300
+ const startY = event.clientY;
301
+ const from = drawn;
302
+ const start = store.geometry();
303
+ const frame = frameBox();
304
+ followPointer(
305
+ event.currentTarget,
306
+ event.pointerId,
307
+ (move) => {
308
+ moveFrom(
309
+ from,
310
+ start,
311
+ Math.max(0, Math.min(frame.width - from.w, from.right - (move.clientX - startX))),
312
+ Math.max(0, Math.min(frame.height - from.h, from.bottom - (move.clientY - startY))),
313
+ );
314
+ },
315
+ () => store.setWindowOffsets(start.right, start.bottom),
316
+ );
317
+ };
318
+
319
+ if (!shown) return null;
320
+ const frame: ChatFrameState = { fullscreen, width: drawn.w };
321
+
322
+ return (
323
+ <aside
324
+ ref={rootRef}
325
+ aria-label={label}
326
+ data-chat-window
327
+ {...rootProps}
328
+ className={cn(
329
+ 'absolute z-40 flex min-h-0 min-w-0 flex-col overflow-hidden',
330
+ floatingDialogChrome,
331
+ 'bg-card',
332
+ className,
333
+ fullscreen ? 'inset-0 rounded-none border-none shadow-none' : 'max-h-full max-w-full',
334
+ )}
335
+ style={
336
+ fullscreen
337
+ ? undefined
338
+ : {
339
+ width: drawn.w,
340
+ height: drawn.h,
341
+ right: drawn.right,
342
+ bottom: drawn.bottom,
343
+ }
344
+ }
345
+ >
346
+ {/* The resize grip sits top-left, the window's one free corner. */}
347
+ {!fullscreen && (
348
+ <button
349
+ type="button"
350
+ aria-label={words.resize}
351
+ data-chat-resize-grip
352
+ onPointerDown={onResizePointerDown}
353
+ onKeyDown={onResizeKeyDown}
354
+ className="absolute left-0 top-0 z-10 flex size-5 cursor-nwse-resize touch-none items-center justify-center rounded-sm text-tertiary hover:bg-surface-hover hover:text-muted-foreground"
355
+ >
356
+ <svg
357
+ width="12"
358
+ height="12"
359
+ viewBox="0 0 24 24"
360
+ fill="none"
361
+ stroke="currentColor"
362
+ strokeWidth="2"
363
+ strokeLinecap="round"
364
+ aria-hidden="true"
365
+ >
366
+ <path d="M13 3 3 13" />
367
+ <path d="M8 3 3 8" />
368
+ </svg>
369
+ </button>
370
+ )}
371
+ <header className="flex shrink-0 flex-col gap-2 border-b border-border px-3 pb-2 pt-2.5">
372
+ <div
373
+ className={cn('flex items-center gap-1.5 pl-4', dragEnabled && 'cursor-grab touch-none')}
374
+ data-chat-drag-handle={dragEnabled ? '' : undefined}
375
+ onPointerDown={onHeaderPointerDown}
376
+ onDoubleClick={(event) => {
377
+ if (dragEnabled && isOnTitleRow(event)) store.resetWindowDock();
378
+ }}
379
+ title={dragEnabled ? words.dragHint : undefined}
380
+ >
381
+ {header?.(frame)}
382
+ <span className="ml-auto flex items-center gap-0.5">
383
+ {/* Forced fullscreen hides the toggle: exiting could not work there. */}
384
+ {allowFullscreen && !forcedFullscreen && (
385
+ <Button
386
+ variant="ghost"
387
+ size="icon"
388
+ className={CHAT_ICON_BUTTON}
389
+ aria-label={store.fullscreen ? words.exitFullscreen : words.fullscreen}
390
+ aria-pressed={store.fullscreen}
391
+ onClick={() => store.toggleFullscreen()}
392
+ >
393
+ {store.fullscreen ? (
394
+ <Minimize2 aria-hidden="true" className="size-3.5" />
395
+ ) : (
396
+ <Maximize2 aria-hidden="true" className="size-3.5" />
397
+ )}
398
+ </Button>
399
+ )}
400
+ <Button
401
+ variant="ghost"
402
+ size="icon"
403
+ className={CHAT_ICON_BUTTON}
404
+ aria-label={words.minimize}
405
+ onClick={() => {
406
+ store.setMinimized(true);
407
+ onMinimize?.();
408
+ }}
409
+ >
410
+ <ChevronDown aria-hidden="true" className="size-3.5" />
411
+ </Button>
412
+ {onClose !== undefined && (
413
+ <Button
414
+ variant="ghost"
415
+ size="icon"
416
+ className={CHAT_ICON_BUTTON}
417
+ aria-label={words.close}
418
+ onClick={onClose}
419
+ >
420
+ <X aria-hidden="true" className="size-3.5" />
421
+ </Button>
422
+ )}
423
+ </span>
424
+ </div>
425
+ {headerBelow}
426
+ </header>
427
+ {children?.(frame)}
428
+ </aside>
429
+ );
430
+ }
431
+
432
+ /** The optional launcher slot: its children, the host's launcher button
433
+ * wearing `data-chat-launcher`, show only while the window is minimized. A
434
+ * host that draws its own launcher never mounts this. */
435
+ export function ChatFrameLauncher({
436
+ store,
437
+ children,
438
+ }: {
439
+ store: ChatWindowStore;
440
+ children: React.ReactNode;
441
+ }) {
442
+ 'use no memo'; // reads mutable store state; compiler memoization would go stale
443
+ useSyncExternalStore(store.subscribe, store.getVersion, store.getVersion);
444
+ if (!store.minimized) return null;
445
+ return children;
446
+ }
447
+
448
+ /**
449
+ * The opt-in keyboard map (raw keydown on window). Nothing listens unless a
450
+ * host calls this. Cmd-J (Ctrl-J off a Mac) summons or minimizes, Shift-Cmd-J
451
+ * toggles fullscreen, Escape minimizes an open window. `onKey` runs first for
452
+ * every other key, so the host can take Escape or its own chords; it answers
453
+ * true when it handled the key. Keep `onKey` and `onMinimize` stable: the
454
+ * listener is re-attached when they change.
455
+ */
456
+ export function useChatFrameKeys(
457
+ store: ChatWindowStore,
458
+ {
459
+ summon,
460
+ onKey,
461
+ onMinimize,
462
+ }: {
463
+ /** False: Cmd-J does nothing (the host hides its launcher). */
464
+ summon: boolean;
465
+ onKey?: (event: KeyboardEvent, isMac: boolean) => boolean;
466
+ /** Runs after a key has minimized the store. */
467
+ onMinimize?: () => void;
468
+ },
469
+ ): void {
470
+ useEffect(() => {
471
+ const onKeyDown = (event: KeyboardEvent): void => {
472
+ // A key inside an IME composition is the input method's: Escape there
473
+ // cancels the composition, not the window.
474
+ if (event.defaultPrevented || isImeCompositionKey(event)) return;
475
+ const mod = isMac ? event.metaKey : event.ctrlKey;
476
+ if (mod && event.key.toLowerCase() === 'j') {
477
+ if (!summon) return;
478
+ event.preventDefault();
479
+ if (event.shiftKey) {
480
+ store.toggleFullscreen();
481
+ return;
482
+ }
483
+ const minimize = !store.minimized;
484
+ store.setMinimized(minimize);
485
+ if (minimize) onMinimize?.();
486
+ return;
487
+ }
488
+ if (onKey?.(event, isMac) === true) return;
489
+ if (event.key === 'Escape' && !store.minimized) {
490
+ store.setMinimized(true);
491
+ onMinimize?.();
492
+ }
493
+ };
494
+ window.addEventListener('keydown', onKeyDown);
495
+ return () => window.removeEventListener('keydown', onKeyDown);
496
+ }, [store, summon, onKey, onMinimize]);
497
+ }
498
+
499
+ /** Minimizing returns focus to the launcher, whatever gesture did it, when focus
500
+ * was in the window: the window is gone, so focus has fallen to the body. Focus
501
+ * elsewhere on the page stays where the person put it. With no element wearing
502
+ * `data-chat-launcher` there is nothing to focus and nothing happens. Opening
503
+ * lands in the host's composer, which the host does itself. */
504
+ export function useChatFrameFocusReturn(store: ChatWindowStore): void {
505
+ const minimized = useSyncExternalStore(
506
+ store.subscribe,
507
+ () => store.minimized,
508
+ () => store.minimized,
509
+ );
510
+ const prevMinimizedRef = useRef(minimized);
511
+ useEffect(() => {
512
+ const wasMinimized = prevMinimizedRef.current;
513
+ prevMinimizedRef.current = minimized;
514
+ if (!minimized || wasMinimized) return;
515
+ // Next frame: the launcher renders in the window's place first. Canceled on
516
+ // unmount, so an orphaned callback never focuses into a dead tree.
517
+ const frame = requestAnimationFrame(() => {
518
+ const focused = document.activeElement;
519
+ if (focused !== null && focused !== document.body) return;
520
+ document.querySelector<HTMLElement>(CHAT_LAUNCHER_SELECTOR)?.focus();
521
+ });
522
+ return () => cancelAnimationFrame(frame);
523
+ }, [minimized]);
524
+ }