@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.
Files changed (62) hide show
  1. package/HANDOFF.md +295 -11
  2. package/README.md +8 -0
  3. package/components/AISettingsTab.tsx +32 -0
  4. package/components/AgentToolAnnouncementDialog.tsx +347 -0
  5. package/components/AgentsTab.tsx +9 -9
  6. package/components/AnnotationToolbar.tsx +2 -2
  7. package/components/AskSessionAnnouncementDialog.tsx +329 -0
  8. package/components/BlockRenderer.tsx +10 -1
  9. package/components/CommentPopover.tsx +36 -18
  10. package/components/CompletionOverlay.tsx +12 -5
  11. package/components/DecisionControl.tsx +2 -7
  12. package/components/DiagramBlock.tsx +46 -4
  13. package/components/DocBadges.tsx +12 -8
  14. package/components/FloatingQuickLabelPicker.tsx +2 -2
  15. package/components/PlanHeaderMenu.tsx +1 -1
  16. package/components/QuickLabelDropdown.tsx +9 -4
  17. package/components/ServerSessionReplacedBanner.tsx +29 -0
  18. package/components/Settings.tsx +76 -192
  19. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  20. package/components/Viewer.tsx +30 -1
  21. package/components/ai/AIProviderBar.tsx +18 -1
  22. package/components/ai/DocumentAIChatPanel.tsx +4 -9
  23. package/components/ai/SessionAskNotice.tsx +33 -21
  24. package/components/blocks/QuestionBlock.tsx +99 -27
  25. package/components/blocks/QuestionContext.tsx +81 -0
  26. package/components/diagram/DiagramComposer.tsx +54 -3
  27. package/components/diagram/DiagramViewer.tsx +31 -2
  28. package/components/diagram/index.ts +1 -1
  29. package/components/html-viewer/HtmlViewer.tsx +13 -3
  30. package/components/html-viewer/bridge-script.asset.js +230 -14
  31. package/components/html-viewer/bridge-script.ts +230 -14
  32. package/components/html-viewer/useHtmlAnnotation.ts +36 -55
  33. package/components/settings/QuickLabelsTab.tsx +378 -0
  34. package/hooks/useAIChat.ts +83 -6
  35. package/hooks/useAIProviderActivation.ts +13 -0
  36. package/hooks/useAIProviderConfig.ts +3 -3
  37. package/hooks/useActiveSection.ts +3 -2
  38. package/hooks/useAgentToolSetting.ts +33 -0
  39. package/hooks/useAnnotationDraft.ts +53 -7
  40. package/hooks/useAnnotationHighlighter.ts +129 -5
  41. package/hooks/useExternalAnnotations.ts +21 -1
  42. package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
  43. package/hooks/useLatchedTrue.ts +15 -0
  44. package/hooks/useLinkedDoc.ts +64 -4
  45. package/hooks/useModelCatalogs.ts +9 -2
  46. package/package.json +2 -2
  47. package/styles.css +1 -1
  48. package/themes/gruvbox.css +6 -0
  49. package/types.ts +19 -0
  50. package/utils/agentClosed.ts +33 -0
  51. package/utils/agentToolAnnouncement.ts +109 -0
  52. package/utils/agentToolSetting.ts +108 -0
  53. package/utils/aiProvider.ts +13 -40
  54. package/utils/annotationOwnsHighlight.ts +27 -0
  55. package/utils/annotationScope.ts +15 -0
  56. package/utils/askSessionAnnouncement.ts +107 -0
  57. package/utils/blockTargeting.ts +61 -2
  58. package/utils/diagramAskAI.ts +161 -0
  59. package/utils/parser.ts +382 -114
  60. package/utils/questionAnswers.ts +3 -1
  61. package/utils/quickLabels.ts +82 -2
  62. package/utils/serverSession.ts +85 -0
@@ -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
+ }
@@ -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 when it can answer here: present, the
56
- * session not gone, and not blocked unless it offers a transient answer.
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 findUsableSessionBridge(providers: AIProviderOption[]): AIProviderOption | null {
59
- for (const provider of providers) {
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
- // An explicit saved pick wins, then "Ask this session" whenever the host
196
- // session can answer, then the origin's own SDK provider and the older
197
- // fallbacks. Without a usable bridge this is exactly the previous order.
198
- const sessionBridge = findUsableSessionBridge(providers);
199
- const explicitPick = sessionBridge
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
+ }
@@ -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
+ }