@plannotator/ui 0.51.0 → 0.52.1

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 CHANGED
@@ -1880,6 +1880,241 @@ Pinned by `components/DiagramBlock.askAI.test.tsx` and
1880
1880
 
1881
1881
  ---
1882
1882
 
1883
+ ## Ask AI names a text selection's source lines (ui 0.52.0; additive, no core change)
1884
+
1885
+ An Ask AI question asked from a markdown text selection now says which
1886
+ source lines the selection is on, so a phrase that occurs more than once in
1887
+ the document is no longer ambiguous (#1731). Nothing to pass; a host that
1888
+ ignores the new fields keeps working.
1889
+
1890
+ - **`CommentAskAIContext.lineStart?` / `lineEnd?`** and
1891
+ **`AIQuestion.scope.lineStart?` / `lineEnd?`** (1-based, additive). `Viewer`
1892
+ fills them on the context its text-selection composer and its code-block
1893
+ composer hand to `onAskAI`: the span of the enclosing block, which for a
1894
+ selection within one block is the same span the exported annotation from
1895
+ that selection prints as `(line 41)` / `(lines 41–44)`. A selection over
1896
+ several blocks gives a range from the first block's start line to the last
1897
+ block's end line, while the export names only the first block. The global
1898
+ composer, HTML / live-app
1899
+ pinpoints and diagram comments carry none (diagrams already send their line
1900
+ in `detail`).
1901
+ - **`buildDefaultPrompt`** appends them to the `Source:` line:
1902
+ `Source: /docs/plan.md, line 41` or `Source: /docs/plan.md, lines 41–44`
1903
+ (`Source: line 41` with no `sourcePath`). A scope without them, or with a
1904
+ value that is not a positive integer, prints exactly what it printed before.
1905
+ A host that maps `CommentAskAIContext` onto `AIQuestion.scope` field by
1906
+ field must copy the two fields to get the line into the question.
1907
+ - **New pure helper `selectionSourceLines(blocks, blockIds)`** (`utils/parser`)
1908
+ returns `{ lineStart, lineEnd }` or `null`.
1909
+ - **`CommentPopoverState.blockIds?`** (`hooks/useAnnotationHighlighter`,
1910
+ additive): the `data-block-id`s the pending selection covers, which is what
1911
+ `Viewer` maps to lines.
1912
+
1913
+ Lines are document lines of the markdown the `Viewer` was given (frontmatter
1914
+ counted). Plannotator drops them for a converted HTML / URL source, whose
1915
+ converted markdown lines do not exist in the original file (its mapping is
1916
+ `packages/editor/askScope.ts`, pinned by `askScope.test.ts`). Pinned by
1917
+ `components/Viewer.askAISelectionLine.test.tsx` (DOM-gated),
1918
+ `utils/parser.selectionLines.test.ts` (including parity with the export
1919
+ heading for single-block selections) and `utils/aiPrompt.test.ts`.
1920
+
1921
+ ---
1922
+
1923
+ ## `CompletionOverlay` `done` state (ui 0.52.0; additive)
1924
+
1925
+ `submitted` accepts a new value, `'done'`: the session finished with nothing
1926
+ sent (Plannotator uses it for an annotate Done with nothing to send). It
1927
+ renders a neutral check icon; the host still supplies the title and subtitle.
1928
+ `ComponentProps<typeof CompletionOverlay>['submitted']` therefore now includes
1929
+ `'done'`, so a host that switches exhaustively over that type gets one more
1930
+ case to handle.
1931
+
1932
+ Neither `'done'` nor `'exited'` (Close) renders the footer's "Your response
1933
+ has been sent." line any more, since neither sends anything. That line only
1934
+ appears in the auto-close `closed` phase (an immediate auto-close whose close
1935
+ request went to a host window such as Glimpse or the VS Code panel's parent),
1936
+ so this is a visible change for `'exited'` only there. Every other value
1937
+ renders exactly as before. The overlay root now carries
1938
+ `data-completion-state={submitted}`.
1939
+
1940
+ ## Quick Labels: reorder and editable emoji (ui 0.52.0; additive, no core change)
1941
+
1942
+ Settings → Labels (#1736) lets a person move a label up or down (per-row
1943
+ buttons, `aria-label="Move <text> up|down"`, a polite live region announces
1944
+ the new position and key) and change its emoji (a one-emoji field; anything
1945
+ that is not exactly one emoji is shown invalid, never saved, and reverts on
1946
+ blur). The tab now lives in `components/settings/QuickLabelsTab` and
1947
+ `Settings` renders it; nothing about `Settings`' props changed.
1948
+
1949
+ **The persisted shape is unchanged.** Labels are still saved as
1950
+ `QuickLabel[]` under `plannotator-quick-labels` through the storage backend,
1951
+ with the same fields; a reorder is just a different array order and an emoji
1952
+ edit a different `emoji` value. Rows get a stable identity for the editing
1953
+ session only (it is never written, so an older reader sees nothing new and the
1954
+ cookie does not grow). `QuickLabel.id` keeps its old meaning (derived from the
1955
+ text when the text is edited; `custom-<ts>` for new labels) and is still not
1956
+ unique: the list renderer `QuickLabelDropdown` now keys rows by position, so
1957
+ two labels with the same text no longer collide.
1958
+
1959
+ What a host should know:
1960
+
1961
+ - **Position is the key binding.** Alt/⌥1…9, 0 apply the label at positions
1962
+ 1…10 (`quickLabelShortcutDigit` / `quickLabelIndexForDigit`); moving a label
1963
+ in Settings is what remaps its key. Lists are read once when a toolbar or
1964
+ picker mounts, so a change applies to the next selection.
1965
+ - **An annotation keeps the emoji it was created with.** The label is copied
1966
+ into the annotation as `${emoji} ${text}`; editing the emoji (like editing
1967
+ the text) affects only annotations created afterwards.
1968
+ - New pure exports in `utils/quickLabels`: `quickLabelShortcutDigit`,
1969
+ `quickLabelIndexForDigit`, `moveQuickLabel`, `parseQuickLabelEmoji` (one
1970
+ emoji grapheme, including ZWJ sequences, flags, keycaps and skin tones, or
1971
+ null) and `emojiFromFieldInput`. Nothing was removed or renamed.
1972
+
1973
+ ## Question decision toggle and status tag (ui 0.52.0; additive, no core change)
1974
+
1975
+ What Workspaces asked for: the reviewer can switch a `Decision: when answered`
1976
+ question's recording off and on from the card itself, and the host can draw
1977
+ its own status tag. Three optional props on `Viewer`, forwarded through
1978
+ `BlockRenderer` to `QuestionBlock` the way `renderQuestionFooter` is.
1979
+ Plannotator passes none of them, so its cards render exactly as before.
1980
+
1981
+ | `Viewer` | `BlockRenderer` | `QuestionBlock` |
1982
+ | --- | --- | --- |
1983
+ | `questionDecisionRecording?: (question: IndexedQuestion) => boolean \| undefined` | `questionDecisionRecording?: boolean` | `decisionRecording?: boolean` |
1984
+ | `onToggleQuestionDecisionRecording?: (key, next) => void` | same | `onToggleDecisionRecording?: (key, next) => void` |
1985
+ | `questionStatusTag?: 'card' \| 'none'` | same | `statusTag?: 'card' \| 'none'` |
1986
+
1987
+ - **Recording state** (`decisionRecording`, default `true`; on `Viewer` a
1988
+ function of the question, called per question card, so it can read your
1989
+ store by `question.question.key`). It applies only to a question carrying
1990
+ `Decision: when answered` (`decisionOnAnswer`) whose decision is not
1991
+ already recorded. Off draws the same "Records a decision" tag dimmed (in
1992
+ the theme's `--muted-foreground`, not by opacity, so it stays as legible as
1993
+ any secondary text) with a dotted outline and no fill (never different
1994
+ words), and hides the "Answering this
1995
+ records a decision" row (`[data-question-decision-row]`). A recorded
1996
+ decision (`Decision: [statement](url)`) and a question with no decision line
1997
+ are never affected.
1998
+ - **The toggle** (`onToggleDecisionRecording`): the tag becomes
1999
+ `<button type="button" aria-pressed>` with `[data-question-decision-toggle]`,
2000
+ keyboard operable, a focus ring (a ring, not an outline: the dotted outline
2001
+ draws the off state). A click calls the handler with the question's key and
2002
+ the NEXT state; the card does not keep the state itself, so pass it back
2003
+ through `decisionRecording` / `questionDecisionRecording`.
2004
+ - **Read-only cards never offer the toggle.** A card is read-only when it has
2005
+ no answer handler (`Viewer`: `readOnly`, or neither `onAnswerQuestion` nor
2006
+ `onSaveQuestionAnswer`); it draws the tag in its current state as a plain
2007
+ tag. `Viewer` also drops `onToggleQuestionDecisionRecording` under
2008
+ `readOnly`, like the answer handlers.
2009
+ - **`decisionRecording: false` without a handler** is honored: the static tag
2010
+ is drawn off and the row is hidden. Use it to show a state the reviewer may
2011
+ not change.
2012
+ - **Attributes.** `data-question-decision` keeps its values (`on-answer`,
2013
+ `recorded`) whatever the toggle says, so a selector keyed on the question's
2014
+ kind does not move. A separate `data-question-decision-recording="on" | "off"`
2015
+ appears on the card only for a `decisionOnAnswer` question when the host
2016
+ passed `decisionRecording` or the handler; a card with neither carries no
2017
+ new attribute.
2018
+ - **`statusTag: 'none'`** hides the card's own Open / Answered / Settled /
2019
+ Skipped tag; the decision tags ("Decision", "Records a decision") stay, and
2020
+ `data-question-status` still carries the status for your own tag.
2021
+ - **No stylesheet change.** The toggle reuses utility classes the package
2022
+ already ships, and the off state is an inline style, so `styles.css` and
2023
+ the guides.show viewer manifest are unchanged (no guides.show deploy needed
2024
+ for this).
2025
+
2026
+ Pinned by `components/blocks/QuestionBlock.decisionToggle.test.tsx`
2027
+ (DOM-gated): the toggle's button, `aria-pressed` and handler arguments, the
2028
+ row hidden when off, recorded and plain questions untouched, read-only cards
2029
+ static, `statusTag: 'none'`, the `Viewer` threading, and a card with none of
2030
+ the props rendering the same markup.
2031
+
2032
+ ## Decision toggle on any question, and an opener for the host's decision card (ui 0.52.1; additive, no core change)
2033
+
2034
+ Two gaps Workspaces found in 0.52.0 against its approved Inbox design
2035
+ ("the tag is the switch" on every question, off by default; "clicking the
2036
+ tag's words opens the decision card"). Plannotator passes none of this, and a
2037
+ card without the new props renders byte-identically to 0.52.0 (checked by
2038
+ rendering main and the branch through `BlockRenderer` and `Viewer`,
2039
+ interactive and read-only, with no props, with a 0.52.0-shaped host, and with
2040
+ a 0.52.0 host returning a boolean for every question).
2041
+
2042
+ | `Viewer` | `BlockRenderer` | `QuestionBlock` |
2043
+ | --- | --- | --- |
2044
+ | `questionDecisionScope?: 'when-answered' \| 'any'` | same | `decisionScope?: 'when-answered' \| 'any'` |
2045
+ | `onOpenQuestionDecision?: (key, anchor: HTMLElement) => void` | same | `onOpenDecision?: (key, anchor: HTMLElement) => void` |
2046
+
2047
+ 1. **Any question can record a decision, opt-in.** With
2048
+ `questionDecisionScope="any"` (`decisionScope: 'any'`),
2049
+ `decisionRecording` / `questionDecisionRecording` also reaches a question
2050
+ WITHOUT a decision line: a boolean adds the "◇ Records a decision" tag in
2051
+ that state (and, with `onToggleDecisionRecording`, makes it the toggle),
2052
+ `undefined` leaves the question as it was (no tag). The default,
2053
+ `'when-answered'`, is the 0.52.0 behaviour: only `Decision: when answered`
2054
+ questions carry the tag, and a boolean returned for a plain question is
2055
+ ignored, as 0.52.0 documented. It is an explicit opt-in rather than
2056
+ "a boolean means show it" so that a 0.52.0 host that returns a boolean for
2057
+ every question does not grow tags on its plain questions in a patch
2058
+ release. On shows the same "Answering this records
2059
+ a decision" row a `Decision: when answered` question shows; off hides it.
2060
+ The default for such a question is whatever you return: start it `false`
2061
+ for "off by default". A question with a recorded decision
2062
+ (`Decision: [statement](url)`) is still never affected, and
2063
+ `data-question-decision` still describes the question's own line (absent
2064
+ for a plain question); `data-question-decision-recording="on" | "off"`
2065
+ appears once you supply a state.
2066
+ 2. **A separate opener (`onOpenDecision` / `onOpenQuestionDecision`).** Given,
2067
+ the tag is drawn as two adjacent targets inside one visual tag
2068
+ (`[data-question-decision-tag]`), siblings, never nested:
2069
+ - the diamond is the switch: `<button aria-pressed aria-label="Record as a
2070
+ decision" data-question-decision-toggle>` calling
2071
+ `onToggleDecisionRecording(key, next)`. Without a toggle handler the
2072
+ diamond is decoration, not a control.
2073
+ - the words "Records a decision" are `<button aria-haspopup="dialog"
2074
+ data-question-decision-open>`, calling `onOpenDecision(key, element)`
2075
+ with the words' own button as the popover anchor. The card keeps no
2076
+ open state; your popover owns focus and returns it to the anchor.
2077
+ The opener is offered whether recording is on or off: what an off
2078
+ question's card does (switch it on as it opens, or open a card that says
2079
+ it is off) is the host's call. Tab order is switch, then words, both with
2080
+ the `ring-2 ring-primary` focus ring the 0.52.0 toggle uses (a ring, not
2081
+ an outline, because the dotted outline draws the off state, which is now
2082
+ on the wrapping tag). The off look is unchanged: muted-foreground text and
2083
+ a dotted outline (#1744's contrast fix). Without `onOpenDecision` the
2084
+ whole tag is the toggle exactly as in 0.52.0.
2085
+ 3. **Read-only cards are never interactive.** No answer handler (or
2086
+ `Viewer` `readOnly`, which also drops `onOpenQuestionDecision`) draws the
2087
+ tag as a plain tag in its current state.
2088
+
2089
+ Measured in headless Chromium with the default theme: switch and words are
2090
+ 7.37:1 (dark) / 8.98:1 (light) off, 5.84:1 / 5.15:1 on; the focus ring is
2091
+ 7.53:1 / 6.53:1 against the card. The split tag draws exactly the 0.52.0 tag
2092
+ (127.66 × 17.75 px, the same 4 px between diamond and words). Target size
2093
+ (WCAG 2.5.8) is met without changing that look: the diamond (18 × 17.75 px
2094
+ drawn) carries an invisible `before:` box 6 px past it on every side, so it
2095
+ clicks as 24 × 29.75 px; the words, positioned and later in the tree, paint
2096
+ over the box's right-hand part, so a click on the words is always the words,
2097
+ and the words (110 px wide) meet the spacing exception.
2098
+
2099
+ **No stylesheet change**: only utility classes the package already ships
2100
+ (the hit box is the `before:absolute before:-inset-1.5 before:content-['']`
2101
+ pattern of the annotation panel's icon buttons), so `styles.css` and the
2102
+ guides.show viewer manifest are unchanged (`check:manifest` in sync).
2103
+
2104
+ Pinned by `components/blocks/QuestionBlock.decisionAnyQuestion.test.tsx`
2105
+ (DOM-gated): the tag on a plain question only with the `'any'` scope and a
2106
+ supplied state (a boolean without the scope changes nothing), the
2107
+ toggle's arguments and the row on/off, the switch and the opener as sibling
2108
+ buttons with the anchor element, tab order, the opener without a toggle
2109
+ handler, 0.52.0 behavior without `onOpenDecision`, read-only cards, `Viewer`
2110
+ threading, and no-prop markup.
2111
+
2112
+ ---
2113
+
2114
+ ## Stale-tab guard: `serverSession` (ui 0.52.0, core 0.25.12; additive)
2115
+
2116
+ Plannotator's servers issue one random `serverSession` nonce per process (new `@plannotator/core/server-session`) and refuse a decision that echoes a different one with `409 { code: "session_mismatch" }`, so a tab left open on a reused port cannot decide a new session. The client half is `utils/serverSession` (`adoptServerSession`, `withServerSession`, `withServerSessionQuery`, `noteServerSessionMismatch`, `useServerSessionReplaced`) plus `components/ServerSessionReplacedBanner`. Hosts that post decisions to their own backend need none of it: nothing in the shared components sends the field on its own, and a request without it is always accepted.
2117
+
1883
2118
  ## The law (guardrails for anyone editing `@plannotator/ui`)
1884
2119
 
1885
2120
  These are enforced socially and, where possible, by CI. They exist because a prior from-scratch reimplementation of this UI broke the app and was reverted.
package/README.md CHANGED
@@ -384,6 +384,21 @@ See HANDOFF.md § "Questions in documents (0.48.0, core 0.25.8)".
384
384
 
385
385
  See HANDOFF.md § "Question host seams (ui 0.49.0, core 0.25.9)".
386
386
 
387
+ **Decision recording toggle and status tag (ui 0.52.0, unreleased).** Also optional on `Viewer`, also unused by Plannotator:
388
+
389
+ - `questionDecisionRecording(question) => boolean | undefined`: false draws a `Decision: when answered` question's "Records a decision" tag dimmed with a dotted outline and hides its "Answering this records a decision" row. A recorded decision is never affected.
390
+ - `onToggleQuestionDecisionRecording(key, next)`: makes that tag an `aria-pressed` toggle button; store `next` and return it from `questionDecisionRecording`. Not offered under `readOnly` or in a card without an answer handler.
391
+ - `questionStatusTag: 'none'`: hides the card's own Open / Answered / Settled / Skipped tag so you can draw your own; the decision tags stay.
392
+
393
+ See HANDOFF.md § "Question decision toggle and status tag (ui 0.52.0, unreleased; …)".
394
+
395
+ **Decision toggle on any question, and a decision-card opener (ui 0.52.1, unreleased).** Also optional on `Viewer`, also unused by Plannotator:
396
+
397
+ - `questionDecisionScope?: 'when-answered' | 'any'` (default `'when-answered'`, the 0.52.0 behaviour). With `'any'`, `questionDecisionRecording(question)` also applies to a question with no decision line: a boolean adds the "Records a decision" tag in that state (true also shows the "Answering this records a decision" row); `undefined` leaves it tag-less. Return `false` for "off by default".
398
+ - `onOpenQuestionDecision(key, anchor)`: splits the tag into a diamond switch (`aria-pressed`, "Record as a decision", driven by `onToggleQuestionDecisionRecording`) and the words as a button (`aria-haspopup="dialog"`) that calls this with its own element to anchor your decision card. Offered on and off; not offered under `readOnly`. Without it the whole tag is the toggle, as in 0.52.0.
399
+
400
+ See HANDOFF.md § "Decision toggle on any question, and an opener for the host's decision card (ui 0.52.1, …)".
401
+
387
402
  ### Diff file tree (`components/DiffFileTree`; next ui release)
388
403
 
389
404
  A read-only file tree for a list of changed files, to sit beside a host's own per-file diff list. It is Plannotator's code-review tree, not a lookalike: the builder (`utils/diffFileTree`), the folder open/closed hook (`hooks/useDiffFileTreeExpansion`) and the row atoms moved here from `packages/review-editor`, and the review now renders from them.
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useMemo } from "react";
2
2
  import { AnnotationType } from "../types";
3
3
  import { createPortal } from "react-dom";
4
4
  import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape";
5
- import { type QuickLabel, getQuickLabels, THUMBS_UP_LABEL } from "../utils/quickLabels";
5
+ import { type QuickLabel, getQuickLabels, quickLabelIndexForDigit, THUMBS_UP_LABEL } from "../utils/quickLabels";
6
6
  import { copyTextToClipboard } from "../utils/clipboard";
7
7
  import { acquireTypeToCommentCapture } from "../shortcuts/plan-review/annotationMode.shortcuts";
8
8
  import { FloatingQuickLabelPicker } from "./FloatingQuickLabelPicker";
@@ -174,7 +174,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
174
174
  e.preventDefault();
175
175
  if (!commentOnly && quickLabelsEnabled) {
176
176
  const digit = parseInt(e.code.slice(5), 10);
177
- const index = digit === 0 ? 9 : digit - 1;
177
+ const index = quickLabelIndexForDigit(digit);
178
178
  if (index < quickLabels.length) {
179
179
  onQuickLabel?.(quickLabels[index]);
180
180
  }
@@ -51,7 +51,17 @@ export const BlockRenderer: React.FC<{
51
51
  saveQuestionAnswerLabel?: QuestionBlockProps['saveLabel'];
52
52
  /** Host actions in a question card's footer (see `QuestionBlock`). */
53
53
  renderQuestionFooter?: QuestionBlockProps['renderFooter'];
54
- }> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, repoHost, headingAnchorId, onNavigateAnchor, question, questionTotal, questionAnswer, onAnswerQuestion, onSaveQuestionAnswer, saveQuestionAnswerLabel, renderQuestionFooter }) => {
54
+ /** Whether answering this question records a decision (see `QuestionBlock`). */
55
+ questionDecisionRecording?: QuestionBlockProps['decisionRecording'];
56
+ /** `'any'` lets a question without a decision line carry the tag (see `QuestionBlock`). */
57
+ questionDecisionScope?: QuestionBlockProps['decisionScope'];
58
+ /** Makes the "Records a decision" tag a toggle (see `QuestionBlock`). */
59
+ onToggleQuestionDecisionRecording?: QuestionBlockProps['onToggleDecisionRecording'];
60
+ /** Opens the host's decision card from the tag's words (see `QuestionBlock`). */
61
+ onOpenQuestionDecision?: QuestionBlockProps['onOpenDecision'];
62
+ /** `'none'` hides the card's own status tag (see `QuestionBlock`). */
63
+ questionStatusTag?: QuestionBlockProps['statusTag'];
64
+ }> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, repoHost, headingAnchorId, onNavigateAnchor, question, questionTotal, questionAnswer, onAnswerQuestion, onSaveQuestionAnswer, saveQuestionAnswerLabel, renderQuestionFooter, questionDecisionRecording, questionDecisionScope, onToggleQuestionDecisionRecording, onOpenQuestionDecision, questionStatusTag }) => {
55
65
  switch (block.type) {
56
66
  case 'heading': {
57
67
  const Tag = `h${block.level || 1}` as React.ElementType;
@@ -178,6 +188,11 @@ export const BlockRenderer: React.FC<{
178
188
  onSaveAnswer={onSaveQuestionAnswer}
179
189
  saveLabel={saveQuestionAnswerLabel}
180
190
  renderFooter={renderQuestionFooter}
191
+ decisionRecording={questionDecisionRecording}
192
+ decisionScope={questionDecisionScope}
193
+ onToggleDecisionRecording={onToggleQuestionDecisionRecording}
194
+ onOpenDecision={onOpenQuestionDecision}
195
+ statusTag={questionStatusTag}
181
196
  onOpenLinkedDoc={onOpenLinkedDoc}
182
197
  onOpenCodeFile={onOpenCodeFile}
183
198
  imageBaseDir={imageBaseDir}
@@ -32,6 +32,10 @@ export interface CommentAskAIContext {
32
32
  /** Agent-facing identity of the selected element(s), for a pinpoint on a
33
33
  * raw-HTML or live-app surface. Rides with the question; not displayed. */
34
34
  detail?: string;
35
+ /** 1-based source lines of a markdown text selection (see
36
+ * `AIQuestion.scope.lineStart`). Rides with the question; not displayed. */
37
+ lineStart?: number;
38
+ lineEnd?: number;
35
39
  }
36
40
 
37
41
  export type CommentAskAIHandler = (
@@ -25,7 +25,10 @@ const ChatBubbleIcon = () => (
25
25
  // ---------------------------------------------------------------------------
26
26
 
27
27
  interface CompletionOverlayProps {
28
- submitted: 'approved' | 'denied' | 'feedback' | 'exited' | null | false;
28
+ /** `done`: the session finished with nothing sent (an annotate Done with
29
+ * no feedback). Neutral styling. `done` and `exited` (Close) send nothing,
30
+ * so neither shows the "response has been sent" line. */
31
+ submitted: 'approved' | 'denied' | 'feedback' | 'done' | 'exited' | null | false;
29
32
  title: string;
30
33
  subtitle: string;
31
34
  agentLabel: string;
@@ -43,16 +46,20 @@ export function CompletionOverlay({ submitted, title, subtitle, agentLabel, link
43
46
  if (!submitted) return null;
44
47
 
45
48
  const isApproved = submitted === 'approved';
49
+ const isDone = submitted === 'done';
50
+ const sentNothing = isDone || submitted === 'exited';
46
51
 
47
52
  return (
48
- <div className="fixed inset-0 z-[100] bg-background flex items-center justify-center">
53
+ <div className="fixed inset-0 z-[100] bg-background flex items-center justify-center" data-completion-state={submitted}>
49
54
  <div className="text-center space-y-6 max-w-md px-8">
50
55
  <div
51
56
  className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center ${
52
- isApproved ? 'bg-success/20 text-success' : 'bg-accent/20 text-accent'
57
+ isApproved ? 'bg-success/20 text-success'
58
+ : isDone ? 'bg-muted text-muted-foreground'
59
+ : 'bg-accent/20 text-accent'
53
60
  }`}
54
61
  >
55
- {isApproved ? <CheckIcon /> : <ChatBubbleIcon />}
62
+ {isApproved || isDone ? <CheckIcon /> : <ChatBubbleIcon />}
56
63
  </div>
57
64
 
58
65
  <div className="space-y-2">
@@ -109,7 +116,7 @@ export function CompletionOverlay({ submitted, title, subtitle, agentLabel, link
109
116
  </label>
110
117
  <p className="text-xs text-muted-foreground/60">You can change the delay in Settings.</p>
111
118
  </>
112
- ) : (
119
+ ) : sentNothing ? null : (
113
120
  <p className="text-xs text-muted-foreground/60">Your response has been sent.</p>
114
121
  )}
115
122
  </>
@@ -1,6 +1,6 @@
1
1
  import React, { useState, useEffect, useRef, useMemo } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
- import { type QuickLabel, getQuickLabels } from '../utils/quickLabels';
3
+ import { type QuickLabel, getQuickLabels, quickLabelIndexForDigit } from '../utils/quickLabels';
4
4
  import { QuickLabelDropdown } from './QuickLabelDropdown';
5
5
 
6
6
  interface FloatingQuickLabelPickerProps {
@@ -78,7 +78,7 @@ export const FloatingQuickLabelPicker: React.FC<FloatingQuickLabelPickerProps> =
78
78
  if (isDigit && !e.ctrlKey && !e.metaKey) {
79
79
  e.preventDefault();
80
80
  const digit = parseInt(e.code.slice(5), 10);
81
- const index = digit === 0 ? 9 : digit - 1;
81
+ const index = quickLabelIndexForDigit(digit);
82
82
  if (index < quickLabels.length) {
83
83
  onSelect(quickLabels[index]);
84
84
  }
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { type QuickLabel, getLabelColors } from '../utils/quickLabels';
2
+ import { type QuickLabel, getLabelColors, quickLabelShortcutDigit } from '../utils/quickLabels';
3
3
 
4
4
  /**
5
5
  * Shared vertical label list used by both FloatingQuickLabelPicker
@@ -25,9 +25,14 @@ export const QuickLabelDropdown: React.FC<{
25
25
  )}
26
26
  {labels.map((label, index) => {
27
27
  const colors = getLabelColors(label.color);
28
+ const digit = quickLabelShortcutDigit(index);
28
29
  return (
29
30
  <button
30
- key={label.id}
31
+ // Keyed by position, not label.id: ids are derived from the
32
+ // label text, so two labels with the same text share one (#1736).
33
+ // The list never reorders while open, and position is exactly
34
+ // what the digit key on each row names.
35
+ key={index}
31
36
  onClick={() => onSelect(label)}
32
37
  className="group w-full flex items-center gap-2 px-2 py-[5px] text-left transition-colors hover:bg-muted/60 active:bg-muted"
33
38
  style={animate ? {
@@ -50,9 +55,9 @@ export const QuickLabelDropdown: React.FC<{
50
55
  {label.text}
51
56
  </span>
52
57
  {/* Shortcut hint */}
53
- {index < 10 && (
58
+ {digit && (
54
59
  <span className="text-[9px] tabular-nums text-muted-foreground/40 group-hover:text-muted-foreground/60 flex-shrink-0 font-mono">
55
- {index === 9 ? '0' : index + 1}
60
+ {digit}
56
61
  </span>
57
62
  )}
58
63
  </button>
@@ -0,0 +1,29 @@
1
+ import React from 'react';
2
+ import { useServerSessionReplaced } from '../utils/serverSession';
3
+
4
+ /**
5
+ * The stale-tab prompt (packages/ui/utils/serverSession.ts): shown once a
6
+ * decision was refused with `409 session_mismatch`, i.e. the Plannotator
7
+ * session this tab was opened for is gone and a newer one owns the address.
8
+ * Nothing was submitted; reloading shows what is open there now.
9
+ */
10
+ export const ServerSessionReplacedBanner: React.FC<{ onReload?: () => void }> = ({ onReload }) => {
11
+ const replaced = useServerSessionReplaced();
12
+ if (!replaced) return null;
13
+ return (
14
+ <div
15
+ role="alert"
16
+ data-server-session-replaced
17
+ className="fixed inset-x-0 top-0 z-[110] flex items-center justify-center gap-3 border-b border-border bg-destructive/95 px-4 py-2 text-sm text-destructive-foreground shadow-md"
18
+ >
19
+ <span>This review was replaced — reload to see what is open now. Nothing was submitted.</span>
20
+ <button
21
+ type="button"
22
+ onClick={onReload ?? (() => window.location.reload())}
23
+ className="rounded border border-destructive-foreground/40 px-2 py-0.5 font-medium hover:bg-destructive-foreground/10"
24
+ >
25
+ Reload
26
+ </button>
27
+ </div>
28
+ );
29
+ };