@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/HANDOFF.md
CHANGED
|
@@ -1880,6 +1880,157 @@ Pinned by `components/DiagramBlock.askAI.test.tsx` and
|
|
|
1880
1880
|
|
|
1881
1881
|
---
|
|
1882
1882
|
|
|
1883
|
+
## Ask AI names a text selection's source lines (unreleased; 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 (unreleased; 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 (unreleased; 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, unreleased; 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
|
+
---
|
|
2033
|
+
|
|
1883
2034
|
## The law (guardrails for anyone editing `@plannotator/ui`)
|
|
1884
2035
|
|
|
1885
2036
|
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,14 @@ 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
|
+
|
|
387
395
|
### Diff file tree (`components/DiffFileTree`; next ui release)
|
|
388
396
|
|
|
389
397
|
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
|
|
177
|
+
const index = quickLabelIndexForDigit(digit);
|
|
178
178
|
if (index < quickLabels.length) {
|
|
179
179
|
onQuickLabel?.(quickLabels[index]);
|
|
180
180
|
}
|
|
@@ -51,7 +51,13 @@ 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
|
-
|
|
54
|
+
/** Whether answering this question records a decision (see `QuestionBlock`). */
|
|
55
|
+
questionDecisionRecording?: QuestionBlockProps['decisionRecording'];
|
|
56
|
+
/** Makes the "Records a decision" tag a toggle (see `QuestionBlock`). */
|
|
57
|
+
onToggleQuestionDecisionRecording?: QuestionBlockProps['onToggleDecisionRecording'];
|
|
58
|
+
/** `'none'` hides the card's own status tag (see `QuestionBlock`). */
|
|
59
|
+
questionStatusTag?: QuestionBlockProps['statusTag'];
|
|
60
|
+
}> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, repoHost, headingAnchorId, onNavigateAnchor, question, questionTotal, questionAnswer, onAnswerQuestion, onSaveQuestionAnswer, saveQuestionAnswerLabel, renderQuestionFooter, questionDecisionRecording, onToggleQuestionDecisionRecording, questionStatusTag }) => {
|
|
55
61
|
switch (block.type) {
|
|
56
62
|
case 'heading': {
|
|
57
63
|
const Tag = `h${block.level || 1}` as React.ElementType;
|
|
@@ -178,6 +184,9 @@ export const BlockRenderer: React.FC<{
|
|
|
178
184
|
onSaveAnswer={onSaveQuestionAnswer}
|
|
179
185
|
saveLabel={saveQuestionAnswerLabel}
|
|
180
186
|
renderFooter={renderQuestionFooter}
|
|
187
|
+
decisionRecording={questionDecisionRecording}
|
|
188
|
+
onToggleDecisionRecording={onToggleQuestionDecisionRecording}
|
|
189
|
+
statusTag={questionStatusTag}
|
|
181
190
|
onOpenLinkedDoc={onOpenLinkedDoc}
|
|
182
191
|
onOpenCodeFile={onOpenCodeFile}
|
|
183
192
|
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
|
-
|
|
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'
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
+
};
|
package/components/Settings.tsx
CHANGED
|
@@ -70,12 +70,12 @@ import {
|
|
|
70
70
|
} from '../utils/defaultNotesApp';
|
|
71
71
|
import { useAgents } from '../hooks/useAgents';
|
|
72
72
|
import { KeyboardShortcuts } from './KeyboardShortcuts';
|
|
73
|
-
import { type QuickLabel, getQuickLabels, saveQuickLabels, resetQuickLabels, DEFAULT_QUICK_LABELS, getLabelColors, LABEL_COLOR_MAP } from '../utils/quickLabels';
|
|
74
73
|
import { ThemeTab } from './ThemeTab';
|
|
75
|
-
import {
|
|
74
|
+
import { modKey, modKeyWord } from '../utils/platform';
|
|
76
75
|
import { getAIProviderSettings, resolveAIProviderSelection } from '../utils/aiProvider';
|
|
77
76
|
import { AISettingsTab } from './AISettingsTab';
|
|
78
77
|
import { HooksTab } from './settings/HooksTab';
|
|
78
|
+
import { QuickLabelsTab } from './settings/QuickLabelsTab';
|
|
79
79
|
import { OverlayScrollArea } from './OverlayScrollArea';
|
|
80
80
|
import {
|
|
81
81
|
getFileBrowserSettings,
|
|
@@ -1063,9 +1063,6 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
|
|
|
1063
1063
|
const [agentWarning, setAgentWarning] = useState<string | null>(null);
|
|
1064
1064
|
const [autoCloseDelay, setAutoCloseDelayState] = useState<AutoCloseDelay>('off');
|
|
1065
1065
|
const [defaultNotesApp, setDefaultNotesApp] = useState<DefaultNotesApp>('ask');
|
|
1066
|
-
const [quickLabelsState, setQuickLabelsState] = useState<QuickLabel[]>([]);
|
|
1067
|
-
const [editingTipIndex, setEditingTipIndex] = useState<number | null>(null);
|
|
1068
|
-
const [editingTipValue, setEditingTipValue] = useState('');
|
|
1069
1066
|
const [aiProvider, setAiProvider] = useState<string | null>(null);
|
|
1070
1067
|
const [fileBrowserSettings, setFileBrowserSettings] = useState<FileBrowserSettings>({ enabled: false, directories: [] });
|
|
1071
1068
|
const [newDirPath, setNewDirPath] = useState('');
|
|
@@ -1134,7 +1131,6 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
|
|
|
1134
1131
|
setPermissionMode(getPermissionModeSettings().mode);
|
|
1135
1132
|
setAutoCloseDelayState(getAutoCloseDelay());
|
|
1136
1133
|
setDefaultNotesApp(getDefaultNotesApp());
|
|
1137
|
-
setQuickLabelsState(getQuickLabels());
|
|
1138
1134
|
const aiSettings = getAIProviderSettings();
|
|
1139
1135
|
setAiProvider(resolveAIProviderSelection({ providers: aiProviders, origin, settings: aiSettings }).providerId);
|
|
1140
1136
|
setFileBrowserSettings(getFileBrowserSettings());
|
|
@@ -2054,191 +2050,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
|
|
|
2054
2050
|
)}
|
|
2055
2051
|
|
|
2056
2052
|
{/* === LABELS TAB === */}
|
|
2057
|
-
{activeTab === 'labels' &&
|
|
2058
|
-
<>
|
|
2059
|
-
<div className="flex items-center justify-between">
|
|
2060
|
-
<div>
|
|
2061
|
-
<div className="text-sm font-medium flex items-center gap-2">Quick Labels</div>
|
|
2062
|
-
<div className="text-xs text-muted-foreground">
|
|
2063
|
-
Preset annotations for one-click feedback
|
|
2064
|
-
</div>
|
|
2065
|
-
</div>
|
|
2066
|
-
<button
|
|
2067
|
-
onClick={() => {
|
|
2068
|
-
resetQuickLabels();
|
|
2069
|
-
setQuickLabelsState(DEFAULT_QUICK_LABELS);
|
|
2070
|
-
}}
|
|
2071
|
-
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
|
2072
|
-
>
|
|
2073
|
-
Reset to defaults
|
|
2074
|
-
</button>
|
|
2075
|
-
</div>
|
|
2076
|
-
|
|
2077
|
-
<style>{`
|
|
2078
|
-
@keyframes tip-slide-open {
|
|
2079
|
-
from { opacity: 0; transform: translateY(-4px); }
|
|
2080
|
-
to { opacity: 1; transform: translateY(0); }
|
|
2081
|
-
}
|
|
2082
|
-
`}</style>
|
|
2083
|
-
<div className="space-y-1.5">
|
|
2084
|
-
{quickLabelsState.map((label, index) => {
|
|
2085
|
-
const colors = getLabelColors(label.color);
|
|
2086
|
-
const hasTip = !!label.tip;
|
|
2087
|
-
const isEditingTip = editingTipIndex === index;
|
|
2088
|
-
return (
|
|
2089
|
-
<div key={index} className="rounded-lg overflow-hidden" style={{ backgroundColor: colors.bg }}>
|
|
2090
|
-
{/* Main row */}
|
|
2091
|
-
<div className="flex items-center gap-2 p-2">
|
|
2092
|
-
<span className="text-sm flex-shrink-0">{label.emoji}</span>
|
|
2093
|
-
<input
|
|
2094
|
-
type="text"
|
|
2095
|
-
value={label.text}
|
|
2096
|
-
onChange={(e) => {
|
|
2097
|
-
const updated = [...quickLabelsState];
|
|
2098
|
-
updated[index] = {
|
|
2099
|
-
...label,
|
|
2100
|
-
text: e.target.value,
|
|
2101
|
-
id: e.target.value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''),
|
|
2102
|
-
};
|
|
2103
|
-
setQuickLabelsState(updated);
|
|
2104
|
-
saveQuickLabels(updated);
|
|
2105
|
-
}}
|
|
2106
|
-
className="flex-1 px-2 py-1 bg-background/80 rounded text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
2107
|
-
/>
|
|
2108
|
-
{/* Tip indicator button */}
|
|
2109
|
-
<button
|
|
2110
|
-
onClick={() => {
|
|
2111
|
-
if (isEditingTip) {
|
|
2112
|
-
setEditingTipIndex(null);
|
|
2113
|
-
} else {
|
|
2114
|
-
setEditingTipIndex(index);
|
|
2115
|
-
setEditingTipValue(label.tip || '');
|
|
2116
|
-
}
|
|
2117
|
-
}}
|
|
2118
|
-
className={`relative p-1 rounded transition-all flex-shrink-0 ${
|
|
2119
|
-
hasTip
|
|
2120
|
-
? 'bg-foreground/10 text-foreground/70 hover:text-foreground border border-foreground/15'
|
|
2121
|
-
: 'text-muted-foreground/30 hover:text-muted-foreground/60 border border-dashed border-muted-foreground/20 hover:border-muted-foreground/40'
|
|
2122
|
-
}`}
|
|
2123
|
-
title={hasTip ? `Tip: ${label.tip}` : 'Add AI instruction tip'}
|
|
2124
|
-
>
|
|
2125
|
-
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
2126
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
|
2127
|
-
</svg>
|
|
2128
|
-
{hasTip && (
|
|
2129
|
-
<span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-foreground/50" />
|
|
2130
|
-
)}
|
|
2131
|
-
</button>
|
|
2132
|
-
<select
|
|
2133
|
-
value={label.color}
|
|
2134
|
-
onChange={(e) => {
|
|
2135
|
-
const updated = [...quickLabelsState];
|
|
2136
|
-
updated[index] = { ...label, color: e.target.value };
|
|
2137
|
-
setQuickLabelsState(updated);
|
|
2138
|
-
saveQuickLabels(updated);
|
|
2139
|
-
}}
|
|
2140
|
-
className="px-1.5 py-1 bg-background/80 rounded text-[10px] focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
2141
|
-
>
|
|
2142
|
-
{Object.keys(LABEL_COLOR_MAP).map(c => (
|
|
2143
|
-
<option key={c} value={c}>{c}</option>
|
|
2144
|
-
))}
|
|
2145
|
-
</select>
|
|
2146
|
-
<span className="text-[10px] text-muted-foreground/50 font-mono w-8 text-center flex-shrink-0">
|
|
2147
|
-
{index < 10 ? `${altKey}${isMac ? '' : '+'}${index === 9 ? '0' : index + 1}` : ''}
|
|
2148
|
-
</span>
|
|
2149
|
-
<button
|
|
2150
|
-
onClick={() => {
|
|
2151
|
-
const updated = quickLabelsState.filter((_, i) => i !== index);
|
|
2152
|
-
setQuickLabelsState(updated);
|
|
2153
|
-
saveQuickLabels(updated);
|
|
2154
|
-
if (editingTipIndex === index) setEditingTipIndex(null);
|
|
2155
|
-
}}
|
|
2156
|
-
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors flex-shrink-0"
|
|
2157
|
-
title="Remove label"
|
|
2158
|
-
>
|
|
2159
|
-
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
2160
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
2161
|
-
</svg>
|
|
2162
|
-
</button>
|
|
2163
|
-
</div>
|
|
2164
|
-
{/* Tip editor — slides open below the row */}
|
|
2165
|
-
{isEditingTip && (
|
|
2166
|
-
<div
|
|
2167
|
-
className="flex items-center gap-1.5 px-2 pb-2 pt-0"
|
|
2168
|
-
style={{ animation: 'tip-slide-open 0.15s ease-out' }}
|
|
2169
|
-
>
|
|
2170
|
-
<svg className="w-3 h-3 text-muted-foreground/40 flex-shrink-0 ml-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
2171
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
|
2172
|
-
</svg>
|
|
2173
|
-
<input
|
|
2174
|
-
type="text"
|
|
2175
|
-
value={editingTipValue}
|
|
2176
|
-
onChange={(e) => setEditingTipValue(e.target.value)}
|
|
2177
|
-
onKeyDown={(e) => {
|
|
2178
|
-
if (e.key === 'Enter') {
|
|
2179
|
-
const updated = [...quickLabelsState];
|
|
2180
|
-
updated[index] = { ...label, tip: editingTipValue || undefined };
|
|
2181
|
-
setQuickLabelsState(updated);
|
|
2182
|
-
saveQuickLabels(updated);
|
|
2183
|
-
setEditingTipIndex(null);
|
|
2184
|
-
}
|
|
2185
|
-
if (e.key === 'Escape') {
|
|
2186
|
-
e.preventDefault();
|
|
2187
|
-
e.stopPropagation();
|
|
2188
|
-
setEditingTipIndex(null);
|
|
2189
|
-
}
|
|
2190
|
-
}}
|
|
2191
|
-
placeholder="AI instruction tip..."
|
|
2192
|
-
className="flex-1 px-2 py-1 bg-background/60 rounded text-[10px] text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
2193
|
-
autoFocus
|
|
2194
|
-
onFocus={(e) => { e.target.setSelectionRange(0, 0); e.target.scrollLeft = 0; }}
|
|
2195
|
-
/>
|
|
2196
|
-
<button
|
|
2197
|
-
onClick={() => {
|
|
2198
|
-
const updated = [...quickLabelsState];
|
|
2199
|
-
updated[index] = { ...label, tip: editingTipValue || undefined };
|
|
2200
|
-
setQuickLabelsState(updated);
|
|
2201
|
-
saveQuickLabels(updated);
|
|
2202
|
-
setEditingTipIndex(null);
|
|
2203
|
-
}}
|
|
2204
|
-
className="p-1 rounded text-muted-foreground/50 hover:text-green-500 hover:bg-green-500/10 transition-colors flex-shrink-0"
|
|
2205
|
-
title="Save tip"
|
|
2206
|
-
>
|
|
2207
|
-
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
2208
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
2209
|
-
</svg>
|
|
2210
|
-
</button>
|
|
2211
|
-
</div>
|
|
2212
|
-
)}
|
|
2213
|
-
</div>
|
|
2214
|
-
);
|
|
2215
|
-
})}
|
|
2216
|
-
</div>
|
|
2217
|
-
|
|
2218
|
-
{quickLabelsState.length < 12 && (
|
|
2219
|
-
<button
|
|
2220
|
-
onClick={() => {
|
|
2221
|
-
const newLabel: QuickLabel = {
|
|
2222
|
-
id: `custom-${Date.now()}`,
|
|
2223
|
-
emoji: '📌',
|
|
2224
|
-
text: 'New label',
|
|
2225
|
-
color: 'blue',
|
|
2226
|
-
};
|
|
2227
|
-
const updated = [...quickLabelsState, newLabel];
|
|
2228
|
-
setQuickLabelsState(updated);
|
|
2229
|
-
saveQuickLabels(updated);
|
|
2230
|
-
}}
|
|
2231
|
-
className="w-full py-1.5 text-xs text-muted-foreground hover:text-foreground border border-dashed border-border rounded-lg hover:border-foreground/30 transition-colors"
|
|
2232
|
-
>
|
|
2233
|
-
+ Add label
|
|
2234
|
-
</button>
|
|
2235
|
-
)}
|
|
2236
|
-
|
|
2237
|
-
<div className="text-[10px] text-muted-foreground/70">
|
|
2238
|
-
Use {altKey}{isMac ? '' : '+'}1 through {altKey}{isMac ? '' : '+'}0 when the annotation toolbar is visible to apply a label instantly.
|
|
2239
|
-
</div>
|
|
2240
|
-
</>
|
|
2241
|
-
)}
|
|
2053
|
+
{activeTab === 'labels' && <QuickLabelsTab />}
|
|
2242
2054
|
|
|
2243
2055
|
{/* === VIM TAB === */}
|
|
2244
2056
|
{activeTab === 'vim' && mode !== 'review' && (
|