@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.
- package/HANDOFF.md +102 -6
- package/README.md +128 -3
- package/components/AnnotationPanel.tsx +2 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +4 -1
- package/components/CodeFilePopout.tsx +6 -4
- package/components/CodeSnippetPreview.tsx +174 -0
- package/components/ComposerTokenTextarea.tsx +43 -2
- package/components/DeferredMarkdownContentsRail.tsx +38 -0
- package/components/DiffFileTree.tsx +7 -6
- package/components/DocBadges.tsx +47 -35
- package/components/DocumentContents.tsx +74 -0
- package/components/ImageLightbox.tsx +121 -63
- package/components/ImageZoomStage.tsx +32 -4
- package/components/InlineMarkdown.tsx +15 -150
- package/components/MarkdownContentsRail.tsx +777 -0
- package/components/Settings.tsx +2 -1
- package/components/TableOfContents.tsx +22 -7
- package/components/Viewer.tsx +51 -19
- package/components/blocks/QuestionBlock.tsx +14 -4
- package/components/chat/ChatComposer.tsx +192 -0
- package/components/chat/ChatFrame.tsx +524 -0
- package/components/chat/ChatTranscript.tsx +422 -0
- package/components/chat/chatMarkdown.ts +436 -0
- package/components/chat/chatRecipes.tsx +39 -0
- package/components/chat/chatWindowStore.ts +196 -0
- package/components/chat/index.ts +72 -0
- package/components/diagram/DiagramCanvas.tsx +7 -0
- package/components/diagram/DiagramComposer.tsx +15 -3
- package/components/diagram/DiagramViewer.tsx +1 -0
- package/components/docBadgesWidth.ts +29 -0
- package/components/html-viewer/HtmlViewer.tsx +36 -1
- package/components/imageZoom.ts +21 -0
- package/components/inbox-thread/FileName.tsx +118 -0
- package/components/inbox-thread/GuideCard.tsx +39 -0
- package/components/inbox-thread/ReaskNote.tsx +25 -0
- package/components/inbox-thread/SkillsLine.tsx +40 -0
- package/components/inbox-thread/Tip.tsx +105 -0
- package/components/inbox-thread/icons.tsx +262 -0
- package/components/inbox-thread/reask.ts +222 -0
- package/contents-rail.css +337 -0
- package/hooks/useActiveSection.ts +46 -63
- package/hooks/useAnnotationHighlighter.ts +40 -1
- package/hooks/useFileBrowser.ts +4 -3
- package/hooks/useHeadingScrollSpy.ts +77 -0
- package/hooks/useHoverPreview.ts +94 -0
- package/hooks/usePrefersReducedMotion.ts +23 -0
- package/hooks/useSwipeStep.ts +597 -0
- package/inbox-thread.css +4542 -0
- package/package.json +13 -4
- package/styles.css +1 -1
- package/theme.css +1 -0
- package/themes/tokyo-night-moon.css +34 -0
- package/utils/aiChatFormat.ts +32 -11
- package/utils/annotationHelpers.ts +32 -4
- package/utils/blockParse.ts +45 -0
- package/utils/headingScrollSpy.ts +105 -0
- package/utils/syntaxTheme.ts +1 -0
- 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
|
|
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:
|
|
1333
|
+
{...(src ? { src } : { srcDoc: srcdoc, sandbox: srcdocSandbox(sandbox, hostNavigates) })}
|
|
1299
1334
|
style={{
|
|
1300
1335
|
width: "100%",
|
|
1301
1336
|
height: fullViewport ? "100%" : `${iframeHeight}px`,
|
package/components/imageZoom.ts
CHANGED
|
@@ -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
|
+
}
|