@gmickel/gno 2.5.1 → 2.7.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 (160) hide show
  1. package/README.md +60 -5
  2. package/assets/skill/README.md +5 -1
  3. package/assets/skill/SKILL.md +80 -4
  4. package/assets/skill/cli-reference.md +132 -2
  5. package/assets/skill/examples.md +30 -0
  6. package/assets/skill/mcp-reference.md +54 -1
  7. package/assets/skill/recipes/capture-and-file.md +6 -0
  8. package/assets/skill/recipes/memory-file-decision.md +6 -0
  9. package/assets/skill/recipes/memory-supersede-fact.md +5 -0
  10. package/assets/skill/recipes/session-evidence-lookup.md +98 -0
  11. package/assets/spa-production.json.gz +0 -0
  12. package/browser-extension/artifacts/{gno-browser-clipper-v2.5.1.zip → gno-browser-clipper-v2.7.0.zip} +0 -0
  13. package/browser-extension/artifacts/gno-browser-clipper-v2.7.0.zip.sha256 +1 -0
  14. package/browser-extension/dist/manifest.json +1 -1
  15. package/package.json +2 -1
  16. package/spec/cli.md +449 -35
  17. package/spec/mcp.md +234 -4
  18. package/spec/output-schemas/ask.schema.json +1 -1
  19. package/spec/output-schemas/capture-receipt.schema.json +4 -1
  20. package/spec/output-schemas/doctor.schema.json +88 -0
  21. package/spec/output-schemas/error.schema.json +11 -2
  22. package/spec/output-schemas/get.schema.json +1 -1
  23. package/spec/output-schemas/mcp-capture-result.schema.json +4 -2
  24. package/spec/output-schemas/memory-remember.schema.json +10 -4
  25. package/spec/output-schemas/multi-get.schema.json +4 -1
  26. package/spec/output-schemas/peek.schema.json +2 -9
  27. package/spec/output-schemas/request-status.schema.json +113 -0
  28. package/spec/output-schemas/resident-status.schema.json +22 -0
  29. package/spec/output-schemas/search-result.schema.json +1 -1
  30. package/spec/output-schemas/search-results.schema.json +1 -1
  31. package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
  32. package/spec/output-schemas/sessions-discovery.schema.json +38 -0
  33. package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
  34. package/spec/output-schemas/sessions-status.schema.json +432 -0
  35. package/spec/output-schemas/status.schema.json +98 -0
  36. package/src/cli/commands/ask.ts +14 -2
  37. package/src/cli/commands/capture.ts +55 -96
  38. package/src/cli/commands/daemon.ts +41 -0
  39. package/src/cli/commands/doctor.ts +54 -20
  40. package/src/cli/commands/embed.ts +41 -3
  41. package/src/cli/commands/ls.ts +3 -0
  42. package/src/cli/commands/memory.ts +12 -3
  43. package/src/cli/commands/query.ts +5 -0
  44. package/src/cli/commands/request-status.ts +59 -0
  45. package/src/cli/commands/reset.ts +39 -5
  46. package/src/cli/commands/sessions.ts +713 -0
  47. package/src/cli/commands/shared.ts +14 -1
  48. package/src/cli/commands/status.ts +63 -5
  49. package/src/cli/commands/vec.ts +54 -0
  50. package/src/cli/detach.ts +29 -1
  51. package/src/cli/errors.ts +13 -9
  52. package/src/cli/program.ts +441 -2
  53. package/src/cli/session-binding.ts +49 -0
  54. package/src/config/types.ts +8 -0
  55. package/src/core/capture-publish.ts +239 -0
  56. package/src/core/capture-sync.ts +12 -2
  57. package/src/core/host-paths.ts +31 -0
  58. package/src/core/memory-remember.ts +234 -122
  59. package/src/core/memory-types.ts +11 -0
  60. package/src/core/network-boundary-inventory.ts +8 -0
  61. package/src/core/request-receipts.ts +671 -0
  62. package/src/core/shutdown-budget.ts +6 -0
  63. package/src/core/vector-partition-status.ts +52 -0
  64. package/src/embed/backlog.ts +124 -18
  65. package/src/embed/fingerprint.ts +6 -3
  66. package/src/embed/retry.ts +66 -27
  67. package/src/embed/variant-backlog.ts +15 -10
  68. package/src/embed/variant-retry.ts +31 -22
  69. package/src/index.ts +30 -2
  70. package/src/llm/native-worker/dispatcher.ts +2 -0
  71. package/src/llm/native-worker/embedding-identity.ts +42 -0
  72. package/src/llm/native-worker/protocol.ts +1 -0
  73. package/src/llm/types.ts +3 -0
  74. package/src/mcp/context.ts +17 -0
  75. package/src/mcp/http-egress.ts +4 -0
  76. package/src/mcp/http-transport.ts +2 -0
  77. package/src/mcp/resources/index.ts +6 -5
  78. package/src/mcp/tool-descriptions-core.ts +1 -1
  79. package/src/mcp/tools/capture.ts +87 -85
  80. package/src/mcp/tools/index.ts +77 -4
  81. package/src/mcp/tools/memory-remember.ts +8 -1
  82. package/src/mcp/tools/memory-shared.ts +7 -1
  83. package/src/mcp/tools/request-status.ts +73 -0
  84. package/src/mcp/tools/sessions.ts +208 -0
  85. package/src/mcp/tools/status.ts +4 -0
  86. package/src/pipeline/hybrid.ts +37 -7
  87. package/src/pipeline/vsearch.ts +14 -2
  88. package/src/sdk/client.ts +180 -84
  89. package/src/sdk/index.ts +6 -0
  90. package/src/sdk/types.ts +54 -2
  91. package/src/serve/capture-service.ts +98 -32
  92. package/src/serve/config-sync.ts +3 -2
  93. package/src/serve/embed-scheduler.ts +133 -19
  94. package/src/serve/host-path-redaction.ts +79 -0
  95. package/src/serve/public/app.tsx +4 -1
  96. package/src/serve/public/components/CaptureModal.tsx +26 -8
  97. package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
  98. package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
  99. package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
  100. package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
  101. package/src/serve/public/components/sessions/api.ts +40 -0
  102. package/src/serve/public/globals.built.css +1 -1
  103. package/src/serve/public/hooks/use-api.ts +26 -3
  104. package/src/serve/public/lib/request-intent.ts +77 -0
  105. package/src/serve/public/lib/snippet.tsx +52 -0
  106. package/src/serve/public/lib/workspace-actions.ts +12 -1
  107. package/src/serve/public/lib/workspace-tabs.ts +2 -0
  108. package/src/serve/public/pages/Dashboard.tsx +22 -9
  109. package/src/serve/public/pages/DocView.tsx +25 -6
  110. package/src/serve/public/pages/DocumentEditor.tsx +224 -104
  111. package/src/serve/public/pages/Search.tsx +1 -41
  112. package/src/serve/public/pages/Sessions.tsx +350 -0
  113. package/src/serve/resident-runtime.ts +69 -4
  114. package/src/serve/resident-status.ts +13 -1
  115. package/src/serve/routes/api.ts +476 -147
  116. package/src/serve/routes/sessions.ts +766 -0
  117. package/src/serve/security.ts +9 -0
  118. package/src/serve/server.ts +215 -10
  119. package/src/serve/session-automation.ts +146 -0
  120. package/src/serve/status-model.ts +16 -0
  121. package/src/serve/status.ts +2 -0
  122. package/src/serve/watch-reconciliation-shared.ts +3 -0
  123. package/src/serve/watch-service-events.ts +3 -2
  124. package/src/serve/watch-service-run-flush.ts +35 -2
  125. package/src/serve/watch-service.ts +5 -0
  126. package/src/sessions/archive.ts +348 -0
  127. package/src/sessions/automation-state.ts +444 -0
  128. package/src/sessions/automation-status.ts +239 -0
  129. package/src/sessions/automation.ts +1169 -0
  130. package/src/sessions/binding.ts +105 -0
  131. package/src/sessions/claude-hook.ts +240 -0
  132. package/src/sessions/config.ts +176 -0
  133. package/src/sessions/format.ts +191 -0
  134. package/src/sessions/import-child-env.ts +8 -0
  135. package/src/sessions/import-child.ts +152 -0
  136. package/src/sessions/parsers/claude-code.ts +259 -0
  137. package/src/sessions/parsers/codex.ts +303 -0
  138. package/src/sessions/parsers/hermes.ts +248 -0
  139. package/src/sessions/parsers/openclaw.ts +496 -0
  140. package/src/sessions/parsers/shared.ts +184 -0
  141. package/src/sessions/sanitize.ts +222 -0
  142. package/src/sessions/service.ts +1533 -0
  143. package/src/sessions/setup.ts +477 -0
  144. package/src/sessions/sources.ts +518 -0
  145. package/src/sessions/state.ts +118 -0
  146. package/src/sessions/types.ts +457 -0
  147. package/src/store/migrations/031-runtime-independent-vectors.ts +29 -0
  148. package/src/store/migrations/032-vector-runtime-callers.ts +17 -0
  149. package/src/store/migrations/index.ts +4 -0
  150. package/src/store/sqlite/adapter.ts +76 -16
  151. package/src/store/sqlite/scoped-index.ts +9 -0
  152. package/src/store/types.ts +11 -1
  153. package/src/store/vector/lazy.ts +46 -43
  154. package/src/store/vector/runtime-compat.ts +651 -0
  155. package/src/store/vector/sqlite-vec.ts +20 -2
  156. package/src/store/vector/status.ts +276 -35
  157. package/src/store/vector/types.ts +2 -0
  158. package/src/store/vector/variant-search.ts +71 -23
  159. package/src/store/vector/variants.ts +49 -14
  160. package/browser-extension/artifacts/gno-browser-clipper-v2.5.1.zip.sha256 +0 -1
