@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
@@ -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 toolbar's 👍 button and
36
- * the composer's "Looks good" action. Deliberately NOT part of 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
+ }