@plannotator/ui 0.50.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 +295 -11
- package/README.md +8 -0
- package/components/AISettingsTab.tsx +32 -0
- package/components/AgentToolAnnouncementDialog.tsx +347 -0
- package/components/AgentsTab.tsx +9 -9
- package/components/AnnotationToolbar.tsx +2 -2
- package/components/AskSessionAnnouncementDialog.tsx +329 -0
- package/components/BlockRenderer.tsx +10 -1
- package/components/CommentPopover.tsx +36 -18
- package/components/CompletionOverlay.tsx +12 -5
- package/components/DecisionControl.tsx +2 -7
- package/components/DiagramBlock.tsx +46 -4
- package/components/DocBadges.tsx +12 -8
- package/components/FloatingQuickLabelPicker.tsx +2 -2
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/QuickLabelDropdown.tsx +9 -4
- package/components/ServerSessionReplacedBanner.tsx +29 -0
- package/components/Settings.tsx +76 -192
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/Viewer.tsx +30 -1
- package/components/ai/AIProviderBar.tsx +18 -1
- package/components/ai/DocumentAIChatPanel.tsx +4 -9
- package/components/ai/SessionAskNotice.tsx +33 -21
- package/components/blocks/QuestionBlock.tsx +99 -27
- package/components/blocks/QuestionContext.tsx +81 -0
- package/components/diagram/DiagramComposer.tsx +54 -3
- package/components/diagram/DiagramViewer.tsx +31 -2
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +13 -3
- package/components/html-viewer/bridge-script.asset.js +230 -14
- package/components/html-viewer/bridge-script.ts +230 -14
- package/components/html-viewer/useHtmlAnnotation.ts +36 -55
- package/components/settings/QuickLabelsTab.tsx +378 -0
- package/hooks/useAIChat.ts +83 -6
- package/hooks/useAIProviderActivation.ts +13 -0
- package/hooks/useAIProviderConfig.ts +3 -3
- package/hooks/useActiveSection.ts +3 -2
- package/hooks/useAgentToolSetting.ts +33 -0
- package/hooks/useAnnotationDraft.ts +53 -7
- package/hooks/useAnnotationHighlighter.ts +129 -5
- package/hooks/useExternalAnnotations.ts +21 -1
- package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
- package/hooks/useLatchedTrue.ts +15 -0
- package/hooks/useLinkedDoc.ts +64 -4
- package/hooks/useModelCatalogs.ts +9 -2
- package/package.json +2 -2
- package/styles.css +1 -1
- package/themes/gruvbox.css +6 -0
- package/types.ts +19 -0
- package/utils/agentClosed.ts +33 -0
- package/utils/agentToolAnnouncement.ts +109 -0
- package/utils/agentToolSetting.ts +108 -0
- package/utils/aiProvider.ts +13 -40
- package/utils/annotationOwnsHighlight.ts +27 -0
- package/utils/annotationScope.ts +15 -0
- package/utils/askSessionAnnouncement.ts +107 -0
- package/utils/blockTargeting.ts +61 -2
- package/utils/diagramAskAI.ts +161 -0
- package/utils/parser.ts +382 -114
- package/utils/questionAnswers.ts +3 -1
- package/utils/quickLabels.ts +82 -2
- package/utils/serverSession.ts +85 -0
package/themes/gruvbox.css
CHANGED
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
|
|
31
31
|
--code-bg: #32302f;
|
|
32
32
|
--focus-highlight: #83a598;
|
|
33
|
+
|
|
34
|
+
/* The gruvbox Shiki theme sets the git "added" colour to the text colour,
|
|
35
|
+
so @pierre/diffs would draw added lines grey. Use gruvbox green. */
|
|
36
|
+
--diffs-addition-color-override: #b8bb26;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
.theme-gruvbox.light {
|
|
@@ -59,4 +63,6 @@
|
|
|
59
63
|
|
|
60
64
|
--code-bg: #ebdbb2;
|
|
61
65
|
--focus-highlight: #076678;
|
|
66
|
+
|
|
67
|
+
--diffs-addition-color-override: #79740e;
|
|
62
68
|
}
|
package/types.ts
CHANGED
|
@@ -188,6 +188,10 @@ export interface HtmlElementContext {
|
|
|
188
188
|
component?: string;
|
|
189
189
|
/** Live-app sessions only: the route the element was seen on and the page title. */
|
|
190
190
|
page?: { url: string; title?: string };
|
|
191
|
+
/** Media elements only: the file the element shows, resolved by the bridge
|
|
192
|
+
* exactly as the composer quote names it (src, data-src, srcset,
|
|
193
|
+
* <picture>/<source>, poster), query and fragment scrubbed. */
|
|
194
|
+
sourceName?: string;
|
|
191
195
|
}
|
|
192
196
|
|
|
193
197
|
export type AlertKind = 'note' | 'tip' | 'warning' | 'caution' | 'important';
|
|
@@ -446,6 +450,15 @@ export interface AIQuestion {
|
|
|
446
450
|
label?: string;
|
|
447
451
|
text?: string;
|
|
448
452
|
sourcePath?: string;
|
|
453
|
+
/** Agent-facing identity of the selected element(s) (raw-HTML / live-app
|
|
454
|
+
* pinpoints): sent with the question, never shown in the chat. */
|
|
455
|
+
detail?: string;
|
|
456
|
+
/** 1-based source lines of a text selection in a markdown document (the
|
|
457
|
+
* enclosing block's span, or first to last block for a selection over
|
|
458
|
+
* several). `buildDefaultPrompt` appends them to the `Source:` line
|
|
459
|
+
* (`, line 41` / `, lines 41–44`). Optional: absent prints no lines. */
|
|
460
|
+
lineStart?: number;
|
|
461
|
+
lineEnd?: number;
|
|
449
462
|
};
|
|
450
463
|
/** undefined = general question (no file scope) */
|
|
451
464
|
filePath?: string;
|
|
@@ -466,6 +479,12 @@ export interface AIResponse {
|
|
|
466
479
|
errorCode?: string;
|
|
467
480
|
/** "Ask this session": the question is waiting for a busy session, or interrupting it. */
|
|
468
481
|
status?: 'waiting' | 'interrupting';
|
|
482
|
+
/**
|
|
483
|
+
* "Ask this session": a note shown under the answer (not an error). Set when
|
|
484
|
+
* the person typed into the session while it answered, so the answer stops
|
|
485
|
+
* where their prompt took the turn over.
|
|
486
|
+
*/
|
|
487
|
+
notice?: string;
|
|
469
488
|
createdAt: number;
|
|
470
489
|
}
|
|
471
490
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy for a review the agent closed (`POST /api/host/close`,
|
|
3
|
+
* packages/shared/host-control.ts). The tab learns it from the
|
|
4
|
+
* `session-closed` event on the external-annotation stream.
|
|
5
|
+
*
|
|
6
|
+
* The close keeps the draft; what brings it back depends on the draft's key,
|
|
7
|
+
* so the copy names the case:
|
|
8
|
+
* - `file` / `folder`: a local file's draft follows its PATH as well as its
|
|
9
|
+
* text (packages/shared/annotate-draft.ts), so it comes back even after
|
|
10
|
+
* the file was edited. A folder's files each carry their own copy.
|
|
11
|
+
* - `document`: a URL or an agent message is keyed by its content only, so
|
|
12
|
+
* only the same, unchanged document brings it back.
|
|
13
|
+
* - `app`: a live app is keyed by its address.
|
|
14
|
+
* - `changes`: code review, keyed by the patch (or the PR).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const AGENT_CLOSED_TITLE = 'Closed by the Agent';
|
|
18
|
+
|
|
19
|
+
export type AgentClosedSurface = 'file' | 'folder' | 'document' | 'app' | 'changes';
|
|
20
|
+
|
|
21
|
+
const RESTORE: Record<AgentClosedSurface, string> = {
|
|
22
|
+
file: 'Opening the file again brings it back, even after it is edited.',
|
|
23
|
+
folder: 'Opening the folder or its files again brings it back, even after they are edited.',
|
|
24
|
+
document: 'Reopening the same document, unchanged, brings it back.',
|
|
25
|
+
app: 'Opening the same app again brings it back.',
|
|
26
|
+
changes: 'Reopening a review of the same changes brings it back.',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function agentClosedSubtitle(unsentAnnotations: number, surface: AgentClosedSurface = 'document'): string {
|
|
30
|
+
if (unsentAnnotations <= 0) return 'The agent closed this review.';
|
|
31
|
+
const comments = unsentAnnotations === 1 ? 'Your comment is' : `Your ${unsentAnnotations} comments are`;
|
|
32
|
+
return `The agent closed this review. ${comments} saved as a draft. ${RESTORE[surface]}`;
|
|
33
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time gate for the "turn the agent tool on" offer. The `plannotator`
|
|
3
|
+
* agent tool is off by default on Pi and OpenCode 2 (a full tool definition
|
|
4
|
+
* in every request), so those sessions get one offer explaining what turning
|
|
5
|
+
* it on does. Cookie-backed like the other announcement gates, so a dismissal
|
|
6
|
+
* survives Plannotator's random localhost ports, and shared by the plan
|
|
7
|
+
* editor, the annotate surfaces and the code review editor.
|
|
8
|
+
*
|
|
9
|
+
* A plain storage key rather than a settings-registry entry for the same
|
|
10
|
+
* reason as terminalToolsAnnouncement.ts: configStore seeds every registry
|
|
11
|
+
* default into a cookie on first access, so a registry flag could not tell
|
|
12
|
+
* "never seen" from "seeded default".
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { AgentToolSetting } from './agentToolSetting';
|
|
16
|
+
import { storage } from './storage';
|
|
17
|
+
import { needsTerminalToolsAnnouncement } from './terminalToolsAnnouncement';
|
|
18
|
+
|
|
19
|
+
const STORAGE_KEY = 'plannotator-announce-agent-tool-seen';
|
|
20
|
+
// Bump to re-offer after a meaningful revision.
|
|
21
|
+
const CURRENT_VERSION = '1';
|
|
22
|
+
|
|
23
|
+
export function needsAgentToolAnnouncement(): boolean {
|
|
24
|
+
return storage.getItem(STORAGE_KEY) !== CURRENT_VERSION;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function markAgentToolAnnouncementSeen(): void {
|
|
28
|
+
storage.setItem(STORAGE_KEY, CURRENT_VERSION);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether this page load may show the offer, latched at mount by the Apps.
|
|
33
|
+
* False while the terminal-tools announcement is still pending: that one
|
|
34
|
+
* takes this load. The "Ask this session" announcement is ordered ahead of
|
|
35
|
+
* this one per load instead (see `earlierAnnouncementMayShow`), because it
|
|
36
|
+
* only shows in a connected session and a reader who never has one must
|
|
37
|
+
* still get this offer.
|
|
38
|
+
*/
|
|
39
|
+
export function agentToolAnnouncementPendingThisLoad(): boolean {
|
|
40
|
+
return needsAgentToolAnnouncement() && !needsTerminalToolsAnnouncement();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The offer describes something the reader can actually do here: a host
|
|
45
|
+
* whose tool is off by default (Pi, OpenCode 2), still off for the next
|
|
46
|
+
* session, never chosen in config.json (someone who turned it off in Settings
|
|
47
|
+
* is not asked again), and not decided by PLANNOTATOR_AGENT_TOOL (a toggle
|
|
48
|
+
* cannot change that).
|
|
49
|
+
*/
|
|
50
|
+
export function agentToolOfferApplies(setting: AgentToolSetting | undefined): setting is AgentToolSetting {
|
|
51
|
+
return (
|
|
52
|
+
setting !== undefined &&
|
|
53
|
+
(setting.host === 'pi' || setting.host === 'opencode') &&
|
|
54
|
+
!setting.enabled &&
|
|
55
|
+
!setting.configured &&
|
|
56
|
+
setting.env === undefined
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The host an open offer names, or null where there is never an offer. Read
|
|
62
|
+
* from the host alone: after "Turn it on" succeeds the setting is on, and the
|
|
63
|
+
* open dialog must stay to say so.
|
|
64
|
+
*/
|
|
65
|
+
export function agentToolOfferHostOf(setting: AgentToolSetting | undefined): 'pi' | 'opencode' | null {
|
|
66
|
+
return setting?.host === 'pi' || setting?.host === 'opencode' ? setting.host : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AgentToolAnnouncementGateState {
|
|
70
|
+
/** Latched at mount from agentToolAnnouncementPendingThisLoad(). */
|
|
71
|
+
readonly announcementPending: boolean;
|
|
72
|
+
/** The app has not finished loading its initial payload. */
|
|
73
|
+
readonly isLoading: boolean;
|
|
74
|
+
/** parseAgentToolSetting() of the session's serverConfig. */
|
|
75
|
+
readonly setting: AgentToolSetting | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Archive browsing, a read-only shared plan, or no Plannotator server
|
|
78
|
+
* (nothing could write config.json). Deferred, not consumed.
|
|
79
|
+
*/
|
|
80
|
+
readonly readOnlySession: boolean;
|
|
81
|
+
/** Plannotator's compact touch shell. Deferred, not consumed. */
|
|
82
|
+
readonly compact: boolean;
|
|
83
|
+
/** Any other first-run dialog is on screen. The chain dialogs never stack. */
|
|
84
|
+
readonly otherFirstRunDialogVisible: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* The "Ask this session" announcement is pending and may still take this
|
|
87
|
+
* load (its capabilities answer is outstanding, or it is eligible). The
|
|
88
|
+
* offer then waits for a later load: never two announcements on one load.
|
|
89
|
+
*/
|
|
90
|
+
readonly earlierAnnouncementMayShow: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether the offer may open now. LAST in each app's first-run chain, after
|
|
95
|
+
* the terminal-tools and "Ask this session" announcements. The Apps pass this
|
|
96
|
+
* through useFirstRunAnnouncementWindow, so it only opens before the reader
|
|
97
|
+
* has started working.
|
|
98
|
+
*/
|
|
99
|
+
export function agentToolAnnouncementEligible(state: AgentToolAnnouncementGateState): boolean {
|
|
100
|
+
return (
|
|
101
|
+
state.announcementPending &&
|
|
102
|
+
!state.isLoading &&
|
|
103
|
+
agentToolOfferApplies(state.setting) &&
|
|
104
|
+
!state.readOnlySession &&
|
|
105
|
+
!state.compact &&
|
|
106
|
+
!state.otherFirstRunDialogVisible &&
|
|
107
|
+
!state.earlierAnnouncementMayShow
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `plannotator` agent tool switch as the UI sees it: what the server
|
|
3
|
+
* reports in `serverConfig` (packages/shared/config.ts getServerConfig) and
|
|
4
|
+
* the POST /api/config write that changes it.
|
|
5
|
+
*
|
|
6
|
+
* Only a Plannotator server that knows the session's tool host sends
|
|
7
|
+
* `agentToolHost` (the Claude Code mod, Pi, OpenCode 2). Everything else, an
|
|
8
|
+
* older server, OpenCode 1, Codex, a `@plannotator/ui` host such as
|
|
9
|
+
* Workspaces, parses to undefined, and no toggle or offer is shown.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The hosts that register the `plannotator` agent tool. */
|
|
13
|
+
export type AgentToolHost = 'claude-code' | 'pi' | 'opencode';
|
|
14
|
+
|
|
15
|
+
export interface AgentToolSetting {
|
|
16
|
+
readonly host: AgentToolHost;
|
|
17
|
+
/** The value the NEXT session uses (env, then config file, then the host default). */
|
|
18
|
+
readonly enabled: boolean;
|
|
19
|
+
/** config.json holds an explicit choice (false: the host default applies). */
|
|
20
|
+
readonly configured: boolean;
|
|
21
|
+
/** PLANNOTATOR_AGENT_TOOL, when it overrides the file. A toggle cannot change it. */
|
|
22
|
+
readonly env?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isAgentToolHost(value: unknown): value is AgentToolHost {
|
|
26
|
+
return value === 'claude-code' || value === 'pi' || value === 'opencode';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Reads the agent tool fields out of a `serverConfig` payload, or undefined when the server reports no tool host. */
|
|
30
|
+
export function parseAgentToolSetting(serverConfig: unknown): AgentToolSetting | undefined {
|
|
31
|
+
if (typeof serverConfig !== 'object' || serverConfig === null) return undefined;
|
|
32
|
+
const sc = serverConfig as Record<string, unknown>;
|
|
33
|
+
if (!isAgentToolHost(sc.agentToolHost) || typeof sc.agentToolEnabled !== 'boolean') return undefined;
|
|
34
|
+
return {
|
|
35
|
+
host: sc.agentToolHost,
|
|
36
|
+
enabled: sc.agentToolEnabled,
|
|
37
|
+
configured: sc.agentToolConfigured === true,
|
|
38
|
+
...(typeof sc.agentToolEnv === 'boolean' ? { env: sc.agentToolEnv } : {}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The setting after a successful write of `value` (an env override still decides). */
|
|
43
|
+
export function withSavedAgentTool(setting: AgentToolSetting, value: boolean): AgentToolSetting {
|
|
44
|
+
return { ...setting, enabled: setting.env ?? value, configured: true };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Writes `{ agentTool }` to config.json through POST /api/config. Resolves
|
|
49
|
+
* once the server confirms the file holds the value; rejects with a readable
|
|
50
|
+
* message otherwise (the server answers 500 when the write did not land).
|
|
51
|
+
* Not debounced and not best-effort like the settings registry's write-back:
|
|
52
|
+
* the offer and the switch must know whether it worked.
|
|
53
|
+
*/
|
|
54
|
+
export async function saveAgentToolSetting(
|
|
55
|
+
value: boolean,
|
|
56
|
+
fetchImpl: typeof fetch = fetch,
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
let response: Response;
|
|
59
|
+
try {
|
|
60
|
+
response = await fetchImpl('/api/config', {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/json' },
|
|
63
|
+
body: JSON.stringify({ agentTool: value }),
|
|
64
|
+
});
|
|
65
|
+
} catch {
|
|
66
|
+
throw new Error('Could not reach Plannotator. Nothing was changed.');
|
|
67
|
+
}
|
|
68
|
+
if (response.ok) return;
|
|
69
|
+
let detail = '';
|
|
70
|
+
try {
|
|
71
|
+
const body = (await response.json()) as { error?: unknown };
|
|
72
|
+
if (typeof body.error === 'string') detail = body.error;
|
|
73
|
+
} catch {
|
|
74
|
+
// No JSON body: the status line below is all there is.
|
|
75
|
+
}
|
|
76
|
+
throw new Error(detail || `Plannotator answered ${response.status}. Nothing was changed.`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const HOST_NAMES: Record<AgentToolHost, string> = {
|
|
80
|
+
'claude-code': 'Claude Code',
|
|
81
|
+
pi: 'Pi',
|
|
82
|
+
opencode: 'OpenCode',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export function agentToolHostName(host: AgentToolHost): string {
|
|
86
|
+
return HOST_NAMES[host];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* When a change takes effect on this host: the tool list is part of the
|
|
91
|
+
* model's prompt, so each host reads the setting once. Pi reads it per
|
|
92
|
+
* session; the Claude Code mod and the OpenCode plugin decide once per
|
|
93
|
+
* process (when the module or plugin loads), so those take a restart.
|
|
94
|
+
*/
|
|
95
|
+
export function agentToolAppliesWhen(host: AgentToolHost): string {
|
|
96
|
+
return host === 'pi' ? 'the next Pi session' : `the next time ${HOST_NAMES[host]} starts`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* What the tool costs in context. Pi and OpenCode send its full definition
|
|
101
|
+
* with every request; Claude Code defers it behind tool search, so it costs
|
|
102
|
+
* about its name until it is used.
|
|
103
|
+
*/
|
|
104
|
+
export function agentToolCostNote(host: AgentToolHost): string {
|
|
105
|
+
return host === 'claude-code'
|
|
106
|
+
? 'Claude Code loads it only when needed.'
|
|
107
|
+
: 'Adds about 780 tokens to every request.';
|
|
108
|
+
}
|
package/utils/aiProvider.ts
CHANGED
|
@@ -45,6 +45,8 @@ export const SESSION_ASK_ERROR_CODES = {
|
|
|
45
45
|
agentBusy: 'agent_busy',
|
|
46
46
|
blocked: 'session_blocked',
|
|
47
47
|
gone: 'session_gone',
|
|
48
|
+
/** The person typed into the session while it answered: a note under the partial answer, not an error. */
|
|
49
|
+
takenOver: 'session_taken_over',
|
|
48
50
|
} as const;
|
|
49
51
|
|
|
50
52
|
export function isSessionBridgeProvider(provider: Pick<AIProviderOption, 'name' | 'sessionBridge'> | null | undefined): boolean {
|
|
@@ -52,18 +54,12 @@ export function isSessionBridgeProvider(provider: Pick<AIProviderOption, 'name'
|
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
/**
|
|
55
|
-
* The "Ask this session" provider
|
|
56
|
-
*
|
|
57
|
+
* The "Ask this session" provider, whatever its status. A server that has one
|
|
58
|
+
* offers nothing else (the session that opened Plannotator is the only thing
|
|
59
|
+
* Ask AI talks to), so when it is present it is the selection.
|
|
57
60
|
*/
|
|
58
|
-
export function
|
|
59
|
-
|
|
60
|
-
const bridge = provider.sessionBridge;
|
|
61
|
-
if (!bridge) continue;
|
|
62
|
-
if (bridge.status === 'gone') continue;
|
|
63
|
-
if (bridge.status === 'blocked' && !bridge.modes.transient) continue;
|
|
64
|
-
return provider;
|
|
65
|
-
}
|
|
66
|
-
return null;
|
|
61
|
+
export function findSessionBridge(providers: AIProviderOption[]): AIProviderOption | null {
|
|
62
|
+
return providers.find(isSessionBridgeProvider) ?? null;
|
|
67
63
|
}
|
|
68
64
|
|
|
69
65
|
export interface AIProviderSettings {
|
|
@@ -192,20 +188,14 @@ export function resolveAIProviderSelection(options: {
|
|
|
192
188
|
const byId = (id: string | null | undefined) =>
|
|
193
189
|
id ? providers.find(provider => provider.id === id) ?? null : null;
|
|
194
190
|
|
|
195
|
-
//
|
|
196
|
-
// session
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
? byId(originHasDedicatedAIProvider(origin)
|
|
201
|
-
? (origin ? settings.providerByOrigin[origin] : null)
|
|
202
|
-
: settings.providerId)
|
|
203
|
-
: null;
|
|
204
|
-
|
|
191
|
+
// "Ask this session" is the only choice when the server offers it, even when
|
|
192
|
+
// the session is busy, blocked or gone: a saved pick never routes a question
|
|
193
|
+
// anywhere else (and is not rewritten, so it still applies to sessions with
|
|
194
|
+
// no bridge). Without a bridge this is the previous order.
|
|
195
|
+
const sessionBridge = findSessionBridge(providers);
|
|
205
196
|
const provider =
|
|
206
|
-
explicitPick ??
|
|
207
|
-
byId(origin ? settings.providerByOrigin[origin] : null) ??
|
|
208
197
|
sessionBridge ??
|
|
198
|
+
byId(origin ? settings.providerByOrigin[origin] : null) ??
|
|
209
199
|
findOriginAIProvider(providers, origin) ??
|
|
210
200
|
byId(settings.providerId) ??
|
|
211
201
|
byId(serverDefaultProvider) ??
|
|
@@ -218,23 +208,6 @@ export function resolveAIProviderSelection(options: {
|
|
|
218
208
|
};
|
|
219
209
|
}
|
|
220
210
|
|
|
221
|
-
/**
|
|
222
|
-
* Where "Ask a separate AI instead" goes when "Ask this session" cannot
|
|
223
|
-
* answer: the selection the app would make if the bridge were not there.
|
|
224
|
-
*/
|
|
225
|
-
export function resolveSessionBridgeFallback(options: {
|
|
226
|
-
providers: AIProviderOption[];
|
|
227
|
-
origin?: Origin | null;
|
|
228
|
-
settings?: AIProviderSettings;
|
|
229
|
-
serverDefaultProvider?: string | null;
|
|
230
|
-
}): AIProviderSelection {
|
|
231
|
-
const providers = options.providers.filter(provider => !isSessionBridgeProvider(provider));
|
|
232
|
-
const serverDefaultProvider = providers.some(provider => provider.id === options.serverDefaultProvider)
|
|
233
|
-
? options.serverDefaultProvider
|
|
234
|
-
: null;
|
|
235
|
-
return resolveAIProviderSelection({ ...options, providers, serverDefaultProvider });
|
|
236
|
-
}
|
|
237
|
-
|
|
238
211
|
export function saveAIProviderSelection(options: {
|
|
239
212
|
providerId: string | null;
|
|
240
213
|
model?: string | null;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { QUESTION_ANSWER_ANNOTATION_PREFIX } from '@plannotator/core/question-block';
|
|
2
|
+
import { AnnotationType, type Annotation } from '../types';
|
|
3
|
+
|
|
4
|
+
/** Ids of checkbox-toggle annotations: keyed by their list item's block,
|
|
5
|
+
* quoting raw markdown, and never a text highlight. */
|
|
6
|
+
export const CHECKBOX_ANNOTATION_PREFIX = 'ann-checkbox-';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whether an annotation is restored as a text highlight in the document view.
|
|
10
|
+
*
|
|
11
|
+
* Not: comments made in the plan diff view (`diffContext`, `blockId`
|
|
12
|
+
* `diff-block-N`, drawn by the diff view), general comments, checkbox
|
|
13
|
+
* toggles, and answers to `:::question` blocks (drawn by the question card).
|
|
14
|
+
* Restoring any of those through the highlighter finds no text, so it would
|
|
15
|
+
* be reported unanchored and its `blockId` rewritten. Every caller that
|
|
16
|
+
* re-applies a stored list filters with this, and the highlighter skips the
|
|
17
|
+
* same rows itself.
|
|
18
|
+
*/
|
|
19
|
+
export function annotationOwnsHighlight(
|
|
20
|
+
annotation: Pick<Annotation, 'id' | 'type' | 'diffContext' | 'questionAnswer'>,
|
|
21
|
+
): boolean {
|
|
22
|
+
return !annotation.diffContext
|
|
23
|
+
&& annotation.type !== AnnotationType.GLOBAL_COMMENT
|
|
24
|
+
&& !annotation.id.startsWith(CHECKBOX_ANNOTATION_PREFIX)
|
|
25
|
+
&& annotation.questionAnswer == null
|
|
26
|
+
&& !annotation.id.startsWith(QUESTION_ANSWER_ANNOTATION_PREFIX);
|
|
27
|
+
}
|
package/utils/annotationScope.ts
CHANGED
|
@@ -87,6 +87,12 @@ export function groupAnnotationsByDocument(
|
|
|
87
87
|
documents: Iterable<AnnotationDocumentInput>,
|
|
88
88
|
currentPath: string | null,
|
|
89
89
|
roots: readonly string[] = [],
|
|
90
|
+
/**
|
|
91
|
+
* A fixed reading order (a review of several files): groups follow it, the
|
|
92
|
+
* open document included, and documents outside it come after, by path.
|
|
93
|
+
* Absent: the open document first, the rest by path.
|
|
94
|
+
*/
|
|
95
|
+
order?: readonly string[] | null,
|
|
90
96
|
): AnnotationDocumentGroup[] {
|
|
91
97
|
const normalizedCurrent = currentPath ? normalizeBrowserPath(currentPath) : null;
|
|
92
98
|
const groups: AnnotationDocumentGroup[] = [];
|
|
@@ -102,6 +108,12 @@ export function groupAnnotationsByDocument(
|
|
|
102
108
|
isCurrent: path === normalizedCurrent,
|
|
103
109
|
});
|
|
104
110
|
}
|
|
111
|
+
if (order) {
|
|
112
|
+
const ranks = new Map(order.map((path, index) => [normalizeBrowserPath(path), index]));
|
|
113
|
+
const rank = (path: string) => ranks.get(path) ?? order.length;
|
|
114
|
+
groups.sort((a, b) => rank(a.path) - rank(b.path) || a.path.localeCompare(b.path));
|
|
115
|
+
return groups;
|
|
116
|
+
}
|
|
105
117
|
groups.sort((a, b) => {
|
|
106
118
|
if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1;
|
|
107
119
|
return a.path.localeCompare(b.path);
|
|
@@ -124,6 +136,8 @@ export function buildAnnotationDocumentGroups(input: {
|
|
|
124
136
|
cached: Iterable<readonly [string, readonly Annotation[]]>;
|
|
125
137
|
current: { key: string; label?: string; annotations: readonly Annotation[] };
|
|
126
138
|
roots?: readonly string[];
|
|
139
|
+
/** A fixed reading order; see groupAnnotationsByDocument. */
|
|
140
|
+
order?: readonly string[] | null;
|
|
127
141
|
}): AnnotationDocumentGroup[] {
|
|
128
142
|
const byPath = new Map<string, readonly Annotation[]>();
|
|
129
143
|
for (const [path, annotations] of input.cached) byPath.set(path, annotations);
|
|
@@ -136,6 +150,7 @@ export function buildAnnotationDocumentGroups(input: {
|
|
|
136
150
|
})),
|
|
137
151
|
input.current.key,
|
|
138
152
|
input.roots ?? [],
|
|
153
|
+
input.order,
|
|
139
154
|
);
|
|
140
155
|
}
|
|
141
156
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time gate for the "Ask this session" announcement: Ask AI answered by
|
|
3
|
+
* the agent session that opened Plannotator, and reviews that no longer hold
|
|
4
|
+
* that session. Cookie-backed like the other announcement gates, so a
|
|
5
|
+
* dismissal survives Plannotator's random localhost ports, and shared by the
|
|
6
|
+
* plan editor, the annotate surfaces and the code review editor: dismissing it
|
|
7
|
+
* anywhere retires it everywhere.
|
|
8
|
+
*
|
|
9
|
+
* A plain storage key rather than a settings-registry entry for the same reason
|
|
10
|
+
* as terminalToolsAnnouncement.ts: configStore seeds every registry default
|
|
11
|
+
* into a cookie on first access, so a registry flag could not tell "never seen"
|
|
12
|
+
* from "seeded default".
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { isSessionBridgeProvider, type AIProviderOption } from './aiProvider';
|
|
16
|
+
import { storage } from './storage';
|
|
17
|
+
import { needsTerminalToolsAnnouncement } from './terminalToolsAnnouncement';
|
|
18
|
+
|
|
19
|
+
const STORAGE_KEY = 'plannotator-announce-ask-session-seen';
|
|
20
|
+
// Bump to re-announce after a meaningful revision.
|
|
21
|
+
const CURRENT_VERSION = '1';
|
|
22
|
+
|
|
23
|
+
export function needsAskSessionAnnouncement(): boolean {
|
|
24
|
+
return storage.getItem(STORAGE_KEY) !== CURRENT_VERSION;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function markAskSessionAnnouncementSeen(): void {
|
|
28
|
+
storage.setItem(STORAGE_KEY, CURRENT_VERSION);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether this page load may show the announcement, latched at mount by the
|
|
33
|
+
* Apps. False while the terminal-tools announcement is still pending: that one
|
|
34
|
+
* takes this load, and this one waits for the next, so a reader never gets two
|
|
35
|
+
* announcements back to back. A reader who dismissed the terminal-tools
|
|
36
|
+
* announcement on an earlier load gets this one on the next load.
|
|
37
|
+
*/
|
|
38
|
+
export function askSessionAnnouncementPendingThisLoad(): boolean {
|
|
39
|
+
return needsAskSessionAnnouncement() && !needsTerminalToolsAnnouncement();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The hosts whose sessions answer Ask AI through the session bridge. */
|
|
43
|
+
export type AskSessionAgent = 'claude-code' | 'pi' | 'opencode';
|
|
44
|
+
|
|
45
|
+
function askSessionAgentForHost(host: string | undefined): AskSessionAgent | null {
|
|
46
|
+
return host === 'claude-code' || host === 'pi' || host === 'opencode' ? host : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The agent whose session is connected to THIS Plannotator session right now:
|
|
51
|
+
* the server lists the "Ask this session" provider and its session is not
|
|
52
|
+
* gone. Null everywhere the feature is not live (remote, --tailscale, Windows,
|
|
53
|
+
* the Claude Code mod turned off, `-p`, Pi's event-API path, OpenCode 1, an
|
|
54
|
+
* older CLI): the announcement only describes what the reader can use here.
|
|
55
|
+
*/
|
|
56
|
+
export function connectedAskSessionAgent(
|
|
57
|
+
providers: ReadonlyArray<Pick<AIProviderOption, 'name' | 'sessionBridge'>>,
|
|
58
|
+
): AskSessionAgent | null {
|
|
59
|
+
const bridge = providers.find(isSessionBridgeProvider)?.sessionBridge;
|
|
60
|
+
if (!bridge || bridge.status === 'gone') return null;
|
|
61
|
+
return askSessionAgentForHost(bridge.host);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface AskSessionAnnouncementGateState {
|
|
65
|
+
/** Latched at mount from askSessionAnnouncementPendingThisLoad(). */
|
|
66
|
+
readonly announcementPending: boolean;
|
|
67
|
+
/** The app has not finished loading its initial payload. */
|
|
68
|
+
readonly isLoading: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* connectedAskSessionAgent() of the capabilities answer. Null while that
|
|
71
|
+
* answer is pending and whenever the session is not connected: deferred,
|
|
72
|
+
* never consumed, so the reader sees it in a session where it is true.
|
|
73
|
+
*/
|
|
74
|
+
readonly connectedAgent: AskSessionAgent | null;
|
|
75
|
+
/**
|
|
76
|
+
* Ask AI is actually reachable on this surface (the plan editor's canUseAI,
|
|
77
|
+
* not taken over by the annotate agent terminal; code review's AI button),
|
|
78
|
+
* so "Open Ask AI to try it" never points at nothing.
|
|
79
|
+
*/
|
|
80
|
+
readonly askAIUsable: boolean;
|
|
81
|
+
/** Archive browsing, a read-only shared plan, or no Plannotator server. Deferred, not consumed. */
|
|
82
|
+
readonly readOnlySession: boolean;
|
|
83
|
+
/** Plannotator's compact touch shell. Deferred, not consumed. */
|
|
84
|
+
readonly compact: boolean;
|
|
85
|
+
/** Any other first-run dialog is on screen. The chain dialogs never stack. */
|
|
86
|
+
readonly otherFirstRunDialogVisible: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether the announcement may open now. LAST in each app's first-run chain,
|
|
91
|
+
* after the terminal-tools announcement (see askSessionAnnouncementPendingThisLoad)
|
|
92
|
+
* and behind every dialog that asks the user to decide something, for the
|
|
93
|
+
* reasons terminalToolsAnnouncementCanShow gives. The Apps pass this through
|
|
94
|
+
* useFirstRunAnnouncementWindow, which only lets it open before the reader has
|
|
95
|
+
* started working.
|
|
96
|
+
*/
|
|
97
|
+
export function askSessionAnnouncementEligible(state: AskSessionAnnouncementGateState): boolean {
|
|
98
|
+
return (
|
|
99
|
+
state.announcementPending &&
|
|
100
|
+
!state.isLoading &&
|
|
101
|
+
state.connectedAgent !== null &&
|
|
102
|
+
state.askAIUsable &&
|
|
103
|
+
!state.readOnlySession &&
|
|
104
|
+
!state.compact &&
|
|
105
|
+
!state.otherFirstRunDialogVisible
|
|
106
|
+
);
|
|
107
|
+
}
|