@plannotator/ui 0.51.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/types.ts CHANGED
@@ -453,6 +453,12 @@ export interface AIQuestion {
453
453
  /** Agent-facing identity of the selected element(s) (raw-HTML / live-app
454
454
  * pinpoints): sent with the question, never shown in the chat. */
455
455
  detail?: string;
456
+ /** 1-based source lines of a text selection in a markdown document (the
457
+ * enclosing block's span, or first to last block for a selection over
458
+ * several). `buildDefaultPrompt` appends them to the `Source:` line
459
+ * (`, line 41` / `, lines 41–44`). Optional: absent prints no lines. */
460
+ lineStart?: number;
461
+ lineEnd?: number;
456
462
  };
457
463
  /** undefined = general question (no file scope) */
458
464
  filePath?: string;
package/utils/parser.ts CHANGED
@@ -4,6 +4,7 @@ import { resolveReplyParents } from '@plannotator/core/annotation-threads';
4
4
  import { diagramAnchorLocationLine, parseDiagramAnchor } from '@plannotator/core/diagram-anchor';
5
5
  import {
6
6
  formatQuestionAnswerLines,
7
+ formatQuestionAnswerText,
7
8
  formatQuestionAnswersSection,
8
9
  indexQuestionBlocks,
9
10
  parseQuestionAnswer,
@@ -774,14 +775,19 @@ const isElementPlaceholderQuote = (text: unknown): boolean =>
774
775
  * element instead (`Feedback on the <nav> element — "Primary"`); every other
775
776
  * annotation keeps the quote line exactly as before. */
776
777
  const commentHeadingLine = (ann: any): string => {
778
+ const element = elementSubject(ann);
779
+ return element ? `Feedback on ${element}` : `Feedback on: "${ann.originalText}"`;
780
+ };
781
+
782
+ /** `the <nav> element — "Primary"` for a text-less pinpoint whose quote is
783
+ * the bridge's placeholder; null for every other annotation. */
784
+ const elementSubject = (ann: any): string | null => {
777
785
  const context = ann?.elementContext;
778
- if (context && typeof context.tag === 'string' && isElementPlaceholderQuote(ann.originalText)) {
779
- const tag = safeInline(context.tag, 32);
780
- const name = context.name ? safeInline(context.name, 120) : '';
781
- const file = elementSourceFileName(context);
782
- return `Feedback on the <${tag}> element${name ? ` — "${name}"` : ''}${file && file !== name ? ` (${file})` : ''}`;
783
- }
784
- return `Feedback on: "${ann.originalText}"`;
786
+ if (!context || typeof context.tag !== 'string' || !isElementPlaceholderQuote(ann.originalText)) return null;
787
+ const tag = safeInline(context.tag, 32);
788
+ const name = context.name ? safeInline(context.name, 120) : '';
789
+ const file = elementSourceFileName(context);
790
+ return `the <${tag}> element${name ? ` — "${name}"` : ''}${file && file !== name ? ` (${file})` : ''}`;
785
791
  };
786
792
 
787
793
  /** The file a media element shows, so two nameless images read differently in a heading:
@@ -1031,6 +1037,29 @@ const lineLabelForAnnotation = (blocks: Block[], ann: any): string | null => {
1031
1037
  return `lines ${block.startLine}–${end}`;
1032
1038
  };
1033
1039
 
1040
+ /** The source lines a text selection covers, for an Ask AI question
1041
+ * (`AIQuestion.scope.lineStart` / `lineEnd`). `blockIds` are the blocks the
1042
+ * selection touches; the range runs from the first block's start line to
1043
+ * the last block's end line, so a selection inside one block names exactly
1044
+ * the span its annotation would export (`(line 41)` / `(lines 41–44)`).
1045
+ * Blocks without a line (diff blocks, unknown ids) are skipped; `null` when
1046
+ * none is left. */
1047
+ export const selectionSourceLines = (
1048
+ blocks: readonly Block[],
1049
+ blockIds: readonly string[],
1050
+ ): { lineStart: number; lineEnd: number } | null => {
1051
+ let lineStart = Infinity;
1052
+ let lineEnd = -Infinity;
1053
+ for (const id of blockIds) {
1054
+ if (id.startsWith('diff-block-')) continue;
1055
+ const block = blocks.find(b => b.id === id);
1056
+ if (!block || typeof block.startLine !== 'number' || block.startLine < 1) continue;
1057
+ lineStart = Math.min(lineStart, block.startLine);
1058
+ lineEnd = Math.max(lineEnd, blockEndLine(block), block.startLine);
1059
+ }
1060
+ return Number.isFinite(lineStart) ? { lineStart, lineEnd } : null;
1061
+ };
1062
+
1034
1063
  /** Separate valid question answers from ordinary feedback. A row whose
1035
1064
  * `questionAnswer` fails validation stays ordinary feedback (its one-line
1036
1065
  * text still reads), so nothing is ever dropped. */
@@ -1493,6 +1522,119 @@ export const exportCodeFileAnnotations = (annotations: CodeAnnotation[]): string
1493
1522
  return output;
1494
1523
  };
