@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
|
@@ -477,8 +477,8 @@ export function useHtmlAnnotation({
|
|
|
477
477
|
flashDraftTarget: (key: string) => void;
|
|
478
478
|
/** Bumped after every target add/remove so the composer can refocus its textarea. */
|
|
479
479
|
composerFocusToken: number;
|
|
480
|
-
/** Composer one-click
|
|
481
|
-
*
|
|
480
|
+
/** Composer one-click 👍: submits the hardcoded positive label with the
|
|
481
|
+
* same anchor and multi-select targets a typed comment would carry. */
|
|
482
482
|
handleCommentLooksGood: () => void;
|
|
483
483
|
/** Ids this module minted for locally created annotations (create-mark),
|
|
484
484
|
* for the unanchored union: a minted id the host never listed is a
|
|
@@ -910,8 +910,14 @@ export function useHtmlAnnotation({
|
|
|
910
910
|
[getOrCreateAnchor],
|
|
911
911
|
);
|
|
912
912
|
|
|
913
|
-
|
|
914
|
-
|
|
913
|
+
// One commit path for everything the pinpoint composer submits (a typed
|
|
914
|
+
// comment or the one-click 👍), so both carry the same anchor, element
|
|
915
|
+
// context and multi-select targets.
|
|
916
|
+
const commitComposerDraft = useCallback(
|
|
917
|
+
(
|
|
918
|
+
body: Pick<Annotation, "text"> &
|
|
919
|
+
Partial<Pick<Annotation, "images" | "mentions" | "isQuickLabel" | "quickLabelTip">>,
|
|
920
|
+
) => {
|
|
915
921
|
if (!enabledRef.current) return;
|
|
916
922
|
// Prefer the text captured when the popover opened — it can't be clobbered by
|
|
917
923
|
// a later selection change or clear while the user is composing the comment.
|
|
@@ -939,14 +945,10 @@ export function useHtmlAnnotation({
|
|
|
939
945
|
startOffset: 0,
|
|
940
946
|
endOffset: 0,
|
|
941
947
|
type: AnnotationType.COMMENT,
|
|
942
|
-
text: comment,
|
|
943
948
|
originalText: text,
|
|
944
949
|
author: getIdentity(),
|
|
945
950
|
createdA: Date.now(),
|
|
946
|
-
|
|
947
|
-
// Host capability: present only when a mentionSource was supplied AND
|
|
948
|
-
// a token survived, so a comment without one is unchanged.
|
|
949
|
-
...(mentions && mentions.length > 0 ? { mentions } : {}),
|
|
951
|
+
...body,
|
|
950
952
|
htmlAnchor: pendingAnchorRef.current ?? undefined,
|
|
951
953
|
elementContext: pendingContextRef.current ?? undefined,
|
|
952
954
|
htmlAdditionalTargets: additionalTargets,
|
|
@@ -961,53 +963,32 @@ export function useHtmlAnnotation({
|
|
|
961
963
|
[post],
|
|
962
964
|
);
|
|
963
965
|
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
const additionalTargets: HtmlAnnotationTarget[] | undefined =
|
|
976
|
-
targets.length > 1
|
|
977
|
-
? targets.slice(1, 1 + maxTargetsRef.current).map((t) => ({
|
|
978
|
-
label: t.label,
|
|
979
|
-
text: t.text,
|
|
980
|
-
anchor: t.anchor ?? undefined,
|
|
981
|
-
...(t.context ? { context: t.context } : {}),
|
|
982
|
-
}))
|
|
983
|
-
: undefined;
|
|
984
|
-
|
|
985
|
-
const id = nextHtmlAnnId();
|
|
986
|
-
mintedIdsRef.current.add(id);
|
|
987
|
-
post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
|
|
988
|
-
onAddRef.current?.({
|
|
989
|
-
id,
|
|
990
|
-
blockId: "",
|
|
991
|
-
startOffset: 0,
|
|
992
|
-
endOffset: 0,
|
|
993
|
-
type: AnnotationType.COMMENT,
|
|
994
|
-
text: THUMBS_UP_LABEL.text,
|
|
995
|
-
originalText: text,
|
|
996
|
-
isQuickLabel: true,
|
|
997
|
-
quickLabelTip: THUMBS_UP_LABEL.tip,
|
|
998
|
-
author: getIdentity(),
|
|
999
|
-
createdA: Date.now(),
|
|
1000
|
-
htmlAnchor: pendingAnchorRef.current ?? undefined,
|
|
1001
|
-
elementContext: pendingContextRef.current ?? undefined,
|
|
1002
|
-
htmlAdditionalTargets: additionalTargets,
|
|
1003
|
-
});
|
|
966
|
+
const handleCommentSubmit = useCallback(
|
|
967
|
+
(comment: string, images?: ImageAttachment[], mentions?: readonly string[]) =>
|
|
968
|
+
commitComposerDraft({
|
|
969
|
+
text: comment,
|
|
970
|
+
images,
|
|
971
|
+
// Host capability: present only when a mentionSource was supplied AND
|
|
972
|
+
// a token survived, so a comment without one is unchanged.
|
|
973
|
+
...(mentions && mentions.length > 0 ? { mentions } : {}),
|
|
974
|
+
}),
|
|
975
|
+
[commitComposerDraft],
|
|
976
|
+
);
|
|
1004
977
|
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
978
|
+
// The composer's one-click 👍. A pinpoint click opens this composer
|
|
979
|
+
// directly and never shows the selection toolbar, so without it a reviewer
|
|
980
|
+
// could not mark an element "looks good" in one click. Emits the same
|
|
981
|
+
// hardcoded positive label as the toolbar's 👍 (the only label comment-only
|
|
982
|
+
// surfaces may create), on the pinned element(s).
|
|
983
|
+
const handleCommentLooksGood = useCallback(
|
|
984
|
+
() =>
|
|
985
|
+
commitComposerDraft({
|
|
986
|
+
text: THUMBS_UP_LABEL.text,
|
|
987
|
+
isQuickLabel: true,
|
|
988
|
+
quickLabelTip: THUMBS_UP_LABEL.tip,
|
|
989
|
+
}),
|
|
990
|
+
[commitComposerDraft],
|
|
991
|
+
);
|
|
1011
992
|
|
|
1012
993
|
const handleCommentClose = useCallback(() => {
|
|
1013
994
|
post({ type: `${PREFIX}cancel-selection` });
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import React, { useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import { isMac, isWindows, altKey } from '../../utils/platform';
|
|
3
|
+
import {
|
|
4
|
+
type QuickLabel,
|
|
5
|
+
DEFAULT_QUICK_LABELS,
|
|
6
|
+
LABEL_COLOR_MAP,
|
|
7
|
+
emojiFromFieldInput,
|
|
8
|
+
getLabelColors,
|
|
9
|
+
getQuickLabels,
|
|
10
|
+
moveQuickLabel,
|
|
11
|
+
quickLabelShortcutDigit,
|
|
12
|
+
resetQuickLabels,
|
|
13
|
+
saveQuickLabels,
|
|
14
|
+
} from '../../utils/quickLabels';
|
|
15
|
+
|
|
16
|
+
/** Most labels the tab lets a person keep (only the first ten get a key). */
|
|
17
|
+
const MAX_QUICK_LABELS = 12;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One editable row. `key` is a session-only identity, never persisted: the
|
|
21
|
+
* saved shape stays exactly `QuickLabel[]`. A label's `id` cannot serve here —
|
|
22
|
+
* it is re-derived from the text on every keystroke and two labels with the
|
|
23
|
+
* same text share it — and position cannot either, because moving a row must
|
|
24
|
+
* not move the open tip editor, an emoji draft, or focus onto a neighbour
|
|
25
|
+
* (the #829 bug class, #1736).
|
|
26
|
+
*/
|
|
27
|
+
interface QuickLabelRow {
|
|
28
|
+
key: string;
|
|
29
|
+
label: QuickLabel;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let rowKeySeq = 0;
|
|
33
|
+
const nextRowKey = () => `quick-label-row-${++rowKeySeq}`;
|
|
34
|
+
const toRows = (labels: readonly QuickLabel[]): QuickLabelRow[] =>
|
|
35
|
+
labels.map((label) => ({ key: nextRowKey(), label }));
|
|
36
|
+
|
|
37
|
+
const shortcutHint = (digit: string) => `${altKey}${isMac ? '' : '+'}${digit}`;
|
|
38
|
+
const shortcutWords = (digit: string) => `${isMac ? 'Option' : 'Alt'}+${digit}`;
|
|
39
|
+
const labelName = (label: QuickLabel) => label.text.trim() || 'label';
|
|
40
|
+
|
|
41
|
+
const EMOJI_HINT = isMac
|
|
42
|
+
? 'Enter one emoji. Control-Command-Space opens the emoji picker.'
|
|
43
|
+
: isWindows
|
|
44
|
+
? 'Enter one emoji. Windows+period opens the emoji picker.'
|
|
45
|
+
: 'Enter one emoji.';
|
|
46
|
+
|
|
47
|
+
const ChevronIcon: React.FC<{ up: boolean }> = ({ up }) => (
|
|
48
|
+
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
|
49
|
+
<path strokeLinecap="round" strokeLinejoin="round" d={up ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7'} />
|
|
50
|
+
</svg>
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
/** Settings → Labels: the Quick Labels editor (plan review and annotate). */
|
|
54
|
+
export const QuickLabelsTab: React.FC = () => {
|
|
55
|
+
const [rows, setRows] = useState<QuickLabelRow[]>(() => toRows(getQuickLabels()));
|
|
56
|
+
const [editingTipKey, setEditingTipKey] = useState<string | null>(null);
|
|
57
|
+
const [editingTipValue, setEditingTipValue] = useState('');
|
|
58
|
+
/** Emoji fields whose current text is not (yet) one emoji, by row key. */
|
|
59
|
+
const [emojiDrafts, setEmojiDrafts] = useState<Record<string, string>>({});
|
|
60
|
+
const [announcement, setAnnouncement] = useState('');
|
|
61
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
62
|
+
const pendingFocus = useRef<{ key: string; target: 'up' | 'down' | 'text' } | null>(null);
|
|
63
|
+
|
|
64
|
+
const commit = (next: QuickLabelRow[]) => {
|
|
65
|
+
setRows(next);
|
|
66
|
+
saveQuickLabels(next.map((row) => row.label));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const updateLabel = (key: string, patch: Partial<QuickLabel>) => {
|
|
70
|
+
commit(rows.map((row) => (row.key === key ? { ...row, label: { ...row.label, ...patch } } : row)));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const setEmojiDraft = (key: string, value: string | null) => {
|
|
74
|
+
setEmojiDrafts((drafts) => {
|
|
75
|
+
if (value === null) {
|
|
76
|
+
if (!(key in drafts)) return drafts;
|
|
77
|
+
const { [key]: _dropped, ...rest } = drafts;
|
|
78
|
+
return rest;
|
|
79
|
+
}
|
|
80
|
+
return { ...drafts, [key]: value };
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const move = (index: number, delta: -1 | 1) => {
|
|
85
|
+
const to = index + delta;
|
|
86
|
+
if (to < 0 || to >= rows.length) return;
|
|
87
|
+
const row = rows[index];
|
|
88
|
+
commit(moveQuickLabel(rows, index, to));
|
|
89
|
+
pendingFocus.current = { key: row.key, target: delta < 0 ? 'up' : 'down' };
|
|
90
|
+
const digit = quickLabelShortcutDigit(to);
|
|
91
|
+
setAnnouncement(
|
|
92
|
+
`Moved ${labelName(row.label)} to position ${to + 1} of ${rows.length}, ${digit ? shortcutWords(digit) : 'no shortcut key'}.`,
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Moving a row re-orders DOM nodes, which can drop focus from the button
|
|
97
|
+
// that was pressed; put it back on the same row's button so repeated
|
|
98
|
+
// presses keep walking the same label. At either end the pressed button is
|
|
99
|
+
// disabled, so focus goes to the other direction instead.
|
|
100
|
+
useLayoutEffect(() => {
|
|
101
|
+
const pending = pendingFocus.current;
|
|
102
|
+
if (!pending) return;
|
|
103
|
+
pendingFocus.current = null;
|
|
104
|
+
const row = listRef.current?.querySelector<HTMLElement>(`[data-quick-label-row="${pending.key}"]`);
|
|
105
|
+
if (!row) return;
|
|
106
|
+
if (pending.target === 'text') {
|
|
107
|
+
const input = row.querySelector<HTMLInputElement>('[data-quick-label-text]');
|
|
108
|
+
input?.focus();
|
|
109
|
+
input?.select();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const other = pending.target === 'up' ? 'down' : 'up';
|
|
113
|
+
const wanted = row.querySelector<HTMLButtonElement>(`[data-quick-label-move="${pending.target}"]`);
|
|
114
|
+
const fallback = row.querySelector<HTMLButtonElement>(`[data-quick-label-move="${other}"]`);
|
|
115
|
+
(wanted && !wanted.disabled ? wanted : fallback)?.focus();
|
|
116
|
+
}, [rows]);
|
|
117
|
+
|
|
118
|
+
const saveTip = (key: string) => {
|
|
119
|
+
updateLabel(key, { tip: editingTipValue || undefined });
|
|
120
|
+
setEditingTipKey(null);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<>
|
|
125
|
+
<div className="flex items-center justify-between">
|
|
126
|
+
<div>
|
|
127
|
+
<div className="text-sm font-medium flex items-center gap-2">Quick Labels</div>
|
|
128
|
+
<div className="text-xs text-muted-foreground">
|
|
129
|
+
Preset annotations for one-click feedback
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
<button
|
|
133
|
+
onClick={() => {
|
|
134
|
+
resetQuickLabels();
|
|
135
|
+
setRows(toRows(DEFAULT_QUICK_LABELS));
|
|
136
|
+
setEditingTipKey(null);
|
|
137
|
+
setEmojiDrafts({});
|
|
138
|
+
}}
|
|
139
|
+
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
|
140
|
+
>
|
|
141
|
+
Reset to defaults
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<style>{`
|
|
146
|
+
@keyframes tip-slide-open {
|
|
147
|
+
from { opacity: 0; transform: translateY(-4px); }
|
|
148
|
+
to { opacity: 1; transform: translateY(0); }
|
|
149
|
+
}
|
|
150
|
+
`}</style>
|
|
151
|
+
<div ref={listRef} className="space-y-1.5">
|
|
152
|
+
{rows.map((row, index) => {
|
|
153
|
+
const { key, label } = row;
|
|
154
|
+
const colors = getLabelColors(label.color);
|
|
155
|
+
const hasTip = !!label.tip;
|
|
156
|
+
const isEditingTip = editingTipKey === key;
|
|
157
|
+
const digit = quickLabelShortcutDigit(index);
|
|
158
|
+
const name = labelName(label);
|
|
159
|
+
const emojiDraft = emojiDrafts[key];
|
|
160
|
+
const emojiInvalid = emojiDraft !== undefined;
|
|
161
|
+
const emojiErrorId = `${key}-emoji-error`;
|
|
162
|
+
return (
|
|
163
|
+
<div
|
|
164
|
+
key={key}
|
|
165
|
+
data-quick-label-row={key}
|
|
166
|
+
className="rounded-lg overflow-hidden"
|
|
167
|
+
style={{ backgroundColor: colors.bg }}
|
|
168
|
+
>
|
|
169
|
+
{/* Main row. On a narrow dialog the controls after the text
|
|
170
|
+
field wrap onto a second line instead of being clipped by
|
|
171
|
+
the row's overflow-hidden. */}
|
|
172
|
+
<div className="flex flex-wrap items-center gap-2 p-2">
|
|
173
|
+
<input
|
|
174
|
+
type="text"
|
|
175
|
+
data-quick-label-emoji
|
|
176
|
+
value={emojiDraft ?? label.emoji}
|
|
177
|
+
aria-label={`Emoji for ${name}`}
|
|
178
|
+
aria-invalid={emojiInvalid || undefined}
|
|
179
|
+
aria-describedby={emojiInvalid ? emojiErrorId : undefined}
|
|
180
|
+
title="Change emoji"
|
|
181
|
+
autoComplete="off"
|
|
182
|
+
spellCheck={false}
|
|
183
|
+
onFocus={(e) => e.target.select()}
|
|
184
|
+
onChange={(e) => {
|
|
185
|
+
const picked = emojiFromFieldInput(e.target.value, label.emoji);
|
|
186
|
+
if (!picked) {
|
|
187
|
+
setEmojiDraft(key, e.target.value);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
setEmojiDraft(key, null);
|
|
191
|
+
if (picked !== label.emoji) updateLabel(key, { emoji: picked });
|
|
192
|
+
}}
|
|
193
|
+
onBlur={() => setEmojiDraft(key, null)}
|
|
194
|
+
onKeyDown={(e) => {
|
|
195
|
+
if (e.key === 'Escape' && emojiInvalid) {
|
|
196
|
+
e.preventDefault();
|
|
197
|
+
e.stopPropagation();
|
|
198
|
+
setEmojiDraft(key, null);
|
|
199
|
+
}
|
|
200
|
+
}}
|
|
201
|
+
className="w-8 py-1 bg-background/80 rounded text-sm text-center leading-none focus:outline-none focus:ring-1 focus:ring-primary/50 flex-shrink-0"
|
|
202
|
+
style={emojiInvalid ? { boxShadow: '0 0 0 1px var(--destructive)' } : undefined}
|
|
203
|
+
/>
|
|
204
|
+
<input
|
|
205
|
+
type="text"
|
|
206
|
+
data-quick-label-text
|
|
207
|
+
value={label.text}
|
|
208
|
+
aria-label={`Label text, position ${index + 1}`}
|
|
209
|
+
onChange={(e) => {
|
|
210
|
+
updateLabel(key, {
|
|
211
|
+
text: e.target.value,
|
|
212
|
+
id: e.target.value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''),
|
|
213
|
+
});
|
|
214
|
+
}}
|
|
215
|
+
className="flex-1 min-w-0 px-2 py-1 bg-background/80 rounded text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
216
|
+
// The basis is what makes the controls wrap: a row that
|
|
217
|
+
// cannot give the text this much room moves them below.
|
|
218
|
+
style={{ flexBasis: '8rem' }}
|
|
219
|
+
/>
|
|
220
|
+
<div className="flex items-center gap-2 ml-auto">
|
|
221
|
+
{/* Tip indicator button */}
|
|
222
|
+
<button
|
|
223
|
+
data-quick-label-tip-toggle
|
|
224
|
+
onClick={() => {
|
|
225
|
+
if (isEditingTip) {
|
|
226
|
+
setEditingTipKey(null);
|
|
227
|
+
} else {
|
|
228
|
+
setEditingTipKey(key);
|
|
229
|
+
setEditingTipValue(label.tip || '');
|
|
230
|
+
}
|
|
231
|
+
}}
|
|
232
|
+
className={`relative p-1 rounded transition-all flex-shrink-0 ${
|
|
233
|
+
hasTip
|
|
234
|
+
? 'bg-foreground/10 text-foreground/70 hover:text-foreground border border-foreground/15'
|
|
235
|
+
: 'text-muted-foreground/30 hover:text-muted-foreground/60 border border-dashed border-muted-foreground/20 hover:border-muted-foreground/40'
|
|
236
|
+
}`}
|
|
237
|
+
title={hasTip ? `Tip: ${label.tip}` : 'Add AI instruction tip'}
|
|
238
|
+
>
|
|
239
|
+
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
240
|
+
<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" />
|
|
241
|
+
</svg>
|
|
242
|
+
{hasTip && (
|
|
243
|
+
<span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-foreground/50" />
|
|
244
|
+
)}
|
|
245
|
+
</button>
|
|
246
|
+
<select
|
|
247
|
+
value={label.color}
|
|
248
|
+
aria-label={`Colour for ${name}`}
|
|
249
|
+
onChange={(e) => updateLabel(key, { color: e.target.value })}
|
|
250
|
+
className="px-1.5 py-1 bg-background/80 rounded text-[10px] focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
251
|
+
>
|
|
252
|
+
{Object.keys(LABEL_COLOR_MAP).map(c => (
|
|
253
|
+
<option key={c} value={c}>{c}</option>
|
|
254
|
+
))}
|
|
255
|
+
</select>
|
|
256
|
+
<span
|
|
257
|
+
data-quick-label-shortcut
|
|
258
|
+
// Hidden below sm: touch devices that narrow rarely have an Alt key.
|
|
259
|
+
className="hidden sm:block text-[10px] text-muted-foreground/50 font-mono w-8 text-center flex-shrink-0"
|
|
260
|
+
>
|
|
261
|
+
{digit ? shortcutHint(digit) : ''}
|
|
262
|
+
</span>
|
|
263
|
+
{/* Reorder: position decides which Alt/⌥ digit applies the label. */}
|
|
264
|
+
<button
|
|
265
|
+
type="button"
|
|
266
|
+
data-quick-label-move="up"
|
|
267
|
+
disabled={index === 0}
|
|
268
|
+
onClick={() => move(index, -1)}
|
|
269
|
+
aria-label={`Move ${name} up`}
|
|
270
|
+
title="Move up"
|
|
271
|
+
className="p-1 rounded text-muted-foreground hover:text-foreground transition-colors flex-shrink-0 disabled:opacity-30 disabled:pointer-events-none"
|
|
272
|
+
>
|
|
273
|
+
<ChevronIcon up />
|
|
274
|
+
</button>
|
|
275
|
+
<button
|
|
276
|
+
type="button"
|
|
277
|
+
data-quick-label-move="down"
|
|
278
|
+
disabled={index === rows.length - 1}
|
|
279
|
+
onClick={() => move(index, 1)}
|
|
280
|
+
aria-label={`Move ${name} down`}
|
|
281
|
+
title="Move down"
|
|
282
|
+
className="p-1 rounded text-muted-foreground hover:text-foreground transition-colors flex-shrink-0 disabled:opacity-30 disabled:pointer-events-none"
|
|
283
|
+
>
|
|
284
|
+
<ChevronIcon up={false} />
|
|
285
|
+
</button>
|
|
286
|
+
<button
|
|
287
|
+
onClick={() => {
|
|
288
|
+
commit(rows.filter((r) => r.key !== key));
|
|
289
|
+
setEmojiDraft(key, null);
|
|
290
|
+
if (editingTipKey === key) setEditingTipKey(null);
|
|
291
|
+
}}
|
|
292
|
+
aria-label={`Remove ${name}`}
|
|
293
|
+
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors flex-shrink-0"
|
|
294
|
+
title="Remove label"
|
|
295
|
+
>
|
|
296
|
+
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
297
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
298
|
+
</svg>
|
|
299
|
+
</button>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
{emojiInvalid && (
|
|
303
|
+
<div id={emojiErrorId} className="px-2 pb-2 text-[10px] text-destructive">
|
|
304
|
+
{EMOJI_HINT}
|
|
305
|
+
</div>
|
|
306
|
+
)}
|
|
307
|
+
{/* Tip editor — slides open below the row */}
|
|
308
|
+
{isEditingTip && (
|
|
309
|
+
<div
|
|
310
|
+
className="flex items-center gap-1.5 px-2 pb-2 pt-0"
|
|
311
|
+
style={{ animation: 'tip-slide-open 0.15s ease-out' }}
|
|
312
|
+
>
|
|
313
|
+
<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}>
|
|
314
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
|
315
|
+
</svg>
|
|
316
|
+
<input
|
|
317
|
+
type="text"
|
|
318
|
+
data-quick-label-tip
|
|
319
|
+
value={editingTipValue}
|
|
320
|
+
aria-label={`AI instruction tip for ${name}`}
|
|
321
|
+
onChange={(e) => setEditingTipValue(e.target.value)}
|
|
322
|
+
onKeyDown={(e) => {
|
|
323
|
+
if (e.key === 'Enter') saveTip(key);
|
|
324
|
+
if (e.key === 'Escape') {
|
|
325
|
+
e.preventDefault();
|
|
326
|
+
e.stopPropagation();
|
|
327
|
+
setEditingTipKey(null);
|
|
328
|
+
}
|
|
329
|
+
}}
|
|
330
|
+
placeholder="AI instruction tip..."
|
|
331
|
+
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"
|
|
332
|
+
autoFocus
|
|
333
|
+
onFocus={(e) => { e.target.setSelectionRange(0, 0); e.target.scrollLeft = 0; }}
|
|
334
|
+
/>
|
|
335
|
+
<button
|
|
336
|
+
onClick={() => saveTip(key)}
|
|
337
|
+
className="p-1 rounded text-muted-foreground/50 hover:text-green-500 hover:bg-green-500/10 transition-colors flex-shrink-0"
|
|
338
|
+
title="Save tip"
|
|
339
|
+
>
|
|
340
|
+
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
341
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
342
|
+
</svg>
|
|
343
|
+
</button>
|
|
344
|
+
</div>
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
})}
|
|
349
|
+
</div>
|
|
350
|
+
<div role="status" aria-live="polite" className="sr-only">{announcement}</div>
|
|
351
|
+
|
|
352
|
+
{rows.length < MAX_QUICK_LABELS && (
|
|
353
|
+
<button
|
|
354
|
+
onClick={() => {
|
|
355
|
+
const row: QuickLabelRow = {
|
|
356
|
+
key: nextRowKey(),
|
|
357
|
+
label: {
|
|
358
|
+
id: `custom-${Date.now()}`,
|
|
359
|
+
emoji: '📌',
|
|
360
|
+
text: 'New label',
|
|
361
|
+
color: 'blue',
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
commit([...rows, row]);
|
|
365
|
+
pendingFocus.current = { key: row.key, target: 'text' };
|
|
366
|
+
}}
|
|
367
|
+
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"
|
|
368
|
+
>
|
|
369
|
+
+ Add label
|
|
370
|
+
</button>
|
|
371
|
+
)}
|
|
372
|
+
|
|
373
|
+
<div className="text-[10px] text-muted-foreground/70">
|
|
374
|
+
Use {shortcutHint('1')} through {shortcutHint('0')} when the annotation toolbar is visible to apply a label instantly. The order of the list decides the key; use the arrows to move a label, and click its emoji to change it.
|
|
375
|
+
</div>
|
|
376
|
+
</>
|
|
377
|
+
);
|
|
378
|
+
};
|
package/hooks/useAIChat.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
2
2
|
import type { AIContext } from '@plannotator/core';
|
|
3
3
|
import type { AIQuestion, AIResponse } from '../types';
|
|
4
4
|
import { generateId } from '../utils/generateId';
|
|
5
|
+
import { SESSION_ASK_ERROR_CODES } from '../utils/aiProvider';
|
|
5
6
|
|
|
6
7
|
export interface AIChatEntry {
|
|
7
8
|
question: AIQuestion;
|
|
@@ -45,8 +46,33 @@ export interface AskAIParams {
|
|
|
45
46
|
/** "Ask this session" only: what to do when the host session is busy
|
|
46
47
|
* ("wait" = ask when it finishes, "interrupt" = stop its turn and ask now). */
|
|
47
48
|
busyPolicy?: 'wait' | 'interrupt';
|
|
49
|
+
/** "Ask this session" only: the reviewer's CURRENT unsubmitted annotations
|
|
50
|
+
* as a plain list (`formatDraftAnnotationsForAsk`; '' when there are none).
|
|
51
|
+
* The hook sends it only when it differs from what this session last
|
|
52
|
+
* received, and the server frames it as read-only context, never as
|
|
53
|
+
* feedback (#1748). Leave unset for every other provider. */
|
|
54
|
+
draftAnnotations?: string;
|
|
48
55
|
}
|
|
49
56
|
|
|
57
|
+
/** What to send as `draftAnnotations` for one question: the list when this
|
|
58
|
+
* session has not received it yet, '' once to clear drafts it saw earlier,
|
|
59
|
+
* nothing when it already has the current list. */
|
|
60
|
+
export function draftAnnotationsToSend(
|
|
61
|
+
delivered: { sessionId: string; text: string } | null,
|
|
62
|
+
sessionId: string,
|
|
63
|
+
current: string | undefined,
|
|
64
|
+
): string | undefined {
|
|
65
|
+
if (current === undefined) return undefined;
|
|
66
|
+
const seen = delivered?.sessionId === sessionId ? delivered.text : '';
|
|
67
|
+
return current === seen ? undefined : current;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `draftAnnotationsToSend` key for "Ask this session": one host session per page. */
|
|
71
|
+
const BRIDGE_DRAFTS_KEY = 'session-bridge';
|
|
72
|
+
|
|
73
|
+
/** Stream messages that show the session received the question. */
|
|
74
|
+
const DELIVERED_MESSAGE_TYPES = new Set(['text_delta', 'text', 'tool_use', 'tool_result', 'result']);
|
|
75
|
+
|
|
50
76
|
/** Overrides for re-asking a question that ended in a busy/gone/blocked answer. */
|
|
51
77
|
export interface AIRetryOptions {
|
|
52
78
|
busyPolicy?: 'wait' | 'interrupt';
|
|
@@ -65,6 +91,16 @@ interface UseAIChatOptions {
|
|
|
65
91
|
threadTitle?: string;
|
|
66
92
|
}
|
|
67
93
|
|
|
94
|
+
/** `line 41` / `lines 41–44` (the export's spelling) for a selection's
|
|
95
|
+
* source lines; `null` when the scope carries no valid line. */
|
|
96
|
+
function selectionLinesLabel(lineStart: unknown, lineEnd: unknown): string | null {
|
|
97
|
+
if (typeof lineStart !== 'number' || !Number.isInteger(lineStart) || lineStart < 1) return null;
|
|
98
|
+
const end = typeof lineEnd === 'number' && Number.isInteger(lineEnd) && lineEnd > lineStart
|
|
99
|
+
? lineEnd
|
|
100
|
+
: lineStart;
|
|
101
|
+
return end === lineStart ? `line ${lineStart}` : `lines ${lineStart}–${end}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
68
104
|
export function buildDefaultPrompt(params: AskAIParams): string {
|
|
69
105
|
// The "changes under review" context (and any other preamble) leads the
|
|
70
106
|
// message, so the agent is oriented to the live view before the question and
|
|
@@ -89,9 +125,12 @@ export function buildDefaultPrompt(params: AskAIParams): string {
|
|
|
89
125
|
|
|
90
126
|
if (params.scope?.kind === 'selection') {
|
|
91
127
|
const label = params.scope.label ? `Re: ${params.scope.label}` : 'Re: selected text';
|
|
92
|
-
const
|
|
128
|
+
const lines = selectionLinesLabel(params.scope.lineStart, params.scope.lineEnd);
|
|
129
|
+
const sourceParts = [params.scope.sourcePath, lines].filter(Boolean);
|
|
130
|
+
const source = sourceParts.length > 0 ? `\nSource: ${sourceParts.join(', ')}` : '';
|
|
93
131
|
const selection = params.scope.text ? `\n\nSelected text:\n\`\`\`\n${params.scope.text}\n\`\`\`` : '';
|
|
94
|
-
|
|
132
|
+
const detail = params.scope.detail?.trim() ? `\n\nSelected element:\n${params.scope.detail.trim()}` : '';
|
|
133
|
+
return `${label}${source}${selection}${detail}\n\n${params.prompt}`;
|
|
95
134
|
}
|
|
96
135
|
|
|
97
136
|
// General question (no explicit file/line/selection): tell the agent what the
|
|
@@ -289,9 +328,17 @@ export function useAIChat({
|
|
|
289
328
|
return safeAbort(sessionId);
|
|
290
329
|
}, []);
|
|
291
330
|
|
|
292
|
-
// The params each question was asked with, so a busy
|
|
293
|
-
// re-asked ("Ask when it finishes", "
|
|
331
|
+
// The params each question was asked with, so a busy answer can be
|
|
332
|
+
// re-asked ("Ask when it finishes", "Interrupt and ask now").
|
|
294
333
|
const paramsByQuestionRef = useRef(new Map<string, AskAIParams>());
|
|
334
|
+
// The draft list the agent session last RECEIVED, so an unchanged list is
|
|
335
|
+
// not pasted into it again on every question. Kept for the page, not per
|
|
336
|
+
// Plannotator AI session: every Ask AI session (a new thread, a document
|
|
337
|
+
// switch in a folder or bundle) reaches the SAME host agent session through
|
|
338
|
+
// the bridge, which keeps what it already read. Per AI session, a new thread
|
|
339
|
+
// re-sent the whole list, and drafts cleared after one never reached the
|
|
340
|
+
// session as cleared.
|
|
341
|
+
const deliveredDraftsRef = useRef<{ sessionId: string; text: string } | null>(null);
|
|
295
342
|
|
|
296
343
|
const ask = useCallback(async (params: AskAIParams, askOptions?: { providerId?: string }) => {
|
|
297
344
|
if (abortRef.current) {
|
|
@@ -350,11 +397,21 @@ export function useAIChat({
|
|
|
350
397
|
}
|
|
351
398
|
|
|
352
399
|
const fullPrompt = buildPrompt(params);
|
|
400
|
+
const drafts = draftAnnotationsToSend(deliveredDraftsRef.current, BRIDGE_DRAFTS_KEY, params.draftAnnotations);
|
|
401
|
+
// Recorded once the session shows it received the question, so a busy,
|
|
402
|
+
// gone or failed ask sends the list again next time.
|
|
403
|
+
const markDraftsDelivered = (msg: { type?: unknown; code?: unknown }) => {
|
|
404
|
+
if (drafts === undefined) return;
|
|
405
|
+
const received = DELIVERED_MESSAGE_TYPES.has(String(msg.type))
|
|
406
|
+
|| (msg.type === 'error' && msg.code === SESSION_ASK_ERROR_CODES.takenOver);
|
|
407
|
+
if (received) deliveredDraftsRef.current = { sessionId: BRIDGE_DRAFTS_KEY, text: drafts };
|
|
408
|
+
};
|
|
353
409
|
const res = await aiTransport.query({
|
|
354
410
|
sessionId: sid,
|
|
355
411
|
prompt: fullPrompt,
|
|
356
412
|
...(params.contextUpdate && { contextUpdate: params.contextUpdate }),
|
|
357
413
|
...(params.busyPolicy && { busyPolicy: params.busyPolicy }),
|
|
414
|
+
...(drafts !== undefined && { draftAnnotations: drafts }),
|
|
358
415
|
}, controller.signal);
|
|
359
416
|
|
|
360
417
|
if (!res.ok || !res.body) {
|
|
@@ -381,6 +438,7 @@ export function useAIChat({
|
|
|
381
438
|
|
|
382
439
|
try {
|
|
383
440
|
const msg = JSON.parse(data);
|
|
441
|
+
markDraftsDelivered(msg);
|
|
384
442
|
|
|
385
443
|
if (msg.type === 'text_delta') {
|
|
386
444
|
updateMessages(prev =>
|
|
@@ -420,6 +478,25 @@ export function useAIChat({
|
|
|
420
478
|
description: msg.description,
|
|
421
479
|
toolUseId: msg.toolUseId,
|
|
422
480
|
}]);
|
|
481
|
+
} else if (msg.type === 'error' && msg.code === SESSION_ASK_ERROR_CODES.takenOver) {
|
|
482
|
+
// "Ask this session": the person typed into the session while it
|
|
483
|
+
// answered. The answer so far stands; the reason is a note under
|
|
484
|
+
// it, not an error that would replace it.
|
|
485
|
+
updateMessages(prev =>
|
|
486
|
+
prev.map(m =>
|
|
487
|
+
m.question.id === questionId
|
|
488
|
+
? {
|
|
489
|
+
...m,
|
|
490
|
+
response: {
|
|
491
|
+
...m.response,
|
|
492
|
+
notice: typeof msg.error === 'string' ? msg.error : undefined,
|
|
493
|
+
...(m.response.status && { status: undefined }),
|
|
494
|
+
isStreaming: false,
|
|
495
|
+
},
|
|
496
|
+
}
|
|
497
|
+
: m
|
|
498
|
+
)
|
|
499
|
+
);
|
|
423
500
|
} else if (msg.type === 'error') {
|
|
424
501
|
updateMessages(prev =>
|
|
425
502
|
prev.map(m =>
|
|
@@ -498,8 +575,8 @@ export function useAIChat({
|
|
|
498
575
|
|
|
499
576
|
/**
|
|
500
577
|
* Re-ask an earlier question in place of its answer: the busy-session choice
|
|
501
|
-
* ("Ask when it finishes" / "Interrupt and ask now")
|
|
502
|
-
*
|
|
578
|
+
* ("Ask when it finishes" / "Interrupt and ask now"). `providerId` re-asks
|
|
579
|
+
* on another provider for hosts that offer one.
|
|
503
580
|
*/
|
|
504
581
|
const retry = useCallback((questionId: string, options: AIRetryOptions = {}) => {
|
|
505
582
|
const original = paramsByQuestionRef.current.get(questionId);
|