@plannotator/ui 0.54.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/HANDOFF.md +102 -6
  2. package/README.md +128 -3
  3. package/components/AnnotationPanel.tsx +2 -1
  4. package/components/AttachmentsButton.tsx +1 -1
  5. package/components/BlockRenderer.tsx +4 -1
  6. package/components/CodeFilePopout.tsx +6 -4
  7. package/components/CodeSnippetPreview.tsx +174 -0
  8. package/components/ComposerTokenTextarea.tsx +43 -2
  9. package/components/DeferredMarkdownContentsRail.tsx +38 -0
  10. package/components/DiffFileTree.tsx +7 -6
  11. package/components/DocBadges.tsx +47 -35
  12. package/components/DocumentContents.tsx +74 -0
  13. package/components/ImageLightbox.tsx +121 -63
  14. package/components/ImageZoomStage.tsx +32 -4
  15. package/components/InlineMarkdown.tsx +15 -150
  16. package/components/MarkdownContentsRail.tsx +777 -0
  17. package/components/Settings.tsx +2 -1
  18. package/components/TableOfContents.tsx +22 -7
  19. package/components/Viewer.tsx +51 -19
  20. package/components/blocks/QuestionBlock.tsx +14 -4
  21. package/components/chat/ChatComposer.tsx +192 -0
  22. package/components/chat/ChatFrame.tsx +524 -0
  23. package/components/chat/ChatTranscript.tsx +422 -0
  24. package/components/chat/chatMarkdown.ts +436 -0
  25. package/components/chat/chatRecipes.tsx +39 -0
  26. package/components/chat/chatWindowStore.ts +196 -0
  27. package/components/chat/index.ts +72 -0
  28. package/components/diagram/DiagramCanvas.tsx +7 -0
  29. package/components/diagram/DiagramComposer.tsx +15 -3
  30. package/components/diagram/DiagramViewer.tsx +1 -0
  31. package/components/docBadgesWidth.ts +29 -0
  32. package/components/html-viewer/HtmlViewer.tsx +36 -1
  33. package/components/imageZoom.ts +21 -0
  34. package/components/inbox-thread/FileName.tsx +118 -0
  35. package/components/inbox-thread/GuideCard.tsx +39 -0
  36. package/components/inbox-thread/ReaskNote.tsx +25 -0
  37. package/components/inbox-thread/SkillsLine.tsx +40 -0
  38. package/components/inbox-thread/Tip.tsx +105 -0
  39. package/components/inbox-thread/icons.tsx +262 -0
  40. package/components/inbox-thread/reask.ts +222 -0
  41. package/contents-rail.css +337 -0
  42. package/hooks/useActiveSection.ts +46 -63
  43. package/hooks/useAnnotationHighlighter.ts +40 -1
  44. package/hooks/useFileBrowser.ts +4 -3
  45. package/hooks/useHeadingScrollSpy.ts +77 -0
  46. package/hooks/useHoverPreview.ts +94 -0
  47. package/hooks/usePrefersReducedMotion.ts +23 -0
  48. package/hooks/useSwipeStep.ts +597 -0
  49. package/inbox-thread.css +4542 -0
  50. package/package.json +13 -4
  51. package/styles.css +1 -1
  52. package/theme.css +1 -0
  53. package/themes/tokyo-night-moon.css +34 -0
  54. package/utils/aiChatFormat.ts +32 -11
  55. package/utils/annotationHelpers.ts +32 -4
  56. package/utils/blockParse.ts +45 -0
  57. package/utils/headingScrollSpy.ts +105 -0
  58. package/utils/syntaxTheme.ts +1 -0
  59. package/utils/themeRegistry.ts +11 -0
