@plannotator/ui 0.50.0 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HANDOFF.md +295 -11
- package/README.md +8 -0
- package/components/AISettingsTab.tsx +32 -0
- package/components/AgentToolAnnouncementDialog.tsx +347 -0
- package/components/AgentsTab.tsx +9 -9
- package/components/AnnotationToolbar.tsx +2 -2
- package/components/AskSessionAnnouncementDialog.tsx +329 -0
- package/components/BlockRenderer.tsx +10 -1
- package/components/CommentPopover.tsx +36 -18
- package/components/CompletionOverlay.tsx +12 -5
- package/components/DecisionControl.tsx +2 -7
- package/components/DiagramBlock.tsx +46 -4
- package/components/DocBadges.tsx +12 -8
- package/components/FloatingQuickLabelPicker.tsx +2 -2
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/QuickLabelDropdown.tsx +9 -4
- package/components/ServerSessionReplacedBanner.tsx +29 -0
- package/components/Settings.tsx +76 -192
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/Viewer.tsx +30 -1
- package/components/ai/AIProviderBar.tsx +18 -1
- package/components/ai/DocumentAIChatPanel.tsx +4 -9
- package/components/ai/SessionAskNotice.tsx +33 -21
- package/components/blocks/QuestionBlock.tsx +99 -27
- package/components/blocks/QuestionContext.tsx +81 -0
- package/components/diagram/DiagramComposer.tsx +54 -3
- package/components/diagram/DiagramViewer.tsx +31 -2
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +13 -3
- package/components/html-viewer/bridge-script.asset.js +230 -14
- package/components/html-viewer/bridge-script.ts +230 -14
- package/components/html-viewer/useHtmlAnnotation.ts +36 -55
- package/components/settings/QuickLabelsTab.tsx +378 -0
- package/hooks/useAIChat.ts +83 -6
- package/hooks/useAIProviderActivation.ts +13 -0
- package/hooks/useAIProviderConfig.ts +3 -3
- package/hooks/useActiveSection.ts +3 -2
- package/hooks/useAgentToolSetting.ts +33 -0
- package/hooks/useAnnotationDraft.ts +53 -7
- package/hooks/useAnnotationHighlighter.ts +129 -5
- package/hooks/useExternalAnnotations.ts +21 -1
- package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
- package/hooks/useLatchedTrue.ts +15 -0
- package/hooks/useLinkedDoc.ts +64 -4
- package/hooks/useModelCatalogs.ts +9 -2
- package/package.json +2 -2
- package/styles.css +1 -1
- package/themes/gruvbox.css +6 -0
- package/types.ts +19 -0
- package/utils/agentClosed.ts +33 -0
- package/utils/agentToolAnnouncement.ts +109 -0
- package/utils/agentToolSetting.ts +108 -0
- package/utils/aiProvider.ts +13 -40
- package/utils/annotationOwnsHighlight.ts +27 -0
- package/utils/annotationScope.ts +15 -0
- package/utils/askSessionAnnouncement.ts +107 -0
- package/utils/blockTargeting.ts +61 -2
- package/utils/diagramAskAI.ts +161 -0
- package/utils/parser.ts +382 -114
- package/utils/questionAnswers.ts +3 -1
- package/utils/quickLabels.ts +82 -2
- package/utils/serverSession.ts +85 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
+
};
|
package/components/Settings.tsx
CHANGED
|
@@ -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 {
|
|
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
|
package/components/Viewer.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
);
|