@plannotator/ui 0.54.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 (59) hide show
  1. package/HANDOFF.md +102 -6
  2. package/README.md +128 -3
  3. package/components/AnnotationPanel.tsx +2 -1
  4. package/components/AttachmentsButton.tsx +1 -1
  5. package/components/BlockRenderer.tsx +4 -1
  6. package/components/CodeFilePopout.tsx +6 -4
  7. package/components/CodeSnippetPreview.tsx +174 -0
  8. package/components/ComposerTokenTextarea.tsx +43 -2
  9. package/components/DeferredMarkdownContentsRail.tsx +38 -0
  10. package/components/DiffFileTree.tsx +7 -6
  11. package/components/DocBadges.tsx +47 -35
  12. package/components/DocumentContents.tsx +74 -0
  13. package/components/ImageLightbox.tsx +121 -63
  14. package/components/ImageZoomStage.tsx +32 -4
  15. package/components/InlineMarkdown.tsx +15 -150
  16. package/components/MarkdownContentsRail.tsx +777 -0
  17. package/components/Settings.tsx +2 -1
  18. package/components/TableOfContents.tsx +22 -7
  19. package/components/Viewer.tsx +51 -19
  20. package/components/blocks/QuestionBlock.tsx +14 -4
  21. package/components/chat/ChatComposer.tsx +192 -0
  22. package/components/chat/ChatFrame.tsx +524 -0
  23. package/components/chat/ChatTranscript.tsx +422 -0
  24. package/components/chat/chatMarkdown.ts +436 -0
  25. package/components/chat/chatRecipes.tsx +39 -0
  26. package/components/chat/chatWindowStore.ts +196 -0
  27. package/components/chat/index.ts +72 -0
  28. package/components/diagram/DiagramCanvas.tsx +7 -0
  29. package/components/diagram/DiagramComposer.tsx +15 -3
  30. package/components/diagram/DiagramViewer.tsx +1 -0
  31. package/components/docBadgesWidth.ts +29 -0
  32. package/components/html-viewer/HtmlViewer.tsx +36 -1
  33. package/components/imageZoom.ts +21 -0
  34. package/components/inbox-thread/FileName.tsx +118 -0
  35. package/components/inbox-thread/GuideCard.tsx +39 -0
  36. package/components/inbox-thread/ReaskNote.tsx +25 -0
  37. package/components/inbox-thread/SkillsLine.tsx +40 -0
  38. package/components/inbox-thread/Tip.tsx +105 -0
  39. package/components/inbox-thread/icons.tsx +262 -0
  40. package/components/inbox-thread/reask.ts +222 -0
  41. package/contents-rail.css +337 -0
  42. package/hooks/useActiveSection.ts +46 -63
  43. package/hooks/useAnnotationHighlighter.ts +40 -1
  44. package/hooks/useFileBrowser.ts +4 -3
  45. package/hooks/useHeadingScrollSpy.ts +77 -0
  46. package/hooks/useHoverPreview.ts +94 -0
  47. package/hooks/usePrefersReducedMotion.ts +23 -0
  48. package/hooks/useSwipeStep.ts +597 -0
  49. package/inbox-thread.css +4542 -0
  50. package/package.json +13 -4
  51. package/styles.css +1 -1
  52. package/theme.css +1 -0
  53. package/themes/tokyo-night-moon.css +34 -0
  54. package/utils/aiChatFormat.ts +32 -11
  55. package/utils/annotationHelpers.ts +32 -4
  56. package/utils/blockParse.ts +45 -0
  57. package/utils/headingScrollSpy.ts +105 -0
  58. package/utils/syntaxTheme.ts +1 -0
  59. package/utils/themeRegistry.ts +11 -0