@@ -0,0 +1,72 @@
1
+ /**
2
+ * A shared floating chat window: screen code only. The host owns the
3
+ * conversation, the transport and anything saved.
4
+ *
5
+ * const store = new ChatWindowStore({ initialGeometry, onGeometryChange });
6
+ * <div className="relative">
7
+ * <ChatFrame store={store} label="Chat" header={...} onClose={...}>
8
+ * {() => (
9
+ * <>
10
+ * <ChatTranscript messages={messages} status={status} agentName="Agent" />
11
+ * <ChatComposer value={value} onChange={setValue} onSend={send} />
12
+ * </>
13
+ * )}
14
+ * </ChatFrame>
15
+ * </div>
16
+ *
17
+ * THEME TOKENS. The classes below are not in `theme.css`, so a host defines
18
+ * them in its own `@theme`. With none defined a class has no rule and the
19
+ * property falls through to its default.
20
+ *
21
+ * --color-tertiary text-tertiary, the ink a step behind muted
22
+ * --color-surface-hover bg-surface-hover, the quiet hover wash
23
+ * --color-ring-soft ring-ring-soft, the focus halo
24
+ * --color-border-subtle border-border-subtle, a blockquote's rule
25
+ * --color-soft-quiet-fill bg-soft-quiet-fill, Stop's resting fill
26
+ * --color-soft-quiet-fill-hover bg-soft-quiet-fill-hover, Stop's hover
27
+ * --radius-dialog rounded-dialog, the window's corner
28
+ * --shadow-dialog shadow-dialog, the window's shadow
29
+ * --text-2xs text-2xs, the Stopped badge
30
+ *
31
+ * Everything else comes from `theme.css`: bg-card, bg-popover,
32
+ * text-popover-foreground, text-foreground, text-muted-foreground, bg-muted,
33
+ * text-primary, border-border, border-input, ring-ring.
34
+ */
35
+
36
+ export {
37
+ CHAT_WINDOW_DEFAULT,
38
+ CHAT_WINDOW_FRAME_MARGIN,
39
+ CHAT_WINDOW_MIN,
40
+ ChatWindowStore,
41
+ type ChatWindowGeometry,
42
+ type ChatWindowStoreOptions,
43
+ } from './chatWindowStore';
44
+ export {
45
+ CHAT_LAUNCHER_SELECTOR,
46
+ ChatFrame,
47
+ ChatFrameLauncher,
48
+ useChatFrameFocusReturn,
49
+ useChatFrameKeys,
50
+ type ChatFrameLabels,
51
+ type ChatFrameProps,
52
+ type ChatFrameRootProps,
53
+ type ChatFrameState,
54
+ } from './ChatFrame';
55
+ export { CHAT_COMPOSER_FIELD_BOX, ChatComposer, type ChatComposerProps } from './ChatComposer';
56
+ export {
57
+ ChatMarkdown,
58
+ ChatMessageView,
59
+ ChatPendingTurn,
60
+ ChatTranscript,
61
+ chatTurnHasOutput,
62
+ clipAtSentenceBoundary,
63
+ type ChatMessage,
64
+ type ChatMessagePart,
65
+ type ChatMessageRole,
66
+ type ChatPartContext,
67
+ type ChatStatus,
68
+ type ChatTextPart,
69
+ type ChatTranscriptProps,
70
+ } from './ChatTranscript';
71
+ export { chatMarkdownHtml, personMarkdownHtml } from './chatMarkdown';
72
+ export { FOCUS_RING, floatingDialogChrome } from './chatRecipes';
@@ -247,6 +247,13 @@ export function DiagramCanvas({
247
247
  pressRef.current = null;
248
248
  return;
249
249
  }
250
+ // A second finger makes it a pinch: neither finger is a tap on a part or
251
+ // a pan any more, so the press ends here.
252
+ if (event.pointerType === 'touch' && !event.isPrimary) {
253
+ if (pressRef.current?.panning) setPanning(false);
254
+ pressRef.current = null;
255
+ return;
256
+ }
250
257
  pressRef.current = {
251
258
  id: event.pointerId,
252
259
  x: event.clientX,
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
1
+ import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent } from 'react';
2
2
  import { diagramTargetName, diagramTargetText } from '@plannotator/core/diagram-anchor';
3
3
  import { cn } from '../../lib/utils';
4
4
  import type { ScreenRect } from '../../utils/diagram-projection';
@@ -30,6 +30,7 @@ export function DiagramComposer({
30
30
  draft,
31
31
  anchorRect,
32
32
  hostWidth,
33
+ hostHeight = 0,
33
34
  sourceDirty,
34
35
  submitting,
35
36
  error,
@@ -41,6 +42,8 @@ export function DiagramComposer({
41
42
  draft: DiagramComposerDraft;
42
43
  anchorRect: ScreenRect;
43
44
  hostWidth: number;
45
+ /** The canvas's height, to keep the composer inside it. */
46
+ hostHeight?: number;
44
47
  sourceDirty: boolean;
45
48
  submitting: boolean;
46
49
  /** The last submit's failure, shown under the textarea. */
@@ -73,6 +76,14 @@ export function DiagramComposer({
73
76
  const canWrite = disabledReason === undefined && !unsavedPart;
74
77
  const rightFits = anchorRect.left + anchorRect.width + 12 + COMPOSER_WIDTH_PX <= hostWidth;
75
78
  const left = rightFits ? anchorRect.left + anchorRect.width + 12 : Math.max(0, anchorRect.left - COMPOSER_WIDTH_PX - 12);
79
+ // The canvas clips what overflows it: a part near the bottom of a short
80
+ // canvas (an inline diagram on a phone) lifts the composer until it all shows.
81
+ const rootRef = useRef<HTMLDivElement | null>(null);
82
+ const [height, setHeight] = useState(0);
83
+ useLayoutEffect(() => {
84
+ setHeight(rootRef.current?.offsetHeight ?? 0);
85
+ });
86
+ const top = Math.max(0, hostHeight > 0 && height > 0 ? Math.min(anchorRect.top, hostHeight - height) : anchorRect.top);
76
87
 
77
88
  const canAskAI = onAskAI !== undefined && !asking && !submitting && text.trim() !== '';
78
89
  const askAI = async () => {
@@ -113,11 +124,12 @@ export function DiagramComposer({
113
124
 
114
125
  return (
115
126
  <div
127
+ ref={rootRef}
116
128
  data-diagram-composer=""
117
129
  role="dialog"
118
130
  aria-label={`Comment on ${diagramTargetText(target)}`}
119
- className="pointer-events-auto absolute z-10 flex w-72 flex-col gap-1.5 rounded-md border border-border bg-card p-2 shadow-lg"
120
- style={{ left, top: Math.max(0, anchorRect.top) }}
131
+ className="pointer-events-auto absolute z-10 flex w-72 max-w-full flex-col gap-1.5 rounded-md border border-border bg-card p-2 shadow-lg"
132
+ style={{ left, top }}
121
133
  onPointerDown={(event) => event.stopPropagation()}
122
134
  onPointerUp={(event) => event.stopPropagation()}
123
135
  onKeyDown={(event) => {
@@ -265,6 +265,7 @@ export function DiagramViewer({
265
265
  draft={commentsState.composer}
266
266
  anchorRect={anchorRect}
267
267
  hostWidth={handle.hostRef.current?.getBoundingClientRect().width ?? 0}
268
+ hostHeight={handle.hostRef.current?.getBoundingClientRect().height ?? 0}
268
269
  sourceDirty={sourceDirty}
269
270
  submitting={commentsState.submitting}
270
271
  error={commentsState.submitError}
@@ -0,0 +1,29 @@
1
+ /** A box's viewport edges, as `getBoundingClientRect()` reports them. */
2
+ export interface BoxEdges {
3
+ readonly left: number;
4
+ readonly right: number;
5
+ readonly top: number;
6
+ readonly bottom: number;
7
+ }
8
+
9
+ /** Space kept between the badge cluster and the action cluster. */
10
+ const ACTIONS_GAP = 8;
11
+
12
+ /**
13
+ * The widest the legacy (absolutely positioned) badge cluster may grow before
14
+ * it runs under the document's action cluster, so a long file name truncates
15
+ * where the buttons start rather than at a fixed width.
16
+ *
17
+ * When no actions share the row (there are none, they have not been measured
18
+ * yet, or the phone layout drops them below the badges), the cluster stops
19
+ * inside the card instead, keeping the same inset on the right as on the left,
20
+ * so the name truncates and the open-in button stays on screen. Null leaves
21
+ * it unbounded while the card has no measured width (hidden, not laid out).
22
+ */
23
+ export function legacyBadgesMaxWidth(badges: BoxEdges, actions: BoxEdges | null, card: BoxEdges): number | null {
24
+ const inCard = card.right > card.left ? Math.max(0, card.right - badges.left - (badges.left - card.left)) : null;
25
+ if (!actions || actions.right <= actions.left) return inCard;
26
+ const sharesRow = actions.top < badges.bottom && actions.bottom > badges.top;
27
+ if (!sharesRow) return inCard;
28
+ return Math.max(0, actions.left - badges.left - ACTIONS_GAP);
29
+ }
@@ -148,6 +148,20 @@ const MAX_VIM_COPY_TEXT_LENGTH = 2 * 1024 * 1024;
148
148
  */
149
149
  export const HTML_VIEWER_SANDBOX = "allow-scripts allow-modals";
150
150
 
151
+ /**
152
+ * The srcdoc frame's `sandbox` attribute: the `sandbox` prop's value as given,
153
+ * plus `allow-popups` when `hostNavigates` needs it and the value lacks it.
154
+ * Anything that is not a string (`undefined`, and also `null` from a host's
155
+ * unset config, which a destructuring default does not catch) becomes
156
+ * `HTML_VIEWER_SANDBOX`, so the frame always carries a sandbox: React would
157
+ * drop a `null` attribute and run the page unsandboxed.
158
+ */
159
+ function srcdocSandbox(sandbox: unknown, hostNavigates: boolean): string {
160
+ const value = typeof sandbox === "string" ? sandbox : HTML_VIEWER_SANDBOX;
161
+ if (!hostNavigates || value.split(/\s+/).includes("allow-popups")) return value;
162
+ return value.trim() ? `${value} allow-popups` : "allow-popups";
163
+ }
164
+
151
165
  /** Default wait for the bridge's `ready` on the `bridgeScriptUrl` path. */
152
166
  export const DEFAULT_BRIDGE_READY_TIMEOUT_MS = 5000;
153
167
 
@@ -373,6 +387,26 @@ export interface HtmlViewerProps {
373
387
  * still pins. Absent → unchanged.
374
388
  */
375
389
  hostNavigates?: boolean;
390
+ /**
391
+ * The srcdoc frame's whole `sandbox` attribute value, for a host whose
392
+ * documents must not open dialogs: `"allow-scripts"` keeps a shared page's
393
+ * `alert`, `confirm` and `prompt` from drawing over the host's origin. Used
394
+ * as given, except that `hostNavigates` still adds `allow-popups`, which it
395
+ * needs. Never add `allow-same-origin` beside `allow-scripts`: the page
396
+ * could then remove its own sandbox. Live-app (`src`) mode has no sandbox
397
+ * and ignores this. Absent, or any non-string such as `null` →
398
+ * `HTML_VIEWER_SANDBOX` (`"allow-scripts allow-modals"`), unchanged.
399
+ *
400
+ * `""` (or only spaces) is kept as given: the strictest frame, with no
401
+ * scripts, so the annotation bridge never starts and the page cannot be
402
+ * annotated. It never falls back to the wider default.
403
+ *
404
+ * Read when the frame loads a document: browsers apply a changed `sandbox`
405
+ * only on the frame's next navigation (a new `rawHtml`, for example). To
406
+ * apply a new value to the document already shown, remount the viewer (for
407
+ * example, key it on the value).
408
+ */
409
+ sandbox?: string;
376
410
  }
377
411
 
378
412
  /**
@@ -429,6 +463,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
429
463
  bridgeErrorDisplay = "banner",
430
464
  onHostDraft,
431
465
  hostNavigates = false,
466
+ sandbox = HTML_VIEWER_SANDBOX,
432
467
  },
433
468
  ref,
434
469
  ) => {
@@ -1295,7 +1330,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1295
1330
  same-origin XHR) and no srcdoc. Srcdoc mode is unchanged. */}
1296
1331
  <iframe
1297
1332
  ref={iframeRef}
1298
- {...(src ? { src } : { srcDoc: srcdoc, sandbox: HTML_VIEWER_SANDBOX + (hostNavigates ? " allow-popups" : "") })}
1333
+ {...(src ? { src } : { srcDoc: srcdoc, sandbox: srcdocSandbox(sandbox, hostNavigates) })}
1299
1334
  style={{
1300
1335
  width: "100%",
1301
1336
  height: fullViewport ? "100%" : `${iframeHeight}px`,
@@ -32,6 +32,16 @@ export interface ZoomInset {
32
32
  readonly y: number;
33
33
  }
34
34
 
35
+ /**
36
+ * Rows of a stage that things float over: Fit and the pan's limits work in the
37
+ * stage between them, while a zoomed image still slides under them and every
38
+ * row of the stage still takes the fingers.
39
+ */
40
+ export interface ZoomClear {
41
+ readonly top: number;
42
+ readonly bottom: number;
43
+ }
44
+
35
45
  /** 800%: an image pixel is then 8 screen pixels, enough to read any screenshot. */
36
46
  export const LIGHTBOX_ZOOM_MAX = 8;
37
47
  /** Wheel zoom: factor = exp(-deltaY * sensitivity), with deltaY capped so a
@@ -147,6 +157,17 @@ export function atFit(view: ZoomViewport, limits: ZoomLimits | number): boolean
147
157
  return Math.abs(view.scale - fit) < 1e-3;
148
158
  }
149
159
 
160
+ /**
161
+ * Whether the part of an image on screen (`visibleBox`, its own pixels) can
162
+ * still pan sideways: `1` shows more of its right (the finger moving left),
163
+ * `-1` more of its left. A swipe that way pans first, and steps once the
164
+ * image is at its edge (approval "inbox-image-swipe").
165
+ */
166
+ export function canPanToward(box: Pick<NaturalBox, 'x' | 'w'> | null, naturalWidth: number, dir: 1 | -1): boolean {
167
+ if (!box || !(naturalWidth > 0)) return false;
168
+ return dir > 0 ? box.x + box.w < naturalWidth - 1 : box.x > 1;
169
+ }
170
+
150
171
  /** The share of a stage that leaves `padding` CSS pixels on every side (at least a tenth). */
151
172
  export function paddingInset(stage: ZoomSize, padding: number): ZoomInset {
152
173
  if (!valid(stage) || padding <= 0) return FULL;
@@ -0,0 +1,118 @@
1
+ import { useEffect } from 'react';
2
+ import { sliceText } from '@plannotator/core/inbox-questions';
3
+ import { Tip } from './Tip';
4
+
5
+ /**
6
+ * A file name that is cut in the middle when it runs short, never at its
7
+ * end: the end is what tells two files apart (the date, the version, the
8
+ * extension), so "SPIKE-claude-code-mobile-annotations-20261012.md" reads
9
+ * "SPIKE-claude-…-20261012.md" in a tile, not "SPIKE-claude-code-mob…".
10
+ * The same two-part pattern code review's file rows use: the start shrinks
11
+ * with an ellipsis, the end keeps its width. The full name is the hover
12
+ * text, and the direction follows the name (`dir="auto"`), so an Arabic name
13
+ * reads right to left and is cut at its own end.
14
+ *
15
+ * `wrap` draws the whole name, broken where it must, for places with room
16
+ * (the download card). A cut name gets its whole name in a tooltip (Tip)
17
+ * only while it is cut.
18
+ */
19
+ export function FileName({ name, className, wrap = false }: { name: string; className?: string; wrap?: boolean }) {
20
+ useEffect(keepNamesWholeOnCopy, []);
21
+ const [head, tail] = splitFileName(name);
22
+ const cls = `ib-fname${wrap ? ' ib-fname-wrap' : ''}${className ? ` ${className}` : ''}`;
23
+ if (wrap) {
24
+ return (
25
+ <span className={cls} dir="auto">
26
+ <span className="ib-fname-h">{name}</span>
27
+ </span>
28
+ );
29
+ }
30
+ if (!tail) {
31
+ return (
32
+ <Tip content={name} cut>
33
+ <span className={cls} dir="auto">
34
+ <span className="ib-fname-h">{name}</span>
35
+ </span>
36
+ </Tip>
37
+ );
38
+ }
39
+ // The two parts are flex items, which a screen reader would read apart
40
+ // ("refund -policy.pdf"): they are hidden from it, and it hears the whole
41
+ // name from `data-name` (inbox.css draws it as a visually hidden ::after).
42
+ // The text content stays the name.
43
+ return (
44
+ <Tip content={name} cut>
45
+ <span className={cls} dir="auto" data-name={name}>
46
+ <span className="ib-fname-h" aria-hidden="true">
47
+ {head}
48
+ </span>
49
+ <span className="ib-fname-t" aria-hidden="true">
50
+ {tail}
51
+ </span>
52
+ </span>
53
+ </Tip>
54
+ );
55
+ }
56
+
57
+ let copyListeners = 0;
58
+
59
+ /**
60
+ * The two parts of a cut name are flex items, so a selection's text puts a
61
+ * line break between them: a copy that takes in a cut name gets the name
62
+ * whole instead. Installed once while any FileName is on the page.
63
+ */
64
+ function keepNamesWholeOnCopy(): () => void {
65
+ if (copyListeners++ === 0) document.addEventListener('copy', onCopy);
66
+ return () => {
67
+ if (--copyListeners === 0) document.removeEventListener('copy', onCopy);
68
+ };
69
+ }
70
+
71
+ function onCopy(event: ClipboardEvent): void {
72
+ const selection = document.getSelection();
73
+ if (!selection || selection.isCollapsed || !event.clipboardData) return;
74
+ const text = selection.toString();
75
+ let whole = text;
76
+ for (const el of document.querySelectorAll<HTMLElement>('.ib-fname[data-name]')) {
77
+ if (!selection.containsNode(el, true)) continue;
78
+ const head = el.querySelector('.ib-fname-h')?.textContent ?? '';
79
+ const tail = el.querySelector('.ib-fname-t')?.textContent ?? '';
80
+ if (!head || !tail) continue;
81
+ // A selection across the cut holds the head's last character, the break, then the tail's first, however
82
+ // much of either part it took.
83
+ whole = whole.split(`${head.slice(-1)}\n${tail.slice(0, 1)}`).join(`${head.slice(-1)}${tail.slice(0, 1)}`);
84
+ }
85
+ if (whole === text) return;
86
+ event.clipboardData.setData('text/plain', whole);
87
+ event.preventDefault();
88
+ }
89
+
90
+ /** Names this short are never cut in the middle: there is nothing to keep apart. */
91
+ const SHORT = 16;
92
+ /** The most of the stem kept with the extension. */
93
+ const TAIL_STEM = 12;
94
+
95
+ /**
96
+ * The name split where a middle cut goes: `[start, end]`, the end being the
97
+ * extension and the last part of the stem (after its last `-`, `_`, `.` or
98
+ * space when that part is short, else its last few characters), never
99
+ * splitting a character. `end` is empty for a short name.
100
+ */
101
+ export function splitFileName(name: string): [string, string] {
102
+ if (name.length <= SHORT) return [name, ''];
103
+ const dot = name.lastIndexOf('.');
104
+ // An extension: a dot that is not the first character, 1 to 8 letters or digits after it.
105
+ const ext = dot > 0 && /^\.[\p{L}\p{N}]{1,8}$/u.test(name.slice(dot)) ? name.slice(dot) : '';
106
+ const stem = ext ? name.slice(0, -ext.length) : name;
107
+ const sep = Math.max(stem.lastIndexOf('-'), stem.lastIndexOf('_'), stem.lastIndexOf(' '), stem.lastIndexOf('.'));
108
+ let keep: string;
109
+ if (sep > 0 && stem.length - sep <= TAIL_STEM + 1) keep = stem.slice(sep);
110
+ else {
111
+ // The last few characters, never half of one: cut the stem's start whole, keep what is left.
112
+ const start = sliceText(stem, Math.max(0, stem.length - 6));
113
+ keep = stem.slice(start.length);
114
+ }
115
+ const tail = `${keep}${ext}`;
116
+ const head = name.slice(0, name.length - tail.length);
117
+ return head ? [head, tail] : [name, ''];
118
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * A guided review at the foot of the message that carries it (the record's
3
+ * 4.1): like any attachment, with its sections, files and line counts, and
4
+ * Open, which takes the window with Plannotator's guide viewer.
5
+ */
6
+
7
+ import type { InboxGuideRef } from '@plannotator/core/inbox-types';
8
+ import { formatCount, plural } from '@plannotator/core/inbox-format';
9
+ import { Icon } from './icons';
10
+ import { Tip } from './Tip';
11
+
12
+ export function GuideCard({ guide, onOpen }: { guide: InboxGuideRef; onOpen: () => void }) {
13
+ return (
14
+ <>
15
+ <div className="ib-attach-h">
16
+ <Icon name="clip" size={13} />1 attachment
17
+ </div>
18
+ <div className="ib-atts">
19
+ <button type="button" className="ib-att-t ib-guide" onClick={onOpen} data-guide-card="">
20
+ <span className="ib-fi">
21
+ <Icon name="book" size={18} />
22
+ </span>
23
+ <span className="ib-att-text">
24
+ <Tip content={`Guided review: ${guide.title}`} cut>
25
+ <span className="ib-nm">
26
+ Guided review: <bdi>{guide.title}</bdi>
27
+ </span>
28
+ </Tip>
29
+ <span className="ib-kd">
30
+ {plural(guide.sections, 'section')}, {plural(guide.files, 'file')}, <span className="ib-add">+{formatCount(guide.additions)}</span>{' '}
31
+ <span className="ib-del">-{formatCount(guide.deletions)}</span>
32
+ </span>
33
+ </span>
34
+ <span className="ib-open">Open</span>
35
+ </button>
36
+ </div>
37
+ </>
38
+ );
39
+ }
@@ -0,0 +1,25 @@
1
+ import type { InboxMessageWire } from '@plannotator/core/inbox-types';
2
+ import { Icon } from './icons';
3
+ import { reaskNoteSentence } from './reask';
4
+
5
+ /**
6
+ * The person's Re-ask note as it reads in the thread (approval "inbox-reask"):
7
+ * one line, and what the agent was sent behind a disclosure.
8
+ */
9
+ export function ReaskNote({ message, agent, picks }: { message: InboxMessageWire; agent: string; picks: number }) {
10
+ const asked = message.reask?.length ?? 0;
11
+ // Action items it asked about (ids `<message id>/a-…`): the line names them.
12
+ const actions = (message.reask ?? []).filter((id) => /\/a-[0-9a-f]/.test(id)).length;
13
+ return (
14
+ <div className="ib-reask" data-reask-note="">
15
+ <p className="ib-reask-l">
16
+ <Icon name="refresh" size={15} />
17
+ <span>{reaskNoteSentence(agent, asked, picks, actions)}</span>
18
+ </p>
19
+ <details className="ib-reask-d">
20
+ <summary>What {agent} was sent</summary>
21
+ <pre>{message.body}</pre>
22
+ </details>
23
+ </div>
24
+ );
25
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The quiet skills line under a person's message (approval
3
+ * "inbox-skills-line", option B, 2026-10-29): the box icon, then the names of
4
+ * the skills the reply used as a comma list. Nothing to open: no label, no
5
+ * size, no Show. The block the window pasted at the end of the message is
6
+ * split off by Thread.tsx's MessageBody; the agent's text is shown whole.
7
+ * The line sits in the message body, above the Delivered line, so it is in
8
+ * the same place whatever the state (sent, queued, answered). A long list
9
+ * wraps between names, never inside one.
10
+ */
11
+ import type { InboxSkillsSplit } from '@plannotator/core/skill-reference-block';
12
+
13
+ export function SkillsLine({ split }: { split: InboxSkillsSplit }) {
14
+ const names = split.skills.map((s) => s.name).join(', ');
15
+ return (
16
+ <div className="ib-skills" data-skills-line="" title={`Skills you referenced: ${names}`}>
17
+ <SkillCube />
18
+ <span className="ib-sr">Skills: </span>
19
+ <span className="ib-skills-n">
20
+ {split.skills.map((s, i) => (
21
+ <span key={s.name}>
22
+ <span className="ib-skill-nm" data-skill-name="">{s.name}</span>
23
+ {i < split.skills.length - 1 ? ', ' : ''}
24
+ </span>
25
+ ))}
26
+ </span>
27
+ </div>
28
+ );
29
+ }
30
+
31
+ /** The cube Plannotator's skill picker shows beside each skill. */
32
+ function SkillCube() {
33
+ return (
34
+ <svg className="ib-skills-i" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
35
+ <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
36
+ <path d="M3.29 7 12 12l8.71-5" />
37
+ <path d="M12 22V12" />
38
+ </svg>
39
+ );
40
+ }
@@ -0,0 +1,105 @@
1
+ import { useCallback, useState, type ReactElement, type ReactNode } from 'react';
2
+ import { Tooltip, TOOLTIP_DELAY_MS } from '../Tooltip';
3
+ import { isTextCut as isTextCutIn, noHover } from '../../utils/textCut';
4
+ import { tooltipContainerOf } from '../HostTitle';
5
+
6
+ /**
7
+ * ui's cut check (a whole pixel, or the text's Range wider than its box). A
8
+ * FileName (`.ib-fname`) counts as cut when its first part (`.ib-fname-h`) is.
9
+ */
10
+ export function isTextCut(el: Element | null | undefined): boolean {
11
+ return isTextCutIn(el, el?.classList.contains('ib-fname') ? '.ib-fname-h' : undefined);
12
+ }
13
+
14
+ /** How long the pointer rests before a tooltip opens (the list chips' timing). */
15
+ export const TIP_DELAY_MS = TOOLTIP_DELAY_MS;
16
+
17
+ /**
18
+ * The Inbox's hover text: ui's Tooltip (the component the list's chips use),
19
+ * never the browser's native `title`, which waits about a second and cannot
20
+ * be styled. The host's TooltipProvider sets the timing (the Inbox has one,
21
+ * in its App.tsx): 150 ms to open, the next one at once within 400 ms.
22
+ *
23
+ * `cut`: open only while the trigger's text is actually cut (an ellipsis, a
24
+ * clamp), so a name that fits gets no tooltip repeating it. Without `cut` it
25
+ * always opens. `when` decides each time instead, for anything else (a mark
26
+ * whose label is hidden).
27
+ *
28
+ * The trigger keeps its own accessible name (an `aria-label`, or its text):
29
+ * the tooltip adds nothing a screen reader needs, and a trigger that is not
30
+ * focusable stays out of the tab order. Nothing opens on a touch screen
31
+ * (`hover: none`): a hover there is a first tap. The popup is portaled into
32
+ * the open native `<dialog>` the trigger sits in (so the top layer does not
33
+ * cover it), else into the window's root (`data-pn-tooltip-root`), so the
34
+ * Soft switches' tokens reach it. Escape closes an open tooltip and still reaches the window
35
+ * (`escapePassesThrough`), so Escape closes a thread whatever holds focus.
36
+ */
37
+ export function Tip({
38
+ content,
39
+ children,
40
+ cut = false,
41
+ when,
42
+ side = 'top',
43
+ align,
44
+ wide = true,
45
+ open,
46
+ }: {
47
+ content: ReactNode;
48
+ children: ReactElement;
49
+ cut?: boolean;
50
+ when?: (trigger: Element) => boolean;
51
+ side?: 'top' | 'right' | 'bottom' | 'left';
52
+ align?: 'start' | 'center' | 'end';
53
+ wide?: boolean;
54
+ /**
55
+ * Shown whatever the pointer does while true (the list rows' badges, while
56
+ * their row has keyboard focus). Unset: hover decides, as for every tip.
57
+ */
58
+ open?: boolean;
59
+ }) {
60
+ const [container, setContainer] = useState<HTMLElement | null>(null);
61
+ const shouldOpen = useCallback(
62
+ (trigger: Element) => {
63
+ if (content === null || content === undefined || content === '') return false;
64
+ if (noHover()) return false;
65
+ if (cut && !isTextCut(trigger)) return false;
66
+ if (when && !when(trigger)) return false;
67
+ setContainer(tooltipContainerOf(trigger));
68
+ return true;
69
+ },
70
+ [content, cut, when],
71
+ );
72
+ // A disabled control gets no pointer events (and React drops mouse events on
73
+ // it), so its tooltip hangs on a wrapper the pointer reaches instead
74
+ // (inbox.css: `.ib-tipwrap > :disabled` lets the pointer through). Any
75
+ // control that can be disabled gets the wrapper, disabled or not, so the
76
+ // control is never remounted (and never loses focus) when it changes.
77
+ const canDisable = 'disabled' in (children.props as object);
78
+ const trigger = canDisable ? (
79
+ <span className="ib-tipwrap" data-tip-wrap="">
80
+ {children}
81
+ </span>
82
+ ) : (
83
+ children
84
+ );
85
+ return (
86
+ <Tooltip
87
+ content={content}
88
+ side={side}
89
+ align={align}
90
+ wide={wide}
91
+ delayDuration={TIP_DELAY_MS}
92
+ shouldOpen={shouldOpen}
93
+ // Opened without a hover, shouldOpen never named the trigger's root: the window's.
94
+ container={container ?? (open ? windowTooltipRoot() : null)}
95
+ escapePassesThrough
96
+ forceOpen={open}
97
+ >
98
+ {trigger}
99
+ </Tooltip>
100
+ );
101
+ }
102
+
103
+ function windowTooltipRoot(): HTMLElement | null {
104
+ return typeof document === 'undefined' ? null : document.querySelector<HTMLElement>('[data-pn-tooltip-root]');
105
+ }