@plannotator/ui 0.49.0 → 0.51.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 +202 -1
- package/LICENSE +191 -0
- package/README.md +23 -0
- package/components/AISettingsTab.tsx +33 -1
- package/components/AgentToolAnnouncementDialog.tsx +347 -0
- package/components/AgentsTab.tsx +9 -9
- package/components/AskSessionAnnouncementDialog.tsx +329 -0
- package/components/CommentPopover.tsx +32 -18
- package/components/DecisionControl.tsx +2 -7
- package/components/DiagramBlock.tsx +46 -4
- package/components/DiffFileTree.tsx +363 -0
- package/components/DocBadges.tsx +12 -8
- package/components/ImageLightbox.tsx +66 -0
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/ProviderIcons.tsx +15 -3
- package/components/Settings.tsx +73 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/Viewer.tsx +5 -28
- package/components/ai/AIProviderBar.tsx +20 -3
- package/components/ai/DocumentAIChatPanel.tsx +23 -3
- package/components/ai/SessionAskNotice.tsx +101 -0
- package/components/blocks/QuestionBlock.tsx +24 -19
- 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 +209 -16
- package/components/html-viewer/bridge-script.ts +209 -16
- package/components/html-viewer/useHtmlAnnotation.ts +36 -55
- package/configure.ts +12 -0
- package/hooks/useAIChat.ts +96 -10
- package/hooks/useAIProviderActivation.ts +13 -0
- package/hooks/useAIProviderConfig.ts +32 -14
- package/hooks/useActiveSection.ts +3 -2
- package/hooks/useAgentToolSetting.ts +33 -0
- package/hooks/useAnnotationDraft.ts +53 -7
- package/hooks/useAnnotationHighlighter.ts +101 -5
- package/hooks/useDiffFileTreeExpansion.ts +92 -0
- 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 +3 -2
- package/styles.css +1 -1
- package/themes/gruvbox.css +6 -0
- package/types.ts +17 -0
- package/utils/agentClosed.ts +33 -0
- package/utils/agentToolAnnouncement.ts +109 -0
- package/utils/agentToolSetting.ts +108 -0
- package/utils/aiPrompt.ts +31 -0
- package/utils/aiProvider.ts +41 -0
- 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/diffFileTree.ts +203 -0
- package/utils/htmlLinkNavigation.ts +87 -3
- package/utils/parser.ts +236 -110
- package/utils/questionAnswers.ts +3 -1
- package/utils/quickLabels.ts +2 -2
- package/utils/syntaxTheme.ts +33 -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
|
+
}
|
|
@@ -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,9 @@ 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;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export type CommentAskAIHandler = (
|
|
@@ -68,11 +70,14 @@ interface CommentPopoverProps {
|
|
|
68
70
|
*/
|
|
69
71
|
onSubmit: (text: string, images?: ImageAttachment[], mentions?: readonly string[]) => void;
|
|
70
72
|
/**
|
|
71
|
-
* One-click "Looks good"
|
|
72
|
-
* pinpoint
|
|
73
|
-
* toolbar's 👍). Renders
|
|
74
|
-
*
|
|
75
|
-
*
|
|
73
|
+
* One-click 👍 "Looks good" (comment-only HTML / live-app surfaces, where a
|
|
74
|
+
* pinpoint click opens this composer directly and never shows the selection
|
|
75
|
+
* toolbar's 👍). Renders an emoji-only button beside Save, named "Looks
|
|
76
|
+
* good" for assistive tech and in its tooltip; disabled once anything is
|
|
77
|
+
* typed or attached so a click can never discard a draft. It takes no
|
|
78
|
+
* keyboard shortcut: Enter / Mod+Enter still Save. Like Save, a click
|
|
79
|
+
* clears the `draftKey` draft and returns focus to the opener. The parent
|
|
80
|
+
* owns annotation creation and closing. Absent renders nothing.
|
|
76
81
|
*/
|
|
77
82
|
onQuickLookGood?: () => void;
|
|
78
83
|
/** Optional live draft observer for submit paths outside the popover. */
|
|
@@ -590,18 +595,33 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
590
595
|
const canAskAI = !!onAskAI && !askAIDisabled && text.trim().length > 0;
|
|
591
596
|
const showsSkillMenu = skillAc.menu !== null;
|
|
592
597
|
|
|
593
|
-
// Shared by both footers
|
|
594
|
-
//
|
|
598
|
+
// Shared by both footers, sits beside Save. Emoji-only like the selection
|
|
599
|
+
// toolbar's 👍 (same hover tint). Disabled once anything is typed or
|
|
600
|
+
// attached so a click can never discard a draft; with content, Save is the
|
|
601
|
+
// path. A plain click handler only: Enter / Mod+Enter stay on Save.
|
|
602
|
+
// The grey-out is an inline filter, not a Tailwind filter utility: this
|
|
603
|
+
// file is scanned into the pinned guides.show viewer CSS, and every class
|
|
604
|
+
// below already exists there, so the 👍 adds nothing to that bundle.
|
|
605
|
+
const handleQuickLookGood = () => {
|
|
606
|
+
if (!onQuickLookGood || hasUnsavedContent) return;
|
|
607
|
+
if (draftKey) draftStore.delete(draftKey);
|
|
608
|
+
onQuickLookGood();
|
|
609
|
+
restoreOpeningFocus();
|
|
610
|
+
};
|
|
595
611
|
const quickLookGoodButton = onQuickLookGood ? (
|
|
596
612
|
<button
|
|
597
613
|
type="button"
|
|
598
|
-
onClick={
|
|
614
|
+
onClick={handleQuickLookGood}
|
|
599
615
|
disabled={hasUnsavedContent}
|
|
600
|
-
|
|
601
|
-
title={hasUnsavedContent ? '
|
|
616
|
+
aria-label="Looks good"
|
|
617
|
+
title={hasUnsavedContent ? 'Looks good (clear the comment to use it)' : 'Looks good'}
|
|
618
|
+
data-quick-look-good="true"
|
|
619
|
+
data-pn-touch-target="true"
|
|
620
|
+
data-pn-touch-target-icon="true"
|
|
621
|
+
style={hasUnsavedContent ? { filter: 'grayscale(1)' } : undefined}
|
|
622
|
+
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
623
|
>
|
|
603
624
|
<span aria-hidden="true">👍</span>
|
|
604
|
-
Looks good
|
|
605
625
|
</button>
|
|
606
626
|
) : null;
|
|
607
627
|
|
|
@@ -723,9 +743,6 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
723
743
|
>
|
|
724
744
|
{isGlobal ? 'Add' : 'Save'}
|
|
725
745
|
</button>
|
|
726
|
-
{!coarsePointer && (
|
|
727
|
-
<span className="text-[10px] text-muted-foreground">{submitHint}</span>
|
|
728
|
-
)}
|
|
729
746
|
{quickLookGoodButton}
|
|
730
747
|
{onAskAI && (
|
|
731
748
|
<button
|
|
@@ -879,9 +896,6 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
879
896
|
>
|
|
880
897
|
{isGlobal ? 'Add' : 'Save'}
|
|
881
898
|
</button>
|
|
882
|
-
{!coarsePointer && (
|
|
883
|
-
<span className="text-[10px] text-muted-foreground">{submitHint}</span>
|
|
884
|
-
)}
|
|
885
899
|
{quickLookGoodButton}
|
|
886
900
|
{onAskAI && (
|
|
887
901
|
<button
|
|
@@ -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>
|