@omercnet/paseo-context-mode 3.1.4-next.382.1 → 3.1.4-next.434.1

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.
@@ -1,230 +1,261 @@
1
+ import type { PluginTheme } from "@getpaseo/plugin";
1
2
  import { type PluginSurfaceProps, useRpc } from "@getpaseo/plugin/client";
2
- import { ExternalLink } from "@getpaseo/plugin/client/ui";
3
- import { useMutation, useQueryClient } from "@tanstack/react-query";
4
- import { type ReactNode, useMemo, useState } from "react";
5
- import {
6
- ActivityIndicator,
7
- Pressable,
8
- ScrollView,
9
- Text,
10
- TextInput,
11
- type TextStyle,
12
- View,
13
- type ViewStyle,
14
- } from "react-native";
3
+ import { ScrollView, SettingsSection, TextInput } from "@getpaseo/plugin/client/ui";
4
+ import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
5
+ import { type ReactNode, useState } from "react";
6
+ import { Text, View } from "react-native";
15
7
  import {
8
+ type FetchAndIndexInput,
16
9
  fetchAndIndexContextMode,
10
+ type IndexPathInput,
17
11
  indexContextModePath,
12
+ type KnowledgeDestination,
18
13
  type KnowledgeToolResult,
14
+ type PurgeKnowledgeInput,
19
15
  purgeContextModeKnowledge,
16
+ type SearchKnowledgeInput,
17
+ type SearchKnowledgeResult,
18
+ type SearchPage,
19
+ sameDestination,
20
20
  searchContextModeKnowledge,
21
21
  } from "../shared/knowledge";
22
+ import { CONTEXT_MODE_PROVIDERS, IN_PROCESS_PROVIDERS } from "../shared/providers";
23
+ import { ActionButton, Card, type ChoiceOption, Choices, ErrorText, Pending } from "./controls";
22
24
  import type { KnowledgeScope } from "./screen-scope";
23
25
 
24
- const PROVIDER_LABELS: Readonly<Record<string, string>> = {
25
- claude: "Claude",
26
- codex: "Codex",
27
- copilot: "Copilot",
28
- cursor: "Cursor",
29
- opencode: "OpenCode",
30
- pi: "Pi",
31
- omp: "OMP",
32
- "omp-plugin": "OMP plugin",
33
- };
34
-
35
26
  /** Used until the integration audit reports the configured providers. */
36
- export const BUILTIN_KNOWLEDGE_PROVIDERS = Object.keys(PROVIDER_LABELS);
27
+ export const BUILTIN_KNOWLEDGE_PROVIDERS: readonly string[] = CONTEXT_MODE_PROVIDERS.map(
28
+ ({ provider }) => provider,
29
+ );
30
+
31
+ /** Built-in providers by name; configured providers (for example `claude-work`) by their id. */
32
+ export function providerLabel(id: string): string {
33
+ const entry = [...CONTEXT_MODE_PROVIDERS, ...IN_PROCESS_PROVIDERS].find(
34
+ ({ provider }) => provider === id,
35
+ );
36
+ return entry ? entry.label : id;
37
+ }
37
38
 
38
- const providerLabel = (id: string) => PROVIDER_LABELS[id] ?? id;
39
+ type SearchScope = SearchKnowledgeInput["scope"];
40
+ type SearchSort = SearchKnowledgeInput["sort"];
41
+ type PurgeScope = PurgeKnowledgeInput["scope"];
39
42
 