@@ -0,0 +1,541 @@
1
+ import {
2
+ DownloadIcon,
3
+ EyeIcon,
4
+ Loader2Icon,
5
+ RadarIcon,
6
+ Trash2Icon,
7
+ } from "lucide-react";
8
+ import { type FormEvent, useEffect, useId, useRef, useState } from "react";
9
+
10
+ import type {
11
+ SessionDiscoveryCandidate,
12
+ SessionImportReceipt,
13
+ SessionsDiscovery,
14
+ SessionSourceStatus,
15
+ } from "./api";
16
+
17
+ import { SESSION_HARNESS_LABELS } from "../../../../sessions/types";
18
+ import { Badge } from "../ui/badge";
19
+ import { Button } from "../ui/button";
20
+ import { Input } from "../ui/input";
21
+ import { sessionsApi } from "./api";
22
+ import { ImportReceipt } from "./ImportReceipt";
23
+
24
+ const SOURCE_ID_PATTERN = "[a-z0-9][a-z0-9_-]{0,63}";
25
+ /** Select value for "type a collection name that does not exist yet". */
26
+ const NEW_COLLECTION = "__new__";
27
+ const SELECT_CLASS =
28
+ "h-9 w-full min-w-0 rounded-md border border-input bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/50";
29
+ const FOCUS_RING =
30
+ "outline-none focus-visible:ring-2 focus-visible:ring-ring/50";
31
+
32
+ /**
33
+ * Focus `ref` whenever `trigger` changes to a new truthy value after mount.
34
+ * Buttons disable while their request runs, which drops keyboard focus to
35
+ * <body>; this hands it to the region that reports the outcome instead.
36
+ */
37
+ function useFocusOnChange<T>(
38
+ trigger: T,
39
+ ref: { current: HTMLElement | null }
40
+ ): void {
41
+ const previous = useRef(trigger);
42
+ useEffect(() => {
43
+ if (trigger && trigger !== previous.current) ref.current?.focus();
44
+ previous.current = trigger;
45
+ }, [trigger, ref]);
46
+ }
47
+
48
+ function formatWhen(iso: string | null): string {
49
+ if (!iso) return "never";
50
+ const date = new Date(iso);
51
+ return Number.isNaN(date.getTime()) ? iso : date.toLocaleString();
52
+ }
53
+
54
+ function formatBytes(bytes: number): string {
55
+ if (bytes < 1024) return `${bytes} B`;
56
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
57
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
58
+ }
59
+
60
+ /** Parse "prefix=collection" lines into project mappings. */
61
+ export function parseProjectLines(
62
+ text: string
63
+ ): Array<{ prefix: string; collection: string }> | string {
64
+ const mappings: Array<{ prefix: string; collection: string }> = [];
65
+ for (const raw of text.split("\n")) {
66
+ const line = raw.trim();
67
+ if (!line) continue;
68
+ const split = line.lastIndexOf("=");
69
+ const prefix = split > 0 ? line.slice(0, split).trim() : "";
70
+ const collection = split > 0 ? line.slice(split + 1).trim() : "";
71
+ if (!(prefix && collection)) {
72
+ return `Project mapping "${line}" must look like /absolute/prefix=collection`;
73
+ }
74
+ mappings.push({ prefix, collection });
75
+ }
76
+ return mappings;
77
+ }
78
+
79
+ interface SourceRowProps {
80
+ source: SessionSourceStatus;
81
+ localClient: boolean;
82
+ busy: string | null;
83
+ receipt: SessionImportReceipt | undefined;
84
+ onImport: (sourceId: string, dryRun: boolean) => void;
85
+ onRemove: (sourceId: string) => void;
86
+ }
87
+
88
+ function SourceRow({
89
+ source,
90
+ localClient,
91
+ busy,
92
+ receipt,
93
+ onImport,
94
+ onRemove,
95
+ }: SourceRowProps) {
96
+ const [confirmRemove, setConfirmRemove] = useState(false);
97
+ const receiptRef = useRef<HTMLElement>(null);
98
+ useFocusOnChange(receipt, receiptRef);
99
+ const running = busy?.startsWith(`${source.id}:`) ?? false;
100
+ const { units } = source;
101
+ return (
102
+ <li className="min-w-0 space-y-3 rounded-lg border border-border/60 p-4">
103
+ <div className="flex flex-wrap items-start justify-between gap-3">
104
+ <div className="min-w-0 space-y-1">
105
+ <div className="flex flex-wrap items-center gap-2">
106
+ <h3 className="break-all font-mono font-semibold">{source.id}</h3>
107
+ <Badge variant="secondary">
108
+ {SESSION_HARNESS_LABELS[source.harness]}
109
+ </Badge>
110
+ {source.available ? (
111
+ <Badge variant="outline">available</Badge>
112
+ ) : (
113
+ <Badge variant="destructive">unavailable — archive kept</Badge>
114
+ )}
115
+ </div>
116
+ <p className="text-muted-foreground text-sm">
117
+ → collection{" "}
118
+ <span className="font-mono text-foreground">
119
+ {source.collection}
120
+ </span>{" "}
121
+ · last import {formatWhen(source.lastImportAt)}
122
+ </p>
123
+ <p className="text-muted-foreground text-xs">
124
+ Units: {units.total} total · {units.complete} complete ·{" "}
125
+ <span
126
+ className={
127
+ units.incomplete > 0 ? "text-amber-700 dark:text-amber-300" : ""
128
+ }
129
+ >
130
+ {units.incomplete} incomplete
131
+ </span>{" "}
132
+ ·{" "}
133
+ <span className={units.failed > 0 ? "text-destructive" : ""}>
134
+ {units.failed} failed
135
+ </span>{" "}
136
+ · {units.pending} pending · {source.archivedThreads} archived
137
+ threads
138
+ </p>
139
+ </div>
140
+ <div className="flex flex-wrap gap-2">
141
+ <Button
142
+ aria-label={`Preview import of ${source.id} (dry run)`}
143
+ disabled={busy !== null || !source.available}
144
+ onClick={() => onImport(source.id, true)}
145
+ size="sm"
146
+ variant="outline"
147
+ >
148
+ {busy === `${source.id}:preview` ? (
149
+ <Loader2Icon className="animate-spin" />
150
+ ) : (
151
+ <EyeIcon />
152
+ )}
153
+ Preview (dry run)
154
+ </Button>
155
+ <Button
156
+ aria-label={`Import ${source.id}`}
157
+ disabled={busy !== null || !source.available}
158
+ onClick={() => onImport(source.id, false)}
159
+ size="sm"
160
+ >
161
+ {busy === `${source.id}:import` ? (
162
+ <Loader2Icon className="animate-spin" />
163
+ ) : (
164
+ <DownloadIcon />
165
+ )}
166
+ Import
167
+ </Button>
168
+ {localClient &&
169
+ (confirmRemove ? (
170
+ <>
171
+ <Button
172
+ disabled={running}
173
+ onClick={() => onRemove(source.id)}
174
+ size="sm"
175
+ variant="destructive"
176
+ >
177
+ Confirm remove
178
+ </Button>
179
+ <Button
180
+ onClick={() => setConfirmRemove(false)}
181
+ size="sm"
182
+ variant="ghost"
183
+ >
184
+ Cancel
185
+ </Button>
186
+ </>
187
+ ) : (
188
+ <Button
189
+ aria-label={`Remove source ${source.id}`}
190
+ disabled={busy !== null}
191
+ onClick={() => setConfirmRemove(true)}
192
+ size="sm"
193
+ variant="ghost"
194
+ >
195
+ <Trash2Icon />
196
+ Remove
197
+ </Button>
198
+ ))}
199
+ </div>
200
+ </div>
201
+ {confirmRemove && (
202
+ <p className="text-muted-foreground text-xs">
203
+ Removing unregisters the source. Its archived sessions stay in the
204
+ archive and index.
205
+ </p>
206
+ )}
207
+ {receipt && <ImportReceipt receipt={receipt} ref={receiptRef} />}
208
+ </li>
209
+ );
210
+ }
211
+
212
+ interface RegisterFormProps {
213
+ candidate: SessionDiscoveryCandidate;
214
+ archiveCollections: string[];
215
+ onRegistered: (sourceId: string, collection: string) => Promise<void>;
216
+ }
217
+
218
+ function RegisterForm({
219
+ candidate,
220
+ archiveCollections,
221
+ onRegistered,
222
+ }: RegisterFormProps) {
223
+ const formId = useId();
224
+ const [id, setId] = useState(`${candidate.harness}-main`);
225
+ // No default: the destination collection is a privacy boundary, so the
226
+ // user must pick it explicitly.
227
+ const [collectionChoice, setCollectionChoice] = useState("");
228
+ const [newCollection, setNewCollection] = useState("");
229
+ const [projects, setProjects] = useState("");
230
+ const [error, setError] = useState<{ text: string } | null>(null);
231
+ const [saving, setSaving] = useState(false);
232
+ const errorRef = useRef<HTMLParagraphElement>(null);
233
+ useFocusOnChange(error, errorRef);
234
+
235
+ const creatingCollection = collectionChoice === NEW_COLLECTION;
236
+ const collection = creatingCollection
237
+ ? newCollection.trim()
238
+ : collectionChoice;
239
+ const hintId = `${formId}-collection-hint`;
240
+
241
+ const submit = async (event: FormEvent<HTMLFormElement>) => {
242
+ event.preventDefault();
243
+ if (!collection) {
244
+ setError({ text: "Choose a destination archive collection." });
245
+ return;
246
+ }
247
+ const mappings = parseProjectLines(projects);
248
+ if (typeof mappings === "string") {
249
+ setError({ text: mappings });
250
+ return;
251
+ }
252
+ setSaving(true);
253
+ const result = await sessionsApi("/api/sessions/sources", {
254
+ method: "POST",
255
+ body: JSON.stringify({
256
+ id: id.trim(),
257
+ harness: candidate.harness,
258
+ path: candidate.path,
259
+ collection,
260
+ ...(mappings.length > 0 ? { projects: mappings } : {}),
261
+ }),
262
+ });
263
+ setSaving(false);
264
+ if (result.error) {
265
+ setError({ text: result.error });
266
+ return;
267
+ }
268
+ setError(null);
269
+ await onRegistered(id.trim(), collection);
270
+ };
271
+
272
+ return (
273
+ <form
274
+ aria-label={`Register ${SESSION_HARNESS_LABELS[candidate.harness]} source`}
275
+ className="grid gap-3 sm:grid-cols-2"
276
+ onSubmit={(event) => void submit(event)}
277
+ >
278
+ <label className="grid gap-1 text-sm" htmlFor={`${formId}-id`}>
279
+ Source ID
280
+ <Input
281
+ id={`${formId}-id`}
282
+ onChange={(event) => setId(event.currentTarget.value)}
283
+ pattern={SOURCE_ID_PATTERN}
284
+ required
285
+ value={id}
286
+ />
287
+ </label>
288
+ <label className="grid gap-1 text-sm" htmlFor={`${formId}-collection`}>
289
+ Destination archive collection
290
+ <select
291
+ aria-describedby={collection ? undefined : hintId}
292
+ className={SELECT_CLASS}
293
+ id={`${formId}-collection`}
294
+ onChange={(event) => setCollectionChoice(event.currentTarget.value)}
295
+ required
296
+ value={collectionChoice}
297
+ >
298
+ <option disabled value="">
299
+ Choose a collection…
300
+ </option>
301
+ {archiveCollections.map((name) => (
302
+ <option key={name} value={name}>
303
+ {name}
304
+ </option>
305
+ ))}
306
+ <option value={NEW_COLLECTION}>New collection…</option>
307
+ </select>
308
+ </label>
309
+ {creatingCollection && (
310
+ <label
311
+ className="grid gap-1 text-sm sm:col-start-2"
312
+ htmlFor={`${formId}-new-collection`}
313
+ >
314
+ New collection name
315
+ <Input
316
+ id={`${formId}-new-collection`}
317
+ onChange={(event) => setNewCollection(event.currentTarget.value)}
318
+ pattern={SOURCE_ID_PATTERN}
319
+ required
320
+ value={newCollection}
321
+ />
322
+ </label>
323
+ )}
324
+ <label
325
+ className="grid gap-1 text-sm sm:col-span-2"
326
+ htmlFor={`${formId}-projects`}
327
+ >
328
+ Project mappings (optional, one per line: /absolute/prefix=collection)
329
+ <textarea
330
+ className="min-h-16 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
331
+ id={`${formId}-projects`}
332
+ onChange={(event) => setProjects(event.currentTarget.value)}
333
+ value={projects}
334
+ />
335
+ </label>
336
+ {error && (
337
+ <p
338
+ className={`break-words text-destructive text-sm sm:col-span-2 ${FOCUS_RING}`}
339
+ ref={errorRef}
340
+ role="alert"
341
+ tabIndex={-1}
342
+ >
343
+ {error.text}
344
+ </p>
345
+ )}
346
+ <div className="sm:col-span-2">
347
+ <Button
348
+ aria-describedby={collection ? undefined : hintId}
349
+ disabled={saving || !collection}
350
+ size="sm"
351
+ type="submit"
352
+ >
353
+ {saving && <Loader2Icon className="animate-spin" />}
354
+ Register source
355
+ </Button>
356
+ <span className="ml-2 text-muted-foreground text-xs">
357
+ Registering imports nothing.
358
+ </span>
359
+ {!collection && (
360
+ <p className="mt-1 text-muted-foreground text-xs" id={hintId}>
361
+ Choose a destination collection to register this source. Its
362
+ sessions become searchable only in that collection.
363
+ </p>
364
+ )}
365
+ </div>
366
+ </form>
367
+ );
368
+ }
369
+
370
+ interface SourcesPanelProps {
371
+ sources: SessionSourceStatus[];
372
+ archiveCollections: string[];
373
+ localClient: boolean;
374
+ busy: string | null;
375
+ receipts: Record<string, SessionImportReceipt>;
376
+ onImport: (sourceId: string, dryRun: boolean) => void;
377
+ onRemove: (sourceId: string) => void;
378
+ onChanged: () => Promise<void>;
379
+ }
380
+
381
+ export function SourcesPanel({
382
+ sources,
383
+ archiveCollections,
384
+ localClient,
385
+ busy,
386
+ receipts,
387
+ onImport,
388
+ onRemove,
389
+ onChanged,
390
+ }: SourcesPanelProps) {
391
+ const [discovery, setDiscovery] = useState<SessionsDiscovery | null>(null);
392
+ const [discovering, setDiscovering] = useState(false);
393
+ const [discoverError, setDiscoverError] = useState<string | null>(null);
394
+ const [notice, setNotice] = useState<{ text: string } | null>(null);
395
+ const noticeRef = useRef<HTMLParagraphElement>(null);
396
+ useFocusOnChange(notice, noticeRef);
397
+
398
+ const discover = async () => {
399
+ setDiscovering(true);
400
+ const result = await sessionsApi<SessionsDiscovery>(
401
+ "/api/sessions/discover"
402
+ );
403
+ setDiscovering(false);
404
+ setDiscoverError(result.error);
405
+ setDiscovery(result.data);
406
+ };
407
+
408
+ const registered = async (sourceId: string, collection: string) => {
409
+ setNotice({
410
+ text: `Registered source ${sourceId} → collection ${collection}. Nothing was imported yet.`,
411
+ });
412
+ await onChanged();
413
+ await discover();
414
+ };
415
+
416
+ return (
417
+ <section
418
+ aria-labelledby="sessions-sources-heading"
419
+ className="min-w-0 space-y-4"
420
+ >
421
+ <div className="flex flex-wrap items-center justify-between gap-3">
422
+ <h2 className="font-semibold text-xl" id="sessions-sources-heading">
423
+ Sources
424
+ </h2>
425
+ {localClient && (
426
+ <Button
427
+ disabled={discovering}
428
+ onClick={() => void discover()}
429
+ size="sm"
430
+ variant="outline"
431
+ >
432
+ {discovering ? (
433
+ <Loader2Icon className="animate-spin" />
434
+ ) : (
435
+ <RadarIcon />
436
+ )}
437
+ Discover local sources
438
+ </Button>
439
+ )}
440
+ </div>
441
+
442
+ {sources.length === 0 ? (
443
+ <p className="text-muted-foreground text-sm">
444
+ No sources registered yet.{" "}
445
+ {localClient
446
+ ? "Discover local sources and register the ones you permit."
447
+ : "Sources can only be registered from a browser on this machine."}
448
+ </p>
449
+ ) : (
450
+ <ul className="space-y-3">
451
+ {sources.map((source) => (
452
+ <SourceRow
453
+ busy={busy}
454
+ key={source.id}
455
+ localClient={localClient}
456
+ onImport={onImport}
457
+ onRemove={onRemove}
458
+ receipt={receipts[source.id]}
459
+ source={source}
460
+ />
461
+ ))}
462
+ </ul>
463
+ )}
464
+
465
+ {notice && (
466
+ <p
467
+ className={`break-words rounded-md border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 text-emerald-700 text-sm dark:text-emerald-300 ${FOCUS_RING}`}
468
+ ref={noticeRef}
469
+ role="status"
470
+ tabIndex={-1}
471
+ >
472
+ {notice.text}
473
+ </p>
474
+ )}
475
+
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.
491
+ </p>
492
+ )}
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(", ")}`}
513
+ </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
+ ))}
537
+ </div>
538
+ )}
539
+ </section>
540
+ );
541
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Client helpers for /api/sessions/*: failures keep the stable
3
+ * `details.sessionsCode` so the page can branch on it (e.g. show the archive
4
+ * setup state for SESSIONS_NOT_CONFIGURED).
5
+ */
6
+
7
+ import { apiFetch } from "../../hooks/use-api";
8
+
9
+ export type {
10
+ SessionAutomationRunResult,
11
+ SessionAutomationStatus,
12
+ SessionDiscoveryCandidate,
13
+ SessionProfileStatus,
14
+ SessionHarness,
15
+ SessionImportReceipt,
16
+ SessionsDiscovery,
17
+ SessionSourceStatus,
18
+ SessionsStatus,
19
+ SessionUnitReceipt,
20
+ } from "../../../../sessions/types";
21
+
22
+ export interface SessionsApiResult<T> {
23
+ data: T | null;
24
+ error: string | null;
25
+ sessionsCode: string | null;
26
+ }
27
+
28
+ /** apiFetch plus the stable `details.sessionsCode` of a failed request. */
29
+ export async function sessionsApi<T>(
30
+ endpoint: string,
31
+ init?: RequestInit
32
+ ): Promise<SessionsApiResult<T>> {
33
+ const result = await apiFetch<T>(endpoint, init);
34
+ const code = result.details?.sessionsCode;
35
+ return {
36
+ data: result.data,
37
+ error: result.error,
38
+ sessionsCode: typeof code === "string" ? code : null,
39
+ };
40
+ }