@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 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 === 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,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
- }> = ({ 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
+ /** 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
- 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
+ };
@@ -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 { isMac, modKey, modKeyWord, altKey } from '../utils/platform';
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' && (