@plannotator/ui 0.52.1 → 0.53.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 +231 -27
- package/README.md +29 -4
- package/components/AnnotationPanel.tsx +159 -107
- package/components/AnnotationToolbar.tsx +17 -14
- package/components/AnnotationToolstrip.tsx +9 -0
- package/components/BlockRenderer.tsx +20 -14
- package/components/CodeFilePopout.tsx +131 -68
- package/components/CommentPopover.tsx +6 -349
- package/components/ComposerTokenTextarea.tsx +379 -0
- package/components/ExportModal.tsx +6 -1
- package/components/HostTitle.tsx +108 -0
- package/components/ImageLightbox.tsx +288 -17
- package/components/ImagePeek.tsx +255 -0
- package/components/ImageResolverContext.tsx +31 -0
- package/components/InlineMarkdown.tsx +123 -15
- package/components/LocalLinkContext.tsx +49 -0
- package/components/MenuVersionSection.tsx +51 -25
- package/components/OpenInAppButton.tsx +18 -49
- package/components/PlanHeaderMenu.tsx +2 -8
- package/components/ProviderIcons.tsx +10 -4
- package/components/RenderedMarkdown.tsx +40 -4
- package/components/Settings.tsx +33 -5
- package/components/SkillReferenceMenu.tsx +28 -6
- package/components/Tooltip.tsx +51 -4
- package/components/Viewer.tsx +189 -48
- package/components/ai/AIProviderBar.tsx +11 -0
- package/components/ai/AskSessionSetupHint.tsx +12 -0
- package/components/ai/DocumentAIChatPanel.tsx +17 -5
- package/components/ai/SessionAskNotice.tsx +4 -3
- package/components/blocks/BlockquoteBlock.tsx +159 -0
- package/components/blocks/HtmlBlock.tsx +121 -9
- package/components/blocks/QuestionBlock.tsx +183 -96
- package/components/diagram/DiagramViewer.tsx +41 -4
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +73 -3
- package/components/html-viewer/bridge-script.asset.js +86 -0
- package/components/html-viewer/bridge-script.ts +86 -0
- package/components/html-viewer/useHtmlAnnotation.ts +51 -3
- package/components/icons/AgentIcons.tsx +9 -7
- package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
- package/components/plan-diff/PlanDiffViewer.tsx +6 -1
- package/configure.ts +27 -1
- package/hooks/useAgentJobs.ts +8 -3
- package/hooks/useAnnotationHighlighter.ts +128 -23
- package/hooks/useExternalAnnotations.ts +6 -0
- package/hooks/useOpenInApps.ts +88 -0
- package/hooks/useUpdateCheck.ts +38 -98
- package/hooks/useUpdateCheckSetting.ts +43 -0
- package/hooks/useUpdateToast.ts +101 -0
- package/package.json +2 -2
- package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
- package/shortcuts/index.ts +1 -0
- package/styles.css +1 -1
- package/types.ts +48 -0
- package/utils/agentClosed.ts +26 -0
- package/utils/agentToolSetting.ts +16 -9
- package/utils/aiProvider.ts +7 -0
- package/utils/askSessionSetupHint.ts +27 -0
- package/utils/blockTargeting.ts +14 -3
- package/utils/parser.ts +65 -51
- package/utils/pasteInsert.ts +234 -0
- package/utils/planDiffEngine.ts +272 -2
- package/utils/skillCatalog.ts +15 -2
- package/utils/skillReferences.ts +50 -13
- package/utils/storage.ts +5 -0
- package/utils/textCut.ts +41 -0
- package/utils/updateNotice.ts +120 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The comment composer's textarea with its token highlight layer (skill
|
|
3
|
+
* references, host `@` mentions), moved out of CommentPopover so a host can
|
|
4
|
+
* put the same field in its own composer (the Plannotator Inbox's reply box
|
|
5
|
+
* and New message). CommentPopover renders it with every default, so its
|
|
6
|
+
* markup is unchanged.
|
|
7
|
+
*/
|
|
8
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
9
|
+
import type { SkillReferenceToken } from '../utils/skillReferences';
|
|
10
|
+
import {
|
|
11
|
+
mentionTokenRanges,
|
|
12
|
+
mergeTokenRanges,
|
|
13
|
+
skillTokenRanges,
|
|
14
|
+
type ComposerTokenRange,
|
|
15
|
+
} from '../utils/composerTokens';
|
|
16
|
+
import type { MentionPerson } from '../utils/mentions';
|
|
17
|
+
|
|
18
|
+
/** Classes shared by the textarea and its highlight mirror — font, size,
|
|
19
|
+
* and box metrics MUST stay identical or the overlay drifts out of alignment. */
|
|
20
|
+
export const COMPOSER_TEXT_CLASSES = 'w-full bg-transparent text-sm px-1 py-0.5';
|
|
21
|
+
|
|
22
|
+
/** Per-composer memo for the overlay gutter sync below. */
|
|
23
|
+
interface OverlayGutterState {
|
|
24
|
+
/** The overlay the applied value belongs to (a remount invalidates it). */
|
|
25
|
+
overlay: HTMLElement | null;
|
|
26
|
+
/** Last `offsetWidth - clientWidth` seen on the textarea. */
|
|
27
|
+
raw: number;
|
|
28
|
+
/** Extra right padding currently written inline on the overlay, in px. */
|
|
29
|
+
applied: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const cssPx = (value: string): number => {
|
|
33
|
+
const n = parseFloat(value);
|
|
34
|
+
return Number.isFinite(n) ? n : 0;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Keep the mirror's text column exactly as wide as the textarea's (#1525).
|
|
39
|
+
*
|
|
40
|
+
* A layout-consuming vertical scrollbar (Windows/Linux classic scrollbars; the
|
|
41
|
+
* app's `scrollbar-width: thin` makes them ~12px) narrows the textarea's
|
|
42
|
+
* content box once the composer hits its max height — but the overlay is
|
|
43
|
+
* `overflow: hidden` and keeps its full width, so from the first line that
|
|
44
|
+
* wraps differently the painted glyphs drift away from the native caret, which
|
|
45
|
+
* is the one thing the textarea itself still draws. `scrollbar-gutter: stable`
|
|
46
|
+
* cannot fix this: it is ignored on an `overflow: hidden` box. Mirroring the
|
|
47
|
+
* measured scrollbar width onto the overlay's right padding makes both boxes
|
|
48
|
+
* wrap identically again (and stops the overlay painting under the scrollbar).
|
|
49
|
+
*
|
|
50
|
+
* Overlay scrollbars (macOS, iOS, any touch surface) consume no layout, so the
|
|
51
|
+
* measured width is 0 — and then this writes NOTHING at all, leaving the
|
|
52
|
+
* class-driven padding untouched exactly as before the fix.
|
|
53
|
+
*/
|
|
54
|
+
function syncOverlayGutter(
|
|
55
|
+
el: HTMLTextAreaElement,
|
|
56
|
+
overlay: HTMLElement,
|
|
57
|
+
state: OverlayGutterState,
|
|
58
|
+
raw: number,
|
|
59
|
+
): void {
|
|
60
|
+
// Nothing measurable changed since the last sync — the common case.
|
|
61
|
+
if (overlay === state.overlay && raw === state.raw) return;
|
|
62
|
+
state.overlay = overlay;
|
|
63
|
+
state.raw = raw;
|
|
64
|
+
|
|
65
|
+
const clear = () => {
|
|
66
|
+
if (state.applied === 0) return; // never wrote anything; leave the DOM alone
|
|
67
|
+
overlay.style.removeProperty('padding-right');
|
|
68
|
+
state.applied = 0;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const style = getComputedStyle(el);
|
|
72
|
+
// `offsetWidth - clientWidth` is scrollbar + left/right borders. The borders
|
|
73
|
+
// are shared with the overlay (both boxes are border-box), so only the
|
|
74
|
+
// scrollbar part is the extra gutter the mirror is missing.
|
|
75
|
+
const borders = cssPx(style.borderLeftWidth) + cssPx(style.borderRightWidth);
|
|
76
|
+
const scrollbar = Math.max(0, raw - borders);
|
|
77
|
+
if (scrollbar === 0) {
|
|
78
|
+
clear();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// Base padding is read from the textarea (never the overlay, whose computed
|
|
82
|
+
// value may already include a gutter we wrote), so the two can never drift
|
|
83
|
+
// apart if COMPOSER_TEXT_CLASSES changes.
|
|
84
|
+
const base = parseFloat(style.paddingRight);
|
|
85
|
+
if (!Number.isFinite(base)) {
|
|
86
|
+
clear(); // unmeasurable: fall back to the pre-fix behavior
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
overlay.style.paddingRight = `${base + scrollbar}px`;
|
|
90
|
+
state.applied = scrollbar;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The default chip look: the theme's primary at a wash, one shade stronger
|
|
95
|
+
* than a skill reference's so the two token kinds in one overlay read as
|
|
96
|
+
* siblings rather than the same thing. Deliberately no ring: every class here
|
|
97
|
+
* is one the package already emitted, so a host build's CSS (and the portable
|
|
98
|
+
* guide viewer's) is byte-identical to 0.43.2. A host that wants a pill adds
|
|
99
|
+
* `box-shadow: 0 0 0 Npx <background>` through `tokenClassName` — paint, not
|
|
100
|
+
* layout, per the metric rule above.
|
|
101
|
+
*/
|
|
102
|
+
const MENTION_CHIP_CLASSES = 'text-primary bg-primary/15 rounded-[3px]';
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* One token's span in the highlight overlay.
|
|
106
|
+
*
|
|
107
|
+
* METRIC RULE, load-bearing for every token kind: a span may change COLOR,
|
|
108
|
+
* BACKGROUND, BORDER-RADIUS, BOX-SHADOW and TEXT-DECORATION only. Anything
|
|
109
|
+
* that moves a glyph — padding, margin, border width, font-weight,
|
|
110
|
+
* letter-spacing, font-size — would shift the overlay's text off the
|
|
111
|
+
* textarea's own layout and drift the caret away from the painted glyphs. A
|
|
112
|
+
* pill's breathing room is faked with a paint-only `box-shadow` ring in the
|
|
113
|
+
* chip's own background color.
|
|
114
|
+
*/
|
|
115
|
+
function renderTokenSpan(
|
|
116
|
+
range: ComposerTokenRange,
|
|
117
|
+
text: string,
|
|
118
|
+
mentionTokenClassName?: string,
|
|
119
|
+
): React.ReactNode {
|
|
120
|
+
if (range.kind === 'mention') {
|
|
121
|
+
// `data-mention-token` / `data-mention-kind` are the host's styling hook;
|
|
122
|
+
// `tokenClassName` is appended verbatim and is the host's to keep
|
|
123
|
+
// metric-safe (see the rule above).
|
|
124
|
+
return (
|
|
125
|
+
<span
|
|
126
|
+
key={`mention-${range.start}`}
|
|
127
|
+
data-mention-token={range.person.id}
|
|
128
|
+
data-mention-kind={range.person.kind}
|
|
129
|
+
className={`${MENTION_CHIP_CLASSES}${
|
|
130
|
+
mentionTokenClassName ? ` ${mentionTokenClassName}` : ''
|
|
131
|
+
}`}
|
|
132
|
+
>
|
|
133
|
+
{text}
|
|
134
|
+
</span>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
// Human-only tokens carry a quiet dotted underline as their inline marker
|
|
138
|
+
// (text-decoration never affects glyph layout, so overlay alignment is
|
|
139
|
+
// safe). The accessible explanation lives in HumanOnlySkillNotice below
|
|
140
|
+
// the textarea — this overlay is aria-hidden.
|
|
141
|
+
return (
|
|
142
|
+
<span
|
|
143
|
+
key={`skill-${range.start}`}
|
|
144
|
+
data-skill-ref-token={range.skill.entry.name}
|
|
145
|
+
data-skill-ref-human-only={range.skill.entry.humanOnly ? 'true' : undefined}
|
|
146
|
+
className={`text-primary bg-primary/10 rounded-[3px] ${
|
|
147
|
+
range.skill.entry.humanOnly
|
|
148
|
+
? 'underline decoration-dotted decoration-primary/60 underline-offset-2'
|
|
149
|
+
: ''
|
|
150
|
+
}`}
|
|
151
|
+
>
|
|
152
|
+
{text}
|
|
153
|
+
</span>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The mention half of the highlight layer. Null → no mention source at all. */
|
|
158
|
+
export interface ComposerMentionChips {
|
|
159
|
+
/** The people whose `@Label` token still survives in the text. */
|
|
160
|
+
readonly people: readonly MentionPerson[];
|
|
161
|
+
/** Host class appended to each chip (see `MentionSource.tokenClassName`). */
|
|
162
|
+
readonly tokenClassName?: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A host's extra classes, appended after a space; nothing at all when absent (the default markup is unchanged). */
|
|
166
|
+
const extra = (className: string | undefined): string => (className ? ` ${className}` : '');
|
|
167
|
+
|
|
168
|
+
export interface ComposerTokenTextareaProps {
|
|
169
|
+
value: string;
|
|
170
|
+
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
|
171
|
+
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
|
172
|
+
/** Caret observer (skill autocomplete). */
|
|
173
|
+
onSelectCaret: () => void;
|
|
174
|
+
placeholder: string;
|
|
175
|
+
/** Mode-specific min/max height classes. */
|
|
176
|
+
sizeClassName: string;
|
|
177
|
+
textareaRef: (el: HTMLTextAreaElement | null) => void;
|
|
178
|
+
/** Positioned skill-reference occurrences to highlight. */
|
|
179
|
+
tokens: SkillReferenceToken[];
|
|
180
|
+
/** Off → no skill-reference contribution to the highlight layer. */
|
|
181
|
+
skillReferences: boolean;
|
|
182
|
+
/**
|
|
183
|
+
* The mention contribution, or null with no `mentionSource`. Present (even
|
|
184
|
+
* with nobody tagged yet) it turns the overlay on, so the first pick paints
|
|
185
|
+
* a chip without swapping the textarea for a different element.
|
|
186
|
+
*/
|
|
187
|
+
mentionChips: ComposerMentionChips | null;
|
|
188
|
+
/** ARIA relationship to the skill-reference listbox. */
|
|
189
|
+
listboxId: string;
|
|
190
|
+
listboxOpen: boolean;
|
|
191
|
+
activeOptionId?: string;
|
|
192
|
+
/**
|
|
193
|
+
* Font, size and box classes shared by the textarea and its mirror; they
|
|
194
|
+
* MUST be the same on both or the overlay drifts. Default:
|
|
195
|
+
* `COMPOSER_TEXT_CLASSES` (the comment composer's).
|
|
196
|
+
*/
|
|
197
|
+
textClassName?: string;
|
|
198
|
+
/** Extra classes on the textarea only (a host's own hook). */
|
|
199
|
+
inputClassName?: string;
|
|
200
|
+
/** Extra classes on the mirror only (a host that keeps its metrics in its own class). */
|
|
201
|
+
mirrorClassName?: string;
|
|
202
|
+
/**
|
|
203
|
+
* Further textarea attributes (`aria-label`, `aria-describedby`, `rows`, …).
|
|
204
|
+
* An `aria-label` here replaces the default, the placeholder.
|
|
205
|
+
*/
|
|
206
|
+
inputProps?: Omit<
|
|
207
|
+
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
|
208
|
+
'value' | 'onChange' | 'onKeyDown' | 'onSelect' | 'onScroll' | 'className' | 'placeholder'
|
|
209
|
+
>;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The composer's textarea. With NEITHER token source active this is exactly
|
|
214
|
+
* the pre-feature `<textarea>`; with either one on, its tokens are painted by
|
|
215
|
+
* a mirrored, aria-hidden overlay rendered BEHIND a transparent-text textarea
|
|
216
|
+
* (a textarea cannot style substrings). ONE overlay serves both sources: two
|
|
217
|
+
* mirrored layers could never stay pixel-aligned with each other, and only
|
|
218
|
+
* one of them could own the scroll sync. The overlay shares the exact
|
|
219
|
+
* font/padding/wrapping metrics and mirrors scroll position, and token spans
|
|
220
|
+
* obey the metric rule on `renderTokenSpan` (paint only, never layout), so
|
|
221
|
+
* the glyphs the browser lays out in the textarea and the glyphs the overlay
|
|
222
|
+
* paints coincide. During IME composition the overlay hides and the textarea
|
|
223
|
+
* text becomes visible again (`.pn-ref-composing`), keeping native
|
|
224
|
+
* composition rendering (underlines, candidate highlights) intact.
|
|
225
|
+
*/
|
|
226
|
+
export const ComposerTokenTextarea: React.FC<ComposerTokenTextareaProps> = ({
|
|
227
|
+
value,
|
|
228
|
+
onChange,
|
|
229
|
+
onKeyDown,
|
|
230
|
+
onSelectCaret,
|
|
231
|
+
placeholder,
|
|
232
|
+
sizeClassName,
|
|
233
|
+
textareaRef,
|
|
234
|
+
tokens,
|
|
235
|
+
skillReferences,
|
|
236
|
+
mentionChips,
|
|
237
|
+
listboxId,
|
|
238
|
+
listboxOpen,
|
|
239
|
+
activeOptionId,
|
|
240
|
+
textClassName = COMPOSER_TEXT_CLASSES,
|
|
241
|
+
inputClassName,
|
|
242
|
+
mirrorClassName,
|
|
243
|
+
inputProps,
|
|
244
|
+
}) => {
|
|
245
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
246
|
+
const [composing, setComposing] = useState(false);
|
|
247
|
+
const gutterRef = useRef<OverlayGutterState>({ overlay: null, raw: 0, applied: 0 });
|
|
248
|
+
|
|
249
|
+
const syncScroll = useCallback((el: HTMLTextAreaElement) => {
|
|
250
|
+
const overlay = overlayRef.current;
|
|
251
|
+
if (!overlay) return;
|
|
252
|
+
// Reads before writes so a sync costs at most one forced layout.
|
|
253
|
+
const { scrollTop, scrollLeft } = el;
|
|
254
|
+
const raw = el.offsetWidth - el.clientWidth;
|
|
255
|
+
overlay.scrollTop = scrollTop;
|
|
256
|
+
overlay.scrollLeft = scrollLeft;
|
|
257
|
+
syncOverlayGutter(el, overlay, gutterRef.current, raw);
|
|
258
|
+
}, []);
|
|
259
|
+
|
|
260
|
+
const innerRef = useRef<HTMLTextAreaElement | null>(null);
|
|
261
|
+
const attachRef = useCallback(
|
|
262
|
+
(el: HTMLTextAreaElement | null) => {
|
|
263
|
+
innerRef.current = el;
|
|
264
|
+
textareaRef(el);
|
|
265
|
+
},
|
|
266
|
+
[textareaRef],
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
// The overlay exists while EITHER source is active. `mentionChips` turns it
|
|
270
|
+
// on for the whole life of a mention composer, not only once somebody is
|
|
271
|
+
// tagged, so a pick never swaps the textarea element under the caret.
|
|
272
|
+
const mentionPeople = mentionChips?.people;
|
|
273
|
+
const highlight = skillReferences || mentionChips !== null;
|
|
274
|
+
|
|
275
|
+
// The one list of spans the overlay paints, from every active source.
|
|
276
|
+
// Skill references are the earlier group, so they win a byte both claim.
|
|
277
|
+
const ranges = useMemo(
|
|
278
|
+
() =>
|
|
279
|
+
mergeTokenRanges(value, [
|
|
280
|
+
skillReferences ? skillTokenRanges(tokens) : [],
|
|
281
|
+
mentionPeople ? mentionTokenRanges(value, mentionPeople) : [],
|
|
282
|
+
]),
|
|
283
|
+
[value, tokens, skillReferences, mentionPeople],
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
// Keep the mirror aligned when the value changes without a scroll event
|
|
287
|
+
// (e.g. programmatic insertion moving the caret into a scrolled region).
|
|
288
|
+
useEffect(() => {
|
|
289
|
+
if (innerRef.current) syncScroll(innerRef.current);
|
|
290
|
+
}, [value, syncScroll]);
|
|
291
|
+
|
|
292
|
+
// `syncScroll` otherwise only runs on scroll and on value change, so a
|
|
293
|
+
// window/panel resize that grows or drops the textarea's scrollbar without a
|
|
294
|
+
// keystroke would leave the gutter stale until the next one.
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
const el = innerRef.current;
|
|
297
|
+
if (!el || typeof ResizeObserver === 'undefined') return;
|
|
298
|
+
const observer = new ResizeObserver(() => {
|
|
299
|
+
if (innerRef.current) syncScroll(innerRef.current);
|
|
300
|
+
});
|
|
301
|
+
observer.observe(el);
|
|
302
|
+
return () => observer.disconnect();
|
|
303
|
+
}, [highlight, syncScroll]);
|
|
304
|
+
|
|
305
|
+
if (!highlight) {
|
|
306
|
+
return (
|
|
307
|
+
<textarea
|
|
308
|
+
data-pn-mobile-editable="true"
|
|
309
|
+
// Absent any open menu these are all undefined, so the plain textarea
|
|
310
|
+
// renders exactly the attributes it always did.
|
|
311
|
+
aria-autocomplete={listboxOpen ? 'list' : undefined}
|
|
312
|
+
aria-controls={listboxOpen ? listboxId : undefined}
|
|
313
|
+
aria-activedescendant={activeOptionId}
|
|
314
|
+
{...inputProps}
|
|
315
|
+
ref={attachRef}
|
|
316
|
+
value={value}
|
|
317
|
+
onChange={onChange}
|
|
318
|
+
onKeyDown={onKeyDown}
|
|
319
|
+
onSelect={onSelectCaret}
|
|
320
|
+
placeholder={placeholder}
|
|
321
|
+
className={`${textClassName} placeholder:text-muted-foreground resize-none focus:outline-none ${sizeClassName}${extra(inputClassName)}`}
|
|
322
|
+
style={{ fieldSizing: 'content' } as React.CSSProperties}
|
|
323
|
+
/>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const segments: React.ReactNode[] = [];
|
|
328
|
+
let pos = 0;
|
|
329
|
+
for (const range of ranges) {
|
|
330
|
+
if (range.start > pos) segments.push(value.slice(pos, range.start));
|
|
331
|
+
segments.push(
|
|
332
|
+
renderTokenSpan(range, value.slice(range.start, range.end), mentionChips?.tokenClassName),
|
|
333
|
+
);
|
|
334
|
+
pos = range.end;
|
|
335
|
+
}
|
|
336
|
+
segments.push(value.slice(pos));
|
|
337
|
+
|
|
338
|
+
return (
|
|
339
|
+
<div className="relative">
|
|
340
|
+
<div
|
|
341
|
+
ref={overlayRef}
|
|
342
|
+
aria-hidden="true"
|
|
343
|
+
// Names the SKILL-reference layer, so a skill composer's overlay is
|
|
344
|
+
// byte-identical to the one it rendered before mentions existed; a
|
|
345
|
+
// mentions-only overlay is found by its mirror attribute below.
|
|
346
|
+
data-skill-ref-overlay={skillReferences ? 'true' : undefined}
|
|
347
|
+
data-pn-mobile-editable-mirror="true"
|
|
348
|
+
className={`${textClassName} ${sizeClassName} pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words${extra(mirrorClassName)}`}
|
|
349
|
+
style={composing ? { visibility: 'hidden' } : undefined}
|
|
350
|
+
>
|
|
351
|
+
{segments}
|
|
352
|
+
{'\n'}
|
|
353
|
+
</div>
|
|
354
|
+
<textarea
|
|
355
|
+
data-pn-mobile-editable="true"
|
|
356
|
+
aria-label={placeholder}
|
|
357
|
+
{...inputProps}
|
|
358
|
+
aria-autocomplete="list"
|
|
359
|
+
aria-haspopup="listbox"
|
|
360
|
+
aria-controls={listboxOpen ? listboxId : undefined}
|
|
361
|
+
aria-owns={listboxOpen ? listboxId : undefined}
|
|
362
|
+
aria-activedescendant={activeOptionId}
|
|
363
|
+
ref={attachRef}
|
|
364
|
+
value={value}
|
|
365
|
+
onChange={onChange}
|
|
366
|
+
onKeyDown={onKeyDown}
|
|
367
|
+
onSelect={onSelectCaret}
|
|
368
|
+
onScroll={(e) => syncScroll(e.currentTarget)}
|
|
369
|
+
onCompositionStart={() => setComposing(true)}
|
|
370
|
+
onCompositionEnd={() => setComposing(false)}
|
|
371
|
+
placeholder={placeholder}
|
|
372
|
+
className={`${textClassName} placeholder:text-muted-foreground resize-none focus:outline-none relative pn-ref-input ${
|
|
373
|
+
composing ? 'pn-ref-composing' : ''
|
|
374
|
+
} ${sizeClassName}${extra(inputClassName)}`}
|
|
375
|
+
style={{ fieldSizing: 'content' } as React.CSSProperties}
|
|
376
|
+
/>
|
|
377
|
+
</div>
|
|
378
|
+
);
|
|
379
|
+
};
|
|
@@ -12,6 +12,7 @@ import { getBearSettings } from '../utils/bear';
|
|
|
12
12
|
import { getOctarineSettings } from '../utils/octarine';
|
|
13
13
|
import { wrapFeedbackForAgent } from '../utils/parser';
|
|
14
14
|
import { copyTextToClipboard } from '../utils/clipboard';
|
|
15
|
+
import { noteServerSessionMismatch, withServerSession } from '../utils/serverSession';
|
|
15
16
|
import { OverlayScrollArea } from './OverlayScrollArea';
|
|
16
17
|
|
|
17
18
|
/** POST body shape sent to the notes endpoint (mirrors what the Notes tab builds today). */
|
|
@@ -31,8 +32,12 @@ async function defaultSaveToNotes(body: SaveToNotesPayload): Promise<SaveToNotes
|
|
|
31
32
|
const res = await fetch('/api/save-notes', {
|
|
32
33
|
method: 'POST',
|
|
33
34
|
headers: { 'Content-Type': 'application/json' },
|
|
34
|
-
|
|
35
|
+
// The stale-tab guard (a no-op for a host that never adopted a session).
|
|
36
|
+
body: JSON.stringify(withServerSession(body)),
|
|
35
37
|
});
|
|
38
|
+
if (await noteServerSessionMismatch(res)) {
|
|
39
|
+
return { results: Object.fromEntries(Object.keys(body).map((k) => [k, { success: false, error: 'This review was replaced. Reload the page.' }])) };
|
|
40
|
+
}
|
|
36
41
|
return res.json();
|
|
37
42
|
}
|
|
38
43
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Tooltip } from './Tooltip';
|
|
3
|
+
import { isTextCut, noHover } from '../utils/textCut';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hover text for a ui control or label, drawn the way the host chooses.
|
|
7
|
+
*
|
|
8
|
+
* Default (every host that does not opt in, Plannotator's plan review,
|
|
9
|
+
* annotate and code review included): the child gets a native `title`,
|
|
10
|
+
* exactly as before.
|
|
11
|
+
*
|
|
12
|
+
* With `configurePlannotatorUI({ componentTooltips: true })` (the Plannotator
|
|
13
|
+
* Inbox): the child gets ui's `Tooltip` instead, and no native title. `name`
|
|
14
|
+
* marks a title that is also the control's accessible name (an icon button
|
|
15
|
+
* with no `aria-label`): the child then gets it as its `aria-label`, so the
|
|
16
|
+
* name never depends on the tooltip. `cut` opens the tooltip only while the
|
|
17
|
+
* child's text is cut. Escape closes it and still reaches the page, and
|
|
18
|
+
* nothing opens on a touch screen.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
let componentTooltips = false;
|
|
22
|
+
|
|
23
|
+
/** Draw ui's hover text as ui's Tooltip (true) or a native title (false, the default). */
|
|
24
|
+
export function setComponentTooltips(on: boolean): void {
|
|
25
|
+
componentTooltips = on;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resetComponentTooltips(): void {
|
|
29
|
+
componentTooltips = false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getComponentTooltips(): boolean {
|
|
33
|
+
return componentTooltips;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** How long the pointer rests before a component tooltip opens. */
|
|
37
|
+
const HOST_TITLE_DELAY_MS = 150;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Where a tooltip for `trigger` is portaled: the open native `<dialog>` it
|
|
41
|
+
* sits in (a popup in the body is drawn under the dialog's top layer), else
|
|
42
|
+
* the nearest host root marked `data-pn-tooltip-root` (its styles and theme
|
|
43
|
+
* tokens then reach the popup), else the body.
|
|
44
|
+
*/
|
|
45
|
+
export function tooltipContainerOf(trigger: Element): HTMLElement | null {
|
|
46
|
+
return trigger.closest<HTMLElement>('dialog[open], [data-pn-tooltip-root]');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function HostTitle({
|
|
50
|
+
title,
|
|
51
|
+
children,
|
|
52
|
+
name = false,
|
|
53
|
+
cut = false,
|
|
54
|
+
side = 'top',
|
|
55
|
+
}: {
|
|
56
|
+
title: string | undefined | null;
|
|
57
|
+
children: React.ReactElement;
|
|
58
|
+
name?: boolean;
|
|
59
|
+
cut?: boolean;
|
|
60
|
+
side?: 'top' | 'right' | 'bottom' | 'left';
|
|
61
|
+
}) {
|
|
62
|
+
const descriptionId = React.useId();
|
|
63
|
+
const [container, setContainer] = React.useState<HTMLElement | null>(null);
|
|
64
|
+
if (title === undefined || title === null) return children;
|
|
65
|
+
// Off: the native title, byte for byte as before (an empty title included).
|
|
66
|
+
if (!componentTooltips) return React.cloneElement(children as React.ReactElement<{ title?: string }>, { title });
|
|
67
|
+
if (!title) return children;
|
|
68
|
+
const props = children.props as { 'aria-label'?: string; 'aria-describedby'?: string };
|
|
69
|
+
const labelled = props['aria-label'] != null;
|
|
70
|
+
// A title that was the control's only name becomes its aria-label. Any other
|
|
71
|
+
// title was its description (a native title is read as one), and a tooltip
|
|
72
|
+
// tags nothing for a screen reader: the text stays in a hidden element the
|
|
73
|
+
// child names with aria-describedby. Not for a cut label (the text is on
|
|
74
|
+
// screen) or a title equal to the control's own label.
|
|
75
|
+
const asName = name && !labelled;
|
|
76
|
+
const describe = !asName && !cut && props['aria-label'] !== title;
|
|
77
|
+
const extra: Record<string, string> = {};
|
|
78
|
+
if (asName) extra['aria-label'] = title;
|
|
79
|
+
if (describe) extra['aria-describedby'] = [props['aria-describedby'], descriptionId].filter(Boolean).join(' ');
|
|
80
|
+
const child = Object.keys(extra).length > 0 ? React.cloneElement(children as React.ReactElement<Record<string, string>>, extra) : children;
|
|
81
|
+
const tooltip = (
|
|
82
|
+
<Tooltip
|
|
83
|
+
content={title}
|
|
84
|
+
side={side}
|
|
85
|
+
wide
|
|
86
|
+
delayDuration={HOST_TITLE_DELAY_MS}
|
|
87
|
+
escapePassesThrough
|
|
88
|
+
shouldOpen={(trigger) => {
|
|
89
|
+
if (noHover() || (cut && !isTextCut(trigger))) return false;
|
|
90
|
+
setContainer(tooltipContainerOf(trigger));
|
|
91
|
+
return true;
|
|
92
|
+
}}
|
|
93
|
+
container={container}
|
|
94
|
+
>
|
|
95
|
+
{child}
|
|
96
|
+
</Tooltip>
|
|
97
|
+
);
|
|
98
|
+
if (!describe) return tooltip;
|
|
99
|
+
return (
|
|
100
|
+
<>
|
|
101
|
+
{tooltip}
|
|
102
|
+
{/* `hidden`: never drawn or read in browse mode; aria-describedby still reads it. */}
|
|
103
|
+
<span id={descriptionId} hidden data-host-title-description="">
|
|
104
|
+
{title}
|
|
105
|
+
</span>
|
|
106
|
+
</>
|
|
107
|
+
);
|
|
108
|
+
}
|