@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/HANDOFF.md +151 -0
- package/README.md +8 -0
- package/components/AnnotationToolbar.tsx +2 -2
- package/components/BlockRenderer.tsx +10 -1
- package/components/CommentPopover.tsx +4 -0
- package/components/CompletionOverlay.tsx +12 -5
- package/components/FloatingQuickLabelPicker.tsx +2 -2
- package/components/QuickLabelDropdown.tsx +9 -4
- package/components/ServerSessionReplacedBanner.tsx +29 -0
- package/components/Settings.tsx +3 -191
- package/components/Viewer.tsx +26 -1
- package/components/blocks/QuestionBlock.tsx +75 -8
- package/components/html-viewer/bridge-script.asset.js +106 -6
- package/components/html-viewer/bridge-script.ts +106 -6
- package/components/settings/QuickLabelsTab.tsx +378 -0
- package/hooks/useAIChat.ts +57 -1
- package/hooks/useAnnotationHighlighter.ts +28 -0
- package/package.json +2 -2
- package/styles.css +1 -1
- package/types.ts +6 -0
- package/utils/parser.ts +149 -7
- package/utils/quickLabels.ts +80 -0
- package/utils/serverSession.ts +85 -0
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
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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;
|
package/utils/quickLabels.ts
CHANGED
|
@@ -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
|
+
}
|