@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
@@ -1,6 +1,6 @@
1
1
  import React, { useState, useEffect, useRef, useMemo } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
- import { type QuickLabel, getQuickLabels } from '../utils/quickLabels';
3
+ import { type QuickLabel, getQuickLabels, quickLabelIndexForDigit } from '../utils/quickLabels';
4
4
  import { QuickLabelDropdown } from './QuickLabelDropdown';
5
5
 
6
6
  interface FloatingQuickLabelPickerProps {
@@ -78,7 +78,7 @@ export const FloatingQuickLabelPicker: React.FC<FloatingQuickLabelPickerProps> =
78
78
  if (isDigit && !e.ctrlKey && !e.metaKey) {
79
79
  e.preventDefault();
80
80
  const digit = parseInt(e.code.slice(5), 10);
81
- const index = digit === 0 ? 9 : digit - 1;
81
+ const index = quickLabelIndexForDigit(digit);
82
82
  if (index < quickLabels.length) {
83
83
  onSelect(quickLabels[index]);
84
84
  }
@@ -40,7 +40,7 @@ interface PlanHeaderMenuProps {
40
40
  }
41
41
 
42
42
  export interface CompactPlanAction {
43
- id: 'exit' | 'feedback' | 'approve' | 'copy' | 'done' | 'edit' | 'tools' | 'annotate' | 'refresh' | 'annotations' | 'ai' | 'review' | 'note' | 'discard-finish';
43
+ id: 'exit' | 'feedback' | 'approve' | 'copy' | 'done' | 'edit' | 'tools' | 'annotate' | 'refresh' | 'annotations' | 'ai' | 'review' | 'note' | 'discard-finish' | 'bundle-previous' | 'bundle-next';
44
44
  label: string;
45
45
  subtitle?: string;
46
46
  onSelect: () => void;
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { type QuickLabel, getLabelColors } from '../utils/quickLabels';
2
+ import { type QuickLabel, getLabelColors, quickLabelShortcutDigit } from '../utils/quickLabels';
3
3
 
4
4
  /**
5
5
  * Shared vertical label list used by both FloatingQuickLabelPicker
@@ -25,9 +25,14 @@ export const QuickLabelDropdown: React.FC<{
25
25
  )}
26
26
  {labels.map((label, index) => {
27
27
  const colors = getLabelColors(label.color);
28
+ const digit = quickLabelShortcutDigit(index);
28
29
  return (
29
30
  <button
30
- key={label.id}
31
+ // Keyed by position, not label.id: ids are derived from the
32
+ // label text, so two labels with the same text share one (#1736).
33
+ // The list never reorders while open, and position is exactly
34
+ // what the digit key on each row names.
35
+ key={index}
31
36
  onClick={() => onSelect(label)}
32
37
  className="group w-full flex items-center gap-2 px-2 py-[5px] text-left transition-colors hover:bg-muted/60 active:bg-muted"
33
38
  style={animate ? {
@@ -50,9 +55,9 @@ export const QuickLabelDropdown: React.FC<{
50
55
  {label.text}
51
56
  </span>
52
57
  {/* Shortcut hint */}
53
- {index < 10 && (
58
+ {digit && (
54
59
  <span className="text-[9px] tabular-nums text-muted-foreground/40 group-hover:text-muted-foreground/60 flex-shrink-0 font-mono">
55
- {index === 9 ? '0' : index + 1}
60
+ {digit}
56
61
  </span>
57
62
  )}
58
63
  </button>
@@ -0,0 +1,29 @@
1
+ import React from 'react';
2
+ import { useServerSessionReplaced } from '../utils/serverSession';
3
+
4
+ /**
5
+ * The stale-tab prompt (packages/ui/utils/serverSession.ts): shown once a
6
+ * decision was refused with `409 session_mismatch`, i.e. the Plannotator
7
+ * session this tab was opened for is gone and a newer one owns the address.
8
+ * Nothing was submitted; reloading shows what is open there now.
9
+ */
10
+ export const ServerSessionReplacedBanner: React.FC<{ onReload?: () => void }> = ({ onReload }) => {
11
+ const replaced = useServerSessionReplaced();
12
+ if (!replaced) return null;
13
+ return (
14
+ <div
15
+ role="alert"
16
+ data-server-session-replaced
17
+ className="fixed inset-x-0 top-0 z-[110] flex items-center justify-center gap-3 border-b border-border bg-destructive/95 px-4 py-2 text-sm text-destructive-foreground shadow-md"
18
+ >
19
+ <span>This review was replaced — reload to see what is open now. Nothing was submitted.</span>
20
+ <button
21
+ type="button"
22
+ onClick={onReload ?? (() => window.location.reload())}
23
+ className="rounded border border-destructive-foreground/40 px-2 py-0.5 font-medium hover:bg-destructive-foreground/10"
24
+ >
25
+ Reload
26
+ </button>
27
+ </div>
28
+ );
29
+ };
@@ -7,6 +7,12 @@ import type { TokenHoverDelay } from '@plannotator/core/token-hover';
7
7
  import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType, setReviewAutoViewed } from '../config';
8
8
  import { setWebMcpToolsEnabled, useWebMcpToolsEnabled } from '../webmcp/preference';
9
9
  import { DIAGRAM_SHADOW_OPTIONS } from '../utils/diagramShadow';
10
+ import {
11
+ agentToolAppliesWhen,
12
+ agentToolCostNote,
13
+ agentToolHostName,
14
+ type AgentToolSetting,
15
+ } from '../utils/agentToolSetting';
10
16
  import { loadDiffFont } from '../utils/diffFonts';
11
17
  import { TaterSpritePullup } from './TaterSpritePullup';
12
18
  import { getIdentity, regenerateIdentity, setCustomIdentity, isIdentityEditable } from '../utils/identity';
@@ -64,12 +70,12 @@ import {
64
70
  } from '../utils/defaultNotesApp';
65
71
  import { useAgents } from '../hooks/useAgents';
66
72
  import { KeyboardShortcuts } from './KeyboardShortcuts';
67
- import { type QuickLabel, getQuickLabels, saveQuickLabels, resetQuickLabels, DEFAULT_QUICK_LABELS, getLabelColors, LABEL_COLOR_MAP } from '../utils/quickLabels';
68
73
  import { ThemeTab } from './ThemeTab';
69
- import { isMac, modKey, modKeyWord, altKey } from '../utils/platform';
74
+ import { modKey, modKeyWord } from '../utils/platform';
70
75
  import { getAIProviderSettings, resolveAIProviderSelection } from '../utils/aiProvider';
71
76
  import { AISettingsTab } from './AISettingsTab';
72
77
  import { HooksTab } from './settings/HooksTab';
78
+ import { QuickLabelsTab } from './settings/QuickLabelsTab';
73
79
  import { OverlayScrollArea } from './OverlayScrollArea';
74
80
  import {
75
81
  getFileBrowserSettings,
@@ -106,6 +112,14 @@ interface SettingsProps {
106
112
  * `env` is PLANNOTATOR_AUTO_UPDATE when it overrides the config file.
107
113
  * Hosts that omit it get no toggle. */
108
114
  autoUpdateSetting?: { env?: boolean };
115
+ /** The `plannotator` agent tool switch, as the server reports it
116
+ * (parseAgentToolSetting of serverConfig: present only when the session's
117
+ * host registers the tool). Shown only together with onAgentToolChange;
118
+ * hosts that omit them get no row. */
119
+ agentToolSetting?: AgentToolSetting;
120
+ /** Saves the agent tool switch (saveAgentToolSetting); rejects with a
121
+ * readable Error, which the row shows. */
122
+ onAgentToolChange?: (enabled: boolean) => Promise<void>;
109
123
  /** Current session is a local git review where since-base ISN'T offered
110
124
  * (base ref unresolvable) — the Git tab shows a note that the Git-status
111
125
  * preference can't take effect in THIS repo. */
@@ -269,6 +283,53 @@ function ToggleSwitch({ checked, onChange, label, description, disabled = false
269
283
  );
270
284
  }
271
285
 
286
+ /**
287
+ * The `plannotator` agent tool switch. The value is the one the NEXT session
288
+ * uses (the tool list is part of the model's prompt, so a running session
289
+ * never changes it). Writes go straight to POST /api/config and wait for the
290
+ * answer, so a failed save is reported and the switch stays where it was.
291
+ */
292
+ function AgentToolSettingRow({ setting, onChange }: {
293
+ readonly setting: AgentToolSetting;
294
+ readonly onChange: (enabled: boolean) => Promise<void>;
295
+ }) {
296
+ const [saving, setSaving] = useState(false);
297
+ const [error, setError] = useState<string | null>(null);
298
+ const locked = setting.env !== undefined;
299
+ const change = async (next: boolean) => {
300
+ if (saving || locked) return;
301
+ setSaving(true);
302
+ setError(null);
303
+ try {
304
+ await onChange(next);
305
+ } catch (e) {
306
+ setError(e instanceof Error && e.message ? e.message : 'Could not save the setting.');
307
+ } finally {
308
+ setSaving(false);
309
+ }
310
+ };
311
+ return (
312
+ <div data-agent-tool-setting={setting.host} aria-busy={saving}>
313
+ <ToggleSwitch
314
+ checked={setting.enabled}
315
+ onChange={(next) => void change(next)}
316
+ disabled={locked}
317
+ label="Let the agent open reviews (plannotator tool)"
318
+ description={
319
+ locked
320
+ ? `Set by PLANNOTATOR_AGENT_TOOL in your environment, so it is ${setting.enabled ? 'on' : 'off'} here.`
321
+ : `${agentToolHostName(setting.host)} can open reviews itself and keep working. ${agentToolCostNote(setting.host)} Applies from ${agentToolAppliesWhen(setting.host)}.`
322
+ }
323
+ />
324
+ {error && (
325
+ <p role="alert" data-agent-tool-setting-error className="mt-1 text-xs text-destructive">
326
+ {error}
327
+ </p>
328
+ )}
329
+ </div>
330
+ );
331
+ }
332
+
272
333
  interface VimSettingsTabProps {
273
334
  readonly vimModeEnabled: boolean;
274
335
  readonly vimHudEnabled: boolean;
@@ -941,7 +1002,7 @@ const CommentsTab: React.FC = () => {
941
1002
  );
942
1003
  };
943
1004
 
944
- export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', annotateParity = false, onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, autoUpdateSetting, sinceBaseUnavailable, isCompactTouchLayout = false, onDetectObsidianVaults, agentTerminalAvailable = false, webmcpAvailable = false }) => {
1005
+ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', annotateParity = false, onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, autoUpdateSetting, agentToolSetting, onAgentToolChange, sinceBaseUnavailable, isCompactTouchLayout = false, onDetectObsidianVaults, agentTerminalAvailable = false, webmcpAvailable = false }) => {
945
1006
  const webmcpTools = useWebMcpToolsEnabled();
946
1007
  const autoUpdate = useConfigValue('autoUpdate');
947
1008
  const [showDialog, setShowDialog] = useState(false);
@@ -1002,9 +1063,6 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1002
1063
  const [agentWarning, setAgentWarning] = useState<string | null>(null);
1003
1064
  const [autoCloseDelay, setAutoCloseDelayState] = useState<AutoCloseDelay>('off');
1004
1065
  const [defaultNotesApp, setDefaultNotesApp] = useState<DefaultNotesApp>('ask');
1005
- const [quickLabelsState, setQuickLabelsState] = useState<QuickLabel[]>([]);
1006
- const [editingTipIndex, setEditingTipIndex] = useState<number | null>(null);
1007
- const [editingTipValue, setEditingTipValue] = useState('');
1008
1066
  const [aiProvider, setAiProvider] = useState<string | null>(null);
1009
1067
  const [fileBrowserSettings, setFileBrowserSettings] = useState<FileBrowserSettings>({ enabled: false, directories: [] });
1010
1068
  const [newDirPath, setNewDirPath] = useState('');
@@ -1073,7 +1131,6 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1073
1131
  setPermissionMode(getPermissionModeSettings().mode);
1074
1132
  setAutoCloseDelayState(getAutoCloseDelay());
1075
1133
  setDefaultNotesApp(getDefaultNotesApp());
1076
- setQuickLabelsState(getQuickLabels());
1077
1134
  const aiSettings = getAIProviderSettings();
1078
1135
  setAiProvider(resolveAIProviderSelection({ providers: aiProviders, origin, settings: aiSettings }).providerId);
1079
1136
  setFileBrowserSettings(getFileBrowserSettings());
@@ -1403,6 +1460,17 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1403
1460
  </>
1404
1461
  )}
1405
1462
 
1463
+ {/* The `plannotator` agent tool switch: only where the
1464
+ server reports a tool host (Claude Code mod, Pi,
1465
+ OpenCode 2). Writes agentTool to config.json; applies
1466
+ to the next session. */}
1467
+ {agentToolSetting && onAgentToolChange && (
1468
+ <>
1469
+ <div className="border-t border-border" />
1470
+ <AgentToolSettingRow setting={agentToolSetting} onChange={onAgentToolChange} />
1471
+ </>
1472
+ )}
1473
+
1406
1474
  {/* Agent tools (WebMCP). Shown only when the browser
1407
1475
  exposes document.modelContext; off unregisters the
1408
1476
  tools for this browser. Cookie-only and idle at its
@@ -1982,191 +2050,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1982
2050
  )}
1983
2051
 
1984
2052
  {/* === LABELS TAB === */}
1985
- {activeTab === 'labels' && (
1986
- <>
1987
- <div className="flex items-center justify-between">
1988
- <div>
1989
- <div className="text-sm font-medium flex items-center gap-2">Quick Labels</div>
1990
- <div className="text-xs text-muted-foreground">
1991
- Preset annotations for one-click feedback
1992
- </div>
1993
- </div>
1994
- <button
1995
- onClick={() => {
1996
- resetQuickLabels();
1997
- setQuickLabelsState(DEFAULT_QUICK_LABELS);
1998
- }}
1999
- className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
2000
- >
2001
- Reset to defaults
2002
- </button>
2003
- </div>
2004
-
2005
- <style>{`
2006
- @keyframes tip-slide-open {
2007
- from { opacity: 0; transform: translateY(-4px); }
2008
- to { opacity: 1; transform: translateY(0); }
2009
- }
2010
- `}</style>
2011
- <div className="space-y-1.5">
2012
- {quickLabelsState.map((label, index) => {
2013
- const colors = getLabelColors(label.color);
2014
- const hasTip = !!label.tip;
2015
- const isEditingTip = editingTipIndex === index;
2016
- return (
2017
- <div key={index} className="rounded-lg overflow-hidden" style={{ backgroundColor: colors.bg }}>
2018
- {/* Main row */}
2019
- <div className="flex items-center gap-2 p-2">
2020
- <span className="text-sm flex-shrink-0">{label.emoji}</span>
2021
- <input
2022
- type="text"
2023
- value={label.text}
2024
- onChange={(e) => {
2025
- const updated = [...quickLabelsState];
2026
- updated[index] = {
2027
- ...label,
2028
- text: e.target.value,
2029
- id: e.target.value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''),
2030
- };
2031
- setQuickLabelsState(updated);
2032
- saveQuickLabels(updated);
2033
- }}
2034
- className="flex-1 px-2 py-1 bg-background/80 rounded text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
2035
- />
2036
- {/* Tip indicator button */}
2037
- <button
2038
- onClick={() => {
2039
- if (isEditingTip) {
2040
- setEditingTipIndex(null);
2041
- } else {
2042
- setEditingTipIndex(index);
2043
- setEditingTipValue(label.tip || '');
2044
- }
2045
- }}
2046
- className={`relative p-1 rounded transition-all flex-shrink-0 ${
2047
- hasTip
2048
- ? 'bg-foreground/10 text-foreground/70 hover:text-foreground border border-foreground/15'
2049
- : 'text-muted-foreground/30 hover:text-muted-foreground/60 border border-dashed border-muted-foreground/20 hover:border-muted-foreground/40'
2050
- }`}
2051
- title={hasTip ? `Tip: ${label.tip}` : 'Add AI instruction tip'}
2052
- >
2053
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
2054
- <path strokeLinecap="round" strokeLinejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
2055
- </svg>
2056
- {hasTip && (
2057
- <span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-foreground/50" />
2058
- )}
2059
- </button>
2060
- <select
2061
- value={label.color}
2062
- onChange={(e) => {
2063
- const updated = [...quickLabelsState];
2064
- updated[index] = { ...label, color: e.target.value };
2065
- setQuickLabelsState(updated);
2066
- saveQuickLabels(updated);
2067
- }}
2068
- className="px-1.5 py-1 bg-background/80 rounded text-[10px] focus:outline-none focus:ring-1 focus:ring-primary/50"
2069
- >
2070
- {Object.keys(LABEL_COLOR_MAP).map(c => (
2071
- <option key={c} value={c}>{c}</option>
2072
- ))}
2073
- </select>
2074
- <span className="text-[10px] text-muted-foreground/50 font-mono w-8 text-center flex-shrink-0">
2075
- {index < 10 ? `${altKey}${isMac ? '' : '+'}${index === 9 ? '0' : index + 1}` : ''}
2076
- </span>
2077
- <button
2078
- onClick={() => {
2079
- const updated = quickLabelsState.filter((_, i) => i !== index);
2080
- setQuickLabelsState(updated);
2081
- saveQuickLabels(updated);
2082
- if (editingTipIndex === index) setEditingTipIndex(null);
2083
- }}
2084
- className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors flex-shrink-0"
2085
- title="Remove label"
2086
- >
2087
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
2088
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
2089
- </svg>
2090
- </button>
2091
- </div>
2092
- {/* Tip editor — slides open below the row */}
2093
- {isEditingTip && (
2094
- <div
2095
- className="flex items-center gap-1.5 px-2 pb-2 pt-0"
2096
- style={{ animation: 'tip-slide-open 0.15s ease-out' }}
2097
- >
2098
- <svg className="w-3 h-3 text-muted-foreground/40 flex-shrink-0 ml-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
2099
- <path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
2100
- </svg>
2101
- <input
2102
- type="text"
2103
- value={editingTipValue}
2104
- onChange={(e) => setEditingTipValue(e.target.value)}
2105
- onKeyDown={(e) => {
2106
- if (e.key === 'Enter') {
2107
- const updated = [...quickLabelsState];
2108
- updated[index] = { ...label, tip: editingTipValue || undefined };
2109
- setQuickLabelsState(updated);
2110
- saveQuickLabels(updated);
2111
- setEditingTipIndex(null);
2112
- }
2113
- if (e.key === 'Escape') {
2114
- e.preventDefault();
2115
- e.stopPropagation();
2116
- setEditingTipIndex(null);
2117
- }
2118
- }}
2119
- placeholder="AI instruction tip..."
2120
- className="flex-1 px-2 py-1 bg-background/60 rounded text-[10px] text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-1 focus:ring-primary/50"
2121
- autoFocus
2122
- onFocus={(e) => { e.target.setSelectionRange(0, 0); e.target.scrollLeft = 0; }}
2123
- />
2124
- <button
2125
- onClick={() => {
2126
- const updated = [...quickLabelsState];
2127
- updated[index] = { ...label, tip: editingTipValue || undefined };
2128
- setQuickLabelsState(updated);
2129
- saveQuickLabels(updated);
2130
- setEditingTipIndex(null);
2131
- }}
2132
- className="p-1 rounded text-muted-foreground/50 hover:text-green-500 hover:bg-green-500/10 transition-colors flex-shrink-0"
2133
- title="Save tip"
2134
- >
2135
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
2136
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
2137
- </svg>
2138
- </button>
2139
- </div>
2140
- )}
2141
- </div>
2142
- );
2143
- })}
2144
- </div>
2145
-
2146
- {quickLabelsState.length < 12 && (
2147
- <button
2148
- onClick={() => {
2149
- const newLabel: QuickLabel = {
2150
- id: `custom-${Date.now()}`,
2151
- emoji: '📌',
2152
- text: 'New label',
2153
- color: 'blue',
2154
- };
2155
- const updated = [...quickLabelsState, newLabel];
2156
- setQuickLabelsState(updated);
2157
- saveQuickLabels(updated);
2158
- }}
2159
- className="w-full py-1.5 text-xs text-muted-foreground hover:text-foreground border border-dashed border-border rounded-lg hover:border-foreground/30 transition-colors"
2160
- >
2161
- + Add label
2162
- </button>
2163
- )}
2164
-
2165
- <div className="text-[10px] text-muted-foreground/70">
2166
- Use {altKey}{isMac ? '' : '+'}1 through {altKey}{isMac ? '' : '+'}0 when the annotation toolbar is visible to apply a label instantly.
2167
- </div>
2168
- </>
2169
- )}
2053
+ {activeTab === 'labels' && <QuickLabelsTab />}
2170
2054
 
2171
2055
  {/* === VIM TAB === */}
2172
2056
  {activeTab === 'vim' && mode !== 'review' && (
@@ -107,7 +107,7 @@ function GitHubMark({ className }: { readonly className?: string }) {
107
107
  );
108
108
  }
109
109
 
110
- function XMark({ className }: { readonly className?: string }) {
110
+ export function XMark({ className }: { readonly className?: string }) {
111
111
  return (
112
112
  <svg viewBox="0 0 24 24" className={className} aria-hidden="true" focusable="false">
113
113
  <path
@@ -6,7 +6,7 @@ import { AnnotationType, type Block, type Annotation, type EditorMode, type Inpu
6
6
  import { applyHighlight, codeBlockClassName, onCodeHighlightSwap } from '../utils/codeHighlight';
7
7
  import { paintCodeBlockMark } from '../utils/codeBlockMark';
8
8
  import { useFenceTheme } from '../hooks/useFenceTheme';
9
- import { computeListIndices, groupBlocks, type Frontmatter, type FrontmatterValue } from '../utils/parser';
9
+ import { computeListIndices, groupBlocks, selectionSourceLines, type Frontmatter, type FrontmatterValue } from '../utils/parser';
10
10
  import { buildHeadingSlugMap } from '../utils/slugify';
11
11
  import { copyTextToClipboard } from '../utils/clipboard';
12
12
  import { BlockRenderer } from './BlockRenderer';
@@ -218,6 +218,21 @@ export interface ViewerProps {
218
218
  * decision"), given the indexed question and its saved answer. Rendered in
219
219
  * read-only cards too; return null for none. */
220
220
  renderQuestionFooter?: (question: IndexedQuestion, answer: QuestionAnswer | undefined) => React.ReactNode;
221
+ /** Whether answering a `Decision: when answered` question records a
222
+ * decision, per question: return false to draw its "Records a decision"
223
+ * tag dimmed with a dotted outline and hide the "Answering this records a
224
+ * decision" row; true or undefined keeps today's card. Not consulted for a
225
+ * question whose decision is already recorded. */
226
+ questionDecisionRecording?: (question: IndexedQuestion) => boolean | undefined;
227
+ /** Makes a `Decision: when answered` question's "Records a decision" tag a
228
+ * toggle button (`aria-pressed`); a click calls this with the question's
229
+ * key and the next state, which the host stores and returns from
230
+ * `questionDecisionRecording`. Ignored when `readOnly`. */
231
+ onToggleQuestionDecisionRecording?: (key: string, next: boolean) => void;
232
+ /** `'none'` hides each question card's own status tag (Open / Answered /
233
+ * Settled / Skipped) so the host can draw its own; the decision tags stay.
234
+ * Default `'card'`. */
235
+ questionStatusTag?: 'card' | 'none';
221
236
  onAskAI?: CommentAskAIHandler;
222
237
  /** Whether comment popovers offer image attachments. Hosts without an
223
238
  * uploadTransport pass false so the attach affordance never dead-ends.
@@ -590,6 +605,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
590
605
  onSaveQuestionAnswer,
591
606
  saveQuestionAnswerLabel,
592
607
  renderQuestionFooter,
608
+ questionDecisionRecording,
609
+ onToggleQuestionDecisionRecording,
610
+ questionStatusTag,
593
611
  onAskAI,
594
612
  allowImages = true,
595
613
  readOnly = false,
@@ -1434,6 +1452,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1434
1452
  onAddAnnotation={readOnly ? undefined : onAddAnnotation}
1435
1453
  readOnly={readOnly}
1436
1454
  onRestoreReport={onRestoreReport}
1455
+ onAskAI={readOnly ? undefined : onAskAI}
1456
+ askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
1437
1457
  />
1438
1458
  </Suspense>
1439
1459
  ) : group.block.type === 'code' && isGraphvizLanguage(group.block.language) ? (
@@ -1446,6 +1466,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1446
1466
  onAddAnnotation={readOnly ? undefined : onAddAnnotation}
1447
1467
  readOnly={readOnly}
1448
1468
  onRestoreReport={onRestoreReport}
1469
+ onAskAI={readOnly ? undefined : onAskAI}
1470
+ askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
1449
1471
  />
1450
1472
  </Suspense>
1451
1473
  ) : group.block.type === 'table' ? (
@@ -1547,6 +1569,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1547
1569
  onSaveQuestionAnswer={readOnly ? undefined : onSaveQuestionAnswer}
1548
1570
  saveQuestionAnswerLabel={saveQuestionAnswerLabel}
1549
1571
  renderQuestionFooter={renderQuestionFooter}
1572
+ questionDecisionRecording={question ? questionDecisionRecording?.(question) : undefined}
1573
+ onToggleQuestionDecisionRecording={readOnly ? undefined : onToggleQuestionDecisionRecording}
1574
+ questionStatusTag={questionStatusTag}
1550
1575
  />
1551
1576
  );
1552
1577
  })()
@@ -1711,6 +1736,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1711
1736
  label: 'Selected text',
1712
1737
  text: hookCommentPopover.selectedText ?? hookCommentPopover.contextText,
1713
1738
  sourcePath: linkedDocInfo?.filepath ?? sourceInfo,
1739
+ ...selectionSourceLines(blocks, hookCommentPopover.blockIds ?? []),
1714
1740
  }}
1715
1741
  />
1716
1742
  )}
@@ -1736,6 +1762,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1736
1762
  label: viewerCommentPopover.isGlobal ? 'Document' : 'Code block',
1737
1763
  text: viewerCommentPopover.selectedText,
1738
1764
  sourcePath: linkedDocInfo?.filepath ?? sourceInfo,
1765
+ ...(!viewerCommentPopover.isGlobal && viewerCommentPopover.codeBlock
1766
+ ? selectionSourceLines(blocks, [viewerCommentPopover.codeBlock.block.id])
1767
+ : null),
1739
1768
  }}
1740
1769
  />
1741
1770
  )}
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { getProviderMeta } from '../ProviderIcons';
3
- import { type AIProviderOption } from '../../utils/aiProvider';
3
+ import { findSessionBridge, type AIProviderOption } from '../../utils/aiProvider';
4
4
  import { ModelSourceHint, modelSourceToolForProvider } from '../ModelSourceHint';
5
5
 
6
6
  interface AIProviderBarProps {
@@ -30,6 +30,23 @@ export const AIProviderBar: React.FC<AIProviderBarProps> = ({
30
30
  );
31
31
  }
32
32
 
33
+ // "Ask this session": the session that opened Plannotator is the only thing
34
+ // Ask AI talks to, so there is no provider or model to choose. Show its label.
35
+ const sessionBridge = findSessionBridge(providers);
36
+ if (sessionBridge) {
37
+ const bridgeMeta = getProviderMeta(sessionBridge.name, sessionBridge.label);
38
+ const BridgeIcon = bridgeMeta.icon;
39
+ return (
40
+ <div
41
+ className="border-t border-border/50 px-2 py-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground"
42
+ data-ai-provider-session-bridge
43
+ >
44
+ <BridgeIcon className="w-3.5 h-3.5 flex-shrink-0" />
45
+ <span className="min-w-0 truncate text-foreground">{bridgeMeta.label}</span>
46
+ </div>
47
+ );
48
+ }
49
+
33
50
  const currentProvider = providers.find(p => p.id === selectedProviderId) ?? providers[0];
34
51
  const effectiveProviderId = currentProvider?.id ?? '';
35
52
  const models = currentProvider?.models ?? [];
@@ -5,7 +5,7 @@ import { formatRelativeTime, renderChatMarkdown } from '../../utils/aiChatFormat
5
5
  import { OverlayScrollArea } from '../OverlayScrollArea';
6
6
  import { SparklesIcon } from '../SparklesIcon';
7
7
  import { AIProviderBar } from './AIProviderBar';
8
- import { SessionAskActions, SessionAskStatus, sessionAskErrorTone, type SessionAskAction } from './SessionAskNotice';
8
+ import { SessionAskActions, SessionAskNote, SessionAskStatus, sessionAskErrorTone, type SessionAskAction } from './SessionAskNotice';
9
9
  import { submitHint } from '../../utils/platform';
10
10
 
11
11
  interface DocumentAIChatPanelProps {
@@ -20,10 +20,8 @@ interface DocumentAIChatPanelProps {
20
20
  aiProviders?: AIProviderOption[];
21
21
  aiConfig?: { providerId: string | null; model: string | null; reasoningEffort?: string | null };
22
22
  onAIConfigChange?: (config: { providerId?: string | null; model?: string | null; reasoningEffort?: string | null }) => void;
23
- /** "Ask this session": the reviewer's choice on a busy/gone/blocked answer. */
23
+ /** "Ask this session": the reviewer's choice on a busy answer. */
24
24
  onSessionAskAction?: (questionId: string, action: SessionAskAction) => void;
25
- /** Label of the provider "Ask a separate AI instead" would use; null hides it. */
26
- sessionAskFallbackLabel?: string | null;
27
25
  }
28
26
 
29
27
  function truncate(text: string, max = 180): string {
@@ -70,7 +68,6 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
70
68
  aiConfig,
71
69
  onAIConfigChange,
72
70
  onSessionAskAction,
73
- sessionAskFallbackLabel,
74
71
  }) => {
75
72
  const scrollRef = useRef<HTMLDivElement>(null);
76
73
  const [generalInput, setGeneralInput] = useState('');
@@ -120,7 +117,6 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
120
117
  key={entry.question.id}
121
118
  entry={entry}
122
119
  onSessionAskAction={onSessionAskAction}
123
- sessionAskFallbackLabel={sessionAskFallbackLabel}
124
120
  />
125
121
  ))}
126
122
  </div>
@@ -167,8 +163,7 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
167
163
  const DocumentQAPair = memo<{
168
164
  entry: AIChatEntry;
169
165
  onSessionAskAction?: (questionId: string, action: SessionAskAction) => void;
170
- sessionAskFallbackLabel?: string | null;
171
- }>(({ entry, onSessionAskAction, sessionAskFallbackLabel }) => {
166
+ }>(({ entry, onSessionAskAction }) => {
172
167
  const { question, response } = entry;
173
168
  const renderedResponse = useMemo(
174
169
  () => response.text ? renderChatMarkdown(response.text) : null,
@@ -210,7 +205,6 @@ const DocumentQAPair = memo<{
210
205
  <p className={`text-xs ${sessionAskErrorTone(response)}`}>{response.error}</p>
211
206
  <SessionAskActions
212
207
  response={response}
213
- fallbackLabel={sessionAskFallbackLabel}
214
208
  onAction={onSessionAskAction ? (action) => onSessionAskAction(question.id, action) : undefined}
215
209
  />
216
210
  </>
@@ -226,6 +220,7 @@ const DocumentQAPair = memo<{
226
220
  <span className="ai-streaming-cursor" /> Thinking...
227
221
  </span>
228
222
  ) : null}
223
+ {!response.error && <SessionAskNote response={response} />}
229
224
  </div>
230
225
  </div>
231
226
  );