@gmickel/gno 2.7.0 → 2.7.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.
Files changed (37) hide show
  1. package/README.md +25 -30
  2. package/assets/spa-production.json.gz +0 -0
  3. package/browser-extension/artifacts/{gno-browser-clipper-v2.7.0.zip → gno-browser-clipper-v2.7.1.zip} +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v2.7.1.zip.sha256 +1 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/package.json +2 -1
  7. package/spec/output-schemas/collection-list.schema.json +2 -2
  8. package/spec/output-schemas/status.schema.json +12 -16
  9. package/src/cli/commands/ask.ts +31 -12
  10. package/src/cli/commands/ls.ts +6 -1
  11. package/src/core/host-paths.ts +23 -5
  12. package/src/core/request-receipts.ts +63 -9
  13. package/src/core/windows-private-path.ts +136 -1
  14. package/src/embed/backlog.ts +22 -10
  15. package/src/embed/variant-backlog.ts +34 -9
  16. package/src/llm/inference-scope.ts +18 -0
  17. package/src/mcp/tools/status.ts +19 -6
  18. package/src/serve/embed-scheduler.ts +2 -2
  19. package/src/serve/host-path-redaction.ts +51 -14
  20. package/src/serve/public/components/BootstrapStatus.tsx +5 -3
  21. package/src/serve/public/components/CaptureModal.tsx +1 -1
  22. package/src/serve/public/components/CollectionModelDialog.tsx +16 -13
  23. package/src/serve/public/components/CollectionsEmptyState.tsx +5 -3
  24. package/src/serve/public/components/FirstRunWizard.tsx +4 -2
  25. package/src/serve/public/components/sessions/SourcesPanel.tsx +77 -60
  26. package/src/serve/public/pages/Collections.tsx +16 -13
  27. package/src/serve/public/pages/Connectors.tsx +7 -4
  28. package/src/serve/public/pages/Dashboard.tsx +8 -6
  29. package/src/serve/resident-runtime.ts +7 -0
  30. package/src/serve/routes/sessions.ts +61 -5
  31. package/src/serve/server.ts +5 -3
  32. package/src/serve/status-model.ts +10 -6
  33. package/src/serve/status.ts +2 -7
  34. package/src/store/sqlite/adapter.ts +2 -2
  35. package/src/store/sqlite/change-journal-store.ts +1 -1
  36. package/src/store/sqlite/legacy-vector-ownership.ts +2 -1
  37. package/browser-extension/artifacts/gno-browser-clipper-v2.7.0.zip.sha256 +0 -1
@@ -19,6 +19,7 @@ import type { AcquireWriteTurn } from "./retry";
19
19
  import {
20
20
  assertInferenceActive,
21
21
  isBackgroundInference,
22
+ withInferencePage,
22
23
  } from "../llm/inference-scope";
23
24
  import { formatDocForEmbedding } from "../pipeline/contextual";
24
25
  import { err, ok } from "../store/types";
@@ -221,16 +222,27 @@ export async function embedBacklog(
221
222
  }
222
223
 
223
224
  const beforeEmbedded = embedded;
224
- const batchStoreResult = await embedAndStoreBatch({
225
- embedPort,
226
- vectorIndex,
227
- items: batch,
228
- modelUri,
229
- embedFingerprint,
230
- identityStillCurrent: deps.identityStillCurrent,
231
- statsPort,
232
- acquireWriteTurn: deps.acquireWriteTurn,
233
- });
225
+ const storePage = () =>
226
+ embedAndStoreBatch({
227
+ embedPort,
228
+ vectorIndex,
229
+ items: batch,
230
+ modelUri,
231
+ embedFingerprint,
232
+ identityStillCurrent: deps.identityStillCurrent,
233
+ statsPort,
234
+ acquireWriteTurn: deps.acquireWriteTurn,
235
+ });
236
+ const batchStoreResult = background
237
+ ? await withInferencePage(storePage)
238
+ : await storePage();
239
+ if (!batchStoreResult) {
240
+ // Past its deadline: this page stays pending for the next pass.
241
+ errors += batch.length;
242
+ deps.onProgress?.(embedded, errors);
243
+ await Bun.sleep(0);
244
+ continue;
245
+ }
234
246
  if (batchStoreResult.deferred)
