@plannotator/ui 0.37.0 → 0.38.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 +4 -2
- package/README.md +8 -1
- package/components/ActionMenu.tsx +22 -24
- package/components/AnnotationToolbar.tsx +3 -1
- package/components/ApproveDropdown.tsx +15 -18
- package/components/CommentPopover.tsx +9 -4
- package/components/DecisionControl.tsx +626 -0
- package/components/FloatingQuickLabelPicker.tsx +4 -1
- package/components/KeyboardShortcuts.tsx +1 -0
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/Settings.tsx +67 -6
- package/components/ToolbarButtons.tsx +31 -9
- package/components/Viewer.tsx +5 -1
- package/components/blocks/AlertBlock.tsx +51 -4
- package/config/reviewView.ts +1 -0
- package/config/settings.ts +48 -3
- package/configure.ts +9 -0
- package/hooks/useDismissablePopover.ts +65 -0
- package/hooks/useVimDocumentFocus.ts +6 -0
- package/package.json +22 -22
- package/shortcuts/decisionControl.shortcuts.ts +31 -0
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/documentView.shortcuts.ts +13 -0
- package/styles.css +1 -1
- package/theme.css +11 -0
- package/utils/alertTitle.ts +69 -0
- package/utils/decisionSpec.ts +430 -0
- package/utils/platform.ts +16 -0
- package/sprite_package_additional/index.html +0 -34
- package/sprite_package_new/index.html +0 -34
- package/sprite_package_pulluphang/index.html +0 -34
package/components/Settings.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
|
|
|
3
3
|
import type { AnnotateAgentTerminalSide } from '@plannotator/core/agent-terminal';
|
|
4
4
|
import type { Origin } from '@plannotator/core/agents';
|
|
5
5
|
import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
|
|
6
|
+
import type { TokenHoverDelay } from '@plannotator/core/token-hover';
|
|
6
7
|
import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType, setReviewAutoViewed } from '../config';
|
|
7
8
|
import { setWebMcpToolsEnabled, useWebMcpToolsEnabled } from '../webmcp/preference';
|
|
8
9
|
import { loadDiffFont } from '../utils/diffFonts';
|
|
@@ -64,7 +65,7 @@ import { useAgents } from '../hooks/useAgents';
|
|
|
64
65
|
import { KeyboardShortcuts } from './KeyboardShortcuts';
|
|
65
66
|
import { type QuickLabel, getQuickLabels, saveQuickLabels, resetQuickLabels, DEFAULT_QUICK_LABELS, getLabelColors, LABEL_COLOR_MAP } from '../utils/quickLabels';
|
|
66
67
|
import { ThemeTab } from './ThemeTab';
|
|
67
|
-
import { isMac, modKey, altKey } from '../utils/platform';
|
|
68
|
+
import { isMac, modKey, modKeyWord, altKey } from '../utils/platform';
|
|
68
69
|
import { getAIProviderSettings, resolveAIProviderSelection } from '../utils/aiProvider';
|
|
69
70
|
import { AISettingsTab } from './AISettingsTab';
|
|
70
71
|
import { HooksTab } from './settings/HooksTab';
|
|
@@ -133,6 +134,23 @@ export const DIFF_STYLE_OPTIONS = [
|
|
|
133
134
|
{ value: 'split' as const, label: 'Split' },
|
|
134
135
|
{ value: 'unified' as const, label: 'Unified' },
|
|
135
136
|
];
|
|
137
|
+
/**
|
|
138
|
+
* The modifier gate is the platform's primary modifier: Cmd on macOS, Ctrl on
|
|
139
|
+
* Windows and Linux. Labeled through modKeyWord so the control names the key
|
|
140
|
+
* the reader actually has.
|
|
141
|
+
*/
|
|
142
|
+
export const TOKEN_HOVER_TRIGGER_OPTIONS = [
|
|
143
|
+
{ value: 'hover' as const, label: 'On hover' },
|
|
144
|
+
{ value: 'modifier' as const, label: `Hold ${modKeyWord}` },
|
|
145
|
+
{ value: 'off' as const, label: 'Off' },
|
|
146
|
+
];
|
|
147
|
+
/** SegmentedControl keys on strings, so the ms values ride as their digits. */
|
|
148
|
+
export type TokenHoverDelayOption = '150' | '300' | '700';
|
|
149
|
+
export const TOKEN_HOVER_DELAY_OPTIONS = [
|
|
150
|
+
{ value: '150' as const, label: 'Fast' },
|
|
151
|
+
{ value: '300' as const, label: 'Default' },
|
|
152
|
+
{ value: '700' as const, label: 'Relaxed' },
|
|
153
|
+
];
|
|
136
154
|
export const OVERFLOW_OPTIONS = [
|
|
137
155
|
{ value: 'scroll' as const, label: 'Scroll' },
|
|
138
156
|
{ value: 'wrap' as const, label: 'Wrap' },
|
|
@@ -157,6 +175,7 @@ const DEFAULT_DIFF_TYPE_OPTIONS = [
|
|
|
157
175
|
// "All Changes" belongs to since-base (the flagship composite); uncommitted
|
|
158
176
|
// reverts to its plain name so the two stay distinguishable side by side.
|
|
159
177
|
{ value: 'since-base' as const, label: 'All Changes (Recommended)', description: "Everything since your branch split from main — committed, uncommitted, and untracked" },
|
|
178
|
+
{ value: 'local-vs-remote' as const, label: 'Local vs Remote Branch', description: "Your local branch and working tree compared with its last-fetched remote-tracking branch" },
|
|
160
179
|
{ value: 'uncommitted' as const, label: 'Uncommitted', description: "Everything you've changed since your last commit" },
|
|
161
180
|
{ value: 'unstaged' as const, label: 'Unstaged', description: "Only changes you haven't staged yet" },
|
|
162
181
|
{ value: 'staged' as const, label: 'Staged', description: "Only changes you've staged for commit" },
|
|
@@ -170,22 +189,27 @@ const AGENT_TERMINAL_SIDE_OPTIONS: { value: AnnotateAgentTerminalSide; label: st
|
|
|
170
189
|
{ value: 'hidden', label: 'Hidden' },
|
|
171
190
|
];
|
|
172
191
|
|
|
173
|
-
function SegmentedControl<T extends string>({ options, value, onChange }: {
|
|
192
|
+
function SegmentedControl<T extends string>({ options, value, onChange, disabled = false }: {
|
|
174
193
|
options: { value: T; label: string }[];
|
|
175
194
|
value: T;
|
|
176
195
|
onChange: (v: T) => void;
|
|
196
|
+
/** Visible but inert, for a control whose axis does not apply right now. */
|
|
197
|
+
disabled?: boolean;
|
|
177
198
|
}) {
|
|
178
199
|
return (
|
|
179
|
-
<div className=
|
|
200
|
+
<div className={['flex items-center gap-1 bg-muted/50 rounded-lg p-0.5', disabled && 'opacity-50'].filter(Boolean).join(' ')}>
|
|
180
201
|
{options.map((opt) => (
|
|
181
202
|
<button
|
|
182
203
|
key={opt.value}
|
|
204
|
+
{...(disabled ? { disabled: true } : {})}
|
|
183
205
|
onClick={() => onChange(opt.value)}
|
|
184
|
-
className={
|
|
206
|
+
className={[
|
|
207
|
+
'flex-1 px-3 py-1.5 text-xs rounded-md transition-colors',
|
|
208
|
+
disabled && 'cursor-not-allowed',
|
|
185
209
|
value === opt.value
|
|
186
210
|
? 'bg-background text-foreground shadow-sm font-medium'
|
|
187
|
-
: 'text-muted-foreground hover:text-foreground'
|
|
188
|
-
}
|
|
211
|
+
: 'text-muted-foreground hover:text-foreground',
|
|
212
|
+
].filter(Boolean).join(' ')}
|
|
189
213
|
>
|
|
190
214
|
{opt.label}
|
|
191
215
|
</button>
|
|
@@ -470,6 +494,8 @@ const ReviewDisplayTab: React.FC<{ isCompactTouchLayout?: boolean }> = ({ isComp
|
|
|
470
494
|
const diffExpandUnchanged = useConfigValue('diffExpandUnchanged');
|
|
471
495
|
const diffFontFamily = useConfigValue('diffFontFamily');
|
|
472
496
|
const diffFontSize = useConfigValue('diffFontSize');
|
|
497
|
+
const tokenHoverTrigger = useConfigValue('tokenHoverTrigger');
|
|
498
|
+
const tokenHoverDelay = useConfigValue('tokenHoverDelay');
|
|
473
499
|
|
|
474
500
|
// Load font for the preview swatch
|
|
475
501
|
useEffect(() => {
|
|
@@ -493,6 +519,41 @@ const ReviewDisplayTab: React.FC<{ isCompactTouchLayout?: boolean }> = ({ isComp
|
|
|
493
519
|
|
|
494
520
|
<div className="border-t border-border" />
|
|
495
521
|
|
|
522
|
+
{/* Hover cards (internally tokenHover*; the label is what changed, not
|
|
523
|
+
the ids). One trigger select rather than a toggle plus a mode: `Off`
|
|
524
|
+
is a value of the same question, so there is no unreachable
|
|
525
|
+
enabled-but-off state to reason about. The delay stays a separate
|
|
526
|
+
axis because "too eager" is a complaint neither the hold-modifier option
|
|
527
|
+
nor Off answers. */}
|
|
528
|
+
<div className="space-y-3">
|
|
529
|
+
<div>
|
|
530
|
+
<div className="text-sm font-medium">Hover cards</div>
|
|
531
|
+
<div className="text-xs text-muted-foreground">
|
|
532
|
+
Rest the pointer on a symbol in a diff to see where it is defined and who
|
|
533
|
+
references it. Needs ripgrep and a local checkout; nothing appears when the
|
|
534
|
+
search comes back empty. {modKeyWord}+click still opens the References panel either way.
|
|
535
|
+
</div>
|
|
536
|
+
</div>
|
|
537
|
+
<SegmentedControl
|
|
538
|
+
options={TOKEN_HOVER_TRIGGER_OPTIONS}
|
|
539
|
+
value={tokenHoverTrigger}
|
|
540
|
+
onChange={(v) => configStore.set('tokenHoverTrigger', v)}
|
|
541
|
+
/>
|
|
542
|
+
<div className="space-y-2">
|
|
543
|
+
<div className="text-xs text-muted-foreground">
|
|
544
|
+
How long the pointer rests before a card is requested
|
|
545
|
+
</div>
|
|
546
|
+
<SegmentedControl
|
|
547
|
+
options={TOKEN_HOVER_DELAY_OPTIONS}
|
|
548
|
+
value={String(tokenHoverDelay) as TokenHoverDelayOption}
|
|
549
|
+
onChange={(v) => configStore.set('tokenHoverDelay', Number(v) as TokenHoverDelay)}
|
|
550
|
+
disabled={tokenHoverTrigger === 'off'}
|
|
551
|
+
/>
|
|
552
|
+
</div>
|
|
553
|
+
</div>
|
|
554
|
+
|
|
555
|
+
<div className="border-t border-border" />
|
|
556
|
+
|
|
496
557
|
{/* Font Family */}
|
|
497
558
|
<div className="space-y-2">
|
|
498
559
|
<div>
|
|
@@ -14,7 +14,6 @@ interface FeedbackButtonProps {
|
|
|
14
14
|
loadingLabel?: string;
|
|
15
15
|
shortLoadingLabel?: string;
|
|
16
16
|
title?: string;
|
|
17
|
-
muted?: boolean;
|
|
18
17
|
labelBreakpoint?: ToolbarLabelBreakpoint;
|
|
19
18
|
}
|
|
20
19
|
|
|
@@ -27,7 +26,6 @@ export const FeedbackButton: React.FC<FeedbackButtonProps> = ({
|
|
|
27
26
|
loadingLabel = 'Sending...',
|
|
28
27
|
shortLoadingLabel,
|
|
29
28
|
title = 'Send Feedback',
|
|
30
|
-
muted = false,
|
|
31
29
|
labelBreakpoint = 'md',
|
|
32
30
|
}) => (
|
|
33
31
|
<Button
|
|
@@ -37,7 +35,6 @@ export const FeedbackButton: React.FC<FeedbackButtonProps> = ({
|
|
|
37
35
|
disabled={disabled}
|
|
38
36
|
title={title}
|
|
39
37
|
iconLeft={<Send className="size-3.5" />}
|
|
40
|
-
className={cn(muted && 'opacity-50 cursor-not-allowed')}
|
|
41
38
|
>
|
|
42
39
|
{shortLabel ? (
|
|
43
40
|
<>
|
|
@@ -65,8 +62,10 @@ export interface ApproveButtonProps {
|
|
|
65
62
|
mobileLabel?: string;
|
|
66
63
|
mobileLoadingLabel?: string;
|
|
67
64
|
title?: string;
|
|
65
|
+
/** Kept for plan mode (packages/editor AppHeader) — the softened "you have
|
|
66
|
+
* annotations" treatment. The old platform-mode `muted` prop died with
|
|
67
|
+
* PR6 (DecisionControl owns the self-approval mute now). */
|
|
68
68
|
dimmed?: boolean;
|
|
69
|
-
muted?: boolean;
|
|
70
69
|
labelBreakpoint?: ToolbarLabelBreakpoint;
|
|
71
70
|
}
|
|
72
71
|
|
|
@@ -80,7 +79,6 @@ export const ApproveButton: React.FC<ApproveButtonProps> = ({
|
|
|
80
79
|
mobileLoadingLabel = '...',
|
|
81
80
|
title,
|
|
82
81
|
dimmed = false,
|
|
83
|
-
muted = false,
|
|
84
82
|
labelBreakpoint = 'md',
|
|
85
83
|
}) => (
|
|
86
84
|
<Button
|
|
@@ -91,9 +89,8 @@ export const ApproveButton: React.FC<ApproveButtonProps> = ({
|
|
|
91
89
|
title={title}
|
|
92
90
|
iconLeft={<Check className="size-3.5" />}
|
|
93
91
|
className={cn(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
dimmed && !muted && !disabled && 'bg-success/50 text-success-foreground/70 hover:bg-success hover:text-success-foreground',
|
|
92
|
+
disabled && 'bg-muted text-muted-foreground hover:bg-muted',
|
|
93
|
+
dimmed && !disabled && 'bg-success/50 text-success-foreground/70 hover:bg-success hover:text-success-foreground',
|
|
97
94
|
)}
|
|
98
95
|
>
|
|
99
96
|
<span className={labelBreakpoint === 'lg' ? 'lg:hidden' : 'md:hidden'}>
|
|
@@ -111,6 +108,10 @@ interface ExitButtonProps {
|
|
|
111
108
|
isLoading?: boolean;
|
|
112
109
|
title?: string;
|
|
113
110
|
labelBreakpoint?: ToolbarLabelBreakpoint;
|
|
111
|
+
/** `'ghost'` renders the icon-only ghost-X form that sits beside the
|
|
112
|
+
* decision control. Default `'pill'` keeps every existing caller
|
|
113
|
+
* byte-identical. */
|
|
114
|
+
appearance?: 'pill' | 'ghost';
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
export const ExitButton: React.FC<ExitButtonProps> = ({
|
|
@@ -119,7 +120,28 @@ export const ExitButton: React.FC<ExitButtonProps> = ({
|
|
|
119
120
|
isLoading = false,
|
|
120
121
|
title = 'Close session without sending feedback',
|
|
121
122
|
labelBreakpoint = 'md',
|
|
122
|
-
|
|
123
|
+
appearance = 'pill',
|
|
124
|
+
}) => appearance === 'ghost' ? (
|
|
125
|
+
<Button
|
|
126
|
+
variant="ghost"
|
|
127
|
+
size="xs"
|
|
128
|
+
onClick={onClick}
|
|
129
|
+
disabled={disabled || isLoading}
|
|
130
|
+
title={title}
|
|
131
|
+
aria-label={title}
|
|
132
|
+
// Maintainer ruling (post-demo): a bare X carries "close without
|
|
133
|
+
// sending" semantics an unlabeled icon should not carry alone — the
|
|
134
|
+
// word renders at every breakpoint.
|
|
135
|
+
className="px-2 text-muted-foreground gap-1.5"
|
|
136
|
+
>
|
|
137
|
+
{isLoading ? '…' : (
|
|
138
|
+
<>
|
|
139
|
+
<X className="size-3.5" aria-hidden="true" />
|
|
140
|
+
<span>Close</span>
|
|
141
|
+
</>
|
|
142
|
+
)}
|
|
143
|
+
</Button>
|
|
144
|
+
) : (
|
|
123
145
|
<Button
|
|
124
146
|
variant="secondary"
|
|
125
147
|
size="xs"
|
package/components/Viewer.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { generateId } from '../utils/generateId';
|
|
1
2
|
import React, { useRef, useState, useEffect, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react';
|
|
2
3
|
import { createPortal } from 'react-dom';
|
|
3
4
|
import { AnnotationType, type Block, type Annotation, type EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types';
|
|
@@ -908,7 +909,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
908
909
|
|
|
909
910
|
if (viewerCommentPopover.isGlobal) {
|
|
910
911
|
const newAnnotation: Annotation = {
|
|
911
|
-
|
|
912
|
+
// randomUUID, not Date.now(): two comments minted in the same
|
|
913
|
+
// millisecond (paste + submit, or a concurrent external write)
|
|
914
|
+
// would collide on a timestamp id.
|
|
915
|
+
id: generateId('global'),
|
|
912
916
|
blockId: '',
|
|
913
917
|
startOffset: 0,
|
|
914
918
|
endOffset: 0,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import type { AlertKind } from '../../types';
|
|
3
3
|
import { renderProseBody } from './proseBody';
|
|
4
|
+
import { InlineMarkdown } from '../InlineMarkdown';
|
|
5
|
+
import { parseAlertTitleLine } from '../../utils/alertTitle';
|
|
4
6
|
|
|
5
7
|
interface AlertBlockProps {
|
|
6
8
|
blockId: string;
|
|
@@ -22,6 +24,25 @@ const TITLE: Record<AlertKind, string> = {
|
|
|
22
24
|
important: 'Important',
|
|
23
25
|
};
|
|
24
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Host seam: resolve a named icon from a title line's `<!-- icon: name -->`
|
|
29
|
+
* comment to a React node. Default: null for every name, so the alert keeps
|
|
30
|
+
* the type's own icon exactly as today. A host that ships an icon set (the
|
|
31
|
+
* package deliberately bundles none) registers a renderer once at startup.
|
|
32
|
+
* Returning null for an unknown name falls back to the type's icon.
|
|
33
|
+
*/
|
|
34
|
+
export type AlertIconRenderer = (name: string) => React.ReactNode | null;
|
|
35
|
+
|
|
36
|
+
let alertIconRenderer: AlertIconRenderer | null = null;
|
|
37
|
+
|
|
38
|
+
export function setAlertIconRenderer(renderer: AlertIconRenderer | null): void {
|
|
39
|
+
alertIconRenderer = renderer;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resetAlertIconRenderer(): void {
|
|
43
|
+
alertIconRenderer = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
25
46
|
const Icon: React.FC<{ kind: AlertKind }> = ({ kind }) => {
|
|
26
47
|
const common = { viewBox: '0 0 16 16', width: '16', height: '16', fill: 'currentColor', 'aria-hidden': true as const };
|
|
27
48
|
switch (kind) {
|
|
@@ -41,6 +62,19 @@ const Icon: React.FC<{ kind: AlertKind }> = ({ kind }) => {
|
|
|
41
62
|
export const AlertBlock: React.FC<AlertBlockProps> = ({
|
|
42
63
|
blockId, kind, body, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, githubRepo, onNavigateAnchor,
|
|
43
64
|
}) => {
|
|
65
|
+
const proseProps = { imageBaseDir, onImageClick, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor, githubRepo };
|
|
66
|
+
|
|
67
|
+
// A bold-only first line (optionally led by one emoji, optionally trailed by
|
|
68
|
+
// an `<!-- icon: name -->` comment) is the alert's title and rides the icon
|
|
69
|
+
// row in place of the type word; the emoji, or a host-resolved named icon,
|
|
70
|
+
// takes the icon slot. See utils/alertTitle for the exact grammar. A body
|
|
71
|
+
// with no such line renders exactly as before.
|
|
72
|
+
const titleLine = parseAlertTitleLine(body);
|
|
73
|
+
const hostIcon = titleLine?.icon && alertIconRenderer ? alertIconRenderer(titleLine.icon) : null;
|
|
74
|
+
const icon = titleLine?.emoji
|
|
75
|
+
? <span aria-hidden="true" className="alert-emoji text-base leading-none">{titleLine.emoji}</span>
|
|
76
|
+
: hostIcon ?? <Icon kind={kind} />;
|
|
77
|
+
|
|
44
78
|
return (
|
|
45
79
|
<div
|
|
46
80
|
className={`alert alert-${kind} my-4 pl-4 pr-3 py-2 border-l-[3px]`}
|
|
@@ -48,11 +82,24 @@ export const AlertBlock: React.FC<AlertBlockProps> = ({
|
|
|
48
82
|
data-block-type="alert"
|
|
49
83
|
data-alert-kind={kind}
|
|
50
84
|
>
|
|
51
|
-
<div
|
|
52
|
-
|
|
53
|
-
|
|
85
|
+
<div
|
|
86
|
+
className="alert-title flex items-center gap-2 font-semibold mb-1"
|
|
87
|
+
aria-label={titleLine?.title ? `${TITLE[kind]}: ${titleLine.title}` : undefined}
|
|
88
|
+
>
|
|
89
|
+
{icon}
|
|
90
|
+
<span>
|
|
91
|
+
{titleLine?.title
|
|
92
|
+
? <InlineMarkdown text={titleLine.title} {...proseProps} />
|
|
93
|
+
: TITLE[kind]}
|
|
94
|
+
</span>
|
|
54
95
|
</div>
|
|
55
|
-
{
|
|
96
|
+
{titleLine
|
|
97
|
+
? (titleLine.rest.trim()
|
|
98
|
+
// Indent the body by the icon's width plus the row gap so it starts
|
|
99
|
+
// at the title's left edge (16px icon + 8px gap = pl-6).
|
|
100
|
+
? <div className="alert-body pl-6">{renderProseBody({ body: titleLine.rest, ...proseProps })}</div>
|
|
101
|
+
: null)
|
|
102
|
+
: renderProseBody({ body, ...proseProps })}
|
|
56
103
|
</div>
|
|
57
104
|
);
|
|
58
105
|
};
|
package/config/reviewView.ts
CHANGED
package/config/settings.ts
CHANGED
|
@@ -15,6 +15,13 @@ import {
|
|
|
15
15
|
} from '@plannotator/core/agent-terminal';
|
|
16
16
|
import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
|
|
17
17
|
import { isFaviconStyle, type FaviconStyle } from '@plannotator/core/favicon';
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_TOKEN_HOVER_DELAY_MS,
|
|
20
|
+
isTokenHoverDelay,
|
|
21
|
+
resolveStoredTokenHoverTrigger,
|
|
22
|
+
type TokenHoverDelay,
|
|
23
|
+
type TokenHoverTrigger,
|
|
24
|
+
} from '@plannotator/core/token-hover';
|
|
18
25
|
import { storage } from '../utils/storage';
|
|
19
26
|
import { generateIdentity } from '../utils/generateIdentity';
|
|
20
27
|
import {
|
|
@@ -276,6 +283,44 @@ export const SETTINGS = {
|
|
|
276
283
|
serverKey: undefined, fromServer: undefined, toServer: undefined,
|
|
277
284
|
},
|
|
278
285
|
|
|
286
|
+
// Hovering a token in a code-review diff opens a card with what the search
|
|
287
|
+
// backend knows about that symbol. Cookie-only like the other review-chrome
|
|
288
|
+
// preferences: it is presentational, per-browser, and changes no review
|
|
289
|
+
// semantics — `off` simply means no listeners, no requests and no card.
|
|
290
|
+
//
|
|
291
|
+
// This one select REPLACED the original `tokenHoverCards` boolean rather
|
|
292
|
+
// than sitting beside it: a toggle plus a mode has an unreachable state
|
|
293
|
+
// (disabled + modifier) and asks one question with two controls. The legacy
|
|
294
|
+
// cookie is still read — on every load until the user touches this setting,
|
|
295
|
+
// since a migrating read returns a value and so never triggers the
|
|
296
|
+
// registry's default-seeding write — so an early adopter who turned cards
|
|
297
|
+
// off stays off. Resolution is pure and identical every time; see
|
|
298
|
+
// resolveStoredTokenHoverTrigger.
|
|
299
|
+
tokenHoverTrigger: {
|
|
300
|
+
defaultValue: 'hover' as TokenHoverTrigger,
|
|
301
|
+
fromCookie: () => resolveStoredTokenHoverTrigger(
|
|
302
|
+
storage.getItem('plannotator-token-hover-trigger'),
|
|
303
|
+
storage.getItem('plannotator-token-hover-cards'),
|
|
304
|
+
),
|
|
305
|
+
toCookie: (value: TokenHoverTrigger) =>
|
|
306
|
+
storage.setItem('plannotator-token-hover-trigger', value),
|
|
307
|
+
serverKey: undefined, fromServer: undefined, toServer: undefined,
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
// How long the pointer rests on a symbol before a card is requested. Three
|
|
311
|
+
// fixed steps, not a slider: "too eager" is a real complaint that neither
|
|
312
|
+
// `modifier` nor `off` answers, but nobody can tell 340ms from 360ms.
|
|
313
|
+
tokenHoverDelay: {
|
|
314
|
+
defaultValue: DEFAULT_TOKEN_HOVER_DELAY_MS as TokenHoverDelay,
|
|
315
|
+
fromCookie: () => {
|
|
316
|
+
const parsed = Number(storage.getItem('plannotator-token-hover-delay'));
|
|
317
|
+
return isTokenHoverDelay(parsed) ? parsed : undefined;
|
|
318
|
+
},
|
|
319
|
+
toCookie: (value: TokenHoverDelay) =>
|
|
320
|
+
storage.setItem('plannotator-token-hover-delay', String(value)),
|
|
321
|
+
serverKey: undefined, fromServer: undefined, toServer: undefined,
|
|
322
|
+
},
|
|
323
|
+
|
|
279
324
|
reviewShowStageControls: {
|
|
280
325
|
defaultValue: true as boolean,
|
|
281
326
|
fromCookie: () => {
|
|
@@ -288,18 +333,18 @@ export const SETTINGS = {
|
|
|
288
333
|
},
|
|
289
334
|
|
|
290
335
|
defaultDiffType: {
|
|
291
|
-
defaultValue: 'since-base' as 'since-base' | 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all',
|
|
336
|
+
defaultValue: 'since-base' as 'since-base' | 'local-vs-remote' | 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all',
|
|
292
337
|
fromCookie: () => {
|
|
293
338
|
const v = storage.getItem('plannotator-default-diff-type');
|
|
294
339
|
if (v === 'branch') return 'merge-base' as const;
|
|
295
|
-
return v === 'since-base' || v === 'uncommitted' || v === 'unstaged' || v === 'staged' || v === 'merge-base' || v === 'all' ? v : undefined;
|
|
340
|
+
return v === 'since-base' || v === 'local-vs-remote' || v === 'uncommitted' || v === 'unstaged' || v === 'staged' || v === 'merge-base' || v === 'all' ? v : undefined;
|
|
296
341
|
},
|
|
297
342
|
toCookie: (v: string) => storage.setItem('plannotator-default-diff-type', v),
|
|
298
343
|
serverKey: 'diffOptions',
|
|
299
344
|
fromServer: (sc: Record<string, unknown>) => {
|
|
300
345
|
const v = (sc.diffOptions as Record<string, unknown> | undefined)?.defaultDiffType;
|
|
301
346
|
if (v === 'branch') return 'merge-base' as const;
|
|
302
|
-
return v === 'since-base' || v === 'uncommitted' || v === 'unstaged' || v === 'staged' || v === 'merge-base' || v === 'all' ? v : undefined;
|
|
347
|
+
return v === 'since-base' || v === 'local-vs-remote' || v === 'uncommitted' || v === 'unstaged' || v === 'staged' || v === 'merge-base' || v === 'all' ? v : undefined;
|
|
303
348
|
},
|
|
304
349
|
toServer: (v: string) => ({ diffOptions: { defaultDiffType: v } }),
|
|
305
350
|
},
|
package/configure.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { setSkillCatalogTransport, setSkillContentTransport, type SkillCatalogTr
|
|
|
11
11
|
import { setWebMcpPolicy, type WebMcpPolicy } from './webmcp/policy';
|
|
12
12
|
import { setMathRendererLoader, type MathRenderer, type MathRendererLoader } from './utils/math';
|
|
13
13
|
import { setIdentityGenerator, type IdentityGenerator } from './utils/generateIdentity';
|
|
14
|
+
import { setAlertIconRenderer, type AlertIconRenderer } from './components/blocks/AlertBlock';
|
|
14
15
|
import { configStore } from './config';
|
|
15
16
|
import type { ServerSyncFn } from './config/configStore';
|
|
16
17
|
import type { ExternalAnnotationEvent, VaultNode } from './types';
|
|
@@ -38,6 +39,7 @@ export type {
|
|
|
38
39
|
MathRenderer,
|
|
39
40
|
MathRendererLoader,
|
|
40
41
|
IdentityGenerator,
|
|
42
|
+
AlertIconRenderer,
|
|
41
43
|
};
|
|
42
44
|
|
|
43
45
|
type ExternalAnnotationBase = { id: string; source?: string };
|
|
@@ -85,6 +87,12 @@ export interface PlannotatorUIConfig {
|
|
|
85
87
|
* dictionary by importing `@plannotator/ui/utils/identity-tater`.
|
|
86
88
|
*/
|
|
87
89
|
identityGenerator?: IdentityGenerator;
|
|
90
|
+
/**
|
|
91
|
+
* Resolve a GitHub alert title line's `<!-- icon: name -->` to a React node.
|
|
92
|
+
* Default: null for every name, so alerts keep the type's own icon exactly as
|
|
93
|
+
* today; the package bundles no icon set. A host with one registers a renderer.
|
|
94
|
+
*/
|
|
95
|
+
alertIconRenderer?: AlertIconRenderer;
|
|
88
96
|
/** Re-hydrate settings from the installed (SYNCHRONOUS) storageBackend after install. */
|
|
89
97
|
loadSettingsFromBackend?: boolean;
|
|
90
98
|
}
|
|
@@ -105,6 +113,7 @@ export function configurePlannotatorUI(config: PlannotatorUIConfig): void {
|
|
|
105
113
|
if (config.webmcp) setWebMcpPolicy(config.webmcp);
|
|
106
114
|
if (config.mathRendererLoader) setMathRendererLoader(config.mathRendererLoader);
|
|
107
115
|
if (config.identityGenerator) setIdentityGenerator(config.identityGenerator);
|
|
116
|
+
if (config.alertIconRenderer) setAlertIconRenderer(config.alertIconRenderer);
|
|
108
117
|
// Re-hydrate AFTER storageBackend is installed (load-bearing order — gated last).
|
|
109
118
|
if (config.loadSettingsFromBackend) configStore.loadFromBackend();
|
|
110
119
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Outside-`pointerdown` + Escape dismissal for anchored popovers — the one
|
|
5
|
+
* shared effect behind DecisionControl, ActionMenu, and ApproveDropdown.
|
|
6
|
+
* (FloatingQuickLabelPicker deliberately keeps its own dismissal: it needs
|
|
7
|
+
* deferred capture-phase registration so the click that opens it cannot
|
|
8
|
+
* dismiss it, and its Escape shares a listener with the digit-select keys.)
|
|
9
|
+
*
|
|
10
|
+
* `dismissOnIframeFocus` is the explicit strategy for framed surfaces
|
|
11
|
+
* (raw-HTML srcdoc, live-app proxy): a click inside the iframe never produces
|
|
12
|
+
* a `pointerdown` in the parent document, but it does move focus into the
|
|
13
|
+
* frame, which fires `blur` on the parent window. The check runs on the next
|
|
14
|
+
* task because `document.activeElement` is not yet updated inside the blur
|
|
15
|
+
* handler.
|
|
16
|
+
*/
|
|
17
|
+
export function useDismissablePopover({
|
|
18
|
+
enabled,
|
|
19
|
+
ref,
|
|
20
|
+
onDismiss,
|
|
21
|
+
dismissOnIframeFocus,
|
|
22
|
+
}: {
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
ref: React.RefObject<HTMLElement | null>;
|
|
25
|
+
onDismiss: () => void;
|
|
26
|
+
dismissOnIframeFocus?: boolean;
|
|
27
|
+
}) {
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!enabled) return;
|
|
30
|
+
|
|
31
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
32
|
+
const target = event.target as Node | null;
|
|
33
|
+
if (target && ref.current && ref.current.contains(target)) return;
|
|
34
|
+
onDismiss();
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
38
|
+
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
|
39
|
+
// Fail closed: an Escape that dismisses the popover is consumed here
|
|
40
|
+
// (document bubbles before window), so the host apps' window-level
|
|
41
|
+
// Escape ladders never also act on it — one Escape, one rung.
|
|
42
|
+
event.preventDefault();
|
|
43
|
+
event.stopPropagation();
|
|
44
|
+
onDismiss();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
let blurTimer: ReturnType<typeof setTimeout> | undefined;
|
|
48
|
+
const handleWindowBlur = () => {
|
|
49
|
+
blurTimer = setTimeout(() => {
|
|
50
|
+
if (document.activeElement?.tagName === 'IFRAME') onDismiss();
|
|
51
|
+
}, 0);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
document.addEventListener('pointerdown', handlePointerDown);
|
|
55
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
56
|
+
if (dismissOnIframeFocus) window.addEventListener('blur', handleWindowBlur);
|
|
57
|
+
|
|
58
|
+
return () => {
|
|
59
|
+
document.removeEventListener('pointerdown', handlePointerDown);
|
|
60
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
61
|
+
if (dismissOnIframeFocus) window.removeEventListener('blur', handleWindowBlur);
|
|
62
|
+
if (blurTimer !== undefined) clearTimeout(blurTimer);
|
|
63
|
+
};
|
|
64
|
+
}, [enabled, ref, onDismiss, dismissOnIframeFocus]);
|
|
65
|
+
}
|
|
@@ -18,6 +18,12 @@ const BLOCKING_OVERLAY_SELECTOR = [
|
|
|
18
18
|
// Vim from focusing the obscured document underneath those full-screen
|
|
19
19
|
// overlays while they are brought onto the shared dialog primitive.
|
|
20
20
|
'.fixed.inset-0',
|
|
21
|
+
// An open dismissable popover (ActionMenu, ApproveDropdown, DecisionControl)
|
|
22
|
+
// owns Escape through useDismissablePopover. Without this, the vim handler
|
|
23
|
+
// runs first (it registered earlier on document), preventDefaults while
|
|
24
|
+
// reclaiming focus, and the popover's own Escape handler then skips the
|
|
25
|
+
// defaultPrevented event — the menu never closes.
|
|
26
|
+
'[data-pn-dismissable-popover]',
|
|
21
27
|
].join(',');
|
|
22
28
|
|
|
23
29
|
/** Inputs for restoring keyboard ownership to a Vim-enabled document. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plannotator/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./components/*": "./components/*.tsx",
|
|
@@ -54,57 +54,57 @@
|
|
|
54
54
|
"!test-setup"
|
|
55
55
|
],
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@base-ui/react": "^1.
|
|
57
|
+
"@base-ui/react": "^1.7.0",
|
|
58
58
|
"@codemirror/autocomplete": "^6.20.3",
|
|
59
|
-
"@codemirror/commands": "^6.
|
|
59
|
+
"@codemirror/commands": "^6.11.0",
|
|
60
60
|
"@codemirror/lang-javascript": "^6.2.5",
|
|
61
61
|
"@codemirror/lang-json": "^6.0.2",
|
|
62
|
-
"@codemirror/lang-markdown": "^6.5.
|
|
62
|
+
"@codemirror/lang-markdown": "^6.5.2",
|
|
63
63
|
"@codemirror/lang-python": "^6.2.1",
|
|
64
64
|
"@codemirror/lang-yaml": "^6.1.3",
|
|
65
|
-
"@codemirror/language": "^6.12.
|
|
65
|
+
"@codemirror/language": "^6.12.4",
|
|
66
66
|
"@codemirror/legacy-modes": "^6.5.3",
|
|
67
67
|
"@codemirror/merge": "^6.12.2",
|
|
68
|
-
"@codemirror/search": "^6.7.
|
|
68
|
+
"@codemirror/search": "^6.7.2",
|
|
69
69
|
"@codemirror/state": "^6.6.0",
|
|
70
|
-
"@codemirror/view": "^6.43.
|
|
71
|
-
"@fontsource-variable/geist-mono": "5.
|
|
72
|
-
"@fontsource-variable/inter": "^5.
|
|
70
|
+
"@codemirror/view": "^6.43.10",
|
|
71
|
+
"@fontsource-variable/geist-mono": "5.3.0",
|
|
72
|
+
"@fontsource-variable/inter": "^5.3.0",
|
|
73
73
|
"@lezer/common": "^1.5.2",
|
|
74
74
|
"@lezer/highlight": "^1.2.3",
|
|
75
|
-
"@pierre/diffs": "1.3.
|
|
75
|
+
"@pierre/diffs": "1.3.6",
|
|
76
76
|
"@plannotator/atomic-editor": "^0.8.0",
|
|
77
77
|
"@plannotator/core": "0.25.1",
|
|
78
78
|
"@plannotator/markdown-editor": "^0.4.0",
|
|
79
79
|
"@plannotator/web-highlighter": "^0.8.1",
|
|
80
80
|
"@tanstack/react-table": "^8.21.3",
|
|
81
|
-
"@viz-js/viz": "^3.
|
|
81
|
+
"@viz-js/viz": "^3.29.0",
|
|
82
82
|
"class-variance-authority": "^0.7.1",
|
|
83
83
|
"clsx": "^2.1.1",
|
|
84
84
|
"diff": "^8.0.4",
|
|
85
|
-
"dompurify": "^3.
|
|
85
|
+
"dompurify": "^3.4.14",
|
|
86
86
|
"katex": "^0.16.47",
|
|
87
|
-
"lucide-react": "^1.
|
|
87
|
+
"lucide-react": "^1.38.0",
|
|
88
88
|
"marked": "^17.0.6",
|
|
89
|
-
"mermaid": "^11.
|
|
89
|
+
"mermaid": "^11.17.2",
|
|
90
90
|
"motion": "^12.38.0",
|
|
91
91
|
"perfect-freehand": "^1.2.2",
|
|
92
92
|
"tailwind-merge": "^3.6.0",
|
|
93
93
|
"unique-username-generator": "^1.5.1"
|
|
94
94
|
},
|
|
95
95
|
"peerDependencies": {
|
|
96
|
-
"react": "^19.2.
|
|
97
|
-
"react-dom": "^19.2.
|
|
96
|
+
"react": "^19.2.8",
|
|
97
|
+
"react-dom": "^19.2.8",
|
|
98
98
|
"tailwindcss": "^4.1.18"
|
|
99
99
|
},
|
|
100
100
|
"devDependencies": {
|
|
101
|
-
"@happy-dom/global-registrator": "^20.
|
|
102
|
-
"@tailwindcss/vite": "^4.
|
|
101
|
+
"@happy-dom/global-registrator": "^20.12.0",
|
|
102
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
103
103
|
"@types/bun": "^1.2.0",
|
|
104
|
-
"@types/react": "^19.2.
|
|
105
|
-
"@types/react-dom": "^19.2.
|
|
106
|
-
"react": "^19.2.
|
|
107
|
-
"react-dom": "^19.2.
|
|
104
|
+
"@types/react": "^19.2.18",
|
|
105
|
+
"@types/react-dom": "^19.2.5",
|
|
106
|
+
"react": "^19.2.8",
|
|
107
|
+
"react-dom": "^19.2.8",
|
|
108
108
|
"tailwindcss": "^4.1.18",
|
|
109
109
|
"typescript": "~5.8.2",
|
|
110
110
|
"vite": "^6.2.0"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { defineShortcutScope } from './core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The header decision control's note composer (shortcuts root, not
|
|
5
|
+
* plan-review/ or code-review/: both apps mount the identical control with
|
|
6
|
+
* identical semantics).
|
|
7
|
+
*
|
|
8
|
+
* Documents ONLY the chords the control actually implements — bindings must
|
|
9
|
+
* match shipped behavior (`DecisionControl.tsx`). Enter is a newline in the
|
|
10
|
+
* note field and must never be documented as submit.
|
|
11
|
+
*/
|
|
12
|
+
export const decisionControlShortcuts = defineShortcutScope({
|
|
13
|
+
id: 'decision-control',
|
|
14
|
+
title: 'Decision control',
|
|
15
|
+
shortcuts: {
|
|
16
|
+
submitNote: {
|
|
17
|
+
description: 'Send the note with the decision you picked',
|
|
18
|
+
bindings: ['Mod+Enter'],
|
|
19
|
+
section: 'Actions',
|
|
20
|
+
hint: "Available while the decision control's note field is open. Enter inserts a newline.",
|
|
21
|
+
displayOrder: 12,
|
|
22
|
+
},
|
|
23
|
+
closeNote: {
|
|
24
|
+
description: 'Step back to the decision menu, keeping the note',
|
|
25
|
+
bindings: ['Escape'],
|
|
26
|
+
section: 'Actions',
|
|
27
|
+
hint: "Available while the decision control's note field is open.",
|
|
28
|
+
displayOrder: 14,
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
});
|
package/shortcuts/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from './core';
|
|
2
2
|
export * from './runtime';
|
|
3
3
|
export { historyShortcuts, useHistoryShortcuts } from './history.shortcuts';
|
|
4
|
+
export { decisionControlShortcuts } from './decisionControl.shortcuts';
|
|
4
5
|
|
|
5
6
|
// plan-review scopes
|
|
6
7
|
export { annotationModeShortcuts, useAnnotationModeShortcuts } from './plan-review/annotationMode.shortcuts';
|