@plannotator/ui 0.50.0 → 0.52.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 +295 -11
- package/README.md +8 -0
- package/components/AISettingsTab.tsx +32 -0
- package/components/AgentToolAnnouncementDialog.tsx +347 -0
- package/components/AgentsTab.tsx +9 -9
- package/components/AnnotationToolbar.tsx +2 -2
- package/components/AskSessionAnnouncementDialog.tsx +329 -0
- package/components/BlockRenderer.tsx +10 -1
- package/components/CommentPopover.tsx +36 -18
- package/components/CompletionOverlay.tsx +12 -5
- package/components/DecisionControl.tsx +2 -7
- package/components/DiagramBlock.tsx +46 -4
- package/components/DocBadges.tsx +12 -8
- package/components/FloatingQuickLabelPicker.tsx +2 -2
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/QuickLabelDropdown.tsx +9 -4
- package/components/ServerSessionReplacedBanner.tsx +29 -0
- package/components/Settings.tsx +76 -192
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/Viewer.tsx +30 -1
- package/components/ai/AIProviderBar.tsx +18 -1
- package/components/ai/DocumentAIChatPanel.tsx +4 -9
- package/components/ai/SessionAskNotice.tsx +33 -21
- package/components/blocks/QuestionBlock.tsx +99 -27
- package/components/blocks/QuestionContext.tsx +81 -0
- package/components/diagram/DiagramComposer.tsx +54 -3
- package/components/diagram/DiagramViewer.tsx +31 -2
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +13 -3
- package/components/html-viewer/bridge-script.asset.js +230 -14
- package/components/html-viewer/bridge-script.ts +230 -14
- package/components/html-viewer/useHtmlAnnotation.ts +36 -55
- package/components/settings/QuickLabelsTab.tsx +378 -0
- package/hooks/useAIChat.ts +83 -6
- package/hooks/useAIProviderActivation.ts +13 -0
- package/hooks/useAIProviderConfig.ts +3 -3
- package/hooks/useActiveSection.ts +3 -2
- package/hooks/useAgentToolSetting.ts +33 -0
- package/hooks/useAnnotationDraft.ts +53 -7
- package/hooks/useAnnotationHighlighter.ts +129 -5
- package/hooks/useExternalAnnotations.ts +21 -1
- package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
- package/hooks/useLatchedTrue.ts +15 -0
- package/hooks/useLinkedDoc.ts +64 -4
- package/hooks/useModelCatalogs.ts +9 -2
- package/package.json +2 -2
- package/styles.css +1 -1
- package/themes/gruvbox.css +6 -0
- package/types.ts +19 -0
- package/utils/agentClosed.ts +33 -0
- package/utils/agentToolAnnouncement.ts +109 -0
- package/utils/agentToolSetting.ts +108 -0
- package/utils/aiProvider.ts +13 -40
- package/utils/annotationOwnsHighlight.ts +27 -0
- package/utils/annotationScope.ts +15 -0
- package/utils/askSessionAnnouncement.ts +107 -0
- package/utils/blockTargeting.ts +61 -2
- package/utils/diagramAskAI.ts +161 -0
- package/utils/parser.ts +382 -114
- package/utils/questionAnswers.ts +3 -1
- package/utils/quickLabels.ts +82 -2
- package/utils/serverSession.ts +85 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { ExternalLink, Pause, Play } from 'lucide-react';
|
|
4
|
+
import { prefersReducedMotion, XMark } from './TerminalToolsAnnouncementDialog';
|
|
5
|
+
import type { AskSessionAgent } from '../utils/askSessionAnnouncement';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One-time announcement for "Ask this session": Ask AI is answered by the
|
|
9
|
+
* agent session that opened Plannotator (Claude Code, Pi, OpenCode), and
|
|
10
|
+
* reviews no longer hold that session. Video first, like the terminal-tools
|
|
11
|
+
* announcement it follows: real footage of a real Claude Code session and the
|
|
12
|
+
* real review app fills the top of the panel, over one headline, two short
|
|
13
|
+
* sentences and one action row. Same shell (portal, z-[100], hand-rolled
|
|
14
|
+
* Escape + Tab wrap + focus restore, backdrop dismiss, capture-phase keydown
|
|
15
|
+
* that swallows Mod+Enter).
|
|
16
|
+
*
|
|
17
|
+
* LAST in each app's first-run dialog chain; the Apps gate rendering through
|
|
18
|
+
* askSessionAnnouncementEligible and useFirstRunAnnouncementWindow.
|
|
19
|
+
*
|
|
20
|
+
* The footage is hosted on plannotator.ai (`apps/marketing/public/assets/`),
|
|
21
|
+
* not inlined, so the dialog has to look right without it: the poster stays up
|
|
22
|
+
* while the video buffers, and if the media cannot load the frame keeps its
|
|
23
|
+
* place and offers the X post instead.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The feature's docs page (goes live with the 0.28.0 release that ships this dialog). */
|
|
27
|
+
export const ASK_SESSION_DOCS_URL = 'https://docs.plannotator.ai/open-source/workflows/ask-this-session';
|
|
28
|
+
|
|
29
|
+
/** The X post the footage was cut for. */
|
|
30
|
+
export const ASK_SESSION_WATCH_URL = 'https://x.com/plannotator/status/2106875215170899992';
|
|
31
|
+
|
|
32
|
+
/** Same look as the terminal-tools announcement's outbound actions. */
|
|
33
|
+
const OUTBOUND_ACTION_CLASS =
|
|
34
|
+
'inline-flex min-h-9 items-center gap-2 rounded-lg border border-border bg-surface-0/40 px-3 text-sm font-medium text-foreground outline-none transition-colors motion-reduce:transition-none hover:bg-surface-1/70 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-card';
|
|
35
|
+
|
|
36
|
+
/** Where the marketing deploy publishes `apps/marketing/public/assets/`. */
|
|
37
|
+
const MEDIA_BASE_URL = 'https://plannotator.ai/assets';
|
|
38
|
+
|
|
39
|
+
export const ASK_SESSION_DEMO = {
|
|
40
|
+
mp4: `${MEDIA_BASE_URL}/ask-this-session-demo.mp4`,
|
|
41
|
+
webm: `${MEDIA_BASE_URL}/ask-this-session-demo.webm`,
|
|
42
|
+
poster: `${MEDIA_BASE_URL}/ask-this-session-poster.jpg`,
|
|
43
|
+
description:
|
|
44
|
+
'A code review opened from Claude Code: two lines are selected and asked about in Plannotator, the question arrives in the Claude Code session, and its answer streams back into the Ask AI panel. Then a comment is sent and arrives in Claude Code as a message.',
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
/** The footage is 1280x800. */
|
|
48
|
+
const DEMO_ASPECT = 1280 / 800;
|
|
49
|
+
|
|
50
|
+
interface AgentCopy {
|
|
51
|
+
readonly name: string;
|
|
52
|
+
/** How the session stays free. OpenCode's plan review still holds the session. */
|
|
53
|
+
readonly freeLine: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const AGENT_COPY: Record<AskSessionAgent, AgentCopy> = {
|
|
57
|
+
'claude-code': {
|
|
58
|
+
name: 'Claude Code',
|
|
59
|
+
freeLine: 'Claude Code stays free while you review, and your decision arrives as a message.',
|
|
60
|
+
},
|
|
61
|
+
pi: {
|
|
62
|
+
name: 'Pi',
|
|
63
|
+
freeLine: 'Pi stays free while you review, and your decision arrives as a message.',
|
|
64
|
+
},
|
|
65
|
+
opencode: {
|
|
66
|
+
name: 'OpenCode',
|
|
67
|
+
freeLine: 'Code review and annotate no longer hold the session, and your feedback arrives as a message.',
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
interface AskSessionAnnouncementDialogProps {
|
|
72
|
+
readonly isOpen: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* The host whose session is connected to this Plannotator session
|
|
75
|
+
* (connectedAskSessionAgent); names the agent in the copy. The Apps only
|
|
76
|
+
* open the dialog while one is connected.
|
|
77
|
+
*/
|
|
78
|
+
readonly agent: AskSessionAgent;
|
|
79
|
+
/** Marks the announcement seen and closes it. Also wired to Escape and the backdrop. */
|
|
80
|
+
readonly onDismiss: () => void;
|
|
81
|
+
/** Test seam. Defaults to the media query. */
|
|
82
|
+
readonly reducedMotion?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The footage. Muted, looping, inline; autoplays unless the reader asked for
|
|
87
|
+
* reduced motion, in which case the poster waits behind a play button.
|
|
88
|
+
*/
|
|
89
|
+
function DemoPlayer({ reducedMotion }: { readonly reducedMotion: boolean }) {
|
|
90
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
91
|
+
const [playing, setPlaying] = useState(!reducedMotion);
|
|
92
|
+
const [failed, setFailed] = useState(false);
|
|
93
|
+
|
|
94
|
+
const toggle = useCallback(() => {
|
|
95
|
+
const video = videoRef.current;
|
|
96
|
+
if (!video) return;
|
|
97
|
+
if (video.paused) {
|
|
98
|
+
// Autoplay policies can reject play(); the button simply stays put.
|
|
99
|
+
void video.play().catch(() => {});
|
|
100
|
+
} else {
|
|
101
|
+
video.pause();
|
|
102
|
+
}
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div
|
|
107
|
+
className="group relative w-full overflow-hidden bg-muted"
|
|
108
|
+
style={{ aspectRatio: String(DEMO_ASPECT) }}
|
|
109
|
+
>
|
|
110
|
+
<video
|
|
111
|
+
ref={videoRef}
|
|
112
|
+
data-ask-session-demo
|
|
113
|
+
poster={ASK_SESSION_DEMO.poster}
|
|
114
|
+
muted
|
|
115
|
+
playsInline
|
|
116
|
+
loop
|
|
117
|
+
autoPlay={!reducedMotion}
|
|
118
|
+
preload="auto"
|
|
119
|
+
aria-label={ASK_SESSION_DEMO.description}
|
|
120
|
+
onPlay={() => setPlaying(true)}
|
|
121
|
+
onPause={() => setPlaying(false)}
|
|
122
|
+
onCanPlayThrough={(event) => setPlaying(!event.currentTarget.paused)}
|
|
123
|
+
onClick={toggle}
|
|
124
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
125
|
+
>
|
|
126
|
+
<source src={ASK_SESSION_DEMO.mp4} type="video/mp4" />
|
|
127
|
+
{/* The last source is the one whose error means nothing could load. */}
|
|
128
|
+
<source src={ASK_SESSION_DEMO.webm} type="video/webm" onError={() => setFailed(true)} />
|
|
129
|
+
</video>
|
|
130
|
+
|
|
131
|
+
{failed ? (
|
|
132
|
+
<div
|
|
133
|
+
data-ask-session-demo-unavailable
|
|
134
|
+
className="absolute inset-0 grid place-items-center bg-background/70 backdrop-blur-sm"
|
|
135
|
+
>
|
|
136
|
+
<a
|
|
137
|
+
href={ASK_SESSION_WATCH_URL}
|
|
138
|
+
target="_blank"
|
|
139
|
+
rel="noopener noreferrer"
|
|
140
|
+
className="inline-flex min-h-10 items-center gap-2 rounded-lg border border-border bg-card px-4 text-sm font-medium text-foreground outline-none hover:bg-surface-1 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
141
|
+
>
|
|
142
|
+
<XMark className="size-3.5" />
|
|
143
|
+
Watch on X
|
|
144
|
+
</a>
|
|
145
|
+
</div>
|
|
146
|
+
) : (
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
onClick={toggle}
|
|
150
|
+
aria-label={playing ? 'Pause demo' : 'Play demo'}
|
|
151
|
+
data-ask-session-playback={playing ? 'pause' : 'play'}
|
|
152
|
+
className={
|
|
153
|
+
playing
|
|
154
|
+
? 'absolute bottom-3 left-3 grid size-9 place-items-center rounded-full border border-border/60 bg-background/70 text-foreground opacity-0 outline-none backdrop-blur-sm transition-opacity motion-reduce:transition-none group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-primary'
|
|
155
|
+
: 'absolute left-1/2 top-1/2 grid size-16 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-border/60 bg-background/80 text-foreground shadow-lg outline-none backdrop-blur-sm transition-transform motion-reduce:transition-none hover:scale-105 focus-visible:ring-2 focus-visible:ring-primary'
|
|
156
|
+
}
|
|
157
|
+
>
|
|
158
|
+
{playing ? (
|
|
159
|
+
<Pause className="size-4" aria-hidden="true" />
|
|
160
|
+
) : (
|
|
161
|
+
<Play className="ml-0.5 size-6 fill-current" aria-hidden="true" />
|
|
162
|
+
)}
|
|
163
|
+
</button>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function AskSessionAnnouncementDialog({
|
|
170
|
+
isOpen,
|
|
171
|
+
agent,
|
|
172
|
+
onDismiss,
|
|
173
|
+
reducedMotion,
|
|
174
|
+
}: AskSessionAnnouncementDialogProps) {
|
|
175
|
+
const dismissRef = useRef<HTMLButtonElement>(null);
|
|
176
|
+
const previousFocusRef = useRef<HTMLElement | null>(null);
|
|
177
|
+
const onDismissRef = useRef(onDismiss);
|
|
178
|
+
const [motionPreference] = useState(() => reducedMotion ?? prefersReducedMotion());
|
|
179
|
+
const reduced = reducedMotion ?? motionPreference;
|
|
180
|
+
const copy = AGENT_COPY[agent];
|
|
181
|
+
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
onDismissRef.current = onDismiss;
|
|
184
|
+
}, [onDismiss]);
|
|
185
|
+
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (!isOpen) return;
|
|
188
|
+
|
|
189
|
+
previousFocusRef.current = document.activeElement instanceof HTMLElement
|
|
190
|
+
? document.activeElement
|
|
191
|
+
: null;
|
|
192
|
+
dismissRef.current?.focus();
|
|
193
|
+
|
|
194
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
195
|
+
if (event.key === 'Escape') {
|
|
196
|
+
event.preventDefault();
|
|
197
|
+
event.stopPropagation();
|
|
198
|
+
onDismissRef.current();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
// Both apps submit their decision on Mod+Enter from a window-level
|
|
202
|
+
// handler. This listener runs on the capture phase, so swallowing the
|
|
203
|
+
// chord here stops a keystroke aimed at the announcement from approving
|
|
204
|
+
// a plan or posting a review behind it.
|
|
205
|
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
|
206
|
+
event.preventDefault();
|
|
207
|
+
event.stopPropagation();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (event.key !== 'Tab') return;
|
|
211
|
+
|
|
212
|
+
const dialog = document.querySelector<HTMLElement>('[data-ask-session-announcement-dialog]');
|
|
213
|
+
const focusable = Array.from(
|
|
214
|
+
dialog?.querySelectorAll<HTMLElement>('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])')
|
|
215
|
+
?? [],
|
|
216
|
+
).filter((element) => element.getAttribute('tabindex') !== '-1');
|
|
217
|
+
if (focusable.length === 0) return;
|
|
218
|
+
|
|
219
|
+
const first = focusable[0];
|
|
220
|
+
const last = focusable[focusable.length - 1];
|
|
221
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
222
|
+
event.preventDefault();
|
|
223
|
+
last.focus();
|
|
224
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
225
|
+
event.preventDefault();
|
|
226
|
+
first.focus();
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
document.addEventListener('keydown', handleKeyDown, true);
|
|
231
|
+
return () => {
|
|
232
|
+
document.removeEventListener('keydown', handleKeyDown, true);
|
|
233
|
+
previousFocusRef.current?.focus();
|
|
234
|
+
previousFocusRef.current = null;
|
|
235
|
+
};
|
|
236
|
+
}, [isOpen]);
|
|
237
|
+
|
|
238
|
+
if (!isOpen) return null;
|
|
239
|
+
|
|
240
|
+
return createPortal(
|
|
241
|
+
<div
|
|
242
|
+
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/90 p-4 backdrop-blur-sm"
|
|
243
|
+
// The dialog collects no decision, so closing it from the backdrop loses nothing.
|
|
244
|
+
onMouseDown={(event) => {
|
|
245
|
+
if (event.target === event.currentTarget) onDismissRef.current();
|
|
246
|
+
}}
|
|
247
|
+
>
|
|
248
|
+
<div
|
|
249
|
+
data-ask-session-announcement-dialog
|
|
250
|
+
data-ask-session-agent={agent}
|
|
251
|
+
role="dialog"
|
|
252
|
+
aria-modal="true"
|
|
253
|
+
aria-labelledby="ask-session-announcement-title"
|
|
254
|
+
aria-describedby="ask-session-announcement-description"
|
|
255
|
+
// Width follows the viewport height as well as its width: the footage
|
|
256
|
+
// keeps its aspect ratio, so on a short window the panel narrows until
|
|
257
|
+
// video plus footer fit instead of scrolling the video out of view.
|
|
258
|
+
// The 12rem is the footer's height, with the wrap at narrow widths.
|
|
259
|
+
style={{ width: `min(1120px, 100%, calc((100dvh - 2rem - 12rem) * ${DEMO_ASPECT}))` }}
|
|
260
|
+
className="flex max-h-[calc(100dvh-2rem)] min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl"
|
|
261
|
+
>
|
|
262
|
+
<div className="shrink-0 border-b border-border">
|
|
263
|
+
<DemoPlayer reducedMotion={reduced} />
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<div className="min-h-0 overflow-y-auto px-5 py-5 sm:px-7 sm:py-6">
|
|
267
|
+
<h2
|
|
268
|
+
id="ask-session-announcement-title"
|
|
269
|
+
className="text-balance text-xl font-semibold tracking-tight sm:text-2xl"
|
|
270
|
+
>
|
|
271
|
+
Ask {copy.name}, right from Plannotator
|
|
272
|
+
</h2>
|
|
273
|
+
<p
|
|
274
|
+
id="ask-session-announcement-description"
|
|
275
|
+
className="mt-1 text-pretty text-sm leading-relaxed text-muted-foreground"
|
|
276
|
+
>
|
|
277
|
+
Ask AI now goes to the {copy.name} session that opened Plannotator, and the answer
|
|
278
|
+
streams back here. {copy.freeLine}
|
|
279
|
+
</p>
|
|
280
|
+
|
|
281
|
+
<div className="mt-5 flex flex-wrap items-center gap-x-4 gap-y-3">
|
|
282
|
+
<p
|
|
283
|
+
data-ask-session-status="connected"
|
|
284
|
+
className="inline-flex min-w-0 items-center gap-2 text-sm text-foreground"
|
|
285
|
+
>
|
|
286
|
+
<span className="size-2 shrink-0 rounded-full bg-success" aria-hidden="true" />
|
|
287
|
+
This session is connected. Open Ask AI to try it.
|
|
288
|
+
</p>
|
|
289
|
+
{/* The actions wrap as one group, so a narrow panel puts them on their
|
|
290
|
+
own row under the status line instead of splitting them. */}
|
|
291
|
+
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
|
292
|
+
{/* Like the terminal-tools announcement's outbound links: opening
|
|
293
|
+
either one does not dismiss the announcement or spend its cookie. */}
|
|
294
|
+
<a
|
|
295
|
+
href={ASK_SESSION_WATCH_URL}
|
|
296
|
+
target="_blank"
|
|
297
|
+
rel="noopener noreferrer"
|
|
298
|
+
data-ask-session-watch
|
|
299
|
+
className={OUTBOUND_ACTION_CLASS}
|
|
300
|
+
>
|
|
301
|
+
<XMark className="size-3.5" />
|
|
302
|
+
Watch on X
|
|
303
|
+
</a>
|
|
304
|
+
<a
|
|
305
|
+
href={ASK_SESSION_DOCS_URL}
|
|
306
|
+
target="_blank"
|
|
307
|
+
rel="noopener noreferrer"
|
|
308
|
+
data-ask-session-learn-more
|
|
309
|
+
className={OUTBOUND_ACTION_CLASS}
|
|
310
|
+
>
|
|
311
|
+
Learn more
|
|
312
|
+
<ExternalLink className="size-3.5" aria-hidden="true" />
|
|
313
|
+
</a>
|
|
314
|
+
<button
|
|
315
|
+
ref={dismissRef}
|
|
316
|
+
type="button"
|
|
317
|
+
onClick={onDismiss}
|
|
318
|
+
className="min-h-9 rounded-lg bg-primary px-5 text-sm font-medium text-primary-foreground outline-none transition-opacity motion-reduce:transition-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-card"
|
|
319
|
+
>
|
|
320
|
+
Got it
|
|
321
|
+
</button>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
</div>,
|
|
327
|
+
document.body,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
@@ -51,7 +51,13 @@ export const BlockRenderer: React.FC<{
|
|
|
51
51
|
saveQuestionAnswerLabel?: QuestionBlockProps['saveLabel'];
|
|
52
52
|
/** Host actions in a question card's footer (see `QuestionBlock`). */
|
|
53
53
|
renderQuestionFooter?: QuestionBlockProps['renderFooter'];
|
|
54
|
-
|
|
54
|
+
/** Whether answering this question records a decision (see `QuestionBlock`). */
|
|
55
|
+
questionDecisionRecording?: QuestionBlockProps['decisionRecording'];
|
|
56
|
+
/** Makes the "Records a decision" tag a toggle (see `QuestionBlock`). */
|
|
57
|
+
onToggleQuestionDecisionRecording?: QuestionBlockProps['onToggleDecisionRecording'];
|
|
58
|
+
/** `'none'` hides the card's own status tag (see `QuestionBlock`). */
|
|
59
|
+
questionStatusTag?: QuestionBlockProps['statusTag'];
|
|
60
|
+
}> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, repoHost, headingAnchorId, onNavigateAnchor, question, questionTotal, questionAnswer, onAnswerQuestion, onSaveQuestionAnswer, saveQuestionAnswerLabel, renderQuestionFooter, questionDecisionRecording, onToggleQuestionDecisionRecording, questionStatusTag }) => {
|
|
55
61
|
switch (block.type) {
|
|
56
62
|
case 'heading': {
|
|
57
63
|
const Tag = `h${block.level || 1}` as React.ElementType;
|
|
@@ -178,6 +184,9 @@ export const BlockRenderer: React.FC<{
|
|
|
178
184
|
onSaveAnswer={onSaveQuestionAnswer}
|
|
179
185
|
saveLabel={saveQuestionAnswerLabel}
|
|
180
186
|
renderFooter={renderQuestionFooter}
|
|
187
|
+
decisionRecording={questionDecisionRecording}
|
|
188
|
+
onToggleDecisionRecording={onToggleQuestionDecisionRecording}
|
|
189
|
+
statusTag={questionStatusTag}
|
|
181
190
|
onOpenLinkedDoc={onOpenLinkedDoc}
|
|
182
191
|
onOpenCodeFile={onOpenCodeFile}
|
|
183
192
|
imageBaseDir={imageBaseDir}
|
|
@@ -2,7 +2,6 @@ import React, { useState, useEffect, useMemo, useRef, useCallback, useId } from
|
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
3
|
import type { ImageAttachment } from '../types';
|
|
4
4
|
import { AttachmentsButton } from './AttachmentsButton';
|
|
5
|
-
import { submitHint } from '../utils/platform';
|
|
6
5
|
import { useDraggable } from '../hooks/useDraggable';
|
|
7
6
|
import { SparklesIcon } from './SparklesIcon';
|
|
8
7
|
import { hasUnsavedCommentContent } from '../utils/commentContent';
|
|
@@ -30,6 +29,13 @@ export interface CommentAskAIContext {
|
|
|
30
29
|
label?: string;
|
|
31
30
|
text?: string;
|
|
32
31
|
sourcePath?: string;
|
|
32
|
+
/** Agent-facing identity of the selected element(s), for a pinpoint on a
|
|
33
|
+
* raw-HTML or live-app surface. Rides with the question; not displayed. */
|
|
34
|
+
detail?: string;
|
|
35
|
+
/** 1-based source lines of a markdown text selection (see
|
|
36
|
+
* `AIQuestion.scope.lineStart`). Rides with the question; not displayed. */
|
|
37
|
+
lineStart?: number;
|
|
38
|
+
lineEnd?: number;
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
export type CommentAskAIHandler = (
|
|
@@ -68,11 +74,14 @@ interface CommentPopoverProps {
|
|
|
68
74
|
*/
|
|
69
75
|
onSubmit: (text: string, images?: ImageAttachment[], mentions?: readonly string[]) => void;
|
|
70
76
|
/**
|
|
71
|
-
* One-click "Looks good"
|
|
72
|
-
* pinpoint
|
|
73
|
-
* toolbar's 👍). Renders
|
|
74
|
-
*
|
|
75
|
-
*
|
|
77
|
+
* One-click 👍 "Looks good" (comment-only HTML / live-app surfaces, where a
|
|
78
|
+
* pinpoint click opens this composer directly and never shows the selection
|
|
79
|
+
* toolbar's 👍). Renders an emoji-only button beside Save, named "Looks
|
|
80
|
+
* good" for assistive tech and in its tooltip; disabled once anything is
|
|
81
|
+
* typed or attached so a click can never discard a draft. It takes no
|
|
82
|
+
* keyboard shortcut: Enter / Mod+Enter still Save. Like Save, a click
|
|
83
|
+
* clears the `draftKey` draft and returns focus to the opener. The parent
|
|
84
|
+
* owns annotation creation and closing. Absent renders nothing.
|
|
76
85
|
*/
|
|
77
86
|
onQuickLookGood?: () => void;
|
|
78
87
|
/** Optional live draft observer for submit paths outside the popover. */
|
|
@@ -590,18 +599,33 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
590
599
|
const canAskAI = !!onAskAI && !askAIDisabled && text.trim().length > 0;
|
|
591
600
|
const showsSkillMenu = skillAc.menu !== null;
|
|
592
601
|
|
|
593
|
-
// Shared by both footers
|
|
594
|
-
//
|
|
602
|
+
// Shared by both footers, sits beside Save. Emoji-only like the selection
|
|
603
|
+
// toolbar's 👍 (same hover tint). Disabled once anything is typed or
|
|
604
|
+
// attached so a click can never discard a draft; with content, Save is the
|
|
605
|
+
// path. A plain click handler only: Enter / Mod+Enter stay on Save.
|
|
606
|
+
// The grey-out is an inline filter, not a Tailwind filter utility: this
|
|
607
|
+
// file is scanned into the pinned guides.show viewer CSS, and every class
|
|
608
|
+
// below already exists there, so the 👍 adds nothing to that bundle.
|
|
609
|
+
const handleQuickLookGood = () => {
|
|
610
|
+
if (!onQuickLookGood || hasUnsavedContent) return;
|
|
611
|
+
if (draftKey) draftStore.delete(draftKey);
|
|
612
|
+
onQuickLookGood();
|
|
613
|
+
restoreOpeningFocus();
|
|
614
|
+
};
|
|
595
615
|
const quickLookGoodButton = onQuickLookGood ? (
|
|
596
616
|
<button
|
|
597
617
|
type="button"
|
|
598
|
-
onClick={
|
|
618
|
+
onClick={handleQuickLookGood}
|
|
599
619
|
disabled={hasUnsavedContent}
|
|
600
|
-
|
|
601
|
-
title={hasUnsavedContent ? '
|
|
620
|
+
aria-label="Looks good"
|
|
621
|
+
title={hasUnsavedContent ? 'Looks good (clear the comment to use it)' : 'Looks good'}
|
|
622
|
+
data-quick-look-good="true"
|
|
623
|
+
data-pn-touch-target="true"
|
|
624
|
+
data-pn-touch-target-icon="true"
|
|
625
|
+
style={hasUnsavedContent ? { filter: 'grayscale(1)' } : undefined}
|
|
626
|
+
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-sm leading-none hover:bg-green-500/10 outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors"
|
|
602
627
|
>
|
|
603
628
|
<span aria-hidden="true">👍</span>
|
|
604
|
-
Looks good
|
|
605
629
|
</button>
|
|
606
630
|
) : null;
|
|
607
631
|
|
|
@@ -723,9 +747,6 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
723
747
|
>
|
|
724
748
|
{isGlobal ? 'Add' : 'Save'}
|
|
725
749
|
</button>
|
|
726
|
-
{!coarsePointer && (
|
|
727
|
-
<span className="text-[10px] text-muted-foreground">{submitHint}</span>
|
|
728
|
-
)}
|
|
729
750
|
{quickLookGoodButton}
|
|
730
751
|
{onAskAI && (
|
|
731
752
|
<button
|
|
@@ -879,9 +900,6 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
879
900
|
>
|
|
880
901
|
{isGlobal ? 'Add' : 'Save'}
|
|
881
902
|
</button>
|
|
882
|
-
{!coarsePointer && (
|
|
883
|
-
<span className="text-[10px] text-muted-foreground">{submitHint}</span>
|
|
884
|
-
)}
|
|
885
903
|
{quickLookGoodButton}
|
|
886
904
|
{onAskAI && (
|
|
887
905
|
<button
|
|
@@ -25,7 +25,10 @@ const ChatBubbleIcon = () => (
|
|
|
25
25
|
// ---------------------------------------------------------------------------
|
|
26
26
|
|
|
27
27
|
interface CompletionOverlayProps {
|
|
28
|
-
|
|
28
|
+
/** `done`: the session finished with nothing sent (an annotate Done with
|
|
29
|
+
* no feedback). Neutral styling. `done` and `exited` (Close) send nothing,
|
|
30
|
+
* so neither shows the "response has been sent" line. */
|
|
31
|
+
submitted: 'approved' | 'denied' | 'feedback' | 'done' | 'exited' | null | false;
|
|
29
32
|
title: string;
|
|
30
33
|
subtitle: string;
|
|
31
34
|
agentLabel: string;
|
|
@@ -43,16 +46,20 @@ export function CompletionOverlay({ submitted, title, subtitle, agentLabel, link
|
|
|
43
46
|
if (!submitted) return null;
|
|
44
47
|
|
|
45
48
|
const isApproved = submitted === 'approved';
|
|
49
|
+
const isDone = submitted === 'done';
|
|
50
|
+
const sentNothing = isDone || submitted === 'exited';
|
|
46
51
|
|
|
47
52
|
return (
|
|
48
|
-
<div className="fixed inset-0 z-[100] bg-background flex items-center justify-center">
|
|
53
|
+
<div className="fixed inset-0 z-[100] bg-background flex items-center justify-center" data-completion-state={submitted}>
|
|
49
54
|
<div className="text-center space-y-6 max-w-md px-8">
|
|
50
55
|
<div
|
|
51
56
|
className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center ${
|
|
52
|
-
isApproved ? 'bg-success/20 text-success'
|
|
57
|
+
isApproved ? 'bg-success/20 text-success'
|
|
58
|
+
: isDone ? 'bg-muted text-muted-foreground'
|
|
59
|
+
: 'bg-accent/20 text-accent'
|
|
53
60
|
}`}
|
|
54
61
|
>
|
|
55
|
-
{isApproved ? <CheckIcon /> : <ChatBubbleIcon />}
|
|
62
|
+
{isApproved || isDone ? <CheckIcon /> : <ChatBubbleIcon />}
|
|
56
63
|
</div>
|
|
57
64
|
|
|
58
65
|
<div className="space-y-2">
|
|
@@ -109,7 +116,7 @@ export function CompletionOverlay({ submitted, title, subtitle, agentLabel, link
|
|
|
109
116
|
</label>
|
|
110
117
|
<p className="text-xs text-muted-foreground/60">You can change the delay in Settings.</p>
|
|
111
118
|
</>
|
|
112
|
-
) : (
|
|
119
|
+
) : sentNothing ? null : (
|
|
113
120
|
<p className="text-xs text-muted-foreground/60">Your response has been sent.</p>
|
|
114
121
|
)}
|
|
115
122
|
</>
|
|
@@ -7,7 +7,6 @@ import { ActionMenuDivider, ActionMenuItem } from './ActionMenu';
|
|
|
7
7
|
import { ConfirmDialog, type ConfirmDialogProps } from './ConfirmDialog';
|
|
8
8
|
import { useDismissablePopover } from '../hooks/useDismissablePopover';
|
|
9
9
|
import { useCompactTouchLayout } from '../hooks/useIsMobile';
|
|
10
|
-
import { submitHint } from '../utils/platform';
|
|
11
10
|
import { cn } from '../lib/utils';
|
|
12
11
|
import type {
|
|
13
12
|
DecisionActionId,
|
|
@@ -228,8 +227,7 @@ export const DecisionNoteDialog: React.FC<{
|
|
|
228
227
|
disabled={disabled}
|
|
229
228
|
autoFocus={!isCompactTouchLayout}
|
|
230
229
|
/>
|
|
231
|
-
<div className="mt-3 flex items-center justify-
|
|
232
|
-
<span className="text-[11px] leading-snug text-muted-foreground">{submitHint}</span>
|
|
230
|
+
<div className="mt-3 flex items-center justify-end gap-2">
|
|
233
231
|
<Button
|
|
234
232
|
variant={toneButtonVariant(composer.tone)}
|
|
235
233
|
size="xs"
|
|
@@ -571,10 +569,7 @@ export const DecisionControl: React.FC<DecisionControlProps> = ({
|
|
|
571
569
|
onCancel={backToMenu}
|
|
572
570
|
placeholder={activeItem.composer.placeholder}
|
|
573
571
|
/>
|
|
574
|
-
<div className="mt-2 flex items-center justify-
|
|
575
|
-
<span className="text-[11px] leading-snug text-muted-foreground">
|
|
576
|
-
{submitHint} send · Esc back, note kept
|
|
577
|
-
</span>
|
|
572
|
+
<div className="mt-2 flex items-center justify-end gap-2 px-0.5">
|
|
578
573
|
<Button
|
|
579
574
|
variant={toneButtonVariant(activeItem.composer.tone)}
|
|
580
575
|
size="xs"
|
|
@@ -9,7 +9,9 @@ import { getIdentity } from '../utils/identity';
|
|
|
9
9
|
import { createRuntimeRetryEpoch } from '../utils/runtimeRetry';
|
|
10
10
|
import { DiagramAnchorClaims, DiagramAnchorClaimsContext } from './diagram/anchorClaims';
|
|
11
11
|
import { DiagramPending, DiagramInlineSource } from './diagram/DiagramPending';
|
|
12
|
-
import { DiagramViewer } from './diagram/DiagramViewer';
|
|
12
|
+
import { DiagramViewer, type DiagramAskAI } from './diagram/DiagramViewer';
|
|
13
|
+
import type { CommentAskAIHandler } from './CommentPopover';
|
|
14
|
+
import { diagramAskAIContext } from '../utils/diagramAskAI';
|
|
13
15
|
import { svgContentSize } from './diagram/svgContentSize';
|
|
14
16
|
import type { DiagramComment, DiagramCreateComment } from './diagram/useDiagramComments';
|
|
15
17
|
import type { DiagramRenderState } from './diagram/useDiagramRender';
|
|
@@ -77,6 +79,13 @@ export interface DiagramBlockProps {
|
|
|
77
79
|
* `attempted`, the ones whose part is gone as `unanchored`, so the host's
|
|
78
80
|
* panel shows the same "Unanchored" chip a text comment gets. */
|
|
79
81
|
onRestoreReport?: (report: AnnotationRestoreReport) => void;
|
|
82
|
+
/** The document's Ask AI handler (the one the markdown composer takes).
|
|
83
|
+
* With it the diagram composer offers "Ask AI", and the question carries
|
|
84
|
+
* the part's identity and the bounded diagram source as `detail`. Absent:
|
|
85
|
+
* no Ask AI, exactly as before. */
|
|
86
|
+
onAskAI?: CommentAskAIHandler;
|
|
87
|
+
/** The document's path, sent with an Ask AI question (`sourcePath`). */
|
|
88
|
+
askAISourcePath?: string;
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
const NO_ANNOTATIONS: readonly Annotation[] = [];
|
|
@@ -90,6 +99,8 @@ export const DiagramBlock: React.FC<DiagramBlockProps & { kind: DiagramKind }> =
|
|
|
90
99
|
onAddAnnotation,
|
|
91
100
|
readOnly = false,
|
|
92
101
|
onRestoreReport,
|
|
102
|
+
onAskAI,
|
|
103
|
+
askAISourcePath,
|
|
93
104
|
}) => {
|
|
94
105
|
const label = LABELS[kind];
|
|
95
106
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
@@ -208,6 +219,37 @@ export const DiagramBlock: React.FC<DiagramBlockProps & { kind: DiagramKind }> =
|
|
|
208
219
|
};
|
|
209
220
|
}, [block.id, onAddAnnotation, readOnly]);
|
|
210
221
|
|
|
222
|
+
// A fence's offset is its own opening line; a whole-file diagram source
|
|
223
|
+
// overrides it with 0 (see Block.diagramSourceLineOffset).
|
|
224
|
+
const sourceLineOffset = block.diagramSourceLineOffset ?? block.startLine;
|
|
225
|
+
|
|
226
|
+
const handleAskAI = useMemo<DiagramAskAI | undefined>(() => {
|
|
227
|
+
if (readOnly || onAskAI === undefined) return undefined;
|
|
228
|
+
return (question, anchor, additionalTargets) =>
|
|
229
|
+
onAskAI(
|
|
230
|
+
question,
|
|
231
|
+
diagramAskAIContext({
|
|
232
|
+
kind,
|
|
233
|
+
anchor,
|
|
234
|
+
additionalTargets,
|
|
235
|
+
source: block.content,
|
|
236
|
+
sourceLineOffset,
|
|
237
|
+
sourcePath: askAISourcePath,
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
}, [askAISourcePath, block.content, kind, onAskAI, readOnly, sourceLineOffset]);
|
|
241
|
+
|
|
242
|
+
// The popout covers the panel the answer streams into, so a question
|
|
243
|
+
// asked there closes it once the host takes the question.
|
|
244
|
+
const handlePopoutAskAI = useMemo<DiagramAskAI | undefined>(() => {
|
|
245
|
+
if (handleAskAI === undefined) return undefined;
|
|
246
|
+
return async (question, anchor, additionalTargets) => {
|
|
247
|
+
const accepted = await handleAskAI(question, anchor, additionalTargets);
|
|
248
|
+
if (accepted !== false) setIsExpanded(false);
|
|
249
|
+
return accepted;
|
|
250
|
+
};
|
|
251
|
+
}, [handleAskAI]);
|
|
252
|
+
|
|
211
253
|
const [resolution, setResolution] = useState<ReadonlyMap<string, boolean> | null>(null);
|
|
212
254
|
const svgReady = renderState?.svgNode != null;
|
|
213
255
|
const renderFailed = renderState !== null && renderState.error !== null && !svgReady;
|
|
@@ -292,9 +334,7 @@ export const DiagramBlock: React.FC<DiagramBlockProps & { kind: DiagramKind }> =
|
|
|
292
334
|
onCreateComment: handleCreate,
|
|
293
335
|
selectedCommentId,
|
|
294
336
|
onSelectComment: onSelectAnnotation,
|
|
295
|
-
|
|
296
|
-
// overrides it with 0 (see Block.diagramSourceLineOffset).
|
|
297
|
-
sourceLineOffset: block.diagramSourceLineOffset ?? block.startLine,
|
|
337
|
+
sourceLineOffset,
|
|
298
338
|
retryToken,
|
|
299
339
|
};
|
|
300
340
|
|
|
@@ -355,6 +395,7 @@ export const DiagramBlock: React.FC<DiagramBlockProps & { kind: DiagramKind }> =
|
|
|
355
395
|
<DiagramViewer
|
|
356
396
|
{...viewerProps}
|
|
357
397
|
renderId={`${kind}-${block.id}`}
|
|
398
|
+
onAskAI={handleAskAI}
|
|
358
399
|
onResolutionChange={setResolution}
|
|
359
400
|
onRenderState={setRenderState}
|
|
360
401
|
renderFallback={renderFallback}
|
|
@@ -370,6 +411,7 @@ export const DiagramBlock: React.FC<DiagramBlockProps & { kind: DiagramKind }> =
|
|
|
370
411
|
onClose={() => setIsExpanded(false)}
|
|
371
412
|
title={`${label} diagram`}
|
|
372
413
|
renderId={`${kind}-${block.id}-popout`}
|
|
414
|
+
onAskAI={handlePopoutAskAI}
|
|
373
415
|
dataAttributes={{ 'data-block-id': block.id }}
|
|
374
416
|
/>
|
|
375
417
|
</Suspense>
|
package/components/DocBadges.tsx
CHANGED
|
@@ -23,7 +23,9 @@ import { OpenInAppButton } from './OpenInAppButton';
|
|
|
23
23
|
|
|
24
24
|
export interface LinkedDocBadgeInfo {
|
|
25
25
|
filepath: string;
|
|
26
|
-
|
|
26
|
+
/** Leave the document. Optional for `folder-file`: absent shows no Close
|
|
27
|
+
* (a file of a review of several files, which the switcher moves between). */
|
|
28
|
+
onBack?: () => void;
|
|
27
29
|
label?: string;
|
|
28
30
|
backLabel?: string;
|
|
29
31
|
variant?: 'breadcrumb' | 'folder-file';
|
|
@@ -198,13 +200,15 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
|
|
|
198
200
|
{!isRow && linkedDocInfo && (
|
|
199
201
|
linkedDocInfo.variant === 'folder-file' ? (
|
|
200
202
|
<div className="flex items-center gap-2">
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
203
|
+
{linkedDocInfo.onBack && (
|
|
204
|
+
<button
|
|
205
|
+
type="button"
|
|
206
|
+
onClick={linkedDocInfo.onBack}
|
|
207
|
+
className="rounded-sm text-[9px] font-medium text-primary transition-colors hover:text-primary/80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
208
|
+
>
|
|
209
|
+
Close
|
|
210
|
+
</button>
|
|
211
|
+
)}
|
|
208
212
|
<span
|
|
209
213
|
className="truncate rounded bg-muted/50 px-1.5 py-0.5 text-[9px] text-muted-foreground max-w-[220px]"
|
|
210
214
|
title={linkedDocInfo.filepath}
|