@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.
- package/README.md +25 -30
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.7.0.zip → gno-browser-clipper-v2.7.1.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.7.1.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -1
- package/spec/output-schemas/collection-list.schema.json +2 -2
- package/spec/output-schemas/status.schema.json +12 -16
- package/src/cli/commands/ask.ts +31 -12
- package/src/cli/commands/ls.ts +6 -1
- package/src/core/host-paths.ts +23 -5
- package/src/core/request-receipts.ts +63 -9
- package/src/core/windows-private-path.ts +136 -1
- package/src/embed/backlog.ts +22 -10
- package/src/embed/variant-backlog.ts +34 -9
- package/src/llm/inference-scope.ts +18 -0
- package/src/mcp/tools/status.ts +19 -6
- package/src/serve/embed-scheduler.ts +2 -2
- package/src/serve/host-path-redaction.ts +51 -14
- package/src/serve/public/components/BootstrapStatus.tsx +5 -3
- package/src/serve/public/components/CaptureModal.tsx +1 -1
- package/src/serve/public/components/CollectionModelDialog.tsx +16 -13
- package/src/serve/public/components/CollectionsEmptyState.tsx +5 -3
- package/src/serve/public/components/FirstRunWizard.tsx +4 -2
- package/src/serve/public/components/sessions/SourcesPanel.tsx +77 -60
- package/src/serve/public/pages/Collections.tsx +16 -13
- package/src/serve/public/pages/Connectors.tsx +7 -4
- package/src/serve/public/pages/Dashboard.tsx +8 -6
- package/src/serve/resident-runtime.ts +7 -0
- package/src/serve/routes/sessions.ts +61 -5
- package/src/serve/server.ts +5 -3
- package/src/serve/status-model.ts +10 -6
- package/src/serve/status.ts +2 -7
- package/src/store/sqlite/adapter.ts +2 -2
- package/src/store/sqlite/change-journal-store.ts +1 -1
- package/src/store/sqlite/legacy-vector-ownership.ts +2 -1
- package/browser-extension/artifacts/gno-browser-clipper-v2.7.0.zip.sha256 +0 -1
package/src/embed/backlog.ts
CHANGED
|
@@ -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
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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 (
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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>
|
package/src/mcp/tools/status.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
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 `
|
|
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(
|
|
33
|
-
? JSON.stringify(
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
|
@@ -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
|
|
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
|
-
|
|
288
|
-
<
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
45
|
+
{quickPicks.length > 0 && (
|
|
44
46
|
<div className="grid gap-3 text-left md:grid-cols-2">
|
|
45
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
<
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
<
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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>
|