@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.
- package/HANDOFF.md +43 -1
- package/README.md +81 -4
- package/components/AnnotationPanel.tsx +2 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/CodeFilePopout.tsx +6 -4
- package/components/ComposerTokenTextarea.tsx +43 -2
- package/components/DocBadges.tsx +47 -35
- package/components/ImageZoomStage.tsx +32 -4
- package/components/Settings.tsx +2 -1
- package/components/TableOfContents.tsx +2 -1
- package/components/Viewer.tsx +43 -17
- package/components/chat/ChatComposer.tsx +192 -0
- package/components/chat/ChatFrame.tsx +524 -0
- package/components/chat/ChatTranscript.tsx +422 -0
- package/components/chat/chatMarkdown.ts +436 -0
- package/components/chat/chatRecipes.tsx +39 -0
- package/components/chat/chatWindowStore.ts +196 -0
- package/components/chat/index.ts +72 -0
- package/components/diagram/DiagramCanvas.tsx +7 -0
- package/components/diagram/DiagramComposer.tsx +15 -3
- package/components/diagram/DiagramViewer.tsx +1 -0
- package/components/docBadgesWidth.ts +29 -0
- package/components/imageZoom.ts +10 -0
- package/components/inbox-thread/FileName.tsx +118 -0
- package/components/inbox-thread/GuideCard.tsx +39 -0
- package/components/inbox-thread/ReaskNote.tsx +25 -0
- package/components/inbox-thread/SkillsLine.tsx +40 -0
- package/components/inbox-thread/Tip.tsx +105 -0
- package/components/inbox-thread/icons.tsx +262 -0
- package/components/inbox-thread/reask.ts +222 -0
- package/hooks/useActiveSection.ts +46 -63
- package/hooks/useAnnotationHighlighter.ts +40 -1
- package/hooks/useFileBrowser.ts +4 -3
- package/inbox-thread.css +4542 -0
- package/package.json +10 -3
- package/styles.css +1 -1
- package/theme.css +1 -0
- package/themes/tokyo-night-moon.css +34 -0
- package/utils/aiChatFormat.ts +32 -11
- package/utils/headingScrollSpy.ts +19 -6
- package/utils/syntaxTheme.ts +1 -0
- 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
|
+
}
|