@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
package/utils/quickLabels.ts
CHANGED
|
@@ -32,8 +32,8 @@ export const LABEL_COLOR_MAP: Record<string, { bg: string; text: string; darkTex
|
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
* The hardcoded one-click positive label behind the
|
|
36
|
-
* the
|
|
35
|
+
* The hardcoded one-click positive label behind the 👍 "Looks good" buttons
|
|
36
|
+
* (the selection toolbar's and the HTML pinpoint composer's). Deliberately NOT part of the
|
|
37
37
|
* configurable set: it is the ONLY label comment-only surfaces (HTML /
|
|
38
38
|
* live-app) may emit — their restricted handlers filter on this id.
|
|
39
39
|
*/
|
|
@@ -76,6 +76,86 @@ export function resetQuickLabels(): void {
|
|
|
76
76
|
storage.removeItem(STORAGE_KEY);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The Alt/⌥ digit that applies the label at list position `index`: "1".."9"
|
|
81
|
+
* for the first nine, "0" for the tenth, null past it. Position is the whole
|
|
82
|
+
* contract — the pickers, the toolbar and the Settings key hints all read it
|
|
83
|
+
* from here, so reordering the list (Settings → Labels) is what remaps keys.
|
|
84
|
+
*/
|
|
85
|
+
export function quickLabelShortcutDigit(index: number): string | null {
|
|
86
|
+
if (!Number.isInteger(index) || index < 0 || index > 9) return null;
|
|
87
|
+
return index === 9 ? '0' : String(index + 1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Inverse of {@link quickLabelShortcutDigit}: list position for a digit key. */
|
|
91
|
+
export function quickLabelIndexForDigit(digit: number): number {
|
|
92
|
+
return digit === 0 ? 9 : digit - 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Return a copy of `labels` with the entry at `from` moved to `to`. The moved
|
|
97
|
+
* label keeps every field (emoji, text, colour, tip). Out-of-range indices
|
|
98
|
+
* return an unchanged copy.
|
|
99
|
+
*/
|
|
100
|
+
export function moveQuickLabel<T>(labels: readonly T[], from: number, to: number): T[] {
|
|
101
|
+
const next = labels.slice();
|
|
102
|
+
if (from === to || from < 0 || to < 0 || from >= next.length || to >= next.length) return next;
|
|
103
|
+
const [moved] = next.splice(from, 1);
|
|
104
|
+
next.splice(to, 0, moved);
|
|
105
|
+
return next;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Longest single emoji we accept (ZWJ family sequences run ~11 code points). */
|
|
109
|
+
const MAX_EMOJI_LENGTH = 32;
|
|
110
|
+
const PICTOGRAPHIC = /\p{Extended_Pictographic}/u;
|
|
111
|
+
const FLAG = /^\p{Regional_Indicator}{2}$/u;
|
|
112
|
+
const KEYCAP = /^[0-9#*]️?⃣$/u;
|
|
113
|
+
const WHITESPACE_OR_CONTROL = /[\s\p{Cc}]/u;
|
|
114
|
+
|
|
115
|
+
function splitGraphemes(value: string): string[] {
|
|
116
|
+
const Segmenter = (Intl as { Segmenter?: typeof Intl.Segmenter }).Segmenter;
|
|
117
|
+
if (Segmenter) {
|
|
118
|
+
return Array.from(new Segmenter(undefined, { granularity: 'grapheme' }).segment(value), (s) => s.segment);
|
|
119
|
+
}
|
|
120
|
+
// No grapheme segmentation: treat the whole string as one candidate and let
|
|
121
|
+
// the emoji check decide. Every browser Plannotator supports has it.
|
|
122
|
+
return value ? [value] : [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Validate a quick-label emoji: exactly one emoji grapheme (pictographic
|
|
127
|
+
* symbol, ZWJ sequence, skin-tone variant, flag or keycap), surrounding
|
|
128
|
+
* whitespace ignored. Returns the emoji, or null when the input is empty,
|
|
129
|
+
* more than one character, or not an emoji.
|
|
130
|
+
*/
|
|
131
|
+
export function parseQuickLabelEmoji(input: string): string | null {
|
|
132
|
+
const value = input.trim();
|
|
133
|
+
if (!value || value.length > MAX_EMOJI_LENGTH) return null;
|
|
134
|
+
const graphemes = splitGraphemes(value);
|
|
135
|
+
if (graphemes.length !== 1) return null;
|
|
136
|
+
const [g] = graphemes;
|
|
137
|
+
if (WHITESPACE_OR_CONTROL.test(g)) return null;
|
|
138
|
+
if (FLAG.test(g) || KEYCAP.test(g) || PICTOGRAPHIC.test(g)) return g;
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The emoji a person meant by editing an emoji field that held `current`.
|
|
144
|
+
* Accepts a field holding just the new emoji, and also the field after a new
|
|
145
|
+
* emoji was typed or picked BESIDE the old one (the caret was not on a
|
|
146
|
+
* selection): the old one is set aside once. Null when the field does not
|
|
147
|
+
* name exactly one emoji.
|
|
148
|
+
*/
|
|
149
|
+
export function emojiFromFieldInput(value: string, current: string): string | null {
|
|
150
|
+
const graphemes = splitGraphemes(value.trim()).filter((g) => !/^\s+$/u.test(g));
|
|
151
|
+
if (graphemes.length === 1) return parseQuickLabelEmoji(graphemes[0]);
|
|
152
|
+
if (graphemes.length === 2) {
|
|
153
|
+
const at = graphemes.indexOf(current);
|
|
154
|
+
if (at >= 0) return parseQuickLabelEmoji(graphemes[1 - at]);
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
79
159
|
/** Find a configured label whose "emoji text" matches an annotation's text field */
|
|
80
160
|
export function findLabelByText(annotationText: string): QuickLabel | undefined {
|
|
81
161
|
return getQuickLabels().find(l => `${l.emoji} ${l.text}` === annotationText);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client half of the stale-tab guard (packages/core/server-session.ts).
|
|
3
|
+
*
|
|
4
|
+
* The tab keeps the `serverSession` nonce the server advertised on its first
|
|
5
|
+
* payload (`/api/plan`, `/api/diff`) and echoes it on every decision it posts.
|
|
6
|
+
* A server that does not recognize it (a NEW Plannotator session that took
|
|
7
|
+
* over the same port) answers `409 { code: "session_mismatch" }`; the tab then
|
|
8
|
+
* shows the reload prompt (`ServerSessionReplacedBanner`) instead of treating
|
|
9
|
+
* the refusal as an ordinary failure.
|
|
10
|
+
*
|
|
11
|
+
* Module state on purpose: one tab talks to one server, and every decision
|
|
12
|
+
* path (plan approve/deny, annotate feedback/approve/exit, review
|
|
13
|
+
* feedback/exit) reads the same value without threading it through props.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useSyncExternalStore } from 'react';
|
|
17
|
+
import { SERVER_SESSION_FIELD, SERVER_SESSION_MISMATCH_CODE } from '@plannotator/core/server-session';
|
|
18
|
+
|
|
19
|
+
let nonce: string | null = null;
|
|
20
|
+
let replaced = false;
|
|
21
|
+
const listeners = new Set<() => void>();
|
|
22
|
+
|
|
23
|
+
/** Adopt the nonce a payload advertised (absent from an older server: no guard). */
|
|
24
|
+
export function adoptServerSession(value: unknown): void {
|
|
25
|
+
if (typeof value === 'string' && value) nonce = value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The nonce this tab holds, or null when the server never advertised one. */
|
|
29
|
+
export function currentServerSession(): string | null {
|
|
30
|
+
return nonce;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A decision body with the nonce added (unchanged when none is held). */
|
|
34
|
+
export function withServerSession<T extends object>(body: T): T & { serverSession?: string } {
|
|
35
|
+
return nonce ? { ...body, [SERVER_SESSION_FIELD]: nonce } : body;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A decision URL with the nonce as a query parameter (exit posts carry no body). */
|
|
39
|
+
export function withServerSessionQuery(url: string): string {
|
|
40
|
+
if (!nonce) return url;
|
|
41
|
+
return `${url}${url.includes('?') ? '&' : '?'}${SERVER_SESSION_FIELD}=${encodeURIComponent(nonce)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Whether a decision response is the server's session-mismatch refusal. Records it when it is. */
|
|
45
|
+
export async function noteServerSessionMismatch(res: Response): Promise<boolean> {
|
|
46
|
+
if (res.status !== 409) return false;
|
|
47
|
+
let code: unknown;
|
|
48
|
+
try {
|
|
49
|
+
code = ((await res.clone().json()) as { code?: unknown } | null)?.code;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
if (code !== SERVER_SESSION_MISMATCH_CODE) return false;
|
|
54
|
+
markServerSessionReplaced();
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function markServerSessionReplaced(): void {
|
|
59
|
+
if (replaced) return;
|
|
60
|
+
replaced = true;
|
|
61
|
+
for (const listener of listeners) listener();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isServerSessionReplaced(): boolean {
|
|
65
|
+
return replaced;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function subscribe(listener: () => void): () => void {
|
|
69
|
+
listeners.add(listener);
|
|
70
|
+
return () => {
|
|
71
|
+
listeners.delete(listener);
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** React: true once a decision was refused because this tab's server was replaced. */
|
|
76
|
+
export function useServerSessionReplaced(): boolean {
|
|
77
|
+
return useSyncExternalStore(subscribe, isServerSessionReplaced, isServerSessionReplaced);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Tests only: forget the nonce and the replaced flag. */
|
|
81
|
+
export function __resetServerSessionForTests(): void {
|
|
82
|
+
nonce = null;
|
|
83
|
+
replaced = false;
|
|
84
|
+
for (const listener of listeners) listener();
|
|
85
|
+
}
|