@plannotator/ui 0.28.0 → 0.29.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/README.md +6 -0
- package/components/AISettingsTab.tsx +5 -4
- package/components/ActionMenu.tsx +5 -1
- package/components/AgentsTab.tsx +10 -23
- package/components/AnnotationPanel.tsx +9 -5
- package/components/AnnotationToolbar.tsx +5 -13
- package/components/AnnotationToolstrip.tsx +2 -2
- package/components/ApproveDropdown.tsx +1 -1
- package/components/BlockRenderer.tsx +1 -1
- package/components/CodeFilePopout.tsx +5 -4
- package/components/CommentPopover.tsx +391 -65
- package/components/DocBadges.tsx +29 -11
- package/components/ExportModal.tsx +16 -8
- package/components/GraphvizBlock.tsx +1 -1
- package/components/InlineMarkdown.tsx +30 -13
- package/components/KeyboardShortcuts.tsx +37 -2
- package/components/Landing.tsx +7 -7
- package/components/MenuVersionSection.tsx +4 -4
- package/components/MermaidBlock.tsx +1 -1
- package/components/ModeToggle.tsx +7 -6
- package/components/OpenInAppButton.tsx +2 -5
- package/components/PinpointOverlay.tsx +9 -7
- package/components/PlanHeaderMenu.tsx +8 -8
- package/components/PopoutDialog.tsx +6 -1
- package/components/ResizeHandle.tsx +1 -0
- package/components/Settings.tsx +172 -12
- package/components/SkillReferenceMenu.tsx +260 -0
- package/components/StickyHeaderLane.tsx +7 -0
- package/components/ThemeProvider.tsx +131 -32
- package/components/ThemeTab.tsx +123 -77
- package/components/ToolbarButtons.tsx +29 -8
- package/components/Viewer.tsx +396 -130
- package/components/VimKeyHud.tsx +695 -0
- package/components/VimModeAnnouncementDialog.tsx +557 -0
- package/components/VimModeOverlay.tsx +235 -0
- package/components/VimTargetReticle.tsx +284 -0
- package/components/ai/DocumentAIChatPanel.tsx +1 -1
- package/components/blocks/CodeBlock.tsx +18 -18
- package/components/blocks/TablePopout.tsx +7 -8
- package/components/blocks/TableToolbar.tsx +7 -8
- package/components/goal-setup/GoalSetupSurface.tsx +16 -3
- package/components/html-viewer/HtmlViewer.tsx +450 -47
- package/components/html-viewer/annotationNumbering.ts +37 -0
- package/components/html-viewer/bridge-script.ts +4051 -298
- package/components/html-viewer/composerYield.ts +51 -0
- package/components/html-viewer/srcdoc.ts +18 -3
- package/components/html-viewer/useHtmlAnnotation.ts +457 -32
- package/components/icons/themeIcons.tsx +1 -1
- package/components/plan-diff/PlanCleanDiffView.tsx +9 -9
- package/components/plan-diff/PlanDiffBadge.tsx +22 -1
- package/components/settings/HooksTab.tsx +12 -8
- package/components/sidebar/FileBrowser.tsx +4 -1
- package/components/themeModes.tsx +28 -0
- package/config/configStore.ts +76 -1
- package/config/settings.ts +152 -0
- package/configure.ts +9 -0
- package/globals.d.ts +7 -1
- package/hooks/useAIChat.ts +5 -2
- package/hooks/useAIProviderActivation.ts +47 -0
- package/hooks/useAIProviderConfig.ts +5 -1
- package/hooks/useAgentSettings.ts +64 -23
- package/hooks/useAgents.ts +4 -4
- package/hooks/useAnnotationHighlighter.ts +100 -3
- package/hooks/useArchive.ts +2 -1
- package/hooks/useFenceTheme.ts +17 -0
- package/hooks/useLinkedDoc.ts +68 -1
- package/hooks/usePinpoint.ts +76 -75
- package/hooks/usePlanDiff.ts +73 -2
- package/hooks/useSkillReferenceAutocomplete.ts +239 -0
- package/hooks/useUpdateCheck.ts +1 -2
- package/hooks/useVimDocumentFocus.ts +116 -0
- package/hooks/useVimSelection.ts +1063 -0
- package/package.json +4 -4
- package/print.css +14 -13
- package/shortcuts/core.ts +38 -13
- package/shortcuts/index.ts +10 -0
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +7 -0
- package/shortcuts/plan-review/vimSelection.shortcuts.ts +251 -0
- package/shortcuts/runtime.ts +111 -12
- package/styles.css +1 -1
- package/theme.css +504 -0
- package/themes/colorblind.css +89 -0
- package/themes/plannotator.css +2 -2
- package/types.ts +93 -10
- package/utils/agentSwitch.ts +33 -7
- package/utils/blockTargeting.ts +462 -178
- package/utils/clipboard.ts +110 -0
- package/utils/codeBlockMark.ts +50 -0
- package/utils/codeHighlight.ts +293 -0
- package/utils/codexModels.ts +79 -0
- package/utils/domSelection.ts +84 -0
- package/utils/htmlChrome.ts +73 -0
- package/utils/inputMethod.ts +79 -6
- package/utils/parser.ts +517 -21
- package/utils/preferenceTtl.ts +15 -0
- package/utils/sharing.ts +0 -1
- package/utils/skillCatalog.ts +269 -0
- package/utils/skillReferences.ts +475 -0
- package/utils/syntaxTheme.ts +83 -0
- package/utils/themeRegistry.ts +154 -0
- package/utils/vimHud.ts +263 -0
- package/utils/vimModeAnnouncement.ts +23 -0
- package/utils/vimNavigation.ts +417 -0
- package/utils/vimReticle.ts +88 -0
- package/utils/vimScroll.ts +162 -0
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import React, { useEffect, useRef, useState, useCallback } from "react";
|
|
10
|
-
import hljs from "highlight.js";
|
|
11
10
|
import { parseMarkdownToBlocks, computeListIndices } from "../../utils/parser";
|
|
11
|
+
import { applyHighlight, codeBlockClassName } from "../../utils/codeHighlight";
|
|
12
|
+
import { useFenceTheme } from "../../hooks/useFenceTheme";
|
|
12
13
|
import { ListItemBody } from "../ListItemBody";
|
|
13
14
|
import type { Block, Annotation, EditorMode, ImageAttachment } from "../../types";
|
|
14
15
|
import { AnnotationType } from "../../types";
|
|
@@ -331,6 +332,7 @@ export const PlanCleanDiffView: React.FC<PlanCleanDiffViewProps> = ({
|
|
|
331
332
|
initialText={commentPopover.initialText}
|
|
332
333
|
onSubmit={handleCommentSubmit}
|
|
333
334
|
onClose={handleCommentClose}
|
|
335
|
+
skillReferences
|
|
334
336
|
/>
|
|
335
337
|
)}
|
|
336
338
|
|
|
@@ -695,22 +697,20 @@ const SimpleBlockRenderer: React.FC<{ block: Block; orderedIndex?: number | null
|
|
|
695
697
|
|
|
696
698
|
const SimpleCodeBlock: React.FC<{ block: Block }> = ({ block }) => {
|
|
697
699
|
const codeRef = useRef<HTMLElement>(null);
|
|
700
|
+
const fenceTheme = useFenceTheme();
|
|
698
701
|
|
|
699
702
|
useEffect(() => {
|
|
700
703
|
if (codeRef.current) {
|
|
701
|
-
codeRef.current.
|
|
702
|
-
|
|
703
|
-
|
|
704
|
+
codeRef.current.className = codeBlockClassName(block.language);
|
|
705
|
+
// Language-less fences stay plain (#1212) — applyHighlight never guesses.
|
|
706
|
+
applyHighlight(codeRef.current, block.content, block.language, fenceTheme);
|
|
704
707
|
}
|
|
705
|
-
}, [block.content, block.language]);
|
|
708
|
+
}, [block.content, block.language, fenceTheme]);
|
|
706
709
|
|
|
707
710
|
return (
|
|
708
711
|
<div className="relative group my-5">
|
|
709
712
|
<pre className="bg-muted/50 border border-border/30 rounded-lg overflow-x-auto">
|
|
710
|
-
<code
|
|
711
|
-
ref={codeRef}
|
|
712
|
-
className={`hljs font-mono${block.language ? ` language-${block.language}` : ""}`}
|
|
713
|
-
>
|
|
713
|
+
<code ref={codeRef} className={codeBlockClassName(block.language)}>
|
|
714
714
|
{block.content}
|
|
715
715
|
</code>
|
|
716
716
|
</pre>
|
|
@@ -13,6 +13,18 @@ interface PlanDiffBadgeProps {
|
|
|
13
13
|
isActive: boolean;
|
|
14
14
|
onToggle: () => void;
|
|
15
15
|
hasPreviousVersion: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Optional baseline context for surfaces whose diff baseline is NOT "the
|
|
18
|
+
* previous plan revision" — annotate/folder sessions pass "since last
|
|
19
|
+
* review" so the counts read against the right baseline (not, e.g., the
|
|
20
|
+
* git uncommitted-vs-HEAD numbers shown elsewhere). Rendered as a short
|
|
21
|
+
* muted suffix after the counts. Plan review passes nothing and renders
|
|
22
|
+
* exactly as before.
|
|
23
|
+
*/
|
|
24
|
+
baselineLabel?: string;
|
|
25
|
+
/** Tooltip override for the inactive state, paired with `baselineLabel`
|
|
26
|
+
* (e.g. "Changes since you last reviewed this file"). */
|
|
27
|
+
baselineTooltip?: string;
|
|
16
28
|
}
|
|
17
29
|
|
|
18
30
|
export const PlanDiffBadge: React.FC<PlanDiffBadgeProps> = ({
|
|
@@ -20,6 +32,8 @@ export const PlanDiffBadge: React.FC<PlanDiffBadgeProps> = ({
|
|
|
20
32
|
isActive,
|
|
21
33
|
onToggle,
|
|
22
34
|
hasPreviousVersion,
|
|
35
|
+
baselineLabel,
|
|
36
|
+
baselineTooltip,
|
|
23
37
|
}) => {
|
|
24
38
|
if (!hasPreviousVersion || !stats) return null;
|
|
25
39
|
|
|
@@ -34,7 +48,11 @@ export const PlanDiffBadge: React.FC<PlanDiffBadgeProps> = ({
|
|
|
34
48
|
? "bg-primary/15"
|
|
35
49
|
: "bg-muted/50 hover:bg-muted"
|
|
36
50
|
}`}
|
|
37
|
-
title={
|
|
51
|
+
title={
|
|
52
|
+
isActive
|
|
53
|
+
? "Exit plan diff view"
|
|
54
|
+
: baselineTooltip ?? "Show what changed from previous version"
|
|
55
|
+
}
|
|
38
56
|
>
|
|
39
57
|
<span className={isActive ? "text-success" : "text-success/70"}>
|
|
40
58
|
+{stats.additions}
|
|
@@ -43,6 +61,9 @@ export const PlanDiffBadge: React.FC<PlanDiffBadgeProps> = ({
|
|
|
43
61
|
<span className={isActive ? "text-destructive" : "text-destructive/70"}>
|
|
44
62
|
-{stats.deletions}
|
|
45
63
|
</span>
|
|
64
|
+
{baselineLabel && (
|
|
65
|
+
<span className="text-muted-foreground/60 ml-1">{baselineLabel}</span>
|
|
66
|
+
)}
|
|
46
67
|
</button>
|
|
47
68
|
);
|
|
48
69
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, { useEffect, useState } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { FAVICON_PNG_DATA_URL } from '@plannotator/core/favicon';
|
|
3
|
+
import { copyTextToClipboard } from '../../utils/clipboard';
|
|
3
4
|
|
|
4
5
|
interface HooksStatus {
|
|
5
6
|
pfmReminder: { enabled: boolean };
|
|
@@ -22,7 +23,8 @@ const CopyPathButton: React.FC<{ filePath: string }> = ({ filePath }) => {
|
|
|
22
23
|
const [copied, setCopied] = useState(false);
|
|
23
24
|
|
|
24
25
|
const copy = () => {
|
|
25
|
-
|
|
26
|
+
void copyTextToClipboard(filePath).then((ok) => {
|
|
27
|
+
if (!ok) return;
|
|
26
28
|
setCopied(true);
|
|
27
29
|
setTimeout(() => setCopied(false), 1500);
|
|
28
30
|
});
|
|
@@ -86,9 +88,10 @@ export const HooksTab: React.FC = () => {
|
|
|
86
88
|
{/* PFM Reminder Card */}
|
|
87
89
|
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
|
|
88
90
|
<div className="flex items-start gap-3">
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
<img
|
|
92
|
+
src={FAVICON_PNG_DATA_URL}
|
|
93
|
+
alt=""
|
|
94
|
+
className="w-8 h-8 flex-shrink-0 object-contain"
|
|
92
95
|
/>
|
|
93
96
|
<div className="flex-1 min-w-0">
|
|
94
97
|
<div className="flex items-center justify-between gap-2">
|
|
@@ -130,9 +133,10 @@ export const HooksTab: React.FC = () => {
|
|
|
130
133
|
{/* Compound Improvement Hook Card */}
|
|
131
134
|
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
|
|
132
135
|
<div className="flex items-start gap-3">
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
+
<img
|
|
137
|
+
src={FAVICON_PNG_DATA_URL}
|
|
138
|
+
alt=""
|
|
139
|
+
className="w-8 h-8 flex-shrink-0 object-contain"
|
|
136
140
|
/>
|
|
137
141
|
<div className="flex-1 min-w-0">
|
|
138
142
|
<div className="flex items-center justify-between gap-2">
|
|
@@ -43,6 +43,9 @@ interface AggregateWorkspaceChange {
|
|
|
43
43
|
files: number;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// Display-name stripping only — deliberately narrower than the annotatable
|
|
47
|
+
// set. Config files (config.yaml vs config.json) keep their extensions so
|
|
48
|
+
// same-named siblings stay distinguishable in the tree.
|
|
46
49
|
const FILE_EXTENSION_RE = /\.(mdx?|txt|html?)$/i;
|
|
47
50
|
|
|
48
51
|
function normalizeFilterText(value: string): string {
|
|
@@ -418,7 +421,7 @@ const DirSection: React.FC<{
|
|
|
418
421
|
if (dir.tree.length === 0) {
|
|
419
422
|
return (
|
|
420
423
|
<div className="px-3 py-2 text-[11px] text-muted-foreground">
|
|
421
|
-
No
|
|
424
|
+
No annotatable files found
|
|
422
425
|
</div>
|
|
423
426
|
);
|
|
424
427
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { FC } from 'react';
|
|
2
|
+
import { MoonIcon, SunIcon, SystemIcon, type IconProps } from './icons/themeIcons';
|
|
3
|
+
|
|
4
|
+
interface ThemeMode {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly label: string;
|
|
7
|
+
readonly Icon: FC<IconProps>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The shared catalog rendered by every Light/Dark/System picker. */
|
|
11
|
+
export const THEME_MODES = [
|
|
12
|
+
{ id: 'light', label: 'Light', Icon: SunIcon },
|
|
13
|
+
{ id: 'dark', label: 'Dark', Icon: MoonIcon },
|
|
14
|
+
{ id: 'system', label: 'System', Icon: SystemIcon },
|
|
15
|
+
] as const satisfies readonly ThemeMode[];
|
|
16
|
+
|
|
17
|
+
/** A persisted theme-mode preference. */
|
|
18
|
+
export type Mode = (typeof THEME_MODES)[number]['id'];
|
|
19
|
+
|
|
20
|
+
/** Return whether an unknown persisted value is a supported theme mode. */
|
|
21
|
+
export function isThemeMode(value: unknown): value is Mode {
|
|
22
|
+
return typeof value === 'string' && THEME_MODES.some(({ id }) => id === value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Parse an unknown persisted value, falling back to a caller-provided mode. */
|
|
26
|
+
export function parseThemeMode(value: unknown, fallback: Mode): Mode {
|
|
27
|
+
return isThemeMode(value) ? value : fallback;
|
|
28
|
+
}
|
package/config/configStore.ts
CHANGED
|
@@ -29,6 +29,28 @@ function deepMerge(target: Record<string, unknown>, source: Record<string, unkno
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
33
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Remove every leaf `shape` describes from `target`, pruning containers that
|
|
38
|
+
* empty out. Used to retract a queued server write once the server has spoken
|
|
39
|
+
* for the same leaves.
|
|
40
|
+
*/
|
|
41
|
+
function deletePaths(target: Record<string, unknown>, shape: Record<string, unknown>): void {
|
|
42
|
+
for (const key of Object.keys(shape)) {
|
|
43
|
+
if (!(key in target)) continue;
|
|
44
|
+
if (isPlainObject(target[key]) && isPlainObject(shape[key])) {
|
|
45
|
+
const child = target[key] as Record<string, unknown>;
|
|
46
|
+
deletePaths(child, shape[key] as Record<string, unknown>);
|
|
47
|
+
if (Object.keys(child).length === 0) delete target[key];
|
|
48
|
+
} else {
|
|
49
|
+
delete target[key];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
32
54
|
/** Server write-back transport: posts a batch of changed server-synced settings. */
|
|
33
55
|
export type ServerSyncFn = (payload: Record<string, unknown>) => void;
|
|
34
56
|
|
|
@@ -56,6 +78,10 @@ class ConfigStore {
|
|
|
56
78
|
private serverSyncTimer: ReturnType<typeof setTimeout> | null = null;
|
|
57
79
|
private pagehideFlushRegistered = false;
|
|
58
80
|
private serverSync: ServerSyncFn = defaultServerSync;
|
|
81
|
+
/** True once a host installed its own transport via setServerSync(). */
|
|
82
|
+
private serverSyncInstalled = false;
|
|
83
|
+
/** Settings whose value came from the server config; seeds must not undo them. */
|
|
84
|
+
private serverOverridden = new Set<string>();
|
|
59
85
|
private loaded = false;
|
|
60
86
|
|
|
61
87
|
/**
|
|
@@ -132,6 +158,17 @@ class ConfigStore {
|
|
|
132
158
|
if (fromServer !== undefined) {
|
|
133
159
|
this.values.set(name, fromServer);
|
|
134
160
|
def.toCookie(fromServer as never);
|
|
161
|
+
this.serverOverridden.add(name);
|
|
162
|
+
// Retract any write still queued for the leaves the server just
|
|
163
|
+
// spoke for. Without this, a write queued before init() flushes
|
|
164
|
+
// AFTER it and pushes the pre-init value back to the config file,
|
|
165
|
+
// which the next session would then restore over the cookie.
|
|
166
|
+
if (def.toServer) {
|
|
167
|
+
deletePaths(
|
|
168
|
+
this.pendingServerWrites,
|
|
169
|
+
def.toServer(fromServer as never) as Record<string, unknown>,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
135
172
|
}
|
|
136
173
|
}
|
|
137
174
|
}
|
|
@@ -139,6 +176,40 @@ class ConfigStore {
|
|
|
139
176
|
this.notify();
|
|
140
177
|
}
|
|
141
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Install a resolved default for a setting: memory + cookie, never the
|
|
181
|
+
* server. For values a component computes at mount (e.g. ThemeProvider's
|
|
182
|
+
* default theme pair) rather than values a user chose — seeding must not
|
|
183
|
+
* write to ~/.plannotator/config.json, and must never overwrite a value the
|
|
184
|
+
* server already supplied through init().
|
|
185
|
+
*/
|
|
186
|
+
seed<K extends SettingName>(key: K, value: SettingValue<K>): void {
|
|
187
|
+
this.ensureLoaded();
|
|
188
|
+
if (this.serverOverridden.has(key)) return;
|
|
189
|
+
const def = SETTINGS[key];
|
|
190
|
+
this.values.set(key, value);
|
|
191
|
+
def.toCookie(value as never);
|
|
192
|
+
this.notify();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Persist a user choice to memory + cookie, queuing the server write-back
|
|
197
|
+
* only when this store is actually talking to a server. Hosts that never
|
|
198
|
+
* installed a serverSync seam have no /api/config endpoint, so the legacy
|
|
199
|
+
* cookie-only APIs (ThemeProvider.setColorTheme) use this instead of set().
|
|
200
|
+
*/
|
|
201
|
+
setLocal<K extends SettingName>(key: K, value: SettingValue<K>): void {
|
|
202
|
+
if (this.serverSyncInstalled) {
|
|
203
|
+
this.set(key, value);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
this.ensureLoaded();
|
|
207
|
+
const def = SETTINGS[key];
|
|
208
|
+
this.values.set(key, value);
|
|
209
|
+
def.toCookie(value as never);
|
|
210
|
+
this.notify();
|
|
211
|
+
}
|
|
212
|
+
|
|
142
213
|
/** Get a resolved config value. Works outside React. */
|
|
143
214
|
get<K extends SettingName>(key: K): SettingValue<K> {
|
|
144
215
|
this.ensureLoaded();
|
|
@@ -169,9 +240,13 @@ class ConfigStore {
|
|
|
169
240
|
/** Override the server write-back transport (default = inline POST /api/config). */
|
|
170
241
|
setServerSync(fn: ServerSyncFn): void {
|
|
171
242
|
this.serverSync = fn;
|
|
243
|
+
this.serverSyncInstalled = true;
|
|
172
244
|
}
|
|
173
245
|
|
|
174
|
-
resetServerSync(): void {
|
|
246
|
+
resetServerSync(): void {
|
|
247
|
+
this.serverSync = defaultServerSync;
|
|
248
|
+
this.serverSyncInstalled = false;
|
|
249
|
+
}
|
|
175
250
|
|
|
176
251
|
private notify(): void {
|
|
177
252
|
this.version++;
|
package/config/settings.ts
CHANGED
|
@@ -12,6 +12,69 @@
|
|
|
12
12
|
import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
|
|
13
13
|
import { storage } from '../utils/storage';
|
|
14
14
|
import { generateIdentity } from '../utils/generateIdentity';
|
|
15
|
+
import {
|
|
16
|
+
getDefaultThemePair,
|
|
17
|
+
normalizeThemePair,
|
|
18
|
+
seedThemePair,
|
|
19
|
+
type ThemePair,
|
|
20
|
+
} from '../utils/themeRegistry';
|
|
21
|
+
import { parseThemeMode } from '../components/themeModes';
|
|
22
|
+
|
|
23
|
+
/** Legacy single-palette key, still written so a downgrade renders styled. */
|
|
24
|
+
const COLOR_THEME_COOKIE = 'plannotator-color-theme';
|
|
25
|
+
const MODE_COOKIE = 'plannotator-theme';
|
|
26
|
+
const LIGHT_THEME_COOKIE = 'plannotator-light-theme';
|
|
27
|
+
const DARK_THEME_COOKIE = 'plannotator-dark-theme';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Where a host keeps the two PRE-PAIR values, if not under Plannotator's own
|
|
31
|
+
* keys. These mirror ThemeProvider's `storageKey` / `colorThemeStorageKey`
|
|
32
|
+
* props, which existed before the pair and are what a host's already-stored
|
|
33
|
+
* user preference lives under.
|
|
34
|
+
*
|
|
35
|
+
* Only these two legacy keys are overridable. The pair halves are a new
|
|
36
|
+
* concept with no pre-existing host data, so they always use the fixed
|
|
37
|
+
* `plannotator-light-theme` / `plannotator-dark-theme` keys; two hosts sharing
|
|
38
|
+
* one origin with different key prefixes would share those halves.
|
|
39
|
+
*/
|
|
40
|
+
export interface ThemePairLegacyKeys {
|
|
41
|
+
/** Where the mode is stored (ThemeProvider's `storageKey`). */
|
|
42
|
+
mode?: string;
|
|
43
|
+
/** Where the single pre-pair palette is stored (`colorThemeStorageKey`). */
|
|
44
|
+
colorTheme?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Persist a pair to its cookies without touching the server.
|
|
49
|
+
*
|
|
50
|
+
* ThemeProvider calls this once it has resolved a pair, because a pair
|
|
51
|
+
* migrated from the legacy single-palette key is DERIVED until it is written:
|
|
52
|
+
* the provider then mirrors the active palette back onto that legacy key, so
|
|
53
|
+
* leaving the halves underived would lose the migration on the next load.
|
|
54
|
+
*/
|
|
55
|
+
export function writeThemePairCookies(pair: ThemePair, keys?: ThemePairLegacyKeys): void {
|
|
56
|
+
storage.setItem(keys?.mode ?? MODE_COOKIE, pair.mode);
|
|
57
|
+
storage.setItem(LIGHT_THEME_COOKIE, pair.light);
|
|
58
|
+
storage.setItem(DARK_THEME_COOKIE, pair.dark);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read the persisted pair, seeding either half from the single palette older
|
|
63
|
+
* releases stored. Returns undefined only when the user has never expressed a
|
|
64
|
+
* theme preference at all — ThemeProvider reads that as "my props decide".
|
|
65
|
+
*
|
|
66
|
+
* `keys` points the two legacy reads at a host's own storage keys so an
|
|
67
|
+
* upgrade migrates that host's stored preference instead of discarding it.
|
|
68
|
+
*/
|
|
69
|
+
export function readThemePairCookies(keys?: ThemePairLegacyKeys): ThemePair | undefined {
|
|
70
|
+
const mode = storage.getItem(keys?.mode ?? MODE_COOKIE);
|
|
71
|
+
const light = storage.getItem(LIGHT_THEME_COOKIE);
|
|
72
|
+
const dark = storage.getItem(DARK_THEME_COOKIE);
|
|
73
|
+
const legacy = storage.getItem(keys?.colorTheme ?? COLOR_THEME_COOKIE);
|
|
74
|
+
if (!mode && !light && !dark && !legacy) return undefined;
|
|
75
|
+
const seeded = seedThemePair(legacy, parseThemeMode(mode, getDefaultThemePair().mode));
|
|
76
|
+
return normalizeThemePair({ mode, light: light ?? seeded.light, dark: dark ?? seeded.dark }, seeded);
|
|
77
|
+
}
|
|
15
78
|
|
|
16
79
|
const DIFF_LINE_BG_INTENSITY_VALUES = ['subtle', 'normal', 'strong'] as const;
|
|
17
80
|
function isDiffLineBgIntensity(v: unknown): v is DiffLineBgIntensity {
|
|
@@ -28,6 +91,7 @@ export interface SettingDef<T> {
|
|
|
28
91
|
toServer?: (value: T) => Record<string, unknown>;
|
|
29
92
|
}
|
|
30
93
|
|
|
94
|
+
/** Typed registry of persisted UI settings and their storage codecs. */
|
|
31
95
|
export const SETTINGS = {
|
|
32
96
|
displayName: {
|
|
33
97
|
defaultValue: () => generateIdentity(),
|
|
@@ -39,6 +103,35 @@ export const SETTINGS = {
|
|
|
39
103
|
toServer: (v: string) => ({ displayName: v }),
|
|
40
104
|
},
|
|
41
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Appearance: the mode plus the palette assigned to each half of the pair.
|
|
108
|
+
* Stored as one value because the three fields are only meaningful together —
|
|
109
|
+
* `mode: system` picks between `light` and `dark` at render time.
|
|
110
|
+
*
|
|
111
|
+
* Cookies: `plannotator-theme` (mode) keeps its meaning, joined by
|
|
112
|
+
* `plannotator-light-theme` / `plannotator-dark-theme`. A user arriving from
|
|
113
|
+
* an older release has neither half, so the pair is seeded from the single
|
|
114
|
+
* `plannotator-color-theme` palette they were on (ThemeProvider keeps writing
|
|
115
|
+
* that key, so a downgrade still finds a palette and never renders unstyled).
|
|
116
|
+
*
|
|
117
|
+
* Server: round-trips through `theme` in ~/.plannotator/config.json exactly
|
|
118
|
+
* like `diffOptions` does, so the choice survives the random port each hook
|
|
119
|
+
* invocation runs on.
|
|
120
|
+
*/
|
|
121
|
+
themePair: {
|
|
122
|
+
defaultValue: () => getDefaultThemePair(),
|
|
123
|
+
fromCookie: () => readThemePairCookies(),
|
|
124
|
+
toCookie: (v: ThemePair) => writeThemePairCookies(v),
|
|
125
|
+
serverKey: 'theme',
|
|
126
|
+
fromServer: (sc: Record<string, unknown>) => {
|
|
127
|
+
const theme = sc.theme as Record<string, unknown> | undefined;
|
|
128
|
+
if (!theme || typeof theme !== 'object') return undefined;
|
|
129
|
+
if (theme.mode === undefined && theme.light === undefined && theme.dark === undefined) return undefined;
|
|
130
|
+
return normalizeThemePair(theme, getDefaultThemePair());
|
|
131
|
+
},
|
|
132
|
+
toServer: (v: ThemePair) => ({ theme: { mode: v.mode, light: v.light, dark: v.dark } }),
|
|
133
|
+
},
|
|
134
|
+
|
|
42
135
|
gridEnabled: {
|
|
43
136
|
// Default ON: plans open in the classic grid / floating-card look. The UI 2.0
|
|
44
137
|
// flat look is offered as an opt-in via the look-and-feel chooser dialog.
|
|
@@ -51,6 +144,46 @@ export const SETTINGS = {
|
|
|
51
144
|
serverKey: undefined, fromServer: undefined, toServer: undefined,
|
|
52
145
|
},
|
|
53
146
|
|
|
147
|
+
vimModeEnabled: {
|
|
148
|
+
// Vim bindings deliberately default OFF. Unmodified letter keys must remain
|
|
149
|
+
// inert for existing users until they explicitly opt into modal document
|
|
150
|
+
// navigation from Settings > Vim.
|
|
151
|
+
defaultValue: false as boolean,
|
|
152
|
+
fromCookie: () => {
|
|
153
|
+
const value = storage.getItem('plannotator-vim-mode-enabled');
|
|
154
|
+
return value === 'true' ? true : value === 'false' ? false : undefined;
|
|
155
|
+
},
|
|
156
|
+
toCookie: (value: boolean) =>
|
|
157
|
+
storage.setItem('plannotator-vim-mode-enabled', String(value)),
|
|
158
|
+
serverKey: undefined, fromServer: undefined, toServer: undefined,
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
vimHudEnabled: {
|
|
162
|
+
// The larger command HUD is an optional presentation layer on top of Vim
|
|
163
|
+
// controls. It has no effect while Vim mode itself is disabled.
|
|
164
|
+
defaultValue: false as boolean,
|
|
165
|
+
fromCookie: () => {
|
|
166
|
+
const value = storage.getItem('plannotator-vim-hud-enabled');
|
|
167
|
+
return value === 'true' ? true : value === 'false' ? false : undefined;
|
|
168
|
+
},
|
|
169
|
+
toCookie: (value: boolean) =>
|
|
170
|
+
storage.setItem('plannotator-vim-hud-enabled', String(value)),
|
|
171
|
+
serverKey: undefined, fromServer: undefined, toServer: undefined,
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
vimHudKeyPanelEnabled: {
|
|
175
|
+
// Preserve the existing full HUD for current users while allowing the
|
|
176
|
+
// bottom-right key panel to be hidden independently from the reticle.
|
|
177
|
+
defaultValue: true as boolean,
|
|
178
|
+
fromCookie: () => {
|
|
179
|
+
const value = storage.getItem('plannotator-vim-hud-key-panel-enabled');
|
|
180
|
+
return value === 'true' ? true : value === 'false' ? false : undefined;
|
|
181
|
+
},
|
|
182
|
+
toCookie: (value: boolean) =>
|
|
183
|
+
storage.setItem('plannotator-vim-hud-key-panel-enabled', String(value)),
|
|
184
|
+
serverKey: undefined, fromServer: undefined, toServer: undefined,
|
|
185
|
+
},
|
|
186
|
+
|
|
54
187
|
// --- Diff display options (namespaced under diffOptions in config.json) ---
|
|
55
188
|
|
|
56
189
|
// Which left-panel view a code review OPENS in. 'sections' = the git-status
|
|
@@ -262,6 +395,25 @@ export const SETTINGS = {
|
|
|
262
395
|
},
|
|
263
396
|
toServer: (v: DiffLineBgIntensity) => ({ diffOptions: { lineBgIntensity: v } }),
|
|
264
397
|
},
|
|
398
|
+
/** Experimental: author suggestions by editing code in place in the review
|
|
399
|
+
* all-files view. Cookie-only (no server sync) while the feature is
|
|
400
|
+
* experimental — default OFF, and when off no edit UI renders and no
|
|
401
|
+
* editor is ever constructed. (In code-split hosts the editor chunk is
|
|
402
|
+
* never fetched; Plannotator's single-file production build inlines all
|
|
403
|
+
* dynamic imports, so there the module namespace exists at page load —
|
|
404
|
+
* audited free of top-level side effects — but stays inert.) */
|
|
405
|
+
editSuggestions: {
|
|
406
|
+
defaultValue: false as boolean,
|
|
407
|
+
fromCookie: () => {
|
|
408
|
+
const v = storage.getItem('plannotator-experimental-edit-suggestions');
|
|
409
|
+
return v === 'true' ? true : v === 'false' ? false : undefined;
|
|
410
|
+
},
|
|
411
|
+
toCookie: (v: boolean) =>
|
|
412
|
+
storage.setItem('plannotator-experimental-edit-suggestions', String(v)),
|
|
413
|
+
serverKey: undefined,
|
|
414
|
+
fromServer: undefined,
|
|
415
|
+
toServer: undefined,
|
|
416
|
+
},
|
|
265
417
|
conventionalComments: {
|
|
266
418
|
defaultValue: false as boolean,
|
|
267
419
|
fromCookie: () => {
|
package/configure.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { setFileTreeBackend, type FileTreeBackend } from './hooks/useFileBrowser
|
|
|
7
7
|
import { setDraftTransport, type DraftTransport } from './hooks/useAnnotationDraft';
|
|
8
8
|
import { setExternalAnnotationTransport, type ExternalAnnotationTransport } from './hooks/useExternalAnnotations';
|
|
9
9
|
import { setAITransport, type AITransport } from './hooks/useAIChat';
|
|
10
|
+
import { setSkillCatalogTransport, setSkillContentTransport, type SkillCatalogTransport, type SkillContentTransport } from './utils/skillCatalog';
|
|
10
11
|
import { configStore } from './config';
|
|
11
12
|
import type { ServerSyncFn } from './config/configStore';
|
|
12
13
|
import type { ExternalAnnotationEvent, VaultNode } from './types';
|
|
@@ -27,6 +28,8 @@ export type {
|
|
|
27
28
|
ExternalAnnotationTransport,
|
|
28
29
|
ExternalAnnotationEvent,
|
|
29
30
|
AITransport,
|
|
31
|
+
SkillCatalogTransport,
|
|
32
|
+
SkillContentTransport,
|
|
30
33
|
ServerSyncFn,
|
|
31
34
|
};
|
|
32
35
|
|
|
@@ -48,6 +51,10 @@ export interface PlannotatorUIConfig {
|
|
|
48
51
|
*/
|
|
49
52
|
externalAnnotationTransport?: ExternalAnnotationTransport<ExternalAnnotationBase>;
|
|
50
53
|
aiTransport?: AITransport;
|
|
54
|
+
/** Skill-reference catalog request. Default: `GET /api/skills` on the page origin. */
|
|
55
|
+
skillCatalogTransport?: SkillCatalogTransport;
|
|
56
|
+
/** Human-only skill contents request for feedback injection. Default: `GET /api/skills/content?name=` on the page origin. */
|
|
57
|
+
skillContentTransport?: SkillContentTransport;
|
|
51
58
|
serverSync?: ServerSyncFn;
|
|
52
59
|
/** Re-hydrate settings from the installed (SYNCHRONOUS) storageBackend after install. */
|
|
53
60
|
loadSettingsFromBackend?: boolean;
|
|
@@ -63,6 +70,8 @@ export function configurePlannotatorUI(config: PlannotatorUIConfig): void {
|
|
|
63
70
|
if (config.draftTransport) setDraftTransport(config.draftTransport);
|
|
64
71
|
if (config.externalAnnotationTransport) setExternalAnnotationTransport(config.externalAnnotationTransport);
|
|
65
72
|
if (config.aiTransport) setAITransport(config.aiTransport);
|
|
73
|
+
if (config.skillCatalogTransport) setSkillCatalogTransport(config.skillCatalogTransport);
|
|
74
|
+
if (config.skillContentTransport) setSkillContentTransport(config.skillContentTransport);
|
|
66
75
|
if (config.serverSync) configStore.setServerSync(config.serverSync);
|
|
67
76
|
// Re-hydrate AFTER storageBackend is installed (load-bearing order — gated last).
|
|
68
77
|
if (config.loadSettingsFromBackend) configStore.loadFromBackend();
|
package/globals.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Allow side-effect CSS imports (
|
|
1
|
+
// Allow side-effect CSS imports (overlayscrollbars, fontsource, etc.)
|
|
2
2
|
declare module '*.css';
|
|
3
3
|
|
|
4
4
|
// Image asset imports (sprites, screenshots). Consumers compiling this shipped
|
|
@@ -12,3 +12,9 @@ declare module '*.webp' {
|
|
|
12
12
|
const src: string;
|
|
13
13
|
export default src;
|
|
14
14
|
}
|
|
15
|
+
// Video asset imports (announcement demo recordings). Inlined as data URIs by
|
|
16
|
+
// the single-file build (assetsInlineLimit), so the import resolves to a string.
|
|
17
|
+
declare module '*.webm' {
|
|
18
|
+
const src: string;
|
|
19
|
+
export default src;
|
|
20
|
+
}
|
package/hooks/useAIChat.ts
CHANGED
|
@@ -224,6 +224,7 @@ export function useAIChat({
|
|
|
224
224
|
}, []);
|
|
225
225
|
|
|
226
226
|
const setSessionId = useCallback((sessionId: string | null) => {
|
|
227
|
+
sessionIdRef.current = sessionId;
|
|
227
228
|
setThread(prev => ({ ...prev, sessionId }));
|
|
228
229
|
}, []);
|
|
229
230
|
|
|
@@ -486,11 +487,12 @@ export function useAIChat({
|
|
|
486
487
|
if (abortRef.current) {
|
|
487
488
|
abortRef.current.abort();
|
|
488
489
|
abortRef.current = null;
|
|
490
|
+
pendingAbortRef.current = postServerAbort();
|
|
489
491
|
}
|
|
490
492
|
setSessionId(null);
|
|
491
493
|
setIsCreatingSession(false);
|
|
492
494
|
setIsStreaming(false);
|
|
493
|
-
}, [setSessionId]);
|
|
495
|
+
}, [postServerAbort, setSessionId]);
|
|
494
496
|
|
|
495
497
|
const resetThread = useCallback(() => {
|
|
496
498
|
sessionEpochRef.current += 1;
|
|
@@ -498,12 +500,13 @@ export function useAIChat({
|
|
|
498
500
|
if (abortRef.current) {
|
|
499
501
|
abortRef.current.abort();
|
|
500
502
|
abortRef.current = null;
|
|
503
|
+
pendingAbortRef.current = postServerAbort();
|
|
501
504
|
}
|
|
502
505
|
setThread(createThread(threadTitle));
|
|
503
506
|
setIsCreatingSession(false);
|
|
504
507
|
setIsStreaming(false);
|
|
505
508
|
setError(null);
|
|
506
|
-
}, [threadTitle]);
|
|
509
|
+
}, [postServerAbort, threadTitle]);
|
|
507
510
|
|
|
508
511
|
useEffect(() => {
|
|
509
512
|
return () => {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useCallback, useRef } from 'react';
|
|
2
|
+
import type { AIProviderOption } from '../utils/aiProvider';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Explicit AI provider activation for lazily-initialized providers.
|
|
6
|
+
*
|
|
7
|
+
* Codex model discovery is deferred until a session starts (it spawns a
|
|
8
|
+
* `codex app-server` process — see #1144), so /api/ai/capabilities advertises
|
|
9
|
+
* static fallback metadata until then. This hook lets the apps run that same
|
|
10
|
+
* deferred initializer on an explicit user gesture — opening the Ask AI
|
|
11
|
+
* surface, or switching the provider picker — via
|
|
12
|
+
* `GET /api/ai/capabilities?activate=<providerId>`, then merge the refreshed
|
|
13
|
+
* provider metadata (real model list + reasoning efforts) back into app state.
|
|
14
|
+
*
|
|
15
|
+
* Single-flight per provider id: repeated gestures don't re-fetch (activation
|
|
16
|
+
* is idempotent server-side anyway), but a failed fetch un-latches so a later
|
|
17
|
+
* gesture can retry. Providers without deferred initialization are a server-side
|
|
18
|
+
* no-op, so callers don't need to know which providers are lazy.
|
|
19
|
+
*/
|
|
20
|
+
export interface ActivatedAIProvider extends AIProviderOption {
|
|
21
|
+
capabilities: Record<string, boolean>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useAIProviderActivation(options: {
|
|
25
|
+
onCapabilities: (providers: ActivatedAIProvider[], defaultProvider: string | null) => void;
|
|
26
|
+
}) {
|
|
27
|
+
const activatedRef = useRef<Set<string>>(new Set());
|
|
28
|
+
const onCapabilitiesRef = useRef(options.onCapabilities);
|
|
29
|
+
onCapabilitiesRef.current = options.onCapabilities;
|
|
30
|
+
|
|
31
|
+
return useCallback((providerId: string | null | undefined) => {
|
|
32
|
+
if (!providerId || activatedRef.current.has(providerId)) return;
|
|
33
|
+
activatedRef.current.add(providerId);
|
|
34
|
+
fetch(`/api/ai/capabilities?activate=${encodeURIComponent(providerId)}`)
|
|
35
|
+
.then(res => (res.ok ? res.json() : null))
|
|
36
|
+
.then(data => {
|
|
37
|
+
if (!data?.available) {
|
|
38
|
+
activatedRef.current.delete(providerId);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
onCapabilitiesRef.current(data.providers ?? [], data.defaultProvider ?? null);
|
|
42
|
+
})
|
|
43
|
+
.catch(() => {
|
|
44
|
+
activatedRef.current.delete(providerId);
|
|
45
|
+
});
|
|
46
|
+
}, []);
|
|
47
|
+
}
|
|
@@ -99,9 +99,13 @@ export function useAIProviderConfig({
|
|
|
99
99
|
}
|
|
100
100
|
const reasoningEffort = model ? (reasoningEffortByModel[model] ?? null) : null;
|
|
101
101
|
const next = { ...prev, providerId, model, reasoningEffort, reasoningEffortByModel };
|
|
102
|
+
// Only an explicit model pick is persisted. A resolver-derived model
|
|
103
|
+
// (e.g. the static fallback a lazily-discovered provider advertises
|
|
104
|
+
// before activation) must never overwrite the user's saved preference
|
|
105
|
+
// — the session request may fall back, the cookie may not.
|
|
102
106
|
saveAIProviderSelection({
|
|
103
107
|
providerId: next.providerId,
|
|
104
|
-
model: next.model,
|
|
108
|
+
model: config.model !== undefined ? next.model : null,
|
|
105
109
|
origin,
|
|
106
110
|
settings: saved,
|
|
107
111
|
});
|