235
247
  return ok({ embedded, errors, contentionErrors, deferred: true });
236
248
  embedded += batchStoreResult.embedded;
@@ -5,6 +5,7 @@ import type { EmbedBacklogDeps, EmbedBacklogResult } from "./backlog";
5
5
  import {
6
6
  assertInferenceActive,
7
7
  isBackgroundInference,
8
+ withInferencePage,
8
9
  } from "../llm/inference-scope";
9
10
  import { err, ok } from "../store/types";
10
11
  import { getVectorStatsDatabase } from "../store/vector/stats";
@@ -38,7 +39,20 @@ export async function embedVariantBacklog(
38
39
  const total = { embedded: 0, errors: 0, contentionErrors: 0 };
39
40
  let after: { documentId: number; seq: number } | undefined;
40
41
  try {
41
- while (identityStillCurrent()) {
42
+ while (true) {
43
+ // A failed request drops the port's cached identity (a timed-out native
44
+ // worker is retired); reload it so one failed page cannot end the pass.
45
+ if (background) {
46
+ const reloaded = await withInferencePage(() => deps.embedPort.init());
47
+ // Without an identity the pass cannot continue. Failing it schedules
48
+ // the scheduler's bounded retry instead of ending with pages pending.
49
+ if (!reloaded?.ok)
50
+ throw new Error(
51
+ reloaded?.error.message ??
52
+ "Embedding model reload exceeded its inference deadline"
53
+ );
54
+ }
55
+ if (!identityStillCurrent()) break;
42
56
  assertInferenceActive();
43
57
  const pending = variantBacklogPage(deps, store, batchSize, after);
44
58
  if (!pending.length) break;
@@ -54,14 +68,25 @@ export async function embedVariantBacklog(
54
68
  .get(owner.documentId, deps.collection!)
55
69
  )
56
70
  : pending;
57
- let result = await embedVariantBatch({
58
- store,
59
- embedPort: deps.embedPort,
60
- owners,
61
- identityStillCurrent,
62
- force: deps.force,
63
- acquireWriteTurn: deps.acquireWriteTurn,
64
- });
71
+ const embedPage = () =>
72
+ embedVariantBatch({
73
+ store,
74
+ embedPort: deps.embedPort,
75
+ owners,
76
+ identityStillCurrent,
77
+ force: deps.force,
78
+ acquireWriteTurn: deps.acquireWriteTurn,
79
+ });
80
+ let result = background
81
+ ? await withInferencePage(embedPage)
82
+ : await embedPage();
83
+ if (!result) {
84
+ // Past its deadline: this page stays pending for the next pass.
85
+ total.errors += owners.length;
86
+ deps.onProgress?.(total.embedded, total.errors);
87
+ await Bun.sleep(0);
88
+ continue;
89
+ }
65
90
  if (result.deferred) return ok({ ...total, deferred: true });
66
91
  total.embedded += result.embedded;
67
92
  total.errors += result.errors;
@@ -168,6 +168,24 @@ export function withOwnedInferenceScope<T>(
168
168
  return scope.run({}, () => withInferenceScope(options, operation));
169
169
  }
170
170
 
171
+ /**
172
+ * One page of a background pass: an inference deadline inside it fails only
173
+ * that page (undefined), leaving the pass scope active for later pages.
174
+ * Cancellation, or a deadline of the enclosing scope itself, still throws.
175
+ */
176
+ export async function withInferencePage<T>(
177
+ operation: () => Promise<T>
178
+ ): Promise<T | undefined> {
179
+ try {
180
+ return await withOwnedInferenceScope(inferenceOptions(), operation);
181
+ } catch (cause) {
182
+ if (!(cause instanceof DOMException && cause.name === "TimeoutError"))
183
+ throw cause;
184
+ assertInferenceActive();
185
+ return undefined;
186
+ }
187
+ }
188
+
171
189
  /** A cancelled queued reader releases only its eventual grant, never another caller's. */
172
190
  export async function acquireInferencePermit(
173
191
  acquire: () => Promise<() => void>
@@ -4,27 +4,37 @@
4
4
  * @module src/mcp/tools/status
5
5
  */
6
6
 
7
- import type { IndexStatus } from "../../store/types";
7
+ import type { CollectionStatus, IndexStatus } from "../../store/types";
8
8
  import type { ToolContext } from "../server";
9
9
 
10
10
  import { buildContentTypeBoostStatus } from "../../config/content-types";
11
11
  import { formatChunkingStatus } from "../../core/chunking-status";
12
+ import { OWNER_CONFIG_PATH_FIELDS, withoutFields } from "../../core/host-paths";
12
13
  import { formatVectorPartitionLines } from "../../core/vector-partition-status";
13
14
  import { resolveModelUri } from "../../llm/registry";
14
15
  import { createStandaloneResidentStatus } from "../../serve/resident-status";
16
+ import { exposesHostPaths } from "../context";
15
17
  import { runTool, type ToolResult } from "./index";
16
18
 
17
19
  type StatusInput = Record<string, never>;
18
20
 
21
+ /** Status as the caller sees it: HTTP callers get no owner config paths. */
22
+ type StatusView = Omit<IndexStatus, "configPath" | "dbPath" | "collections"> &
23
+ Partial<Pick<IndexStatus, "configPath" | "dbPath">> & {
24
+ collections: Array<
25
+ Omit<CollectionStatus, "path"> & Partial<Pick<CollectionStatus, "path">>
26
+ >;
27
+ };
28
+
19
29
  /**
20
30
  * Format status as text for MCP content.
21
31
  */
22
- function formatStatus(status: IndexStatus): string {
32
+ function formatStatus(status: StatusView): string {
23
33
  const lines: string[] = [];
24
34
 
25
35
  lines.push(`Index: ${status.indexName}`);
26
- lines.push(`Config: ${status.configPath}`);
27
- lines.push(`Database: ${status.dbPath}`);
36
+ if (status.configPath) lines.push(`Config: ${status.configPath}`);
37
+ if (status.dbPath) lines.push(`Database: ${status.dbPath}`);
28
38
  lines.push(`Health: ${status.healthy ? "OK" : "DEGRADED"}`);
29
39
  if ("resident" in status) {
30
40
  const resident = status.resident as NonNullable<
@@ -88,7 +98,7 @@ export function handleStatus(
88
98
  return runTool(
89
99
  ctx,
90
100
  "gno_status",
91
- async () => {
101
+ async (): Promise<StatusView> => {
92
102
  const result = await ctx.store.getStatus({
93
103
  embedModel: resolveModelUri(ctx.config, "embed"),
94
104
  chunking: ctx.config.chunking ?? {},
@@ -98,7 +108,7 @@ export function handleStatus(
98
108
  }
99
109
 
100
110
  // Override configPath with actual path from context
101
- return {
111
+ const status = {
102
112
  ...result.value,
103
113
  configPath: ctx.actualConfigPath,
104
114
  contentTypeBoost: buildContentTypeBoostStatus(
@@ -107,6 +117,9 @@ export function handleStatus(
107
117
  resident:
108
118
  ctx.getResidentStatus?.() ?? createStandaloneResidentStatus("stdio"),
109
119
  };
120
+ return exposesHostPaths(ctx)
121
+ ? status
122
+ : withoutFields(status, OWNER_CONFIG_PATH_FIELDS);
110
123
  },
111
124
  formatStatus
112
125
  );
@@ -202,8 +202,8 @@ export function createEmbedScheduler(deps: EmbedSchedulerDeps): EmbedScheduler {
202
202
  } catch (cause) {
203
203
  if (controller.signal.aborted)
204
204
  return { result: { embedded: 0, errors: 0 } };
205
- // An inference deadline or worker exit aborts the whole pass. It is a
206
- // failed pass like any other, never a silently dropped rejection.
205
+ // A worker exit aborts the whole pass (a deadline fails only its page).
206
+ // It is a failed pass like any other, never a silently dropped rejection.
207
207
  return {
208
208
  result: { embedded: 0, errors: 0 },
209
209
  failure: cause instanceof Error ? cause.message : String(cause),
@@ -1,36 +1,72 @@
1
1
  /**
2
2
  * REST boundary for host paths: a caller the request-locality rule judges
3
- * remote never receives an `absPath` field from any `/api/*` JSON response.
4
- * Same-host callers (the loopback Web UI) keep them for Reveal and
5
- * "Open original". Routes that stream original source bytes are exempt:
6
- * their body is the owner's file, not an API envelope.
3
+ * remote never receives an `absPath` field from any `/api/*` JSON response,
4
+ * nor the host `path` fields of the status, collection, connector, and
5
+ * document-mutation routes (plus `configPath`/`dbPath` on status and the
6
+ * `file://` `uri` of a document save). Same-host callers (the loopback Web
7
+ * UI) keep them for Reveal, "Open original", and the Collections page.
8
+ * Routes that stream original source bytes are exempt: their body is the
9
+ * owner's file, not an API envelope.
7
10
  */
8
11
 
9
- import { withoutHostPaths } from "../core/host-paths";
12
+ import {
13
+ HOST_PATH_FIELDS,
14
+ OWNER_CONFIG_PATH_FIELDS,
15
+ withoutFields,
16
+ } from "../core/host-paths";
10
17
  import {
11
18
  isLocalClientRequest,
12
19
  type RequestPeerServer,
13
20
  } from "./request-locality";
14
21
 
15
- const HOST_PATH_KEY = '"absPath"';
16
22
  const SOURCE_BYTE_ROUTES = new Set(["/api/doc-asset"]);
23
+ const OWNER_CONFIG_FIELDS: ReadonlySet<string> = new Set([
24
+ ...HOST_PATH_FIELDS,
25
+ ...OWNER_CONFIG_PATH_FIELDS,
26
+ ]);
27
+ /** Document mutations answer with the file's host `path`. */
28
+ const DOC_PATH_FIELDS: ReadonlySet<string> = new Set([
29
+ ...HOST_PATH_FIELDS,
30
+ "path",
31
+ ]);
32
+ /** A document save also names the file by a `file://` `uri`. */
33
+ const DOC_SAVE_FIELDS: ReadonlySet<string> = new Set([
34
+ ...DOC_PATH_FIELDS,
35
+ "uri",
36
+ ]);
37
+ const ROUTE_FIELDS = new Map<string, ReadonlySet<string>>([
38
+ ["/api/status", OWNER_CONFIG_FIELDS],
39
+ ["/api/collections", OWNER_CONFIG_FIELDS],
40
+ ["/api/collections/:name", OWNER_CONFIG_FIELDS],
41
+ ["/api/connectors", OWNER_CONFIG_FIELDS],
42
+ ["/api/connectors/install", OWNER_CONFIG_FIELDS],
43
+ ["/api/docs", DOC_PATH_FIELDS],
44
+ ["/api/docs/:id", DOC_SAVE_FIELDS],
45
+ ["/api/docs/:id/rename", DOC_PATH_FIELDS],
46
+ ["/api/docs/:id/move", DOC_PATH_FIELDS],
47
+ ["/api/docs/:id/duplicate", DOC_PATH_FIELDS],
48
+ ["/api/docs/:id/trash", DOC_PATH_FIELDS],
49
+ ["/api/docs/:id/editable-copy", DOC_PATH_FIELDS],
50
+ ["/api/folders", DOC_PATH_FIELDS],
51
+ ]);
17
52
 
18
53
  type RouteHandler = (
19
54
  req: Request,
20
55
  server: RequestPeerServer
21
56
  ) => Response | Promise<Response>;
22
57
 
23
- /** The JSON response with every `absPath` field removed. */
58
+ /** The JSON response with every key in `fields` removed. */
24
59
  export async function redactResponseHostPaths(
25
- response: Response
60
+ response: Response,
61
+ fields: ReadonlySet<string>
26
62
  ): Promise<Response> {
27
63
  const contentType = response.headers.get("content-type") ?? "";
28
64
  if (!contentType.includes("application/json") || response.body === null) {
29
65
  return response;
30
66
  }
31
67
  const text = await response.text();
32
- const body = text.includes(HOST_PATH_KEY)
33
- ? JSON.stringify(withoutHostPaths(JSON.parse(text) as unknown))
68
+ const body = [...fields].some((field) => text.includes(`"${field}"`))
69
+ ? JSON.stringify(withoutFields(JSON.parse(text) as unknown, fields))
34
70
  : text;
35
71
  const headers = new Headers(response.headers);
36
72
  headers.delete("content-length");
@@ -42,12 +78,12 @@ export async function redactResponseHostPaths(
42
78
  }
43
79
 
44
80
  const redacting =
45
- (handler: RouteHandler): RouteHandler =>
81
+ (handler: RouteHandler, fields: ReadonlySet<string>): RouteHandler =>
46
82
  async (req, server) => {
47
83
  const response = await handler(req, server);
48
84
  return isLocalClientRequest(req, server)
49
85
  ? response
50
- : redactResponseHostPaths(response);
86
+ : redactResponseHostPaths(response, fields);
51
87
  };
52
88
 
53
89
  const isMethodTable = (route: unknown): route is Record<string, unknown> =>
@@ -62,14 +98,15 @@ export function withRemoteHostPathRedaction<T extends Record<string, unknown>>(
62
98
  const wrapped: Record<string, unknown> = { ...routes };
63
99
  for (const [path, route] of Object.entries(routes)) {
64
100
  if (!path.startsWith("/api/") || SOURCE_BYTE_ROUTES.has(path)) continue;
101
+ const fields = ROUTE_FIELDS.get(path) ?? HOST_PATH_FIELDS;
65
102
  if (typeof route === "function") {
66
- wrapped[path] = redacting(route as RouteHandler);
103
+ wrapped[path] = redacting(route as RouteHandler, fields);
67
104
  } else if (isMethodTable(route)) {
68
105
  wrapped[path] = Object.fromEntries(
69
106
  Object.entries(route).map(([method, handler]) => [
70
107
  method,
71
108
  typeof handler === "function"
72
- ? redacting(handler as RouteHandler)
109
+ ? redacting(handler as RouteHandler, fields)
73
110
  : handler,
74
111
  ])
75
112
  );
@@ -105,9 +105,11 @@ export function BootstrapStatus({
105
105
  </CardHeader>
106
106
  <CardContent className="space-y-2 text-sm">
107
107
  <p>Total cache: {bootstrap.cache.totalSizeLabel}</p>
108
- <p className="font-mono text-muted-foreground text-xs">
109
- {bootstrap.cache.path}
110
- </p>
108
+ {bootstrap.cache.path && (
109
+ <p className="font-mono text-muted-foreground text-xs">
110
+ {bootstrap.cache.path}
111
+ </p>
112
+ )}
111
113
  </CardContent>
112
114
  </Card>
113
115
 
@@ -75,7 +75,7 @@ export interface CaptureModalProps {
75
75
 
76
76
  interface Collection {
77
77
  name: string;
78
- path: string;
78
+ path?: string;
79
79
  }
80
80
 
81
81
  interface CreateDocResponse {
@@ -54,7 +54,8 @@ export interface CollectionModelDetails {
54
54
  modelSources?: Record<ModelRole, ModelSource>;
55
55
  models?: Partial<Record<ModelRole, string>>;
56
56
  name: string;
57
- path: string;
57
+ /** Host path; omitted for remote callers. */
58
+ path?: string;
58
59
  pattern?: string;
59
60
  egressPolicy?: {
60
61
  schemaVersion: "1.0";
@@ -110,7 +111,7 @@ const CODE_EXT_HINTS = [
110
111
  ] as const;
111
112
 
112
113
  function collectionLooksCodeHeavy(collection: CollectionModelDetails): boolean {
113
- const path = collection.path.toLowerCase();
114
+ const path = (collection.path ?? "").toLowerCase();
114
115
  if (
115
116
  CODE_PATH_HINTS.some(
116
117
  (hint) => path.endsWith(hint) || path.includes(`${hint}/`)
@@ -284,17 +285,19 @@ export function CollectionModelDialog({
284
285
  active preset for the rest of the workspace.
285
286
  </DialogDescription>
286
287
  </div>
287
- <div className="hidden shrink-0 max-w-[200px] space-y-1 border-border/15 border-l pl-4 lg:block">
288
- <p className="font-mono text-[10px] text-muted-foreground/50 uppercase tracking-[0.15em]">
289
- Path
290
- </p>
291
- <p
292
- className="break-all font-mono text-[10px] leading-relaxed text-muted-foreground/50"
293
- title={collection?.path}
294
- >
295
- {collection?.path}
296
- </p>
297
- </div>
288
+ {collection?.path && (
289
+ <div className="hidden shrink-0 max-w-[200px] space-y-1 border-border/15 border-l pl-4 lg:block">
290
+ <p className="font-mono text-[10px] text-muted-foreground/50 uppercase tracking-[0.15em]">
291
+ Path
292
+ </p>
293
+ <p
294
+ className="break-all font-mono text-[10px] leading-relaxed text-muted-foreground/50"
295
+ title={collection.path}
296
+ >
297
+ {collection.path}
298
+ </p>
299
+ </div>
300
+ )}
298
301
  </div>
299
302
  </DialogHeader>
300
303
 
@@ -13,6 +13,8 @@ export function CollectionsEmptyState({
13
13
  suggestedCollections,
14
14
  onAddCollection,
15
15
  }: CollectionsEmptyStateProps) {
16
+ // A remote caller's suggestions carry no host path to prefill.
17
+ const quickPicks = suggestedCollections.filter(({ path }) => path);
16
18
  return (
17
19
  <div className="mx-auto max-w-3xl py-16 text-center">
18
20
  <div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-muted">
@@ -29,7 +31,7 @@ export function CollectionsEmptyState({
29
31
  <FolderPlusIcon className="mr-2 size-4" />
30
32
  Add Folder
31
33
  </Button>
32
- {suggestedCollections.map((suggestion) => (
34
+ {quickPicks.map((suggestion) => (
33
35
  <Button
34
36
  key={suggestion.path}
35
37
  onClick={() => onAddCollection(suggestion.path)}
@@ -40,9 +42,9 @@ export function CollectionsEmptyState({
40
42
  ))}
41
43
  </div>
42
44
 
43
- {suggestedCollections.length > 0 && (
45
+ {quickPicks.length > 0 && (
44
46
  <div className="grid gap-3 text-left md:grid-cols-2">
45
- {suggestedCollections.map((suggestion) => (
47
+ {quickPicks.map((suggestion) => (
46
48
  <div
47
49
  className="rounded-xl border border-border/60 bg-background/70 p-4"
48
50
  key={`${suggestion.path}-detail`}
@@ -235,6 +235,8 @@ export function FirstRunWizard({
235
235
  ? steps[activeIndex + 1]
236
236
  : null;
237
237
  const progressValue = getStepProgress(onboarding);
238
+ // A remote caller's suggestions carry no host path to prefill.
239
+ const quickPicks = onboarding.suggestedCollections.filter(({ path }) => path);
238
240
  const isShowingRecommended = activeStep?.id === recommendedStepId;
239
241
  const runRecommendedAction = () => {
240
242
  if (!activeStep) {
@@ -277,7 +279,7 @@ export function FirstRunWizard({
277
279
  </Button>
278
280
  </div>
279
281
 
280
- {onboarding.suggestedCollections.length > 0 && (
282
+ {quickPicks.length > 0 && (
281
283
  <div className="space-y-3">
282
284
  <div className="flex items-center justify-between gap-3">
283
285
  <div>
@@ -295,7 +297,7 @@ export function FirstRunWizard({
295
297
  <Badge variant="outline">Quick picks</Badge>
296
298
  </div>
297
299
  <div className="grid gap-3 md:grid-cols-2">
298
- {onboarding.suggestedCollections.map((suggestion) => (
300
+ {quickPicks.map((suggestion) => (
299
301
  <button
300
302
  className="rounded-xl border border-border/70 bg-background/80 p-4 text-left shadow-sm transition-all hover:border-primary/35 hover:bg-background hover:shadow-md"
301
303
  key={suggestion.path}
@@ -394,8 +394,12 @@ export function SourcesPanel({
394
394
  const [notice, setNotice] = useState<{ text: string } | null>(null);
395
395
  const noticeRef = useRef<HTMLParagraphElement>(null);
396
396
  useFocusOnChange(notice, noticeRef);
397
+ // Bumped when the Discover button's run finishes: focus moves to its outcome.
398
+ const [discoveredCount, setDiscoveredCount] = useState(0);
399
+ const discoveryRef = useRef<HTMLDivElement>(null);
400
+ useFocusOnChange(discoveredCount, discoveryRef);
397
401
 
398
- const discover = async () => {
402
+ const discover = async (focusOutcome = false) => {
399
403
  setDiscovering(true);
400
404
  const result = await sessionsApi<SessionsDiscovery>(
401
405
  "/api/sessions/discover"
@@ -403,6 +407,7 @@ export function SourcesPanel({
403
407
  setDiscovering(false);
404
408
  setDiscoverError(result.error);
405
409
  setDiscovery(result.data);
410
+ if (focusOutcome) setDiscoveredCount((count) => count + 1);
406
411
  };
407
412
 
408
413
  const registered = async (sourceId: string, collection: string) => {
@@ -425,7 +430,7 @@ export function SourcesPanel({
425
430
  {localClient && (
426
431
  <Button
427
432
  disabled={discovering}
428
- onClick={() => void discover()}
433
+ onClick={() => void discover(true)}
429
434
  size="sm"
430
435
  variant="outline"
431
436
  >
@@ -473,67 +478,79 @@ export function SourcesPanel({
473
478
  </p>
474
479
  )}
475
480
 
476
- {discoverError && (
477
- <p className="break-words text-destructive text-sm" role="alert">
478
- {discoverError}
479
- </p>
480
- )}
481
- {discovery && (
482
- <div className="space-y-3 rounded-lg border border-dashed border-border/70 p-4">
483
- <h3 className="font-medium">Discovered on this machine</h3>
484
- <p className="text-muted-foreground text-xs">
485
- Preview only: discovery reads nothing into the archive. Register a
486
- source to permit manual imports from it.
487
- </p>
488
- {discovery.candidates.length === 0 && (
489
- <p className="text-muted-foreground text-sm">
490
- No supported session stores were found.
481
+ {(discoverError || discovery) && (
482
+ <div
483
+ aria-label="Discovery results"
484
+ className={`space-y-3 ${FOCUS_RING}`}
485
+ ref={discoveryRef}
486
+ role="region"
487
+ tabIndex={-1}
488
+ >
489
+ {discoverError && (
490
+ <p className="break-words text-destructive text-sm" role="alert">
491
+ {discoverError}
491
492
  </p>
492
493
  )}
493
- <ul className="space-y-4">
494
- {discovery.candidates.map((candidate) => (
495
- <li
496
- className="min-w-0 space-y-2"
497
- key={`${candidate.harness}:${candidate.path}`}
498
- >
499
- <div className="flex flex-wrap items-center gap-2">
500
- <Badge variant="secondary">
501
- {SESSION_HARNESS_LABELS[candidate.harness]}
502
- </Badge>
503
- <span className="min-w-0 break-all font-mono text-xs">
504
- {candidate.path}
505
- </span>
506
- </div>
507
- <p className="text-muted-foreground text-xs">
508
- {candidate.units}
509
- {candidate.truncated ? "+" : ""} units ·{" "}
510
- {formatBytes(candidate.bytes)}
511
- {candidate.formatVersions.length > 0 &&
512
- ` · format ${candidate.formatVersions.join(", ")}`}
494
+ {discovery && (
495
+ <div className="space-y-3 rounded-lg border border-dashed border-border/70 p-4">
496
+ <h3 className="font-medium">Discovered on this machine</h3>
497
+ <p className="text-muted-foreground text-xs">
498
+ Preview only: discovery reads nothing into the archive. Register
499
+ a source to permit manual imports from it.
500
+ </p>
501
+ {discovery.candidates.length === 0 && (
502
+ <p className="text-muted-foreground text-sm">
503
+ No supported session stores were found.
513
504
  </p>
514
- {candidate.registeredAs ? (
515
- <p className="text-sm">
516
- Registered as{" "}
517
- <span className="font-mono">{candidate.registeredAs}</span>
518
- </p>
519
- ) : (
520
- <RegisterForm
521
- candidate={candidate}
522
- archiveCollections={archiveCollections}
523
- onRegistered={registered}
524
- />
525
- )}
526
- </li>
527
- ))}
528
- </ul>
529
- {discovery.warnings.map((warning) => (
530
- <p
531
- className="text-amber-800 text-xs dark:text-amber-200"
532
- key={warning}
533
- >
534
- {warning}
535
- </p>
536
- ))}
505
+ )}
506
+ <ul className="space-y-4">
507
+ {discovery.candidates.map((candidate) => (
508
+ <li
509
+ className="min-w-0 space-y-2"
510
+ key={`${candidate.harness}:${candidate.path}`}
511
+ >
512
+ <div className="flex flex-wrap items-center gap-2">
513
+ <Badge variant="secondary">
514
+ {SESSION_HARNESS_LABELS[candidate.harness]}
515
+ </Badge>
516
+ <span className="min-w-0 break-all font-mono text-xs">
517
+ {candidate.path}
518
+ </span>
519
+ </div>
520
+ <p className="text-muted-foreground text-xs">
521
+ {candidate.units}
522
+ {candidate.truncated ? "+" : ""} units ·{" "}
523
+ {formatBytes(candidate.bytes)}
524
+ {candidate.formatVersions.length > 0 &&
525
+ ` · format ${candidate.formatVersions.join(", ")}`}
526
+ </p>
527
+ {candidate.registeredAs ? (
528
+ <p className="text-sm">
529
+ Registered as{" "}
530
+ <span className="font-mono">
531
+ {candidate.registeredAs}
532
+ </span>
533
+ </p>
534
+ ) : (
535
+ <RegisterForm
536
+ candidate={candidate}
537
+ archiveCollections={archiveCollections}
538
+ onRegistered={registered}
539
+ />
540
+ )}
541
+ </li>
542
+ ))}
543
+ </ul>
544
+ {discovery.warnings.map((warning) => (
545
+ <p
546
+ className="text-amber-800 text-xs dark:text-amber-200"
547
+ key={warning}
548
+ >
549
+ {warning}
550
+ </p>
551
+ ))}
552
+ </div>
553
+ )}
537
554
  </div>
538
555
  )}
539
556
  </section>