1495
1524
 
1525
+ /** What `formatDraftAnnotationsForAsk` lists: the same collections the
1526
+ * feedback export reads, before the reviewer submits them. */
1527
+ export interface DraftAnnotationsForAsk {
1528
+ /** The session's own document (plan, file, message). */
1529
+ annotations: readonly Annotation[];
1530
+ blocks?: Block[];
1531
+ globalAttachments?: readonly ImageAttachment[];
1532
+ /** Every other document holding drafts, by path. */
1533
+ documents?: ReadonlyMap<string, LinkedDocAnnotationEntry>;
1534
+ codeAnnotations?: readonly CodeAnnotation[];
1535
+ editorAnnotations?: readonly EditorAnnotation[];
1536
+ }
1537
+
1538
+ const MAX_ASK_DRAFT_ENTRIES = 60;
1539
+ const ASK_DRAFT_QUOTE_CHARS = 160;
1540
+ const ASK_DRAFT_TEXT_CHARS = 1000;
1541
+
1542
+ /**
1543
+ * The reviewer's UNSUBMITTED annotations as a plain list, for an "Ask this
1544
+ * session" question (#1748). It is deliberately not the feedback export: no
1545
+ * title, no "I've reviewed this … and have N pieces of feedback", no numbered
1546
+ * "Feedback on" headings, no injected skill instructions. The session that
1547
+ * reads it is the agent the review is for, so a draft must read as something
1548
+ * the reviewer is still writing, not as a request. The server wraps the list
1549
+ * in a read-only frame (`SESSION_ASK_DRAFTS_LABEL`); this function only lists.
1550
+ *
1551
+ * One line per draft (`Draft 2 (line 14): comment on "…" — …`), numbered in
1552
+ * document order so a question can say "draft 2"; every page-, file- or
1553
+ * reviewer-supplied string is collapsed to one line and capped. Empty string
1554
+ * when there are no drafts.
1555
+ */
1556
+ export const formatDraftAnnotationsForAsk = (input: DraftAnnotationsForAsk): string => {
1557
+ type Row = { ann: any; where: string | null };
1558
+ const rows: Row[] = [];
1559
+ const placeOf = (blocks: Block[] | undefined, ann: any): string | null => {
1560
+ if (ann.diffContext) return 'in the version diff';
1561
+ return blocks ? lineLabelForAnnotation(blocks, ann) : null;
1562
+ };
1563
+ for (const ann of sortAnnotationsInDocumentOrder([...input.annotations], input.blocks)) {
1564
+ rows.push({ ann, where: placeOf(input.blocks, ann) });
1565
+ }
1566
+ for (const [path, doc] of input.documents ?? []) {
1567
+ for (const ann of sortAnnotationsInDocumentOrder([...doc.annotations], doc.blocks)) {
1568
+ const line = placeOf(doc.blocks, ann);
1569
+ rows.push({ ann, where: line ? `${safeInline(path, 300)}, ${line}` : safeInline(path, 300) });
1570
+ }
1571
+ }
1572
+
1573
+ const numbers = new Map<string, number>();
1574
+ rows.forEach((row, index) => {
1575
+ if (typeof row.ann?.id === 'string') numbers.set(row.ann.id, index + 1);
1576
+ });
1577
+ const replyParents = resolveReplyParents(rows.map((row) => row.ann));
1578
+ const quote = (value: unknown) => `"${safeInline(value, ASK_DRAFT_QUOTE_CHARS)}"`;
1579
+ const text = (value: unknown) => safeInline(value, ASK_DRAFT_TEXT_CHARS);
1580
+ const imageList = (list: readonly ImageAttachment[]): string =>
1581
+ list.map((img) => `[${safeInline(img?.name, 80)}] ${safeInline(img?.path, 300)}`).join(', ');
1582
+ const images = (list: unknown): string =>
1583
+ Array.isArray(list) && list.length > 0 ? ` (attached: ${imageList(list)})` : '';
1584
+
1585
+ const lines: string[] = [];
1586
+ rows.forEach(({ ann, where }, index) => {
1587
+ const parent = replyParents.get(ann.id);
1588
+ const parentNumber = parent ? numbers.get(parent) : undefined;
1589
+ const head = `Draft ${index + 1}${where ? ` (${where})` : ''}:`;
1590
+ const from = ann.source ? ` (from ${safeInline(ann.source, 60)})` : '';
1591
+ const answer = ann.questionAnswer == null ? null : parseQuestionAnswer(ann.questionAnswer);
1592
+ let body: string;
1593
+ if (answer) {
1594
+ body = `answer to the question ${quote(answer.prompt)} — ${text(formatQuestionAnswerText(answer))}`;
1595
+ } else if (parentNumber !== undefined) {
1596
+ body = `reply to draft ${parentNumber} — ${text(ann.text)}`;
1597
+ } else if (ann.type === 'DELETION') {
1598
+ body = `suggests removing ${quote(ann.originalText)}`;
1599
+ } else if (ann.type === 'GLOBAL_COMMENT') {
1600
+ body = `general note — ${text(ann.text)}`;
1601
+ } else {
1602
+ const subject = elementSubject(ann) ?? quote(ann.originalText);
1603
+ const anchor = ann.diagramAnchor === undefined ? null : parseDiagramAnchor(ann.diagramAnchor);
1604
+ const target = anchor ? `${subject} [${safeInline(diagramAnchorLocationLine(anchor), 300)}]` : subject;
1605
+ body = ann.isQuickLabel
1606
+ ? `label ${quote(ann.text)} on ${target}`
1607
+ : `comment on ${target} — ${text(ann.text)}`;
1608
+ }
1609
+ lines.push(`${head} ${body}${from}${images(ann.images)}`);
1610
+ });
1611
+
1612
+ let next = rows.length;
1613
+ for (const ann of input.codeAnnotations ?? []) {
1614
+ next += 1;
1615
+ const range = ann.lineStart === ann.lineEnd ? `line ${ann.lineStart}` : `lines ${ann.lineStart}–${ann.lineEnd}`;
1616
+ const on = ann.originalCode ? ` on ${quote(ann.originalCode)}` : '';
1617
+ lines.push(`Draft ${next} (${safeInline(ann.filePath, 300)}, ${range}): comment${on} — ${text(ann.text)}${ann.source ? ` (from ${safeInline(ann.source, 60)})` : ''}${images(ann.images)}`);
1618
+ }
1619
+ for (const ann of input.editorAnnotations ?? []) {
1620
+ next += 1;
1621
+ const range = ann.lineStart === ann.lineEnd ? `line ${ann.lineStart}` : `lines ${ann.lineStart}–${ann.lineEnd}`;
1622
+ lines.push(`Draft ${next} (${safeInline(ann.filePath, 300)}, ${range}): comment on ${quote(ann.selectedText)} — ${text(ann.comment)}`);
1623
+ }
1624
+
1625
+ const shown = lines.slice(0, MAX_ASK_DRAFT_ENTRIES);
1626
+ if (lines.length > shown.length) shown.push(`… and ${lines.length - shown.length} more draft annotations`);
1627
+
1628
+ const attachments = input.globalAttachments ?? [];
1629
+ if (attachments.length > 0) shown.push(`Reference images: ${imageList(attachments)}`);
1630
+ for (const [path, doc] of input.documents ?? []) {
1631
+ if (doc.globalAttachments.length > 0) {
1632
+ shown.push(`Reference images (${safeInline(path, 300)}): ${imageList(doc.globalAttachments)}`);
1633
+ }
1634
+ }
1635
+ return shown.join('\n');
1636
+ };
1637
+
1496
1638
  export interface MessageAnnotationEntry {
1497
1639
  messageId: string;
1498
1640
  text: string;
@@ -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
+ }