@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
@@ -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,
@@ -146,7 +147,7 @@ export function TableOfContents({
146
147
  )}
147
148
  </div>
148
149
  <p className="text-[11px] text-foreground/70 truncate mt-0.5" title={linkedDocFilepath}>
149
- {linkedDocFilepath.split('/').pop()}
150
+ {basenameFromPath(linkedDocFilepath)}
150
151
  </p>
151
152
  </div>
152
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,
@@ -403,12 +404,14 @@ export interface ViewerProps {
403
404
  * Opt-in host capability, with `answerOnly`: the reader may comment on the
404
405
  * text. A text selection opens the selection toolbar, comment-only
405
406
  * (Comment and the one-click 👍; no Delete, no label picker), and its
406
- * comment composer; `annotations` are drawn as highlights. Question cards
407
- * are left out of selection (their text is not commented on), and nothing
408
- * else of authoring comes back (no pinpoint, code-block toolbar, Global
409
- * comment or Copy, no Vim). For a host that shows a message whose words
410
- * can be commented on (the Plannotator Inbox's agent messages). Ignored
411
- * 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.
412
415
  */
413
416
  textComments?: boolean;
414
417
  /**
@@ -647,6 +650,7 @@ const StickyActionsLane: React.FC<{ className: string; children: React.ReactNode
647
650
  {/* After the lane: the lane is a flex box, so a float placed before it would narrow it. */}
648
651
  <div
649
652
  data-print-hide
653
+ data-sticky-actions-spacer
650
654
  aria-hidden="true"
651
655
  className="float-right"
652
656
  style={box ? { width: box.width, height: box.height, marginTop: box.marginTop, marginRight: box.marginRight } : undefined}
@@ -920,27 +924,41 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
920
924
  // card's top padding. One row fits; a second row (diff badge) or mobile
921
925
  // wrapping outgrows the padding and lands on the document's first heading.
922
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.
923
930
  const docBadgesRef = useRef<HTMLDivElement | null>(null);
924
931
  const [badgeClearance, setBadgeClearance] = useState(0);
932
+ const [badgeMaxWidth, setBadgeMaxWidth] = useState<number | null>(null);
925
933
  useEffect(() => {
926
934
  if (hasViewerAnnotationHeader) {
927
935
  setBadgeClearance(0);
936
+ setBadgeMaxWidth(null);
928
937
  return;
929
938
  }
930
939
  const el = docBadgesRef.current;
931
940
  const article = containerRef.current;
932
- 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]');
933
946
  const measure = () => {
934
947
  const pad = parseFloat(getComputedStyle(article).paddingTop) || 0;
935
948
  const overflow = el.offsetTop + el.offsetHeight - pad;
936
949
  setBadgeClearance(overflow > 1 ? overflow + 4 : 0);
950
+ setBadgeMaxWidth(legacyBadgesMaxWidth(el.getBoundingClientRect(), actions?.getBoundingClientRect() ?? null, article.getBoundingClientRect()));
937
951
  };
938
952
  measure();
939
953
  const ro = new ResizeObserver(measure);
940
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);
941
959
  window.addEventListener('resize', measure);
942
960
  return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
943
- }, [hasViewerAnnotationHeader, repoInfo, hasPreviousVersion, showDemoBadge, linkedDocInfo, archiveInfo, sourceInfo, planDiffStats, openInAppPath]);
961
+ }, [hasViewerAnnotationHeader, stickyActions, repoInfo, hasPreviousVersion, showDemoBadge, linkedDocInfo, archiveInfo, sourceInfo, planDiffStats, openInAppPath]);
944
962
 
945
963
  // Per-doc heading slug map with dedup — computed once per blocks array so
946
964
  // anchor ids stay stable across re-renders and duplicate heading texts get
@@ -1380,6 +1398,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1380
1398
  const handleCopy = (e: ClipboardEvent) => {
1381
1399
  const tag = (e.target as HTMLElement)?.tagName;
1382
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;
1383
1405
 
1384
1406
  if (toolbarState?.selectionText) {
1385
1407
  e.preventDefault();
@@ -1731,9 +1753,13 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1731
1753
  ...(vimModeActive ? { outline: 'none' } : {}),
1732
1754
  } as React.CSSProperties}
1733
1755
  >
1734
- {/* Legacy badge placement remains byte-for-byte opt-out behavior. */}
1735
1756
  {!viewerAnnotationHeader && (repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo || sourceInfo || openInAppPath) && (
1736
- <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
+ >
1737
1763
  <DocBadges
1738
1764
  layout="column"
1739
1765
  repoInfo={repoInfo}
@@ -1840,10 +1866,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1840
1866
  annotations={annotations}
1841
1867
  selectedAnnotationId={selectedAnnotationId}
1842
1868
  onSelectAnnotation={onSelectAnnotation}
1843
- onAddAnnotation={authoringOff ? undefined : onAddAnnotation}
1844
- readOnly={authoringOff}
1869
+ onAddAnnotation={selectionCommentsOn ? onAddAnnotation : undefined}
1870
+ readOnly={!selectionCommentsOn}
1845
1871
  onRestoreReport={onRestoreReport}
1846
- onAskAI={authoringOff ? undefined : onAskAI}
1872
+ onAskAI={selectionCommentsOn ? onAskAI : undefined}
1847
1873
  askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
1848
1874
  />
1849
1875
  </Suspense>
@@ -1856,10 +1882,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1856
1882
  annotations={annotations}
1857
1883
  selectedAnnotationId={selectedAnnotationId}
1858
1884
  onSelectAnnotation={onSelectAnnotation}
1859
- onAddAnnotation={authoringOff ? undefined : onAddAnnotation}
1860
- readOnly={authoringOff}
1885
+ onAddAnnotation={selectionCommentsOn ? onAddAnnotation : undefined}
1886
+ readOnly={!selectionCommentsOn}
1861
1887
  onRestoreReport={onRestoreReport}
1862
- onAskAI={authoringOff ? undefined : onAskAI}
1888
+ onAskAI={selectionCommentsOn ? onAskAI : undefined}
1863
1889
  askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
1864
1890
  />
1865
1891
  </Suspense>
@@ -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
+ }