@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,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('<', '<')
|
|
11
|
+
.replaceAll('>', '>')
|
|
12
|
+
.replaceAll('"', '"')
|
|
13
|
+
.replaceAll("'", ''');
|
|
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
|
+
}
|