40
- const INSIGHT_URL = "https://context-mode.com/insight";
41
- type Provider = string;
42
- type PurgeScope = "project" | "session";
43
- export type KnowledgeSectionProps = Pick<PluginSurfaceProps, "theme" | "layout" | "host"> & {
44
- initialScope?: KnowledgeScope;
45
- /** Paseo provider ids with a Context Mode store: the built-ins plus every configured provider. */
46
- providers: readonly string[];
43
+ const SEARCH_SCOPES: readonly ChoiceOption<SearchScope>[] = [
44
+ { value: "worktree", label: "Worktree" },
45
+ { value: "project", label: "Project" },
46
+ ];
47
+
48
+ /** Timeline is upstream's oldest-first order. */
49
+ const SEARCH_SORTS: readonly ChoiceOption<SearchSort>[] = [
50
+ { value: "relevance", label: "Relevance" },
51
+ { value: "timeline", label: "Timeline" },
52
+ ];
53
+
54
+ const PURGE_SCOPES: readonly ChoiceOption<PurgeScope>[] = [
55
+ { value: "project", label: "Project" },
56
+ { value: "session", label: "Session" },
57
+ { value: "source", label: "Source" },
58
+ ];
59
+
60
+ /** What `ctx_purge` removes per scope: a session purge deletes only its journal, never indexed sources. */
61
+ const PURGE_CONSEQUENCE: Readonly<Record<PurgeScope, string>> = {
62
+ project: "Every agent working in this project loses its recorded activity and indexed sources.",
63
+ session: "The session's recorded activity and resume state are deleted; indexed sources stay.",
64
+ source: "Every agent working in this project loses it.",
47
65
  };
48
66
 
49
- interface KnowledgeStyles {
50
- section: ViewStyle;
51
- sectionHeader: ViewStyle;
52
- sectionTitle: TextStyle;
53
- card: ViewStyle;
54
- cardHeader: ViewStyle;
55
- cardTitle: TextStyle;
56
- muted: TextStyle;
57
- scopeNote: TextStyle;
58
- field: ViewStyle;
59
- fieldLabel: TextStyle;
60
- input: TextStyle;
61
- placeholder: TextStyle;
62
- choiceRow: ViewStyle;
63
- choice: ViewStyle;
64
- choiceSelected: ViewStyle;
65
- choiceText: TextStyle;
66
- choiceTextSelected: TextStyle;
67
- actions: ViewStyle;
68
- button: ViewStyle;
69
- primaryButton: ViewStyle;
70
- dangerButton: ViewStyle;
71
- disabled: ViewStyle;
72
- buttonText: TextStyle;
73
- primaryButtonText: TextStyle;
74
- dangerButtonText: TextStyle;
75
- linkButton: ViewStyle;
76
- linkText: TextStyle;
77
- pendingRow: ViewStyle;
78
- activity: { color: string };
79
- error: TextStyle;
80
- result: ViewStyle;
81
- metaRow: ViewStyle;
82
- metaLabel: TextStyle;
83
- metaValue: TextStyle;
84
- outputFrame: ViewStyle;
85
- output: TextStyle;
86
- confirmation: ViewStyle;
87
- confirmationTitle: TextStyle;
67
+ type PurgeTarget =
68
+ | { scope: "project" }
69
+ | { scope: "session"; sessionId: string }
70
+ | { scope: "source"; source: string };
71
+ type ForgetTarget = Exclude<PurgeTarget, { scope: "project" }>;
72
+
73
+ interface MoreResultsInput {
74
+ request: SearchKnowledgeInput;
75
+ page: SearchPage;
76
+ }
77
+
78
+ function knowledgeMutationKey(hostId: string) {
79
+ return ["context-mode", hostId, "knowledge"] as const;
80
+ }
81
+
82
+ function useRefreshAnalytics(hostId: string): () => Promise<void> {
83
+ const queryClient = useQueryClient();
84
+ return () =>
85
+ queryClient.invalidateQueries({ queryKey: ["context-mode", hostId, "analytics-dashboard"] });
86
+ }
87
+
88
+ function describePurgeTarget(target: PurgeTarget): string {
89
+ if (target.scope === "session") return `session ${target.sessionId}`;
90
+ if (target.scope === "source") return `source ${target.source}`;
91
+ return "all project knowledge";
92
+ }
93
+
94
+ /** Drops agent-only footers (the More results button pages instead) and tool-name prefixes. */
95
+ export function presentKnowledgeOutput(output: string): string {
96
+ return output
97
+ .split(/\r?\n/)
98
+ .filter((line) => !/^[Mm]ore: ctx_search\(/.test(line))
99
+ .map((line) => line.replace(/^ctx_[a-z_]+: /, ""))
100
+ .join("\n")
101
+ .trim();
102
+ }
103
+
104
+ function createStyles(theme: PluginTheme) {
105
+ return {
106
+ muted: { color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 16 },
107
+ fieldLabel: { color: theme.colors.foregroundMuted, fontSize: 12, fontWeight: "500" as const },
108
+ cardTitle: { color: theme.colors.foreground, fontSize: 14, fontWeight: "500" as const },
109
+ actions: { flexDirection: "row" as const, flexWrap: "wrap" as const, gap: 8 },
110
+ result: {
111
+ gap: 8,
112
+ padding: 12,
113
+ borderWidth: 1,
114
+ borderColor: theme.colors.border,
115
+ borderRadius: 8,
116
+ backgroundColor: theme.colors.surface0,
117
+ },
118
+ metaRow: { flexDirection: "row" as const, alignItems: "flex-start" as const, gap: 8 },
119
+ metaLabel: { width: 56, color: theme.colors.foregroundMuted, fontSize: 12 },
120
+ metaValue: { flex: 1, color: theme.colors.foreground, fontSize: 12 },
121
+ output: {
122
+ color: theme.colors.foreground,
123
+ fontFamily: "monospace",
124
+ fontSize: 12,
125
+ lineHeight: 16,
126
+ },
127
+ };
128
+ }
129
+
130
+ interface FieldProps {
131
+ theme: PluginTheme;
132
+ label: string;
133
+ value: string;
134
+ onChangeText(value: string): void;
135
+ placeholder: string;
136
+ accessibilityLabel: string;
137
+ maxLength: number;
138
+ disabled?: boolean;
88
139
  }
89
140
 
90
141
  function Field({
142
+ theme,
91
143
  label,
92
144
  value,
93
145
  onChangeText,
94
146
  placeholder,
95
- styles,
96
147
  accessibilityLabel,
97
148
  maxLength,
98
- }: {
99
- label: string;
100
- value: string;
101
- onChangeText(value: string): void;
102
- placeholder: string;
103
- styles: KnowledgeStyles;
104
- accessibilityLabel: string;
105
- maxLength?: number;
106
- }) {
149
+ disabled = false,
150
+ }: FieldProps) {
151
+ const styles = createStyles(theme);
107
152
  return (
108
- <View style={styles.field}>
153
+ <View style={{ gap: 4 }}>
109
154
  <Text style={styles.fieldLabel}>{label}</Text>
110
155
  <TextInput
111
156
  accessibilityLabel={accessibilityLabel}
112
157
  autoCapitalize="none"
113
- maxLength={maxLength}
114
158
  autoCorrect={false}
159
+ editable={!disabled}
160
+ maxLength={maxLength}
115
161
  onChangeText={onChangeText}
116
162
  placeholder={placeholder}
117
- placeholderTextColor={styles.placeholder.color}
118
- style={styles.input}
163
+ placeholderTextColor={theme.colors.foregroundMuted}
164
+ style={{
165
+ minHeight: 32,
166
+ width: "100%",
167
+ borderWidth: 1,
168
+ borderColor: theme.colors.border,
169
+ borderRadius: 8,
170
+ paddingHorizontal: 12,
171
+ paddingVertical: 8,
172
+ color: theme.colors.foreground,
173
+ backgroundColor: theme.colors.surface0,
174
+ fontSize: 14,
175
+ }}
119
176
  value={value}
120
177
  />
121
178
  </View>
122
179
  );
123
180
  }
124
181
 
125
- function ActionButton({
126
- label,
127
- onPress,
128
- disabled = false,
129
- danger = false,
130
- primary = false,
131
- styles,
132
- }: {
133
- label: string;
134
- onPress(): void;
135
- disabled?: boolean;
136
- danger?: boolean;
137
- primary?: boolean;
138
- styles: KnowledgeStyles;
139
- }) {
140
- return (
141
- <Pressable
142
- accessibilityLabel={label}
143
- accessibilityRole="button"
144
- accessibilityState={{ disabled }}
145
- disabled={disabled}
146
- onPress={onPress}
147
- style={[
148
- styles.button,
149
- primary ? styles.primaryButton : null,
150
- danger ? styles.dangerButton : null,
151
- disabled ? styles.disabled : null,
152
- ]}
153
- >
154
- <Text
155
- style={[
156
- styles.buttonText,
157
- primary ? styles.primaryButtonText : null,
158
- danger ? styles.dangerButtonText : null,
159
- ]}
160
- >
161
- {label}
162
- </Text>
163
- </Pressable>
164
- );
165
- }
166
-
167
- function WorkflowCard({
168
- title,
169
- description,
170
- children,
171
- styles,
172
- }: {
182
+ interface WorkflowCardProps {
183
+ theme: PluginTheme;
173
184
  title: string;
174
185
  description: string;
175
186
  children: ReactNode;
176
- styles: KnowledgeStyles;
177
- }) {
187
+ }
188
+
189
+ function WorkflowCard({ theme, title, description, children }: WorkflowCardProps) {
190
+ const styles = createStyles(theme);
178
191
  return (
179
- <View style={styles.card}>
180
- <View style={styles.cardHeader}>
192
+ <Card theme={theme}>
193
+ <View style={{ gap: 4 }}>
181
194
  <Text accessibilityRole="header" style={styles.cardTitle}>
182
195
  {title}
183
196
  </Text>
184
197
  <Text style={styles.muted}>{description}</Text>
185
198
  </View>
186
199
  {children}
200
+ </Card>
201
+ );
202
+ }
203
+
204
+ interface LabeledChoicesProps<Value extends string> {
205
+ theme: PluginTheme;
206
+ label: string;
207
+ options: readonly ChoiceOption<Value>[];
208
+ value: Value | null;
209
+ onChange(value: Value): void;
210
+ accessibilityLabel(option: ChoiceOption<Value>): string;
211
+ disabled?: boolean;
212
+ }
213
+
214
+ function LabeledChoices<Value extends string>({ label, ...choices }: LabeledChoicesProps<Value>) {
215
+ return (
216
+ <View style={{ gap: 4 }}>
217
+ <Text style={createStyles(choices.theme).fieldLabel}>{label}</Text>
218
+ <Choices {...choices} />
187
219
  </View>
188
220
  );
189
221
  }
190
222
 
191
- function ResultPanel({
192
- result,
193
- pending,
194
- error,
195
- emptyLabel,
196
- source,
197
- styles,
198
- }: {
223
+ interface OutputFrameProps {
224
+ theme: PluginTheme;
225
+ output: string;
226
+ emptyLabel: string;
227
+ }
228
+
229
+ function OutputFrame({ theme, output, emptyLabel }: OutputFrameProps) {
230
+ return (
231
+ <ScrollView nestedScrollEnabled style={{ maxHeight: 220 }}>
232
+ <Text selectable style={createStyles(theme).output}>
233
+ {presentKnowledgeOutput(output) || emptyLabel}
234
+ </Text>
235
+ </ScrollView>
236
+ );
237
+ }
238
+
239
+ interface ResultPanelProps {
240
+ theme: PluginTheme;
199
241
  result: KnowledgeToolResult | undefined;
200
242
  pending: boolean;
201
243
  error: unknown;
202
244
  emptyLabel: string;
203
245
  source?: string;
204
- styles: KnowledgeStyles;
205
- }) {
206
- if (pending) {
207
- return (
208
- <View accessibilityLiveRegion="polite" style={styles.pendingRow}>
209
- <ActivityIndicator size="small" color={styles.activity.color} />
210
- <Text style={styles.muted}>Working…</Text>
211
- </View>
212
- );
213
- }
214
- if (error) {
215
- return (
216
- <Text accessibilityRole="alert" style={styles.error}>
217
- {error instanceof Error ? error.message : String(error)}
218
- </Text>
219
- );
220
- }
246
+ }
247
+
248
+ function ResultPanel({ theme, result, pending, error, emptyLabel, source }: ResultPanelProps) {
249
+ const styles = createStyles(theme);
250
+ if (pending) return <Pending theme={theme} label="Working..." />;
251
+ if (error) return <ErrorText theme={theme} error={error} />;
221
252
  if (!result) return null;
222
253
  return (
223
254
  <View accessibilityLiveRegion="polite" style={styles.result}>
224
255
  <View style={styles.metaRow}>
225
- <Text style={styles.metaLabel}>Provider</Text>
256
+ <Text style={styles.metaLabel}>Agent</Text>
226
257
  <Text selectable style={styles.metaValue}>
227
- {result.provider}
258
+ {providerLabel(result.provider)}
228
259
  </Text>
229
260
  </View>
230
261
  {source ? (
@@ -235,571 +266,568 @@ function ResultPanel({
235
266
  </Text>
236
267
  </View>
237
268
  ) : null}
238
- <ScrollView nestedScrollEnabled style={styles.outputFrame}>
239
- <Text selectable style={styles.output}>
240
- {result.output || emptyLabel}
241
- </Text>
242
- </ScrollView>
269
+ <OutputFrame theme={theme} output={result.output} emptyLabel={emptyLabel} />
243
270
  </View>
244
271
  );
245
272
  }
246
273
 
247
- export function KnowledgeSection({
274
+ interface ConfirmPanelProps {
275
+ theme: PluginTheme;
276
+ message: string;
277
+ confirmLabel: string;
278
+ pendingLabel: string;
279
+ pending: boolean;
280
+ confirmAccessibilityLabel?: string;
281
+ onConfirm(): void;
282
+ onCancel(): void;
283
+ }
284
+
285
+ /** The review step before a permanent delete: the only place a destructive button appears. */
286
+ function ConfirmPanel({
248
287
  theme,
249
- layout,
250
- host,
251
- initialScope,
252
- providers,
253
- }: KnowledgeSectionProps) {
254
- const styles = useMemo(() => createStyles(theme, layout.compact), [layout.compact, theme]);
255
- const searchKnowledge = useRpc(searchContextModeKnowledge);
256
- const indexPath = useRpc(indexContextModePath);
257
- const fetchAndIndex = useRpc(fetchAndIndexContextMode);
258
- const purgeKnowledge = useRpc(purgeContextModeKnowledge);
259
- const queryClient = useQueryClient();
260
- const refreshAnalytics = () =>
261
- queryClient.invalidateQueries({
262
- queryKey: ["context-mode", host.id, "analytics-dashboard"],
263
- });
288
+ message,
289
+ confirmLabel,
290
+ pendingLabel,
291
+ pending,
292
+ confirmAccessibilityLabel,
293
+ onConfirm,
294
+ onCancel,
295
+ }: ConfirmPanelProps) {
296
+ const styles = createStyles(theme);
297
+ return (
298
+ <View
299
+ accessibilityRole="alert"
300
+ style={{
301
+ gap: 8,
302
+ padding: 12,
303
+ borderWidth: 1,
304
+ borderColor: theme.colors.statusDanger,
305
+ borderRadius: 8,
306
+ backgroundColor: theme.colors.surface0,
307
+ }}
308
+ >
309
+ <Text style={styles.muted}>{message}</Text>
310
+ <View style={styles.actions}>
311
+ <ActionButton
312
+ theme={theme}
313
+ variant="destructive"
314
+ accessibilityLabel={confirmAccessibilityLabel}
315
+ disabled={pending}
316
+ label={pending ? pendingLabel : confirmLabel}
317
+ onPress={onConfirm}
318
+ />
319
+ <ActionButton theme={theme} disabled={pending} label="Cancel" onPress={onCancel} />
320
+ </View>
321
+ </View>
322
+ );
323
+ }
264
324
 
265
- const scopedProvider = initialScope?.provider ?? null;
266
- const scopedProviderKnown = scopedProvider !== null && providers.includes(scopedProvider);
267
- const note =
268
- initialScope?.note ??
269
- (scopedProvider && !scopedProviderKnown
270
- ? `Provider ${scopedProvider} has no Context Mode store on this host. Select one explicitly.`
271
- : undefined);
272
- const [provider, setProvider] = useState<Provider | null>(
273
- initialScope ? (scopedProviderKnown ? scopedProvider : null) : "claude",
325
+ export interface KnowledgeSectionProps extends Pick<PluginSurfaceProps, "theme" | "host"> {
326
+ initialScope?: KnowledgeScope;
327
+ /** Paseo provider ids Context Mode supports: the built-ins plus configured providers extending one. */
328
+ providers: readonly string[];
329
+ }
330
+
331
+ function initialProvider(scope: KnowledgeScope | undefined, providers: readonly string[]) {
332
+ if (!scope) return providers.includes("claude") ? "claude" : null;
333
+ if (scope.provider !== null && providers.includes(scope.provider)) return scope.provider;
334
+ return null;
335
+ }
336
+
337
+ function scopeNote(scope: KnowledgeScope | undefined, providers: readonly string[]) {
338
+ if (scope?.note) return scope.note;
339
+ if (!scope?.provider || providers.includes(scope.provider)) return null;
340
+ return `Knowledge needs an agent Paseo can run Context Mode for, and ${scope.provider} is not one. Select another agent.`;
341
+ }
342
+
343
+ export function KnowledgeSection({ theme, host, initialScope, providers }: KnowledgeSectionProps) {
344
+ const styles = createStyles(theme);
345
+ const note = scopeNote(initialScope, providers);
346
+ const [provider, setProvider] = useState<string | null>(() =>
347
+ initialProvider(initialScope, providers),
274
348
  );
275
349
  const [projectPath, setProjectPath] = useState(initialScope?.projectPath ?? "");
276
- const [query, setQuery] = useState("");
277
- const [searchSource, setSearchSource] = useState("");
278
- const [path, setPath] = useState("");
279
- const [pathSource, setPathSource] = useState("");
280
- const [url, setUrl] = useState("");
281
- const [urlSource, setUrlSource] = useState("");
282
- const [purgeScope, setPurgeScope] = useState<PurgeScope | null>(null);
283
- const [searchResultSource, setSearchResultSource] = useState("");
284
- const [pathResultSource, setPathResultSource] = useState("");
285
- const [urlResultSource, setUrlResultSource] = useState("");
286
- const [sessionId, setSessionId] = useState("");
287
350
  const [managementExpanded, setManagementExpanded] = useState(false);
288
- const [purgeArmed, setPurgeArmed] = useState(false);
289
- const [linkError, setLinkError] = useState<string | null>(null);
351
+ const busy = useIsMutating({ mutationKey: knowledgeMutationKey(host.id) }) > 0;
352
+ const trimmedPath = projectPath.trim();
353
+ const destination: KnowledgeDestination | null =
354
+ provider !== null && trimmedPath ? { provider, projectPath: trimmedPath } : null;
355
+ const destinationNote = destination
356
+ ? `Current destination: the project store for ${destination.projectPath}, recorded as ${providerLabel(destination.provider)}.`
357
+ : "Select an agent and absolute project path.";
358
+ const providerOptions = providers.map((id) => ({ value: id, label: providerLabel(id) }));
359
+ // Results belong to the agent they were recorded as, so a new agent starts the workflows fresh.
360
+ const workflowKey = provider ?? "none";
361
+
362
+ return (
363
+ <SettingsSection
364
+ title="Knowledge"
365
+ info="Search or add material in the Context Mode store of one project. Every agent working in that project shares it and the agent labels who added what. A provider with its own CONTEXT_MODE_DATA_DIR uses the store in that directory."
366
+ >
367
+ <View style={{ gap: 16 }}>
368
+ <Card theme={theme}>
369
+ {note ? <Text style={styles.muted}>{note}</Text> : null}
370
+ <LabeledChoices
371
+ theme={theme}
372
+ label="Agent"
373
+ disabled={busy}
374
+ accessibilityLabel={(option) => `${option.label} agent`}
375
+ onChange={setProvider}
376
+ options={providerOptions}
377
+ value={provider}
378
+ />
379
+ <Field
380
+ theme={theme}
381
+ accessibilityLabel="Knowledge project path"
382
+ label="Project path"
383
+ disabled={busy}
384
+ maxLength={4_096}
385
+ onChangeText={setProjectPath}
386
+ placeholder="/absolute/path/to/project"
387
+ value={projectPath}
388
+ />
389
+ <Text accessibilityLiveRegion="polite" style={styles.muted}>
390
+ {destinationNote}
391
+ </Text>
392
+ </Card>
393
+
394
+ <SearchCard
395
+ key={`search:${workflowKey}`}
396
+ theme={theme}
397
+ hostId={host.id}
398
+ destination={destination}
399
+ />
400
+
401
+ <ActionButton
402
+ theme={theme}
403
+ variant="outline"
404
+ disabled={busy}
405
+ expanded={managementExpanded}
406
+ label={managementExpanded ? "Hide source management" : "Add or manage sources"}
407
+ onPress={() => setManagementExpanded((expanded) => !expanded)}
408
+ />
409
+
410
+ {managementExpanded ? (
411
+ <SourceManagement
412
+ key={`sources:${workflowKey}`}
413
+ theme={theme}
414
+ hostId={host.id}
415
+ destination={destination}
416
+ />
417
+ ) : null}
418
+ </View>
419
+ </SettingsSection>
420
+ );
421
+ }
422
+
423
+ interface WorkflowProps {
424
+ theme: PluginTheme;
425
+ hostId: string;
426
+ destination: KnowledgeDestination | null;
427
+ }
428
+
429
+ /** Result panel state for a mutation, shown only while its destination is still selected. */
430
+ function forDestination(
431
+ mutation: {
432
+ variables?: KnowledgeDestination;
433
+ data?: KnowledgeToolResult;
434
+ error: unknown;
435
+ isPending: boolean;
436
+ },
437
+ destination: KnowledgeDestination | null,
438
+ ) {
439
+ return sameDestination(mutation.variables, destination)
440
+ ? { result: mutation.data, error: mutation.error, pending: mutation.isPending }
441
+ : { result: undefined, error: null, pending: false };
442
+ }
290
443
 
444
+ function SearchCard({ theme, hostId, destination }: WorkflowProps) {
445
+ const styles = createStyles(theme);
446
+ const searchKnowledge = useRpc(searchContextModeKnowledge);
447
+ const [query, setQuery] = useState("");
448
+ const [source, setSource] = useState("");
449
+ const [scope, setScope] = useState<SearchScope>("worktree");
450
+ const [sort, setSort] = useState<SearchSort>("relevance");
451
+ const [extraPages, setExtraPages] = useState<SearchKnowledgeResult[]>([]);
291
452
  const search = useMutation({
292
- mutationFn: () => {
293
- if (!provider) throw new Error("Select a provider store before searching.");
294
- const source = searchSource.trim();
295
- setSearchResultSource(source);
296
- return searchKnowledge({
297
- provider,
298
- projectPath: projectPath.trim(),
299
- queries: [query.trim()],
300
- ...(source ? { source } : {}),
301
- });
302
- },
453
+ mutationKey: knowledgeMutationKey(hostId),
454
+ mutationFn: (request: SearchKnowledgeInput) => searchKnowledge(request),
455
+ onMutate: () => setExtraPages([]),
456
+ });
457
+ const moreResults = useMutation({
458
+ mutationKey: knowledgeMutationKey(hostId),
459
+ mutationFn: ({ request, page }: MoreResultsInput) => searchKnowledge({ ...request, page }),
460
+ onSuccess: (result) => setExtraPages((pages) => [...pages, result]),
303
461
  });
462
+ const request = search.variables;
463
+ // Results stay with the project they came from; editing the path hides them but keeps the form.
464
+ const current = sameDestination(request, destination);
465
+ const lastPage = extraPages.at(-1) ?? search.data;
466
+ const nextPage = lastPage?.next ?? null;
467
+ const trimmedQuery = query.trim();
468
+ const canSearch = destination !== null && trimmedQuery.length > 0;
469
+
470
+ function startSearch() {
471
+ if (!destination) return;
472
+ const trimmedSource = source.trim();
473
+ moreResults.reset();
474
+ search.mutate({
475
+ ...destination,
476
+ queries: [trimmedQuery],
477
+ scope,
478
+ sort,
479
+ ...(trimmedSource ? { source: trimmedSource } : {}),
480
+ });
481
+ }
482
+
483
+ function loadMore() {
484
+ if (request && nextPage) moreResults.mutate({ request, page: nextPage });
485
+ }
486
+
487
+ return (
488
+ <WorkflowCard
489
+ theme={theme}
490
+ title="Search"
491
+ description="Search indexed knowledge. Add a source filter when you only want matches from one named source."
492
+ >
493
+ <Field
494
+ theme={theme}
495
+ accessibilityLabel="Knowledge search query"
496
+ label="Query"
497
+ maxLength={1_024}
498
+ onChangeText={setQuery}
499
+ placeholder="What do you need to find?"
500
+ value={query}
501
+ />
502
+ <Field
503
+ theme={theme}
504
+ accessibilityLabel="Knowledge search source filter"
505
+ label="Source filter (optional)"
506
+ maxLength={512}
507
+ onChangeText={setSource}
508
+ placeholder="docs, repository, or source label"
509
+ value={source}
510
+ />
511
+ <LabeledChoices<SearchScope>
512
+ theme={theme}
513
+ label="Scope"
514
+ accessibilityLabel={(option) => `Search ${option.label.toLowerCase()} scope`}
515
+ onChange={setScope}
516
+ options={SEARCH_SCOPES}
517
+ value={scope}
518
+ />
519
+ <LabeledChoices<SearchSort>
520
+ theme={theme}
521
+ label="Order"
522
+ accessibilityLabel={(option) => `Order results by ${option.label.toLowerCase()}`}
523
+ onChange={setSort}
524
+ options={SEARCH_SORTS}
525
+ value={sort}
526
+ />
527
+ <ActionButton
528
+ theme={theme}
529
+ variant="primary"
530
+ disabled={!canSearch || search.isPending || moreResults.isPending}
531
+ label={search.isPending ? "Searching..." : "Search knowledge"}
532
+ onPress={startSearch}
533
+ />
534
+ <ResultPanel
535
+ theme={theme}
536
+ emptyLabel="No matching knowledge"
537
+ {...forDestination(search, destination)}
538
+ source={request?.source}
539
+ />
540
+ {current
541
+ ? extraPages.map((page, pageIndex) => (
542
+ <View
543
+ key={`${page.completedAt}:${pageIndex}`}
544
+ accessibilityLiveRegion="polite"
545
+ style={styles.result}
546
+ >
547
+ <Text style={styles.metaLabel}>Page {pageIndex + 2}</Text>
548
+ <OutputFrame theme={theme} output={page.output} emptyLabel="No more matches" />
549
+ </View>
550
+ ))
551
+ : null}
552
+ {current && nextPage && !search.isPending ? (
553
+ <ActionButton
554
+ theme={theme}
555
+ accessibilityLabel="Load more search results"
556
+ disabled={moreResults.isPending}
557
+ label={moreResults.isPending ? "Loading..." : "More results"}
558
+ onPress={loadMore}
559
+ />
560
+ ) : null}
561
+ {current ? <ErrorText theme={theme} error={moreResults.error} /> : null}
562
+ </WorkflowCard>
563
+ );
564
+ }
565
+
566
+ function SourceManagement(props: WorkflowProps) {
567
+ return (
568
+ <>
569
+ <IndexPathCard {...props} />
570
+ <IndexUrlCard {...props} />
571
+ <PurgeCard {...props} />
572
+ </>
573
+ );
574
+ }
575
+
576
+ function IndexPathCard({ theme, hostId, destination }: WorkflowProps) {
577
+ const indexPath = useRpc(indexContextModePath);
578
+ const refreshAnalytics = useRefreshAnalytics(hostId);
579
+ const [path, setPath] = useState("");
304
580
  const index = useMutation({
305
- mutationFn: () => {
306
- if (!provider) throw new Error("Select a provider store before indexing.");
307
- const source = pathSource.trim();
308
- setPathResultSource(source || path.trim());
309
- return indexPath({
310
- provider,
311
- projectPath: projectPath.trim(),
312
- path: path.trim(),
313
- ...(source ? { source } : {}),
314
- });
315
- },
581
+ mutationKey: knowledgeMutationKey(hostId),
582
+ mutationFn: (input: IndexPathInput) => indexPath(input),
316
583
  onSuccess: refreshAnalytics,
317
584
  });
585
+ const target = path.trim();
586
+
587
+ function submit() {
588
+ if (destination) index.mutate({ ...destination, path: target });
589
+ }
590
+
591
+ return (
592
+ <WorkflowCard
593
+ theme={theme}
594
+ title="Index a local path"
595
+ description="Add a file or directory inside the selected project."
596
+ >
597
+ <Field
598
+ theme={theme}
599
+ accessibilityLabel="Local path to index"
600
+ label="Local path"
601
+ maxLength={4_096}
602
+ onChangeText={setPath}
603
+ placeholder="/absolute/path/to/docs"
604
+ value={path}
605
+ />
606
+ <ActionButton
607
+ theme={theme}
608
+ disabled={!destination || !target || index.isPending}
609
+ label={index.isPending ? "Indexing..." : "Index path"}
610
+ onPress={submit}
611
+ />
612
+ <ResultPanel
613
+ theme={theme}
614
+ emptyLabel="No report"
615
+ {...forDestination(index, destination)}
616
+ source={index.variables?.path}
617
+ />
618
+ </WorkflowCard>
619
+ );
620
+ }
621
+
622
+ function IndexUrlCard({ theme, hostId, destination }: WorkflowProps) {
623
+ const fetchAndIndex = useRpc(fetchAndIndexContextMode);
624
+ const refreshAnalytics = useRefreshAnalytics(hostId);
625
+ const [url, setUrl] = useState("");
318
626
  const fetch = useMutation({
319
- mutationFn: () => {
320
- if (!provider) throw new Error("Select a provider store before fetching.");
321
- const source = urlSource.trim();
322
- setUrlResultSource(source || url.trim());
323
- return fetchAndIndex({
324
- provider,
325
- projectPath: projectPath.trim(),
326
- url: url.trim(),
327
- ...(source ? { source } : {}),
328
- });
329
- },
627
+ mutationKey: knowledgeMutationKey(hostId),
628
+ mutationFn: (input: FetchAndIndexInput) => fetchAndIndex(input),
330
629
  onSuccess: refreshAnalytics,
331
630
  });
631
+ const target = url.trim();
632
+
633
+ function submit() {
634
+ if (destination) fetch.mutate({ ...destination, url: target });
635
+ }
636
+
637
+ return (
638
+ <WorkflowCard
639
+ theme={theme}
640
+ title="Index a URL"
641
+ description="Fetch one public HTTP or HTTPS page and add it to the selected project store."
642
+ >
643
+ <Field
644
+ theme={theme}
645
+ accessibilityLabel="URL to fetch and index"
646
+ label="URL"
647
+ maxLength={4_096}
648
+ onChangeText={setUrl}
649
+ placeholder="https://docs.example.com/guide"
650
+ value={url}
651
+ />
652
+ <ActionButton
653
+ theme={theme}
654
+ disabled={!destination || !target || fetch.isPending}
655
+ label={fetch.isPending ? "Indexing..." : "Index URL"}
656
+ onPress={submit}
657
+ />
658
+ <ResultPanel
659
+ theme={theme}
660
+ emptyLabel="No report"
661
+ {...forDestination(fetch, destination)}
662
+ source={fetch.variables?.url}
663
+ />
664
+ </WorkflowCard>
665
+ );
666
+ }
667
+
668
+ function purgeTargetFor(
669
+ scope: PurgeScope | null,
670
+ sessionId: string,
671
+ source: string,
672
+ ): PurgeTarget | null {
673
+ if (scope === "project") return { scope };
674
+ if (scope === "session" && sessionId) return { scope, sessionId };
675
+ if (scope === "source" && source) return { scope, source };
676
+ return null;
677
+ }
678
+
679
+ function PurgeCard({ theme, hostId, destination }: WorkflowProps) {
680
+ const purgeKnowledge = useRpc(purgeContextModeKnowledge);
681
+ const refreshAnalytics = useRefreshAnalytics(hostId);
682
+ const [scope, setScope] = useState<PurgeScope | null>(null);
683
+ const [sessionId, setSessionId] = useState("");
684
+ const [source, setSource] = useState("");
685
+ // The armed review names one exact destination and target; editing either disarms it.
686
+ const [armedFor, setArmedFor] = useState<string | null>(null);
332
687
  const purge = useMutation({
333
- mutationFn: () => {
334
- if (!provider || !purgeScope) throw new Error("Select a provider and purge scope.");
335
- if (purgeScope === "session") {
336
- return purgeKnowledge({
337
- provider,
338
- projectPath: projectPath.trim(),
339
- confirm: true,
340
- scope: "session",
341
- sessionId: sessionId.trim(),
342
- });
343
- }
344
- return purgeKnowledge({
345
- provider,
346
- projectPath: projectPath.trim(),
347
- confirm: true,
348
- scope: "project",
349
- });
350
- },
688
+ mutationKey: knowledgeMutationKey(hostId),
689
+ mutationFn: (input: PurgeKnowledgeInput) => purgeKnowledge(input),
351
690
  onSuccess: refreshAnalytics,
352
- onSettled: () => setPurgeArmed(false),
691
+ onSettled: () => setArmedFor(null),
353
692
  });
693
+ const target = purgeTargetFor(scope, sessionId.trim(), source.trim());
694
+ const reviewKey = JSON.stringify([destination, target]);
695
+ const armed = armedFor === reviewKey;
354
696
 
355
- const busy = search.isPending || index.isPending || fetch.isPending || purge.isPending;
356
- const changeProvider = (next: Provider) => {
357
- setProvider(next);
358
- setPurgeArmed(false);
359
- search.reset();
360
- index.reset();
361
- fetch.reset();
362
- purge.reset();
363
- };
364
- const changePurgeScope = (next: PurgeScope) => {
365
- setPurgeScope(next);
366
- setPurgeArmed(false);
697
+ function changeScope(next: PurgeScope) {
698
+ setScope(next);
367
699
  purge.reset();
368
- };
369
- const providerReady = provider !== null && projectPath.trim().length > 0;
370
- const canPreparePurge =
371
- providerReady &&
372
- purgeScope !== null &&
373
- (purgeScope !== "session" || sessionId.trim().length > 0);
374
-
375
- return (
376
- <View style={styles.section}>
377
- <View style={styles.sectionHeader}>
378
- <Text accessibilityRole="header" style={styles.sectionTitle}>
379
- Knowledge
380
- </Text>
381
- <Text style={styles.muted}>
382
- Search or add material in one provider-specific store. Nothing is copied between
383
- providers.
384
- </Text>
385
- </View>
700
+ }
386
701
 
387
- <View style={styles.card}>
388
- <Text style={styles.fieldLabel}>Provider store</Text>
389
- {note ? <Text style={styles.scopeNote}>{note}</Text> : null}
390
- <View accessibilityRole="radiogroup" style={styles.choiceRow}>
391
- {providers.map((id) => {
392
- const selected = provider === id;
393
- return (
394
- <Pressable
395
- key={id}
396
- accessibilityLabel={`${providerLabel(id)} provider store`}
397
- disabled={busy}
398
- accessibilityRole="radio"
399
- accessibilityState={{ checked: selected, disabled: busy }}
400
- onPress={() => changeProvider(id)}
401
- style={[
402
- styles.choice,
403
- selected ? styles.choiceSelected : null,
404
- busy ? styles.disabled : null,
405
- ]}
406
- >
407
- <Text style={[styles.choiceText, selected ? styles.choiceTextSelected : null]}>
408
- {providerLabel(id)}
409
- </Text>
410
- </Pressable>
411
- );
412
- })}
413
- </View>
414
- <Field
415
- accessibilityLabel="Knowledge project path"
416
- label="Project path"
417
- maxLength={4_096}
418
- onChangeText={(value) => {
419
- setProjectPath(value);
420
- setPurgeArmed(false);
421
- }}
422
- placeholder="/absolute/path/to/project"
423
- styles={styles}
424
- value={projectPath}
425
- />
426
- <Text accessibilityLiveRegion="polite" style={styles.scopeNote}>
427
- {provider && projectPath.trim()
428
- ? `Current destination: ${providerLabel(provider)} storage for ${projectPath.trim()}.`
429
- : "Select a provider and absolute project path. No storage destination is selected by default."}
430
- </Text>
431
- </View>
702
+ function confirm() {
703
+ if (destination && target) purge.mutate({ ...destination, confirm: true, ...target });
704
+ }
432
705
 
433
- <WorkflowCard
434
- title="Search"
435
- description="Search indexed knowledge. Add a source filter when you only want matches from one named source."
436
- styles={styles}
437
- >
706
+ return (
707
+ <WorkflowCard
708
+ theme={theme}
709
+ title="Purge knowledge"
710
+ description="Purge part of the selected project store for every agent. Select a scope, review it, then confirm."
711
+ >
712
+ <LabeledChoices
713
+ theme={theme}
714
+ label="Scope"
715
+ accessibilityLabel={(option) => `Purge ${option.label.toLowerCase()} scope`}
716
+ onChange={changeScope}
717
+ options={PURGE_SCOPES}
718
+ value={scope}
719
+ />
720
+ {scope === "session" ? (
438
721
  <Field
439
- accessibilityLabel="Knowledge search query"
440
- label="Query"
441
- maxLength={1_024}
442
- onChangeText={setQuery}
443
- placeholder="What do you need to find?"
444
- styles={styles}
445
- value={query}
722
+ theme={theme}
723
+ accessibilityLabel="Session identifier to purge"
724
+ label="Session ID"
725
+ maxLength={256}
726
+ onChangeText={setSessionId}
727
+ placeholder="Exact Context Mode session ID"
728
+ value={sessionId}
446
729
  />
730
+ ) : null}
731
+ {scope === "source" ? (
447
732
  <Field
448
- accessibilityLabel="Knowledge search source filter"
449
- label="Source filter (optional)"
733
+ theme={theme}
734
+ accessibilityLabel="Source label to forget"
735
+ label="Source label"
450
736
  maxLength={512}
451
- onChangeText={setSearchSource}
452
- placeholder="docs, repository, or source label"
453
- styles={styles}
454
- value={searchSource}
737
+ onChangeText={setSource}
738
+ placeholder="Exact indexed source label"
739
+ value={source}
455
740
  />
456
- <View style={styles.actions}>
457
- <ActionButton
458
- disabled={!providerReady || query.trim().length === 0 || search.isPending}
459
- label={search.isPending ? "Searching…" : "Search knowledge"}
460
- onPress={() => search.mutate()}
461
- primary
462
- styles={styles}
463
- />
464
- </View>
465
- <ResultPanel
466
- emptyLabel="No matching knowledge was returned."
467
- error={search.error}
468
- pending={search.isPending}
469
- result={search.data}
470
- source={searchResultSource || undefined}
471
- styles={styles}
472
- />
473
- </WorkflowCard>
474
-
475
- <Pressable
476
- accessibilityLabel={managementExpanded ? "Hide source management" : "Add or manage sources"}
477
- accessibilityRole="button"
478
- accessibilityState={{ expanded: managementExpanded }}
479
- onPress={() => setManagementExpanded((expanded) => !expanded)}
480
- style={styles.button}
481
- >
482
- <Text style={styles.buttonText}>
483
- {managementExpanded ? "Hide source management" : "Add or manage sources"}
484
- </Text>
485
- </Pressable>
486
-
487
- {managementExpanded ? (
488
- <>
489
- <WorkflowCard
490
- title="Source visibility"
491
- description="Open Context Mode Insight to inspect indexed sources and understand what is available to search."
492
- styles={styles}
493
- >
494
- <View style={styles.actions}>
495
- <ExternalLink
496
- accessibilityLabel="Open Context Mode Insight in a browser"
497
- href={INSIGHT_URL}
498
- onError={(error) =>
499
- setLinkError(error instanceof Error ? error.message : String(error))
500
- }
501
- >
502
- <View style={styles.linkButton}>
503
- <Text style={styles.linkText}>Open Insight</Text>
504
- </View>
505
- </ExternalLink>
506
- </View>
507
- {linkError ? (
508
- <Text accessibilityRole="alert" style={styles.error}>
509
- Could not open Insight: {linkError}
510
- </Text>
511
- ) : null}
512
- </WorkflowCard>
513
-
514
- <WorkflowCard
515
- title="Index a local path"
516
- description="Add a file or directory from the selected provider environment. The source label stays visible with the indexed material."
517
- styles={styles}
518
- >
519
- <Field
520
- accessibilityLabel="Local path to index"
521
- label="Local path"
522
- maxLength={4_096}
523
- onChangeText={setPath}
524
- placeholder="/absolute/path/to/docs"
525
- styles={styles}
526
- value={path}
527
- />
528
- <Field
529
- accessibilityLabel="Local path source label"
530
- label="Source label (optional)"
531
- maxLength={512}
532
- onChangeText={setPathSource}
533
- placeholder="project-docs"
534
- styles={styles}
535
- value={pathSource}
536
- />
537
- <View style={styles.actions}>
538
- <ActionButton
539
- disabled={!providerReady || path.trim().length === 0 || index.isPending}
540
- label={index.isPending ? "Indexing…" : "Index path"}
541
- onPress={() => index.mutate()}
542
- styles={styles}
543
- />
544
- </View>
545
- <ResultPanel
546
- emptyLabel="Context Mode completed without a text report."
547
- error={index.error}
548
- pending={index.isPending}
549
- result={index.data}
550
- source={pathResultSource || undefined}
551
- styles={styles}
552
- />
553
- </WorkflowCard>
554
-
555
- <WorkflowCard
556
- title="Fetch and index a URL"
557
- description="Fetch one HTTP or HTTPS source and add it to the selected provider store."
558
- styles={styles}
559
- >
560
- <Field
561
- accessibilityLabel="URL to fetch and index"
562
- label="URL"
563
- maxLength={4_096}
564
- onChangeText={setUrl}
565
- placeholder="https://docs.example.com/guide"
566
- styles={styles}
567
- value={url}
568
- />
569
- <Field
570
- accessibilityLabel="Fetched URL source label"
571
- label="Source label (optional)"
572
- maxLength={512}
573
- onChangeText={setUrlSource}
574
- placeholder="product-guide"
575
- styles={styles}
576
- value={urlSource}
577
- />
578
- <View style={styles.actions}>
579
- <ActionButton
580
- disabled={!providerReady || url.trim().length === 0 || fetch.isPending}
581
- label={fetch.isPending ? "Fetching…" : "Fetch and index"}
582
- onPress={() => fetch.mutate()}
583
- styles={styles}
584
- />
585
- </View>
586
- <ResultPanel
587
- emptyLabel="Context Mode completed without a text report."
588
- error={fetch.error}
589
- pending={fetch.isPending}
590
- result={fetch.data}
591
- source={urlResultSource || undefined}
592
- styles={styles}
593
- />
594
- </WorkflowCard>
595
-
596
- <WorkflowCard
597
- title="Purge knowledge"
598
- description="Purge only the selected provider store and scope. Select a scope, review it, then confirm."
599
- styles={styles}
600
- >
601
- <Text style={styles.fieldLabel}>Scope</Text>
602
- <View accessibilityRole="radiogroup" style={styles.choiceRow}>
603
- {(["project", "session"] as const).map((scope) => {
604
- const selected = purgeScope === scope;
605
- const label = scope === "project" ? "Project" : "Session";
606
- return (
607
- <Pressable
608
- key={scope}
609
- accessibilityLabel={`Purge ${label.toLowerCase()} scope`}
610
- accessibilityRole="radio"
611
- accessibilityState={{ checked: selected }}
612
- onPress={() => changePurgeScope(scope)}
613
- style={[styles.choice, selected ? styles.choiceSelected : null]}
614
- >
615
- <Text style={[styles.choiceText, selected ? styles.choiceTextSelected : null]}>
616
- {label}
617
- </Text>
618
- </Pressable>
619
- );
620
- })}
621
- </View>
622
- {purgeScope === "session" ? (
623
- <Field
624
- accessibilityLabel="Session identifier to purge"
625
- label="Session ID"
626
- maxLength={256}
627
- onChangeText={(value) => {
628
- setSessionId(value);
629
- setPurgeArmed(false);
630
- }}
631
- placeholder="Exact Context Mode session ID"
632
- styles={styles}
633
- value={sessionId}
634
- />
635
- ) : null}
636
- {!purgeArmed ? (
637
- <View style={styles.actions}>
638
- <ActionButton
639
- danger
640
- disabled={!canPreparePurge || purge.isPending}
641
- label="Review purge"
642
- onPress={() => setPurgeArmed(true)}
643
- styles={styles}
644
- />
645
- </View>
646
- ) : (
647
- <View accessibilityRole="alert" style={styles.confirmation}>
648
- <Text style={styles.confirmationTitle}>Confirm permanent purge</Text>
649
- <Text style={styles.muted}>
650
- {`Delete ${purgeScope === "session" ? `session ${sessionId.trim()}` : "project"} knowledge from ${provider} storage? This does not affect other providers.`}
651
- </Text>
652
- <View style={styles.actions}>
653
- <ActionButton
654
- danger
655
- label={purge.isPending ? "Purging…" : "Confirm purge"}
656
- onPress={() => purge.mutate()}
657
- styles={styles}
658
- />
659
- <ActionButton
660
- disabled={purge.isPending}
661
- label="Cancel"
662
- onPress={() => setPurgeArmed(false)}
663
- styles={styles}
664
- />
665
- </View>
666
- </View>
667
- )}
668
- <ResultPanel
669
- emptyLabel="The selected knowledge scope was purged."
670
- error={purge.error}
671
- pending={purge.isPending}
672
- result={purge.data}
673
- styles={styles}
674
- />
675
- </WorkflowCard>
676
- </>
677
741
  ) : null}
742
+ {armed && destination && target ? (
743
+ <ConfirmPanel
744
+ theme={theme}
745
+ message={`Delete ${describePurgeTarget(target)} from the ${destination.projectPath} store? ${PURGE_CONSEQUENCE[target.scope]}`}
746
+ confirmLabel="Confirm purge"
747
+ pendingLabel="Purging..."
748
+ pending={purge.isPending}
749
+ onConfirm={confirm}
750
+ onCancel={() => setArmedFor(null)}
751
+ />
752
+ ) : (
753
+ <ActionButton
754
+ theme={theme}
755
+ variant="outline"
756
+ disabled={!destination || !target || purge.isPending}
757
+ label="Review purge"
758
+ onPress={() => setArmedFor(reviewKey)}
759
+ />
760
+ )}
761
+ <ResultPanel theme={theme} emptyLabel="Purged" {...forDestination(purge, destination)} />
762
+ </WorkflowCard>
763
+ );
764
+ }
765
+
766
+ interface ForgetButtonProps extends Pick<PluginSurfaceProps, "theme" | "host"> {
767
+ provider: string;
768
+ projectPath: string;
769
+ }
770
+
771
+ interface ForgetProps extends ForgetButtonProps {
772
+ target: ForgetTarget;
773
+ }
774
+
775
+ /** Per-row "Forget" with the same review-then-confirm step as purge. */
776
+ function ForgetButton({ theme, host, provider, projectPath, target }: ForgetProps) {
777
+ const purgeKnowledge = useRpc(purgeContextModeKnowledge);
778
+ const refreshAnalytics = useRefreshAnalytics(host.id);
779
+ const [armed, setArmed] = useState(false);
780
+ const forget = useMutation({
781
+ mutationKey: knowledgeMutationKey(host.id),
782
+ mutationFn: (input: PurgeKnowledgeInput) => purgeKnowledge(input),
783
+ onSuccess: refreshAnalytics,
784
+ onSettled: () => setArmed(false),
785
+ });
786
+ const targetLabel = describePurgeTarget(target);
787
+ const mutedStyle = createStyles(theme).muted;
788
+
789
+ if (forget.isSuccess) return <Text style={mutedStyle}>Forgotten</Text>;
790
+ return (
791
+ <View style={{ gap: 4 }}>
792
+ {armed ? (
793
+ <ConfirmPanel
794
+ theme={theme}
795
+ message={`Forget ${targetLabel} from the ${projectPath} store? ${PURGE_CONSEQUENCE[target.scope]}`}
796
+ confirmAccessibilityLabel={`Confirm forget ${targetLabel}`}
797
+ confirmLabel="Confirm forget"
798
+ pendingLabel="Forgetting..."
799
+ pending={forget.isPending}
800
+ onConfirm={() => forget.mutate({ provider, projectPath, confirm: true, ...target })}
801
+ onCancel={() => setArmed(false)}
802
+ />
803
+ ) : (
804
+ <ActionButton
805
+ theme={theme}
806
+ variant="outline"
807
+ accessibilityLabel={`Forget ${targetLabel}`}
808
+ label="Forget"
809
+ onPress={() => setArmed(true)}
810
+ />
811
+ )}
812
+ <ErrorText theme={theme} error={forget.error} />
678
813
  </View>
679
814
  );
680
815
  }
681
816
 
682
- function createStyles(theme: PluginSurfaceProps["theme"], compact: boolean): KnowledgeStyles {
683
- return {
684
- section: { width: "100%" as const, gap: compact ? 12 : 16 },
685
- sectionHeader: { gap: 4 },
686
- sectionTitle: {
687
- color: theme.colors.foreground,
688
- fontSize: compact ? 18 : 20,
689
- fontWeight: "700" as const,
690
- },
691
- card: {
692
- width: "100%" as const,
693
- gap: 12,
694
- padding: compact ? 12 : 16,
695
- borderWidth: 1,
696
- borderColor: theme.colors.border,
697
- borderRadius: 12,
698
- backgroundColor: theme.colors.surface1,
699
- },
700
- cardHeader: { gap: 3 },
701
- cardTitle: { color: theme.colors.foreground, fontSize: 15, fontWeight: "700" as const },
702
- muted: { color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 17 },
703
- scopeNote: { color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 17 },
704
- field: { gap: 5 },
705
- fieldLabel: {
706
- color: theme.colors.foregroundMuted,
707
- fontSize: 12,
708
- fontWeight: "600" as const,
709
- },
710
- input: {
711
- minHeight: 40,
712
- width: "100%" as const,
713
- borderWidth: 1,
714
- borderColor: theme.colors.border,
715
- borderRadius: 8,
716
- paddingHorizontal: 11,
717
- paddingVertical: 8,
718
- color: theme.colors.foreground,
719
- backgroundColor: theme.colors.surface0,
720
- fontSize: 14,
721
- },
722
- placeholder: { color: theme.colors.foregroundMuted },
723
- choiceRow: { flexDirection: "row" as const, flexWrap: "wrap" as const, gap: 7 },
724
- choice: {
725
- minHeight: 36,
726
- justifyContent: "center" as const,
727
- paddingHorizontal: 11,
728
- paddingVertical: 7,
729
- borderWidth: 1,
730
- borderColor: theme.colors.border,
731
- borderRadius: 999,
732
- backgroundColor: theme.colors.surface0,
733
- },
734
- choiceSelected: { borderColor: theme.colors.accent, backgroundColor: theme.colors.accent },
735
- choiceText: { color: theme.colors.foreground, fontSize: 12, fontWeight: "600" as const },
736
- choiceTextSelected: { color: theme.colors.accentForeground },
737
- actions: { flexDirection: "row" as const, flexWrap: "wrap" as const, gap: 8 },
738
- button: {
739
- minHeight: 38,
740
- alignItems: "center" as const,
741
- justifyContent: "center" as const,
742
- paddingHorizontal: 13,
743
- paddingVertical: 8,
744
- borderWidth: 1,
745
- borderColor: theme.colors.border,
746
- borderRadius: 8,
747
- backgroundColor: theme.colors.surface2,
748
- },
749
- primaryButton: { borderColor: theme.colors.accent, backgroundColor: theme.colors.accent },
750
- dangerButton: {
751
- borderColor: theme.colors.statusDanger,
752
- backgroundColor: theme.colors.surface1,
753
- },
754
- disabled: { opacity: 0.5 },
755
- buttonText: { color: theme.colors.foreground, fontSize: 13, fontWeight: "600" as const },
756
- primaryButtonText: { color: theme.colors.accentForeground },
757
- dangerButtonText: { color: theme.colors.statusDanger },
758
- linkButton: {
759
- minHeight: 38,
760
- alignItems: "center" as const,
761
- justifyContent: "center" as const,
762
- paddingHorizontal: 13,
763
- paddingVertical: 8,
764
- borderWidth: 1,
765
- borderColor: theme.colors.border,
766
- borderRadius: 8,
767
- backgroundColor: theme.colors.surface2,
768
- },
769
- linkText: { color: theme.colors.foreground, fontSize: 13, fontWeight: "600" as const },
770
- pendingRow: { flexDirection: "row" as const, alignItems: "center" as const, gap: 8 },
771
- activity: { color: theme.colors.accent },
772
- error: { color: theme.colors.statusDanger, fontSize: 12, lineHeight: 17 },
773
- result: {
774
- gap: 7,
775
- padding: 10,
776
- borderWidth: 1,
777
- borderColor: theme.colors.border,
778
- borderRadius: 8,
779
- backgroundColor: theme.colors.surface0,
780
- },
781
- metaRow: { flexDirection: "row" as const, alignItems: "flex-start" as const, gap: 8 },
782
- metaLabel: { width: 58, color: theme.colors.foregroundMuted, fontSize: 11 },
783
- metaValue: { flex: 1, color: theme.colors.foreground, fontSize: 11 },
784
- outputFrame: { maxHeight: compact ? 160 : 220 },
785
- output: {
786
- color: theme.colors.foreground,
787
- fontFamily: "monospace",
788
- fontSize: compact ? 11 : 12,
789
- lineHeight: compact ? 16 : 18,
790
- },
791
- confirmation: {
792
- gap: 9,
793
- padding: 11,
794
- borderWidth: 1,
795
- borderColor: theme.colors.statusDanger,
796
- borderRadius: 8,
797
- backgroundColor: theme.colors.surface0,
798
- },
799
- confirmationTitle: {
800
- color: theme.colors.statusDanger,
801
- fontSize: 13,
802
- fontWeight: "700" as const,
803
- },
804
- };
817
+ interface ForgetSourceButtonProps extends ForgetButtonProps {
818
+ source: string;
819
+ }
820
+
821
+ /** Forgets one indexed source from the project store. */
822
+ export function ForgetSourceButton({ source, ...props }: ForgetSourceButtonProps) {
823
+ return <ForgetButton {...props} target={{ scope: "source", source }} />;
824
+ }
825
+
826
+ interface ForgetSessionButtonProps extends ForgetButtonProps {
827
+ sessionId: string;
828
+ }
829
+
830
+ /** Forgets one recorded session's events from the project store. */
831
+ export function ForgetSessionButton({ sessionId, ...props }: ForgetSessionButtonProps) {
832
+ return <ForgetButton {...props} target={{ scope: "session", sessionId }} />;
805
833
  }