@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.
Files changed (62) hide show
  1. package/HANDOFF.md +295 -11
  2. package/README.md +8 -0
  3. package/components/AISettingsTab.tsx +32 -0
  4. package/components/AgentToolAnnouncementDialog.tsx +347 -0
  5. package/components/AgentsTab.tsx +9 -9
  6. package/components/AnnotationToolbar.tsx +2 -2
  7. package/components/AskSessionAnnouncementDialog.tsx +329 -0
  8. package/components/BlockRenderer.tsx +10 -1
  9. package/components/CommentPopover.tsx +36 -18
  10. package/components/CompletionOverlay.tsx +12 -5
  11. package/components/DecisionControl.tsx +2 -7
  12. package/components/DiagramBlock.tsx +46 -4
  13. package/components/DocBadges.tsx +12 -8
  14. package/components/FloatingQuickLabelPicker.tsx +2 -2
  15. package/components/PlanHeaderMenu.tsx +1 -1
  16. package/components/QuickLabelDropdown.tsx +9 -4
  17. package/components/ServerSessionReplacedBanner.tsx +29 -0
  18. package/components/Settings.tsx +76 -192
  19. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  20. package/components/Viewer.tsx +30 -1
  21. package/components/ai/AIProviderBar.tsx +18 -1
  22. package/components/ai/DocumentAIChatPanel.tsx +4 -9
  23. package/components/ai/SessionAskNotice.tsx +33 -21
  24. package/components/blocks/QuestionBlock.tsx +99 -27
  25. package/components/blocks/QuestionContext.tsx +81 -0
  26. package/components/diagram/DiagramComposer.tsx +54 -3
  27. package/components/diagram/DiagramViewer.tsx +31 -2
  28. package/components/diagram/index.ts +1 -1
  29. package/components/html-viewer/HtmlViewer.tsx +13 -3
  30. package/components/html-viewer/bridge-script.asset.js +230 -14
  31. package/components/html-viewer/bridge-script.ts +230 -14
  32. package/components/html-viewer/useHtmlAnnotation.ts +36 -55
  33. package/components/settings/QuickLabelsTab.tsx +378 -0
  34. package/hooks/useAIChat.ts +83 -6
  35. package/hooks/useAIProviderActivation.ts +13 -0
  36. package/hooks/useAIProviderConfig.ts +3 -3
  37. package/hooks/useActiveSection.ts +3 -2
  38. package/hooks/useAgentToolSetting.ts +33 -0
  39. package/hooks/useAnnotationDraft.ts +53 -7
  40. package/hooks/useAnnotationHighlighter.ts +129 -5
  41. package/hooks/useExternalAnnotations.ts +21 -1
  42. package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
  43. package/hooks/useLatchedTrue.ts +15 -0
  44. package/hooks/useLinkedDoc.ts +64 -4
  45. package/hooks/useModelCatalogs.ts +9 -2
  46. package/package.json +2 -2
  47. package/styles.css +1 -1
  48. package/themes/gruvbox.css +6 -0
  49. package/types.ts +19 -0
  50. package/utils/agentClosed.ts +33 -0
  51. package/utils/agentToolAnnouncement.ts +109 -0
  52. package/utils/agentToolSetting.ts +108 -0
  53. package/utils/aiProvider.ts +13 -40
  54. package/utils/annotationOwnsHighlight.ts +27 -0
  55. package/utils/annotationScope.ts +15 -0
  56. package/utils/askSessionAnnouncement.ts +107 -0
  57. package/utils/blockTargeting.ts +61 -2
  58. package/utils/diagramAskAI.ts +161 -0
  59. package/utils/parser.ts +382 -114
  60. package/utils/questionAnswers.ts +3 -1
  61. package/utils/quickLabels.ts +82 -2
  62. 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
- }> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, repoHost, headingAnchorId, onNavigateAnchor, question, questionTotal, questionAnswer, onAnswerQuestion, onSaveQuestionAnswer, saveQuestionAnswerLabel, renderQuestionFooter }) => {
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" action (comment-only HTML/live surfaces, where
72
- * pinpoint clicks open this composer directly and never see the selection
73
- * toolbar's 👍). Renders a thumbs-up button in the footer; disabled once
74
- * the user has typed or attached anything, so a click can never discard a
75
- * draft. The parent owns annotation creation and closing.
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. Disabled once anything is typed or attached so a
594
- // click can never discard a draft; with content present, Save is the path.
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={onQuickLookGood}
618
+ onClick={handleQuickLookGood}
599
619
  disabled={hasUnsavedContent}
600
- className="inline-flex items-center gap-1 px-2 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:text-foreground hover:bg-green-500/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
601
- title={hasUnsavedContent ? 'Clear the comment to use Looks good' : 'Add "Looks good" without typing'}
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
- submitted: 'approved' | 'denied' | 'feedback' | 'exited' | null | false;
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' : 'bg-accent/20 text-accent'
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-between gap-2">
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-between gap-2 px-0.5">
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
- // A fence's offset is its own opening line; a whole-file diagram source
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>
@@ -23,7 +23,9 @@ import { OpenInAppButton } from './OpenInAppButton';
23
23
 
24
24
  export interface LinkedDocBadgeInfo {
25
25
  filepath: string;
26
- onBack: () => void;
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
- <button
202
- type="button"
203
- onClick={linkedDocInfo.onBack}
204
- 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"
205
- >
206
- Close
207
- </button>
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}