@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,422 @@
1
+ import { createContext, memo, useContext, useEffect, useMemo, useRef } from 'react';
2
+ import type * as React from 'react';
3
+
4
+ import { usePrefersReducedMotion } from '../../hooks/usePrefersReducedMotion';
5
+ import { cn } from '../../lib/utils';
6
+ import { chatTextRenderer } from './chatMarkdown';
7
+ import { FOCUS_RING } from './chatRecipes';
8
+
9
+ /** The status of the turn in progress. */
10
+ export type ChatStatus = 'submitted' | 'streaming' | 'ready' | 'error';
11
+
12
+ export type ChatMessageRole = 'user' | 'assistant' | 'system';
13
+
14
+ /** Any part of a message. The transcript draws text; `renderPart` draws the rest. */
15
+ export interface ChatMessagePart {
16
+ readonly type: string;
17
+ }
18
+
19
+ export interface ChatTextPart extends ChatMessagePart {
20
+ readonly type: 'text';
21
+ readonly text: string;
22
+ readonly state?: 'streaming' | 'done';
23
+ }
24
+
25
+ export interface ChatMessage<Part extends ChatMessagePart = ChatMessagePart> {
26
+ readonly id: string;
27
+ readonly role: ChatMessageRole;
28
+ readonly parts: readonly Part[];
29
+ }
30
+
31
+ function isTextPart(part: ChatMessagePart): part is ChatTextPart {
32
+ return part.type === 'text';
33
+ }
34
+
35
+ /**
36
+ * Clip a still-streaming text at its last completed sentence or paragraph, so
37
+ * under reduced motion whole sentences append and never single words. Nothing
38
+ * paints before the first boundary; the finished turn renders in full.
39
+ */
40
+ export function clipAtSentenceBoundary(text: string): string {
41
+ let cut = 0;
42
+ for (const match of text.matchAll(/[.!?](?=\s)|\n/gu)) {
43
+ cut = (match.index ?? 0) + match[0].length;
44
+ }
45
+ return text.slice(0, cut);
46
+ }
47
+
48
+ /**
49
+ * Has this assistant turn painted anything yet? A `step-start` part draws
50
+ * nothing and a text part is empty until its first delta, so "the message has
51
+ * parts" is not "the person can see an answer". Under reduced motion a text part
52
+ * still streaming paints nothing before its first sentence ends. Any other part
53
+ * counts the moment it exists.
54
+ */
55
+ export function chatTurnHasOutput(message: ChatMessage, reducedMotion = false): boolean {
56
+ return message.parts.some((part) => {
57
+ if (part.type === 'step-start') return false;
58
+ if (!isTextPart(part)) return true;
59
+ const clipped = reducedMotion && part.state === 'streaming';
60
+ return (clipped ? clipAtSentenceBoundary(part.text) : part.text).length > 0;
61
+ });
62
+ }
63
+
64
+ const BODY_PROSE = cn(
65
+ 'min-w-0 text-sm text-foreground [overflow-wrap:anywhere]',
66
+ '[&_p]:mb-2 [&_p:last-child]:mb-0',
67
+ '[&_pre]:mb-2 [&_pre]:overflow-x-auto',
68
+ '[&_ol]:mb-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:mb-2 [&_ul]:list-disc [&_ul]:pl-5 [&_li]:mb-1',
69
+ '[&_code]:font-mono [&_code]:text-xs [&_code]:text-primary',
70
+ '[&_strong]:font-semibold',
71
+ '[&_blockquote]:mb-2 [&_blockquote]:border-l-2 [&_blockquote]:border-border-subtle [&_blockquote]:pl-2.5 [&_blockquote]:italic [&_blockquote]:text-muted-foreground',
72
+ '[&_a]:text-primary',
73
+ '[&_h1]:mb-1 [&_h1]:text-base [&_h1]:font-semibold [&_h2]:mb-1 [&_h2]:text-sm [&_h2]:font-semibold',
74
+ );
75
+
76
+ /** False once the turn around a text has ended. A part's own `state` cannot say
77
+ * so, because a stopped or failed turn can leave it `streaming`, and the person
78
+ * must then see what arrived, in full. */
79
+ const TurnLiveContext = createContext(true);
80
+
81
+ /** One text part as sanitized markdown, memoized on its text. */
82
+ export const ChatMarkdown = memo(function ChatMarkdown({
83
+ text,
84
+ streaming = false,
85
+ literalHtml = false,
86
+ messageId,
87
+ role,
88
+ partIndex,
89
+ annotatable,
90
+ rootRef,
91
+ }: {
92
+ text: string;
93
+ /** Still receiving deltas: under reduced motion the paint holds at the last
94
+ * completed sentence. */
95
+ streaming?: boolean;
96
+ /** A person's own message: HTML they typed stays literal text. */
97
+ literalHtml?: boolean;
98
+ /** Where this text sits, as scalars so `memo` short-circuits. A host reads
99
+ * them back off the element to anchor a selection. */
100
+ messageId?: string;
101
+ role?: ChatMessageRole;
102
+ partIndex?: number;
103
+ /** Written as `data-annotatable="true"` or `"false"`, for a host's selection
104
+ * work. Message text cannot write this attribute itself. */
105
+ annotatable?: boolean;
106
+ /** The part's root, for a host painting into its DOM. Keep it stable. The
107
+ * text repaints when it changes, so repaint after that. */
108
+ rootRef?: React.Ref<HTMLDivElement>;
109
+ }) {
110
+ const reducedMotion = usePrefersReducedMotion();
111
+ const live = useContext(TurnLiveContext) && streaming;
112
+ const shown = live && reducedMotion ? clipAtSentenceBoundary(text) : text;
113
+ const render = useMemo(() => chatTextRenderer(literalHtml), [literalHtml]);
114
+ const html = useMemo(() => render(shown, live), [render, shown, live]);
115
+ // One `{__html}` object per html string: React re-sets innerHTML whenever
116
+ // that prop's identity changes, which would collapse a live selection.
117
+ const inner = useMemo(() => ({ __html: html }), [html]);
118
+ return (
119
+ <div
120
+ ref={rootRef}
121
+ className={BODY_PROSE}
122
+ data-text-part={messageId === undefined ? undefined : ''}
123
+ data-message-id={messageId}
124
+ data-message-role={role}
125
+ data-part-index={partIndex}
126
+ data-annotatable={annotatable === undefined ? undefined : String(annotatable)}
127
+ dangerouslySetInnerHTML={inner}
128
+ />
129
+ );
130
+ });
131
+
132
+ /** The answer's place in the list, from the send until the reply's first part. */
133
+ export function ChatPendingTurn({
134
+ agentName,
135
+ agentMark,
136
+ label = 'Thinking…',
137
+ indentContent = false,
138
+ }: {
139
+ agentName: string;
140
+ agentMark?: React.ReactNode;
141
+ label?: React.ReactNode;
142
+ indentContent?: boolean;
143
+ }) {
144
+ return (
145
+ <div className="flex flex-col gap-1.5" data-message-role="assistant" data-chat-pending>
146
+ <div className="flex items-center gap-2 text-xs">
147
+ {agentMark}
148
+ <span className="font-semibold text-foreground">{agentName}</span>
149
+ </div>
150
+ <div className={cn('flex min-w-0 items-center gap-2 text-xs text-tertiary', indentContent && 'pl-7')}>
151
+ <span
152
+ aria-hidden="true"
153
+ className="size-3 shrink-0 animate-spin rounded-full border border-border border-t-primary motion-reduce:animate-pulse"
154
+ />
155
+ <span>{label}</span>
156
+ </div>
157
+ </div>
158
+ );
159
+ }
160
+
161
+ /** Where a part sits, for a host drawing it: which message, and which part of it. */
162
+ export interface ChatPartContext<Part extends ChatMessagePart = ChatMessagePart> {
163
+ readonly message: ChatMessage<Part>;
164
+ readonly index: number;
165
+ }
166
+
167
+ /** One message: its name row, then its parts. */
168
+ export function ChatMessageView<Part extends ChatMessagePart = ChatMessagePart>({
169
+ message,
170
+ agentName,
171
+ agentMark,
172
+ userName = 'You',
173
+ userMark,
174
+ stopped = false,
175
+ stoppedLabel = 'Stopped',
176
+ indentContent = false,
177
+ renderPart,
178
+ footer,
179
+ }: {
180
+ message: ChatMessage<Part>;
181
+ agentName: string;
182
+ agentMark?: React.ReactNode;
183
+ userName?: string;
184
+ userMark?: React.ReactNode;
185
+ /** This turn's run was cancelled: partial text kept, labelled. */
186
+ stopped?: boolean;
187
+ stoppedLabel?: string;
188
+ /** Indent the parts under the name, past a mark. */
189
+ indentContent?: boolean;
190
+ /** Draw a part. `undefined` falls through: text as markdown, anything else as
191
+ * nothing. `null` draws nothing. */
192
+ renderPart?: (part: Part, context: ChatPartContext<Part>) => React.ReactNode | undefined;
193
+ /** Under the parts. */
194
+ footer?: React.ReactNode;
195
+ }) {
196
+ const isUser = message.role === 'user';
197
+ return (
198
+ <div className="flex flex-col gap-1.5" data-message-role={message.role}>
199
+ <div className="flex items-center gap-2 text-xs">
200
+ {isUser ? userMark : agentMark}
201
+ <span className="font-semibold text-foreground">{isUser ? userName : agentName}</span>
202
+ {stopped && (
203
+ <span className="inline-flex h-5 items-center rounded-sm border border-border bg-muted px-1.5 text-2xs font-medium text-muted-foreground">
204
+ {stoppedLabel}
205
+ </span>
206
+ )}
207
+ </div>
208
+ <div className={cn('flex min-w-0 flex-col gap-2', indentContent && 'pl-7')}>
209
+ {message.parts.map((part, index) => {
210
+ const custom = renderPart?.(part, { message, index });
211
+ if (custom === null) return null;
212
+ if (custom !== undefined) return <div key={index}>{custom}</div>;
213
+ if (!isTextPart(part)) return null;
214
+ return (
215
+ <ChatMarkdown
216
+ key={index}
217
+ text={part.text}
218
+ streaming={part.state === 'streaming'}
219
+ literalHtml={isUser}
220
+ messageId={message.id}
221
+ role={message.role}
222
+ partIndex={index}
223
+ />
224
+ );
225
+ })}
226
+ {footer}
227
+ </div>
228
+ </div>
229
+ );
230
+ }
231
+
232
+ export interface ChatTranscriptProps<Part extends ChatMessagePart = ChatMessagePart> {
233
+ /** The conversation, in the order to show it. The host owns its source. */
234
+ readonly messages: readonly ChatMessage<Part>[];
235
+ readonly status: ChatStatus;
236
+ /** The name on the agent's turns. */
237
+ readonly agentName: string;
238
+ /** The face on the agent's turns. */
239
+ readonly agentMark?: React.ReactNode;
240
+ readonly userName?: string;
241
+ /** The face on the person's turns. */
242
+ readonly userMark?: React.ReactNode;
243
+ /** Shown while there are no messages. */
244
+ readonly empty?: React.ReactNode;
245
+ /** One quiet status line at the end. */
246
+ readonly statusLine?: string | null;
247
+ /** The pane's accessible name. */
248
+ readonly label?: string;
249
+ /** With tabs in the header: this pane as the matching tabpanel. */
250
+ readonly panel?: { readonly id: string; readonly labelledBy: string };
251
+ /** Ids of the turns the person stopped. */
252
+ readonly stopped?: ReadonlySet<string>;
253
+ readonly stoppedLabel?: string;
254
+ /** Indent parts under the name, past a mark. */
255
+ readonly indentContent?: boolean;
256
+ /** The line held while the reply has no parts yet. `false` hides it, for a
257
+ * host that says so in `statusLine`. */
258
+ readonly pending?: React.ReactNode | false;
259
+ /** Draw a part. `undefined` falls through to the transcript's own drawing;
260
+ * `null` draws nothing. To paint into a text part, return a `ChatMarkdown` of
261
+ * your own, with the same props the transcript gives its own. */
262
+ readonly renderPart?: (part: Part, context: ChatPartContext<Part>) => React.ReactNode | undefined;
263
+ /** Draw a whole row. `undefined` falls through; `null` draws nothing. */
264
+ readonly renderMessage?: (message: ChatMessage<Part>, index: number) => React.ReactNode | undefined;
265
+ /** Under a message's parts. */
266
+ readonly messageFooter?: (message: ChatMessage<Part>) => React.ReactNode;
267
+ /** The host's handle on the scroll pane, for its own selection work. */
268
+ readonly scrollRef?: React.RefObject<HTMLDivElement | null>;
269
+ /** Positioned in the pane's scroll space, so it rides with the text. */
270
+ readonly overlay?: React.ReactNode;
271
+ }
272
+
273
+ /**
274
+ * Which message can still be arriving, or -1 when no turn runs. It is usually the
275
+ * last, but a person can send while an answer streams, and then that answer sits
276
+ * above their message. A part's `state` alone cannot say so, because a stopped
277
+ * turn can leave it `streaming`, so this goes by what the host knows: `submitted`
278
+ * starts a new turn, `streaming` goes on with the one under way, and `stopped`
279
+ * names a turn that is over.
280
+ */
281
+ function arrivingIndex<Part extends ChatMessagePart>(
282
+ messages: readonly ChatMessage<Part>[],
283
+ status: ChatStatus,
284
+ stopped: ReadonlySet<string> | undefined,
285
+ ): number {
286
+ if (status !== 'submitted' && status !== 'streaming') return -1;
287
+ const last = messages.length - 1;
288
+ if (status === 'submitted') return last;
289
+ let answerAt = last;
290
+ while (answerAt >= 0 && messages[answerAt].role === 'user') answerAt -= 1;
291
+ const answer = messages[answerAt];
292
+ const unfinished =
293
+ answer !== undefined &&
294
+ !stopped?.has(answer.id) &&
295
+ answer.parts.some((part) => isTextPart(part) && part.state === 'streaming');
296
+ return unfinished ? answerAt : last;
297
+ }
298
+
299
+ /** The scroll pane and the message list. The host passes what it reads: the
300
+ * messages and the status. */
301
+ export function ChatTranscript<Part extends ChatMessagePart = ChatMessagePart>({
302
+ messages,
303
+ status,
304
+ agentName,
305
+ agentMark,
306
+ userName,
307
+ userMark,
308
+ empty,
309
+ statusLine,
310
+ label = 'Conversation',
311
+ panel,
312
+ stopped,
313
+ stoppedLabel,
314
+ indentContent = false,
315
+ pending: pendingLine,
316
+ renderPart,
317
+ renderMessage,
318
+ messageFooter,
319
+ scrollRef,
320
+ overlay,
321
+ }: ChatTranscriptProps<Part>) {
322
+ // `submitted` is set the moment a message is sent, and a stream's start can
323
+ // push an assistant message with no parts without changing the status. The
324
+ // whole blank gap is "a run is active and the answer shows nothing yet".
325
+ const running = status === 'submitted' || status === 'streaming';
326
+ const reducedMotion = usePrefersReducedMotion();
327
+ const arrivingAt = arrivingIndex(messages, status, stopped);
328
+ const arriving = messages[arrivingAt];
329
+ const answerStarted =
330
+ arriving !== undefined &&
331
+ arriving.role === 'assistant' &&
332
+ chatTurnHasOutput(arriving, reducedMotion && running);
333
+ const pending = running && !answerStarted && pendingLine !== false;
334
+
335
+ // Follow the output: stick to the live edge, released by scrolling away,
336
+ // re-armed at the edge.
337
+ const ownRef = useRef<HTMLDivElement | null>(null);
338
+ const paneRef = scrollRef ?? ownRef;
339
+ const atBottomRef = useRef(true);
340
+ useEffect(() => {
341
+ const node = paneRef.current;
342
+ if (node !== null && atBottomRef.current) node.scrollTop = node.scrollHeight;
343
+ }, [messages, statusLine, pending, paneRef]);
344
+ // The pane also follows when its own box shrinks (a growing composer).
345
+ useEffect(() => {
346
+ const node = paneRef.current;
347
+ if (node === null || typeof ResizeObserver !== 'function') return;
348
+ const observer = new ResizeObserver(() => {
349
+ if (atBottomRef.current) node.scrollTop = node.scrollHeight;
350
+ });
351
+ observer.observe(node);
352
+ return () => observer.disconnect();
353
+ }, [paneRef]);
354
+
355
+ // An assistant turn that has painted nothing yet would be a bare name row,
356
+ // so it is dropped and the pending turn stands in its place.
357
+ const drawn = (message: ChatMessage<Part>, live: boolean): boolean =>
358
+ message.role === 'user' ||
359
+ (message.role === 'assistant' && chatTurnHasOutput(message, reducedMotion && live));
360
+
361
+ return (
362
+ <div
363
+ ref={paneRef}
364
+ onScroll={(event) => {
365
+ const node = event.currentTarget;
366
+ atBottomRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < 24;
367
+ }}
368
+ // Focusable, so a keyboard user can Tab in and arrow-scroll. Deliberately
369
+ // not a live region: streamed deltas would announce token by token.
370
+ role={panel === undefined ? 'region' : 'tabpanel'}
371
+ tabIndex={0}
372
+ id={panel?.id}
373
+ aria-label={panel === undefined ? label : undefined}
374
+ aria-labelledby={panel?.labelledBy}
375
+ className={cn(
376
+ 'relative flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 py-3.5',
377
+ FOCUS_RING,
378
+ )}
379
+ >
380
+ {overlay}
381
+ {messages.length === 0
382
+ ? empty
383
+ : messages.map((message, index) => {
384
+ const live = index === arrivingAt;
385
+ const custom = renderMessage?.(message, index);
386
+ return (
387
+ <TurnLiveContext.Provider key={message.id} value={live}>
388
+ {custom !== undefined ? (
389
+ custom
390
+ ) : drawn(message, live) ? (
391
+ <ChatMessageView
392
+ message={message}
393
+ agentName={agentName}
394
+ agentMark={agentMark}
395
+ userName={userName}
396
+ userMark={userMark}
397
+ stopped={stopped?.has(message.id) ?? false}
398
+ stoppedLabel={stoppedLabel}
399
+ indentContent={indentContent}
400
+ renderPart={renderPart}
401
+ footer={messageFooter?.(message)}
402
+ />
403
+ ) : null}
404
+ </TurnLiveContext.Provider>
405
+ );
406
+ })}
407
+ {pending && (
408
+ <ChatPendingTurn
409
+ agentName={agentName}
410
+ agentMark={agentMark}
411
+ label={pendingLine ?? undefined}
412
+ indentContent={indentContent}
413
+ />
414
+ )}
415
+ {statusLine && (
416
+ <p role="status" className="text-xs text-muted-foreground">
417
+ {statusLine}
418
+ </p>
419
+ )}
420
+ </div>
421
+ );
422
+ }