@@ -0,0 +1,436 @@
1
+ import DOMPurify from 'dompurify';
2
+ import { Lexer, Marked, type MarkedExtension, type Token, type Tokens } from 'marked';
3
+
4
+ import { CHAT_SANITIZE_CONFIG } from '../../utils/aiChatFormat';
5
+
6
+ /** The characters HTML gives meaning to, as text. */
7
+ function escapeHtml(text: string): string {
8
+ return text
9
+ .replaceAll('&', '&')
10
+ .replaceAll('<', '&lt;')
11
+ .replaceAll('>', '&gt;')
12
+ .replaceAll('"', '&quot;')
13
+ .replaceAll("'", '&#39;');
14
+ }
15
+
16
+ /** Each line break is an element, so a text of nothing but line ends must not
17
+ * draw one per line. Past this they read as spaces. */
18
+ const MAX_PLAIN_BREAKS = 20_000;
19
+
20
+ /**
21
+ * Layout has two costs no limit on length sees, both from text that changes script
22
+ * or direction all the time.
23
+ *
24
+ * Each change starts a new run, and a browser goes back over a block's runs for
25
+ * each line it wraps, so a block draws in about the square of its changes: 250 KB
26
+ * of `سa` takes 13 s as one block and a third of a second as blocks of 2 KB. A line
27
+ * break does not help, because a block is laid out whole, so what counts is the
28
+ * block. ASCII is one run, so every change takes a character past ASCII, and those
29
+ * are what is counted. That leaves a long line of code at no cost, and these
30
+ * limits are several pages of Chinese in one paragraph.
31
+ *
32
+ * WebKit also searches a word with no place to break again for each line, so the
33
+ * same text with no spaces is far worse there: 16 KB of it took three minutes.
34
+ * `withBreaks` gives such a word a place to break, which took that to 130 ms.
35
+ */
36
+ const MAX_NON_ASCII_IN_BLOCK = 10_000;
37
+ /** While a text streams, because that is paid on every delta, and in plain text. */
38
+ const MAX_NON_ASCII_IN_SMALL_BLOCK = 2_000;
39
+ const MAX_UNBROKEN_CHARS = 256;
40
+
41
+ const SPACE = 32;
42
+ const LAST_ASCII = 126;
43
+
44
+ function nonAsciiIn(text: string): number {
45
+ let count = 0;
46
+ for (let index = 0; index < text.length; index += 1) {
47
+ if (text.charCodeAt(index) > LAST_ASCII) count += 1;
48
+ }
49
+ return count;
50
+ }
51
+
52
+ const isHighSurrogate = (code: number): boolean => code >= 0xd800 && code <= 0xdbff;
53
+
54
+ /**
55
+ * The text escaped, with a `<wbr>` in each word past the limit, or null if it has
56
+ * none and can go out as usual. A `<wbr>` adds no character, so a word copies whole.
57
+ */
58
+ function withBreaks(text: string): string | null {
59
+ let html = '';
60
+ let from = 0;
61
+ let unbroken = 0;
62
+ for (let index = 0; index < text.length; index += 1) {
63
+ const code = text.charCodeAt(index);
64
+ unbroken = code <= SPACE ? 0 : unbroken + 1;
65
+ if (unbroken >= MAX_UNBROKEN_CHARS && !isHighSurrogate(code)) {
66
+ html += `${escapeHtml(text.slice(from, index + 1))}<wbr>`;
67
+ from = index + 1;
68
+ unbroken = 0;
69
+ }
70
+ }
71
+ return from === 0 ? null : html + escapeHtml(text.slice(from));
72
+ }
73
+
74
+ const PLAIN_PIECE = /\r\n?|\n|[^\r\n]{1,2000}/gu;
75
+
76
+ /** The one safe answer when markdown or the sanitizer cannot be trusted: the
77
+ * source as plain text, keeping its lines. */
78
+ function asPlainText(source: string): string {
79
+ let breaks = 0;
80
+ let nonAscii = 0;
81
+ const blocks = source.replace(PLAIN_PIECE, (piece) => {
82
+ if (piece[0] === '\r' || piece[0] === '\n') {
83
+ breaks += 1;
84
+ return breaks > MAX_PLAIN_BREAKS ? piece : '<br>';
85
+ }
86
+ const text = withBreaks(piece) ?? escapeHtml(piece);
87
+ nonAscii += nonAsciiIn(piece);
88
+ if (nonAscii < MAX_NON_ASCII_IN_SMALL_BLOCK) return text;
89
+ nonAscii = 0;
90
+ return `${text}</p><p>`;
91
+ });
92
+ return `<p>${blocks}</p>`;
93
+ }
94
+
95
+ /**
96
+ * Tags from the Ask AI panel's list: no scripts, no handlers, no images, so
97
+ * this text cannot load a remote address. Attributes are tighter than that
98
+ * list, because this HTML lands inside a host's page: `class` would hand it the
99
+ * host's own stylesheet (a full-page `fixed` link), and `data-*` or `aria-*`
100
+ * would let it pose as the anchors hosts read off a text part.
101
+ *
102
+ * Read when the sanitizer is built, not on load, so this file and the one that
103
+ * holds the list can import each other.
104
+ */
105
+ function chatPurifyConfig() {
106
+ return {
107
+ ALLOWED_TAGS: [...CHAT_SANITIZE_CONFIG.ALLOWED_TAGS, 'wbr'],
108
+ ALLOWED_ATTR: ['href', 'align'],
109
+ ALLOW_DATA_ATTR: false,
110
+ ALLOW_ARIA_ATTR: false,
111
+ };
112
+ }
113
+
114
+ /** Everything the sanitizer must take out. It proves itself on this before any
115
+ * real text, because `isSupported` is not proof: a partial DOM reports true and
116
+ * lets scripts through. */
117
+ const PROOF_INPUT =
118
+ '<a href="javascript:alert(1)" class="c" data-c="1" onclick="alert(2)">kept</a>' +
119
+ '<img src="x" onerror="alert(3)"><script>alert(4)</script>';
120
+ const PROOF_FAILED = /javascript:|class=|data-c|onclick|onerror|<img|<script/i;
121
+
122
+ type Sanitize = (html: string) => string;
123
+
124
+ function buildSanitizer(): Sanitize | null {
125
+ if (typeof window === 'undefined') return null;
126
+ // A private instance: a hook on the shared one would reach every other caller.
127
+ const purifier = DOMPurify(window);
128
+ // Links open in a new tab: a host must not lose its page to a link in a
129
+ // message. Set after the allowlist has run, so only this code writes them.
130
+ purifier.addHook('afterSanitizeAttributes', (node) => {
131
+ if (node.nodeName !== 'A') return;
132
+ node.setAttribute('target', '_blank');
133
+ node.setAttribute('rel', 'noopener noreferrer');
134
+ });
135
+ const config = chatPurifyConfig();
136
+ const proof = purifier.sanitize(PROOF_INPUT, config);
137
+ if (PROOF_FAILED.test(proof) || !proof.includes('kept')) return null;
138
+ return (html) => purifier.sanitize(html, config);
139
+ }
140
+
141
+ let provenSanitizer: Sanitize | null | undefined;
142
+
143
+ /** The sanitizer, once it has shown it works here. Null: nothing may go out as HTML. */
144
+ function sanitizerThatWorks(): Sanitize | null {
145
+ if (provenSanitizer === undefined) {
146
+ try {
147
+ provenSanitizer = buildSanitizer();
148
+ } catch {
149
+ provenSanitizer = null;
150
+ }
151
+ }
152
+ return provenSanitizer;
153
+ }
154
+
155
+ /**
156
+ * `marked` has inputs that overflow the stack, inputs that take minutes and
157
+ * inputs that build a page's worth of nodes, and running out of memory kills
158
+ * the tab with nothing to catch. So the work is bounded at each step it can
159
+ * grow, and a text past a limit draws as plain text:
160
+ *
161
+ * - Before `marked`: the text's length.
162
+ * - Inside `marked`: a time budget, checked at each token, each run of inline
163
+ * text and each link drawn. It is a check, not a wall: one tokenizer call can
164
+ * overrun it, so `chatTextRenderer` stops a stream paying for that again.
165
+ * - After `marked`: the length of the HTML, lower while a text streams. Raw HTML
166
+ * goes out escaped on both of `marked`'s ways out, the `html` and `text`
167
+ * renderers, so what is left is `marked`'s own well-formed output and its length
168
+ * is its size in the DOM. That is what bounds sanitizing and drawing: a short
169
+ * string of unclosed tags can parse into a million nodes.
170
+ * - For layout: the characters past ASCII in each block, as `checkBlock` counts
171
+ * them. A text past that draws as plain text in smaller blocks.
172
+ *
173
+ * Two limits are local. A table past the cell limit is left as the text it is,
174
+ * and a run of inline text that `tooMuchInline` refuses draws as literal text,
175
+ * because those costs sit where no budget check reaches. The rest of the message
176
+ * stays formatted, and both are asked where `marked` itself would do the work, so
177
+ * a code block never counts.
178
+ *
179
+ * The budget is wall-clock, so a text close to it can draw either way.
180
+ */
181
+ const MARKDOWN_BUDGET_MS = 250;
182
+ const MAX_TEXT_CHARS = 256_000;
183
+ const MAX_TABLE_CELLS = 100_000;
184
+ const MAX_HTML_CHARS = 1_000_000;
185
+ const MAX_STREAMED_HTML_CHARS = 250_000;
186
+ const MAX_WORD_CHARS = 8_000;
187
+ const MAX_SPACE_RUN = 1_000;
188
+ /** About a tenth of a second of copying on a laptop. */
189
+ const MAX_MASKING_WORK = 1_000_000_000;
190
+
191
+ /** What opens a masked run: `<`, `[`, a backslash and a backtick. */
192
+ const MASKED_MARKS = new Set([60, 91, 92, 96]);
193
+
194
+ const TABLE_DELIMITER = /^[ :|-]+$/;
195
+
196
+ /**
197
+ * Would a table starting here pad past the cell limit? Every row is padded to
198
+ * the header's width inside one tokenizer call, which no check inside `marked`
199
+ * can interrupt and which can run the tab out of memory. A table's width is its
200
+ * delimiter row, the second line, and its rows end by the next blank line at the
201
+ * latest.
202
+ */
203
+ function tableTooBig(src: string): boolean {
204
+ const delimiterAt = src.indexOf('\n') + 1;
205
+ if (delimiterAt === 0) return false;
206
+ const lineEnd = src.indexOf('\n', delimiterAt);
207
+ const delimiter = src.slice(delimiterAt, lineEnd === -1 ? src.length : lineEnd);
208
+ if (!TABLE_DELIMITER.test(delimiter)) return false;
209
+ const columns = delimiter.split('|').length;
210
+ let rows = 0;
211
+ let at = lineEnd;
212
+ while (at !== -1 && src[at + 1] !== '\n' && columns * rows <= MAX_TABLE_CELLS) {
213
+ rows += 1;
214
+ at = src.indexOf('\n', at + 1);
215
+ }
216
+ return columns * rows > MAX_TABLE_CELLS;
217
+ }
218
+
219
+ /**
220
+ * Is this run of inline text past what `marked` can do between budget checks?
221
+ *
222
+ * A word: only ASCII adds to one, because `marked`'s inline expressions stop at
223
+ * anything else, so a paragraph of Chinese with no spaces is not one long word.
224
+ * Other letters do not end a word either, or they could split a hostile run.
225
+ *
226
+ * A run of spaces: with `breaks: true` that is quadratic inside one expression.
227
+ *
228
+ * Masking: before its first token `marked` copies the whole run once for each
229
+ * code span, tag, link or escape in it, so marks times length is what it costs.
230
+ */
231
+ function tooMuchInline(text: string): boolean {
232
+ let word = 0;
233
+ let spaces = 0;
234
+ let marks = 0;
235
+ for (let index = 0; index < text.length; index += 1) {
236
+ const code = text.charCodeAt(index);
237
+ spaces = code === SPACE ? spaces + 1 : 0;
238
+ if (spaces > MAX_SPACE_RUN) return true;
239
+ if (code <= SPACE) {
240
+ word = 0;
241
+ } else if (code <= LAST_ASCII) {
242
+ word += 1;
243
+ if (word > MAX_WORD_CHARS) return true;
244
+ if (MASKED_MARKS.has(code)) marks += 1;
245
+ }
246
+ }
247
+ return marks * text.length > MAX_MASKING_WORK;
248
+ }
249
+
250
+ let deadline = 0;
251
+ let blockLimit = MAX_NON_ASCII_IN_BLOCK;
252
+
253
+ /** A tokenizer that matches nothing: `marked` asks it before every token. */
254
+ function checkBudget(): undefined {
255
+ if (Date.now() > deadline) throw new Error('markdown budget spent');
256
+ return undefined;
257
+ }
258
+
259
+ /** Called with all the text of one block: a run of inline text, a code block or a
260
+ * block of raw HTML. Past the limit the whole message draws as plain text. */
261
+ function checkBlock(text: string): void {
262
+ if (nonAsciiIn(text) > blockLimit) throw new Error('block too costly to lay out');
263
+ }
264
+
265
+ const budgetExtension: MarkedExtension = {
266
+ extensions: [
267
+ { name: 'chatBudgetBlock', level: 'block', tokenizer: checkBudget },
268
+ { name: 'chatBudgetInline', level: 'inline', tokenizer: checkBudget },
269
+ ],
270
+ tokenizer: {
271
+ // `marked` asks this only where a table can start, never inside a code
272
+ // block. Undefined: not a table. False: go on and try one.
273
+ table(src) {
274
+ return tableTooBig(src) ? undefined : false;
275
+ },
276
+ },
277
+ // A reference link repeats its whole address at every use, and that is drawn
278
+ // after the tokens are done. False: draw it the usual way.
279
+ renderer: {
280
+ // After a raw `<code>`, `<kbd>`, `<pre>` or `<script>`, `marked` flags the text
281
+ // that follows as already escaped, up to the closing tag, and writes it out as
282
+ // it stands. Nothing here is escaped yet: escaping is this renderer's job.
283
+ text(token) {
284
+ if ('escaped' in token) token.escaped = false;
285
+ if ('tokens' in token && token.tokens) return false;
286
+ return withBreaks(token.text) ?? false;
287
+ },
288
+ codespan({ text }) {
289
+ const html = withBreaks(text);
290
+ return html === null ? false : `<code>${html}</code>`;
291
+ },
292
+ code({ text }) {
293
+ checkBlock(text);
294
+ return false;
295
+ },
296
+ link() {
297
+ checkBudget();
298
+ return false;
299
+ },
300
+ image() {
301
+ checkBudget();
302
+ return false;
303
+ },
304
+ },
305
+ };
306
+
307
+ /** Sees every run of inline text, nested ones too, before `marked`'s own passes
308
+ * over it. A code block is never one, so these limits do not reach it. */
309
+ class BoundedLexer extends Lexer {
310
+ override inlineTokens(src: string, tokens: Token[] = []): Token[] {
311
+ checkBudget();
312
+ checkBlock(src);
313
+ if (tooMuchInline(src)) {
314
+ tokens.push({ type: 'text', raw: src, text: src });
315
+ return tokens;
316
+ }
317
+ return super.inlineTokens(src, tokens);
318
+ }
319
+ }
320
+
321
+ const LINE_BREAK_TAG = /^<br\s*\/?>\s*$/i;
322
+
323
+ /** Raw HTML as the text it is. A block of it keeps its own `<p>`, or blocks in a
324
+ * row would run together into one for layout. */
325
+ function rawHtmlAsText(token: Tokens.HTML | Tokens.Tag): string {
326
+ checkBlock(token.text);
327
+ const text = withBreaks(token.text) ?? escapeHtml(token.text);
328
+ return 'block' in token && token.block ? `<p>${text}</p>` : text;
329
+ }
330
+
331
+ /**
332
+ * Raw HTML never reaches an HTML parser here: it goes out escaped, by this
333
+ * renderer and by the `text` one in `budgetExtension`. A browser rebuilds every
334
+ * open formatting tag in each new paragraph, so 27 KB of unclosed tags makes a
335
+ * million nodes inside the sanitizer, where no limit reaches. An answer is
336
+ * markdown. The one tag kept is a line break, because agents write it in table
337
+ * cells; it is written fresh here, so nothing of the source gets through.
338
+ */
339
+ const agentMarkdown = new Marked(
340
+ {
341
+ gfm: true,
342
+ breaks: false,
343
+ renderer: {
344
+ html(token) {
345
+ return LINE_BREAK_TAG.test(token.text) ? '<br>' : rawHtmlAsText(token);
346
+ },
347
+ },
348
+ },
349
+ budgetExtension,
350
+ );
351
+
352
+ /** A person's HTML, every tag of it, stays the literal text they typed. */
353
+ const personMarkdown = new Marked(
354
+ {
355
+ gfm: true,
356
+ breaks: false,
357
+ renderer: {
358
+ html(token) {
359
+ return rawHtmlAsText(token);
360
+ },
361
+ },
362
+ },
363
+ budgetExtension,
364
+ );
365
+
366
+ function parseMarkdown(markdown: Marked, source: string, streaming = false): string {
367
+ deadline = Date.now() + MARKDOWN_BUDGET_MS;
368
+ blockLimit = streaming ? MAX_NON_ASCII_IN_SMALL_BLOCK : MAX_NON_ASCII_IN_BLOCK;
369
+ return markdown.parser(new BoundedLexer(markdown.defaults).lex(source));
370
+ }
371
+
372
+ interface RenderedMessage {
373
+ readonly html: string;
374
+ /** Too costly to draw again on every delta of a stream. */
375
+ readonly spent: boolean;
376
+ }
377
+
378
+ /** The one way out to the DOM. It never throws: a render that throws would take
379
+ * the host's whole React root with it. */
380
+ function renderMessage(markdown: Marked, source: string, streaming: boolean): RenderedMessage {
381
+ const sanitize = sanitizerThatWorks();
382
+ if (sanitize === null || source.length > MAX_TEXT_CHARS) {
383
+ return { html: asPlainText(source), spent: true };
384
+ }
385
+ try {
386
+ const html = parseMarkdown(markdown, source, streaming);
387
+ const limit = streaming ? MAX_STREAMED_HTML_CHARS : MAX_HTML_CHARS;
388
+ if (html.length > limit) return { html: asPlainText(source), spent: true };
389
+ const clean = sanitize(html);
390
+ return { html: clean, spent: Date.now() > deadline };
391
+ } catch {
392
+ return { html: asPlainText(source), spent: true };
393
+ }
394
+ }
395
+
396
+ /** An agent's text as sanitized HTML. */
397
+ export function chatMarkdownHtml(markdown: string): string {
398
+ return renderMessage(agentMarkdown, markdown, false).html;
399
+ }
400
+
401
+ /** A held text redraws once it has grown by an eighth. */
402
+ const HELD_TEXT_GROWTH = 1.125;
403
+
404
+ /**
405
+ * The render for one text as it streams. Every delta re-renders the whole text,
406
+ * so a text too costly for that is held: plain, and redrawn only as it grows by a
407
+ * fixed share, so what a stream pays per delta stops growing with its length. A
408
+ * text is held once it spends its budget or passes a streaming limit, on its HTML
409
+ * or on a block. Call with `streaming` false when the stream ends: that always renders in full,
410
+ * so an honest message held for a moment comes back whole and formatted.
411
+ */
412
+ export function chatTextRenderer(literalHtml: boolean): (text: string, streaming: boolean) => string {
413
+ const markdown = literalHtml ? personMarkdown : agentMarkdown;
414
+ let held: { readonly text: string; readonly html: string } | null = null;
415
+ return (text, streaming) => {
416
+ if (streaming && held !== null && text.startsWith(held.text)) {
417
+ if (text.length < held.text.length * HELD_TEXT_GROWTH) return held.html;
418
+ held = { text, html: asPlainText(text) };
419
+ return held.html;
420
+ }
421
+ const { html, spent } = renderMessage(markdown, text, streaming);
422
+ held = streaming && spent ? { text, html } : null;
423
+ return html;
424
+ };
425
+ }
426
+
427
+ /** The markdown step of {@link personMarkdownHtml}, before sanitizing. */
428
+ export function personMarkdownToHtml(markdown: string): string {
429
+ return parseMarkdown(personMarkdown, markdown);
430
+ }
431
+
432
+ /** A person's message: markdown, but HTML they typed stays the literal text
433
+ * they typed. The sanitizer still runs after, as for every message. */
434
+ export function personMarkdownHtml(markdown: string): string {
435
+ return renderMessage(personMarkdown, markdown, false).html;
436
+ }
@@ -0,0 +1,39 @@
1
+ import { cn } from '../../lib/utils';
2
+
3
+ // A .tsx on purpose, with no JSX: the apps' Tailwind `@source` globs take
4
+ // `packages/ui/components/**/*.tsx`, so classes in a .ts here get no rule.
5
+
6
+ /** The one focus spelling in this folder: a 1 px accent border and a soft halo. */
7
+ export const FOCUS_RING =
8
+ 'outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring-soft';
9
+
10
+ /** The bordered floating surface the window wears. */
11
+ export const floatingDialogChrome =
12
+ 'bg-popover text-popover-foreground rounded-dialog border border-border shadow-dialog';
13
+
14
+ /** A control is at least 44 px on a phone, whatever it draws at. */
15
+ const PHONE_TARGET = 'max-md:min-h-11 max-md:min-w-11';
16
+
17
+ /** `Button` ghost and icon, narrowed to the window's 28 px control and quiet
18
+ * hover. The `after:` box keeps a 36 px target around the 28 px one. */
19
+ export const CHAT_ICON_BUTTON = cn(
20
+ 'relative size-7 border border-transparent text-muted-foreground after:absolute after:-inset-1',
21
+ '[@media(hover:hover)_and_(pointer:fine)]:hover:bg-surface-hover',
22
+ '[@media(hover:hover)_and_(pointer:fine)]:hover:text-foreground',
23
+ PHONE_TARGET,
24
+ FOCUS_RING,
25
+ );
26
+
27
+ /** The composer's 32 px Send and Stop, with no resting shadow. */
28
+ export const CHAT_SEND_BUTTON = cn(
29
+ 'size-8 border border-transparent shadow-none',
30
+ PHONE_TARGET,
31
+ FOCUS_RING,
32
+ );
33
+
34
+ /** Stop's quiet fill on top of `CHAT_SEND_BUTTON`. */
35
+ export const CHAT_STOP_BUTTON = cn(
36
+ CHAT_SEND_BUTTON,
37
+ 'bg-soft-quiet-fill',
38
+ '[@media(hover:hover)_and_(pointer:fine)]:hover:bg-soft-quiet-fill-hover',
39
+ );
@@ -0,0 +1,196 @@
1
+ /**
2
+ * The floating chat window's own state: minimized, fullscreen, size and the
3
+ * docked or floating position. A plain external store that knows nothing about
4
+ * conversations or the server, so ChatFrame can render from it alone.
5
+ *
6
+ * Geometry: default 420x560, min 360x420, max the frame it floats in minus
7
+ * margins, anchored by its bottom-right offsets so the top-left resize grip
8
+ * grows the window without moving its anchor.
9
+ *
10
+ * The store holds what the person chose. What is drawn is `fitChatWindow` of
11
+ * that against the measured frame, worked out at render, so a frame that
12
+ * narrows for a moment changes the picture and never the choice.
13
+ *
14
+ * Saved geometry belongs to the host. A saved value comes in through
15
+ * `initialGeometry` (or `restore`), every change the person makes goes out
16
+ * through `onGeometryChange`, and this module never reads or writes storage
17
+ * itself. Minimized and fullscreen are runtime state: a page load starts
18
+ * minimized.
19
+ */
20
+
21
+ export const CHAT_WINDOW_DEFAULT = { width: 420, height: 560, right: 16, bottom: 16 } as const;
22
+ export const CHAT_WINDOW_MIN = { width: 360, height: 420 } as const;
23
+ /** Margin the window keeps from its frame at maximum size. */
24
+ export const CHAT_WINDOW_FRAME_MARGIN = 16;
25
+
26
+ /** The shape a host saves and hands back. */
27
+ export interface ChatWindowGeometry {
28
+ readonly w: number;
29
+ readonly h: number;
30
+ readonly right: number;
31
+ readonly bottom: number;
32
+ }
33
+
34
+ /** Clamp a saved dimension into [min, max]; anything non-finite falls back. */
35
+ function clampDimension(value: unknown, min: number, max: number, fallback: number): number {
36
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
37
+ return Math.min(max, Math.max(min, Math.round(value)));
38
+ }
39
+
40
+ /**
41
+ * A saved geometry made usable, with no regard to this screen: a corrupted
42
+ * value falls back and a size under the minimum is raised. A window sized on a
43
+ * big monitor keeps that size here, and `fitChatWindow` draws it to fit a
44
+ * laptop. Takes `unknown` because a saved value can be corrupted.
45
+ */
46
+ function parseSavedGeometry(saved: unknown): ChatWindowGeometry {
47
+ const raw = (typeof saved === 'object' && saved !== null ? saved : {}) as Record<string, unknown>;
48
+ const limit = Number.POSITIVE_INFINITY;
49
+ return {
50
+ w: clampDimension(raw.w, CHAT_WINDOW_MIN.width, limit, CHAT_WINDOW_DEFAULT.width),
51
+ h: clampDimension(raw.h, CHAT_WINDOW_MIN.height, limit, CHAT_WINDOW_DEFAULT.height),
52
+ right: clampDimension(raw.right, 0, limit, CHAT_WINDOW_DEFAULT.right),
53
+ bottom: clampDimension(raw.bottom, 0, limit, CHAT_WINDOW_DEFAULT.bottom),
54
+ };
55
+ }
56
+
57
+ /**
58
+ * What a chosen geometry draws as in a measured frame: as large as the frame
59
+ * minus its margins, never under the minimum, and never off the frame. This is
60
+ * for the picture only: its result is not a choice, so it is neither stored
61
+ * nor reported.
62
+ */
63
+ export function fitChatWindow(
64
+ wanted: ChatWindowGeometry,
65
+ frame: { readonly width: number; readonly height: number },
66
+ ): ChatWindowGeometry {
67
+ const w = Math.min(wanted.w, Math.max(CHAT_WINDOW_MIN.width, frame.width - CHAT_WINDOW_FRAME_MARGIN * 2));
68
+ const h = Math.min(wanted.h, Math.max(CHAT_WINDOW_MIN.height, frame.height - CHAT_WINDOW_FRAME_MARGIN * 2));
69
+ return {
70
+ w,
71
+ h,
72
+ right: Math.max(0, Math.min(wanted.right, frame.width - w)),
73
+ bottom: Math.max(0, Math.min(wanted.bottom, frame.height - h)),
74
+ };
75
+ }
76
+
77
+ export interface ChatWindowStoreOptions {
78
+ /** The host's saved geometry. Absent: the default dock. */
79
+ readonly initialGeometry?: unknown;
80
+ /** Runs on every change the person makes, drag and resize steps included, so
81
+ * a host that saves it should debounce and flush on unload. Not called for
82
+ * `initialGeometry`, for `restore`, or when the frame changes size. */
83
+ readonly onGeometryChange?: (geometry: ChatWindowGeometry) => void;
84
+ /** Runs whenever a minimized window opens, before listeners hear of it. */
85
+ readonly onOpen?: () => void;
86
+ }
87
+
88
+ export class ChatWindowStore {
89
+ minimized = true;
90
+ fullscreen = false;
91
+ winW: number = CHAT_WINDOW_DEFAULT.width;
92
+ winH: number = CHAT_WINDOW_DEFAULT.height;
93
+ winRight: number = CHAT_WINDOW_DEFAULT.right;
94
+ winBottom: number = CHAT_WINDOW_DEFAULT.bottom;
95
+ /** True once this window opened in this page's lifetime. */
96
+ openedOnce = false;
97
+
98
+ private readonly onOpen: (() => void) | null;
99
+ private readonly onGeometryChange: ((geometry: ChatWindowGeometry) => void) | null;
100
+ private listeners = new Set<() => void>();
101
+ private version = 0;
102
+
103
+ constructor(options: ChatWindowStoreOptions = {}) {
104
+ this.onOpen = options.onOpen ?? null;
105
+ this.onGeometryChange = options.onGeometryChange ?? null;
106
+ if (options.initialGeometry !== undefined) this.restore(options.initialGeometry);
107
+ }
108
+
109
+ // The useSyncExternalStore seam.
110
+ subscribe = (listener: () => void): (() => void) => {
111
+ this.listeners.add(listener);
112
+ return () => this.listeners.delete(listener);
113
+ };
114
+ getVersion = (): number => this.version;
115
+ private emit(): void {
116
+ this.version += 1;
117
+ for (const listener of this.listeners) listener();
118
+ }
119
+
120
+ /** Every geometry write ends here: an unchanged value reports nothing. */
121
+ private commitGeometry(next: ChatWindowGeometry): void {
122
+ if (
123
+ next.w === this.winW &&
124
+ next.h === this.winH &&
125
+ next.right === this.winRight &&
126
+ next.bottom === this.winBottom
127
+ ) {
128
+ return;
129
+ }
130
+ this.winW = next.w;
131
+ this.winH = next.h;
132
+ this.winRight = next.right;
133
+ this.winBottom = next.bottom;
134
+ this.emit();
135
+ this.onGeometryChange?.(next);
136
+ }
137
+
138
+ /** Take a saved geometry. An open window redraws; no report, because the
139
+ * value is the host's own. */
140
+ restore(saved: unknown): void {
141
+ const restored = parseSavedGeometry(saved);
142
+ this.winW = restored.w;
143
+ this.winH = restored.h;
144
+ this.winRight = restored.right;
145
+ this.winBottom = restored.bottom;
146
+ this.emit();
147
+ }
148
+
149
+ /** The chosen geometry, in the shape `initialGeometry` takes back. */
150
+ geometry(): ChatWindowGeometry {
151
+ return { w: this.winW, h: this.winH, right: this.winRight, bottom: this.winBottom };
152
+ }
153
+
154
+ private open(): void {
155
+ this.minimized = false;
156
+ this.openedOnce = true;
157
+ this.onOpen?.();
158
+ }
159
+
160
+ setMinimized(minimized: boolean): void {
161
+ if (minimized) this.minimized = true;
162
+ else this.open();
163
+ this.emit();
164
+ }
165
+
166
+ /** The toggle, on an open window. On a minimized one this is the summon into
167
+ * fullscreen, so it always lands fullscreen. */
168
+ toggleFullscreen(): void {
169
+ if (this.minimized) {
170
+ this.fullscreen = true;
171
+ this.open();
172
+ } else {
173
+ this.fullscreen = !this.fullscreen;
174
+ }
175
+ this.emit();
176
+ }
177
+
178
+ /** Committed by the resize grip, already clamped against the frame. */
179
+ setWindowSize(width: number, height: number): void {
180
+ this.commitGeometry({ ...this.geometry(), w: Math.round(width), h: Math.round(height) });
181
+ }
182
+
183
+ /** Committed by the header drag: the bottom-right offsets within the frame. */
184
+ setWindowOffsets(right: number, bottom: number): void {
185
+ this.commitGeometry({ ...this.geometry(), right: Math.round(right), bottom: Math.round(bottom) });
186
+ }
187
+
188
+ /** Double-click on the header: back to the default bottom-right dock. */
189
+ resetWindowDock(): void {
190
+ this.commitGeometry({
191
+ ...this.geometry(),
192
+ right: CHAT_WINDOW_DEFAULT.right,
193
+ bottom: CHAT_WINDOW_DEFAULT.bottom,
194
+ });
195
+ }
196
+ }