@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
@@ -7,6 +7,7 @@ import type { AnnotateAgentTerminalSide } from '@plannotator/core/agent-terminal
7
7
  import type { Origin } from '@plannotator/core/agents';
8
8
  import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
9
9
  import type { TokenHoverDelay } from '@plannotator/core/token-hover';
10
+ import { basenameFromPath } from '@plannotator/core/source-save';
10
11
  import {
11
12
  configStore,
12
13
  useConfigValue,
@@ -2670,7 +2671,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
2670
2671
  >
2671
2672
  {detectedVaults.map((vault) => (
2672
2673
  <option key={vault} value={vault}>
2673
- {vault.split('/').pop() || vault}
2674
+ {basenameFromPath(vault)}
2674
2675
  </option>
2675
2676
  ))}
2676
2677
  <option value={CUSTOM_PATH_SENTINEL}>Custom path...</option>
@@ -1,4 +1,5 @@
1
1
  import React, { useMemo, useCallback } from 'react';
2
+ import { basenameFromPath } from '@plannotator/core/source-save';
2
3
  import type { Block, Annotation } from '../types';
3
4
  import {
4
5
  buildTocHierarchy,
@@ -22,6 +23,17 @@ interface TableOfContentsProps {
22
23
  linkedDocFilepath?: string | null;
23
24
  onLinkedDocBack?: () => void;
24
25
  backLabel?: string;
26
+ /**
27
+ * The element a click scrolls. Default: the `ScrollViewportProvider`
28
+ * context's viewport (plan review). A host that draws the list outside the
29
+ * document's scroll area (the Inbox's comments column) names it here.
30
+ */
31
+ scrollViewport?: HTMLElement | null;
32
+ /**
33
+ * The deepest heading level listed. Default 3 (plan review's list); the
34
+ * contents rail's surface (`DocumentContents`) lists 1 to 4, as the rail does.
35
+ */
36
+ maxLevel?: number;
25
37
  }
26
38
 
27
39
  // The prototype's TOC is a FLAT list — heading depth is conveyed by indentation
@@ -43,7 +55,7 @@ function flattenToc(items: TocItem[]): TocItem[] {
43
55
  // H1 flush + near-full strength, H2/H3 indented and dimmed to muted. Active row
44
56
  // is a soft neutral surface tint (not a loud primary fill).
45
57
  function itemClasses(level: number, isActive: boolean): string {
46
- const indent = level <= 1 ? '' : level === 2 ? 'ml-3' : 'ml-6';
58
+ const indent = level <= 1 ? '' : level === 2 ? 'ml-3' : level === 3 ? 'ml-6' : 'ml-9';
47
59
  const tone = isActive
48
60
  ? 'bg-surface-1 text-foreground'
49
61
  : level <= 1
@@ -62,6 +74,8 @@ export function TableOfContents({
62
74
  linkedDocFilepath,
63
75
  onLinkedDocBack,
64
76
  backLabel,
77
+ scrollViewport: scrollViewportProp,
78
+ maxLevel = 3,
65
79
  }: TableOfContentsProps) {
66
80
  // Annotation count per section (kept — production feature).
67
81
  const annotationCounts = useMemo(
@@ -69,15 +83,16 @@ export function TableOfContents({
69
83
  [blocks, annotations]
70
84
  );
71
85
 
72
- // Build the hierarchy (filters to heading levels ≤ 3 and attaches counts),
73
- // then flatten to a plain list.
86
+ // Build the hierarchy (filters to heading levels ≤ maxLevel and attaches
87
+ // counts), then flatten to a plain list.
74
88
  const tocItems = useMemo(
75
- () => flattenToc(buildTocHierarchy(blocks, annotationCounts)),
76
- [blocks, annotationCounts]
89
+ () => flattenToc(buildTocHierarchy(blocks, annotationCounts, maxLevel)),
90
+ [blocks, annotationCounts, maxLevel]
77
91
  );
78
92
 
79
93
  // The real scroll element is the OverlayScrollArea viewport, not <main>.
80
- const scrollViewport = useScrollViewport();
94
+ const contextViewport = useScrollViewport();
95
+ const scrollViewport = scrollViewportProp === undefined ? contextViewport : scrollViewportProp;
81
96
 
82
97
  // Smooth scroll-to-heading, accounting for the sticky header.
83
98
  const handleNavigate = useCallback(
@@ -132,7 +147,7 @@ export function TableOfContents({
132
147
  )}
133
148
  </div>
134
149
  <p className="text-[11px] text-foreground/70 truncate mt-0.5" title={linkedDocFilepath}>
135
- {linkedDocFilepath.split('/').pop()}
150
+ {basenameFromPath(linkedDocFilepath)}
136
151
  </p>
137
152
  </div>
138
153
  )}
@@ -86,9 +86,10 @@ import { type QuickLabel } from '../utils/quickLabels';
86
86
  import type { SelectionAction } from '../utils/selectionActions';
87
87
  import type { MentionSource } from '../utils/mentions';
88
88
  import { DocBadges, type DocBadgesProps, type LinkedDocBadgeInfo } from './DocBadges';
89
+ import { legacyBadgesMaxWidth } from './docBadgesWidth';
89
90
  import { PinpointOverlay } from './PinpointOverlay';
90
91
  import { usePinpoint } from '../hooks/usePinpoint';
91
- import { useAnnotationHighlighter, type AnnotationRestoreReport } from '../hooks/useAnnotationHighlighter';
92
+ import { rangeInsideExcluded, useAnnotationHighlighter, type AnnotationRestoreReport } from '../hooks/useAnnotationHighlighter';
92
93
  import { useVimSelection } from '../hooks/useVimSelection';
93
94
  import {
94
95
  getScrollViewportIntersectionRoot,
@@ -320,6 +321,10 @@ export interface ViewerProps {
320
321
  /** A small label over each picture tile's picture (see `QuestionBlock`
321
322
  * `pictureBadge`); absent: none. */
322
323
  questionPictureBadge?: (choice: { label: string; image: { alt: string; src: string } }) => string | null;
324
+ /** A picture tile's zoom, told its question's key and the choice's index (see
325
+ * `QuestionBlock` `onZoomChoice`). Absent: the zoom opens the picture
326
+ * through `onImageClick`, as before. */
327
+ onQuestionPictureZoom?: (key: string, index: number, url: string, alt: string) => void;
323
328
  /** Makes a question's "Records a decision" tag a toggle button
324
329
  * (`aria-pressed`); a click calls this with the question's key and the
325
330
  * next state, which the host stores and returns from
@@ -399,12 +404,14 @@ export interface ViewerProps {
399
404
  * Opt-in host capability, with `answerOnly`: the reader may comment on the
400
405
  * text. A text selection opens the selection toolbar, comment-only
401
406
  * (Comment and the one-click 👍; no Delete, no label picker), and its
402
- * comment composer; `annotations` are drawn as highlights. Question cards
403
- * are left out of selection (their text is not commented on), and nothing
404
- * else of authoring comes back (no pinpoint, code-block toolbar, Global
405
- * comment or Copy, no Vim). For a host that shows a message whose words
406
- * can be commented on (the Plannotator Inbox's agent messages). Ignored
407
- * without `answerOnly` and under `readOnly`. Absent → unchanged.
407
+ * comment composer; `annotations` are drawn as highlights. A rendered
408
+ * diagram takes comments on its parts the same way (`onAddAnnotation` gets
409
+ * a row with `diagramAnchor`). Question cards are left out of selection
410
+ * (their text is not commented on), and nothing else of authoring comes
411
+ * back (no pinpoint, code-block toolbar, Global comment or Copy, no Vim).
412
+ * For a host that shows a message whose words can be commented on (the
413
+ * Plannotator Inbox's agent messages). Ignored without `answerOnly` and
414
+ * under `readOnly`. Absent → unchanged.
408
415
  */
409
416
  textComments?: boolean;
410
417
  /**
@@ -643,6 +650,7 @@ const StickyActionsLane: React.FC<{ className: string; children: React.ReactNode
643
650
  {/* After the lane: the lane is a flex box, so a float placed before it would narrow it. */}
644
651
  <div
645
652
  data-print-hide
653
+ data-sticky-actions-spacer
646
654
  aria-hidden="true"
647
655
  className="float-right"
648
656
  style={box ? { width: box.width, height: box.height, marginTop: box.marginTop, marginRight: box.marginRight } : undefined}
@@ -833,6 +841,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
833
841
  questionContractLines = false,
834
842
  questionPictureChoices = true,
835
843
  questionPictureBadge,
844
+ onQuestionPictureZoom,
836
845
  onToggleQuestionDecisionRecording,
837
846
  onOpenQuestionDecision,
838
847
  questionDecisionLocked,
@@ -915,27 +924,41 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
915
924
  // card's top padding. One row fits; a second row (diff badge) or mobile
916
925
  // wrapping outgrows the padding and lands on the document's first heading.
917
926
  // Measure the cluster and insert exactly the clearance it needs (0 when it fits).
927
+ // The cluster also stops where the action cluster starts on the same row, so
928
+ // a long file name truncates at the buttons instead of running under them;
929
+ // with no buttons on its row (the phone layout) it stops inside the card.
918
930
  const docBadgesRef = useRef<HTMLDivElement | null>(null);
919
931
  const [badgeClearance, setBadgeClearance] = useState(0);
932
+ const [badgeMaxWidth, setBadgeMaxWidth] = useState<number | null>(null);
920
933
  useEffect(() => {
921
934
  if (hasViewerAnnotationHeader) {
922
935
  setBadgeClearance(0);
936
+ setBadgeMaxWidth(null);
923
937
  return;
924
938
  }
925
939
  const el = docBadgesRef.current;
926
940
  const article = containerRef.current;
927
- if (!el || !article) { setBadgeClearance(0); return; }
941
+ if (!el || !article) { setBadgeClearance(0); setBadgeMaxWidth(null); return; }
942
+ // The static float that holds the actions' place: the sticky lane's spacer
943
+ // (the lane itself moves while stuck), or the plain float without one.
944
+ const actions = article.querySelector<HTMLElement>('[data-sticky-actions-spacer]')
945
+ ?? article.querySelector<HTMLElement>('[data-sticky-actions]');
928
946
  const measure = () => {
929
947
  const pad = parseFloat(getComputedStyle(article).paddingTop) || 0;
930
948
  const overflow = el.offsetTop + el.offsetHeight - pad;
931
949
  setBadgeClearance(overflow > 1 ? overflow + 4 : 0);
950
+ setBadgeMaxWidth(legacyBadgesMaxWidth(el.getBoundingClientRect(), actions?.getBoundingClientRect() ?? null, article.getBoundingClientRect()));
932
951
  };
933
952
  measure();
934
953
  const ro = new ResizeObserver(measure);
935
954
  ro.observe(el);
955
+ // The card too: when it narrows (the sidebar opens, the layout settles after
956
+ // first paint) the actions float moves without changing size.
957
+ ro.observe(article);
958
+ if (actions) ro.observe(actions);
936
959
  window.addEventListener('resize', measure);
937
960
  return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
938
- }, [hasViewerAnnotationHeader, repoInfo, hasPreviousVersion, showDemoBadge, linkedDocInfo, archiveInfo, sourceInfo, planDiffStats, openInAppPath]);
961
+ }, [hasViewerAnnotationHeader, stickyActions, repoInfo, hasPreviousVersion, showDemoBadge, linkedDocInfo, archiveInfo, sourceInfo, planDiffStats, openInAppPath]);
939
962
 
940
963
  // Per-doc heading slug map with dedup — computed once per blocks array so
941
964
  // anchor ids stay stable across re-renders and duplicate heading texts get
@@ -1329,8 +1352,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1329
1352
  const container = containerRef.current;
1330
1353
  if (!container || !scrollViewport) return false;
1331
1354
 
1332
- const target = document.getElementById(anchor);
1333
- if (!target || !container.contains(target)) return false;
1355
+ const target = container.querySelector<HTMLElement>(`#${CSS.escape(anchor)}`);
1356
+ if (!target) return false;
1334
1357
 
1335
1358
  const documentHeader = stickyActions
1336
1359
  ? container.querySelector<HTMLElement>('[data-viewer-document-header]')
@@ -1375,6 +1398,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1375
1398
  const handleCopy = (e: ClipboardEvent) => {
1376
1399
  const tag = (e.target as HTMLElement)?.tagName;
1377
1400
  if (tag === 'INPUT' || tag === 'TEXTAREA') return;
1401
+ // A live selection where nothing is annotated (an Inbox action item's text) is
1402
+ // the person's own: the browser copies it, never an older toolbar's text.
1403
+ const live = window.getSelection();
1404
+ if (live && !live.isCollapsed && live.rangeCount > 0 && containerRef.current && rangeInsideExcluded(containerRef.current, live.getRangeAt(0))) return;
1378
1405
 
1379
1406
  if (toolbarState?.selectionText) {
1380
1407
  e.preventDefault();
@@ -1726,9 +1753,13 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1726
1753
  ...(vimModeActive ? { outline: 'none' } : {}),
1727
1754
  } as React.CSSProperties}
1728
1755
  >
1729
- {/* Legacy badge placement remains byte-for-byte opt-out behavior. */}
1730
1756
  {!viewerAnnotationHeader && (repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo || sourceInfo || openInAppPath) && (
1731
- <div ref={docBadgesRef} data-print-hide className={`absolute top-3 md:top-4 ${gridEnabled ? 'left-3 md:left-5' : 'left-0'}`}>
1757
+ <div
1758
+ ref={docBadgesRef}
1759
+ data-print-hide
1760
+ className={`absolute top-3 md:top-4 ${gridEnabled ? 'left-3 md:left-5' : 'left-0'}`}
1761
+ style={badgeMaxWidth !== null ? { maxWidth: badgeMaxWidth } : undefined}
1762
+ >
1732
1763
  <DocBadges
1733
1764
  layout="column"
1734
1765
  repoInfo={repoInfo}
@@ -1835,10 +1866,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1835
1866
  annotations={annotations}
1836
1867
  selectedAnnotationId={selectedAnnotationId}
1837
1868
  onSelectAnnotation={onSelectAnnotation}
1838
- onAddAnnotation={authoringOff ? undefined : onAddAnnotation}
1839
- readOnly={authoringOff}
1869
+ onAddAnnotation={selectionCommentsOn ? onAddAnnotation : undefined}
1870
+ readOnly={!selectionCommentsOn}
1840
1871
  onRestoreReport={onRestoreReport}
1841
- onAskAI={authoringOff ? undefined : onAskAI}
1872
+ onAskAI={selectionCommentsOn ? onAskAI : undefined}
1842
1873
  askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
1843
1874
  />
1844
1875
  </Suspense>
@@ -1851,10 +1882,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1851
1882
  annotations={annotations}
1852
1883
  selectedAnnotationId={selectedAnnotationId}
1853
1884
  onSelectAnnotation={onSelectAnnotation}
1854
- onAddAnnotation={authoringOff ? undefined : onAddAnnotation}
1855
- readOnly={authoringOff}
1885
+ onAddAnnotation={selectionCommentsOn ? onAddAnnotation : undefined}
1886
+ readOnly={!selectionCommentsOn}
1856
1887
  onRestoreReport={onRestoreReport}
1857
- onAskAI={authoringOff ? undefined : onAskAI}
1888
+ onAskAI={selectionCommentsOn ? onAskAI : undefined}
1858
1889
  askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
1859
1890
  />
1860
1891
  </Suspense>
@@ -1980,6 +2011,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1980
2011
  questionContractLines={questionContractLines}
1981
2012
  questionPictureChoices={questionPictureChoices}
1982
2013
  questionPictureBadge={questionPictureBadge}
2014
+ onQuestionPictureZoom={onQuestionPictureZoom}
1983
2015
  />
1984
2016
  );
1985
2017
  // `textComments`: a question card's words are not commented on
@@ -271,6 +271,11 @@ export interface QuestionBlockProps {
271
271
  * answer, never read by the parser. Default absent, so every card draws as
272
272
  * before (the Plannotator Inbox's onboarding tour labels its pictures). */
273
273
  pictureBadge?: (choice: { label: string; image: { alt: string; src: string } }) => string | null;
274
+ /** The zoom button on a picture tile, told which question and which choice
275
+ * (its index in the question's choices) as well as the picture: a host can
276
+ * open a gallery of that question's choices (the Plannotator Inbox's option
277
+ * sets). Absent: the zoom calls `onImageClick(url, alt)` as before. */
278
+ onZoomChoice?: (key: string, index: number, url: string, alt: string) => void;
274
279
  }
275
280
 
276
281
  /** The picture tile's label (`pictureBadge`): a small dark chip over the picture's top-left corner. */
@@ -321,6 +326,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
321
326
  onNavigateAnchor,
322
327
  pictureChoices = true,
323
328
  pictureBadge,
329
+ onZoomChoice,
324
330
  }) => {
325
331
  const { question, number } = indexed;
326
332
  const resolveImage = useImageResolver();
@@ -880,7 +886,11 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
880
886
  name={groupName}
881
887
  readOnly={readOnly}
882
888
  onPick={pick}
883
- onZoom={onImageClick}
889
+ onZoom={
890
+ onZoomChoice
891
+ ? (url, alt, index) => onZoomChoice(question.key, index, url, alt)
892
+ : onImageClick
893
+ }
884
894
  onPictureError={markPictureFailed}
885
895
  heldTitle={pictureHeldTitle}
886
896
  renderText={inline}
@@ -1121,8 +1131,8 @@ export interface QuestionPictureTilesProps {
1121
1131
  readOnly?: boolean;
1122
1132
  /** The tile's radio or checkbox changed (the picture and the label pick through it). */
1123
1133
  onPick: (label: string, checked: boolean) => void;
1124
- /** The separate zoom button (absent: no zoom button). Never picks. */
1125
- onZoom?: (url: string, alt: string) => void;
1134
+ /** The separate zoom button (absent: no zoom button). Never picks. `index`: the choice's place in `choices`. */
1135
+ onZoom?: (url: string, alt: string, index: number) => void;
1126
1136
  onPictureError?: (url: string) => void;
1127
1137
  /** The host's reason a picture is held (its image resolver's `unavailable` title). */
1128
1138
  heldTitle?: (src: string) => string | null;
@@ -1256,7 +1266,7 @@ export const QuestionPictureTiles: React.FC<QuestionPictureTilesProps> = ({
1256
1266
  {shown && onZoom && (
1257
1267
  <button
1258
1268
  type="button"
1259
- onClick={() => onZoom(shown, alt)}
1269
+ onClick={() => onZoom(shown, alt, index)}
1260
1270
  aria-label={`Zoom: ${alt}`}
1261
1271
  className="annotation-exclude absolute right-1.5 top-1.5 grid h-7 w-7 place-items-center rounded-md bg-background/85 text-foreground shadow-sm ring-1 ring-border/70 before:absolute before:-inset-2 before:content-[''] hover:bg-background focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary"
1262
1272
  data-pinpoint-ignore=""
@@ -0,0 +1,192 @@
1
+ import { Send, Square } from 'lucide-react';
2
+ import type * as React from 'react';
3
+
4
+ import { cn } from '../../lib/utils';
5
+ import { isImeCompositionKey } from '../../utils/commentSubmitKey';
6
+ import { Button } from '../ui/button';
7
+ import { Textarea } from '../ui/textarea';
8
+ import { CHAT_SEND_BUTTON, CHAT_STOP_BUTTON, FOCUS_RING } from './chatRecipes';
9
+
10
+ /**
11
+ * The text box's own box: border, radius, padding and type size. Spelled once
12
+ * so a host's layer painted over the field (`overlay`) can share its metrics.
13
+ * The scrollbar gutter is reserved, so a field that scrolls wraps where the
14
+ * layer does. 16 px below md keeps iOS Safari from zooming on focus.
15
+ */
16
+ export const CHAT_COMPOSER_FIELD_BOX =
17
+ 'rounded-md border px-3 py-2 text-base md:text-sm [scrollbar-gutter:stable]';
18
+
19
+ export interface ChatComposerProps {
20
+ /** The field's text. The composer is controlled: it holds none itself, so
21
+ * `onChange` must set this in the same tick or typing and IME input are lost. */
22
+ readonly value: string;
23
+ readonly onChange: (value: string) => void;
24
+ /** Enter in the field, Mod+Enter included, and the Send button. It runs even
25
+ * while `busy` or with an empty box, so a host decides what those mean. */
26
+ readonly onSend: () => void;
27
+ /** False: plain Enter is a new line and Mod+Enter or the button sends. For a
28
+ * touch keyboard, which has no Shift to make a new line with. */
29
+ readonly sendOnEnter?: boolean;
30
+ /** Stop, while `busy`. */
31
+ readonly onStop?: () => void;
32
+ /** A reply is running: the Send button becomes Stop. */
33
+ readonly busy?: boolean;
34
+ readonly disabled?: boolean;
35
+ /** The field's accessible name. */
36
+ readonly label?: string;
37
+ readonly placeholder?: string;
38
+ readonly sendLabel?: string;
39
+ readonly stopLabel?: string;
40
+ /** The host's handle on the field. */
41
+ readonly composerRef?: React.RefObject<HTMLTextAreaElement | null>;
42
+ /** Above the box (errors, queued messages). */
43
+ readonly notices?: React.ReactNode;
44
+ /** The quiet context line under the box. */
45
+ readonly below?: React.ReactNode;
46
+ /** The row with Send, left of it. */
47
+ readonly toolbar?: React.ReactNode;
48
+ /** In the box's positioned wrapper, before the field (a host's own menu). */
49
+ readonly assist?: React.ReactNode;
50
+ /** Painted over the field, in the field's own box. */
51
+ readonly overlay?: React.ReactNode;
52
+ /** Extra field classes. */
53
+ readonly fieldClassName?: string;
54
+ /** The id of the element describing what the message carries. */
55
+ readonly describedBy?: string;
56
+ /** With a host's menu in `assist`: whether it is open and which row is active. */
57
+ readonly expanded?: boolean;
58
+ readonly activeDescendant?: string;
59
+ /** Files pasted into the field. Absent: the paste is left to the browser. */
60
+ readonly onFiles?: (files: readonly File[]) => void;
61
+ /** Pasted text, before the field takes it: true when the host took it. */
62
+ readonly onPasteText?: (text: string) => boolean;
63
+ /** The caret or the selection moved. */
64
+ readonly onCaret?: (field: HTMLTextAreaElement) => void;
65
+ /** An IME composition started (true) or ended (false). */
66
+ readonly onComposing?: (composing: boolean) => void;
67
+ /** Runs before the composer's own keys; `preventDefault` keeps Enter from sending. */
68
+ readonly onKeyDown?: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
69
+ }
70
+
71
+ /** The message box. It has no picker of its own; a host adds one through `assist`. */
72
+ export function ChatComposer({
73
+ value,
74
+ onChange,
75
+ onSend,
76
+ sendOnEnter = true,
77
+ onStop,
78
+ busy = false,
79
+ disabled,
80
+ label = 'Message',
81
+ placeholder,
82
+ sendLabel = 'Send',
83
+ stopLabel = 'Stop',
84
+ composerRef,
85
+ notices,
86
+ below,
87
+ toolbar,
88
+ assist,
89
+ overlay,
90
+ fieldClassName,
91
+ describedBy,
92
+ expanded,
93
+ activeDescendant,
94
+ onFiles,
95
+ onPasteText,
96
+ onCaret,
97
+ onComposing,
98
+ onKeyDown,
99
+ }: ChatComposerProps) {
100
+ return (
101
+ <div className="flex max-h-[60%] shrink-0 flex-col gap-2 border-t border-border px-3 pb-2 pt-2.5">
102
+ <div className="flex min-h-0 flex-col gap-2 overflow-y-auto empty:hidden">{notices}</div>
103
+ <div className="relative shrink-0">
104
+ {assist}
105
+ <Textarea
106
+ ref={composerRef}
107
+ rows={2}
108
+ value={value}
109
+ disabled={disabled}
110
+ placeholder={placeholder}
111
+ aria-label={label}
112
+ aria-autocomplete={expanded === undefined ? undefined : 'list'}
113
+ aria-expanded={expanded}
114
+ aria-activedescendant={activeDescendant}
115
+ aria-describedby={describedBy}
116
+ // The box grows with its text up to 128 px, then scrolls inside itself.
117
+ className={cn(
118
+ CHAT_COMPOSER_FIELD_BOX,
119
+ 'field-sizing-content max-h-32 min-h-16 resize-none bg-card shadow-none',
120
+ FOCUS_RING,
121
+ fieldClassName,
122
+ )}
123
+ onChange={(event) => onChange(event.target.value)}
124
+ onSelect={(event) => onCaret?.(event.currentTarget)}
125
+ onClick={(event) => onCaret?.(event.currentTarget)}
126
+ onKeyUp={(event) => {
127
+ if (event.key.startsWith('Arrow') || event.key === 'Home' || event.key === 'End') {
128
+ onCaret?.(event.currentTarget);
129
+ }
130
+ }}
131
+ onCompositionStart={() => onComposing?.(true)}
132
+ onCompositionEnd={() => onComposing?.(false)}
133
+ onPaste={(event) => {
134
+ const files = Array.from(event.clipboardData?.files ?? []);
135
+ if (files.length > 0 && onFiles !== undefined) {
136
+ event.preventDefault();
137
+ onFiles(files);
138
+ return;
139
+ }
140
+ const text = event.clipboardData?.getData('text/plain') ?? '';
141
+ if (text !== '' && onPasteText?.(text) === true) event.preventDefault();
142
+ }}
143
+ onKeyDown={(event) => {
144
+ // Enter, in any chord, does not bubble past the React root, so a
145
+ // bubble listener on window or document (a Send elsewhere on the
146
+ // page) does not hear it. Capture listeners and native listeners on
147
+ // the box's ancestors still do, and keyup is not stopped.
148
+ const enter = event.key === 'Enter';
149
+ if (enter) event.stopPropagation();
150
+ // Safari's Enter that commits a composition has isComposing false
151
+ // and keyCode 229; this checks both.
152
+ if (isImeCompositionKey(event)) return;
153
+ onKeyDown?.(event);
154
+ if (!enter || event.defaultPrevented) return;
155
+ const mod = event.metaKey || event.ctrlKey;
156
+ if (!mod && (event.shiftKey || !sendOnEnter)) return; // a new line
157
+ event.preventDefault();
158
+ if (!event.shiftKey) onSend();
159
+ }}
160
+ />
161
+ {overlay}
162
+ </div>
163
+ {below}
164
+ <div className="flex min-w-0 shrink-0 items-center gap-1" data-chat-composer-toolbar>
165
+ {toolbar}
166
+ <div className="ml-auto shrink-0">
167
+ {busy ? (
168
+ <Button
169
+ variant="ghost"
170
+ size="icon"
171
+ aria-label={stopLabel}
172
+ className={CHAT_STOP_BUTTON}
173
+ onClick={onStop}
174
+ >
175
+ <Square aria-hidden="true" className="size-3" />
176
+ </Button>
177
+ ) : (
178
+ <Button
179
+ size="icon"
180
+ aria-label={sendLabel}
181
+ className={CHAT_SEND_BUTTON}
182
+ disabled={disabled}
183
+ onClick={onSend}
184
+ >
185
+ <Send aria-hidden="true" className="size-3.5" />
186
+ </Button>
187
+ )}
188
+ </div>
189
+ </div>
190
+ </div>
191
+ );
192
+ }