diff --git a/apps/presentation/dashboard/smoke/status-source-catalog-smoke.ts b/apps/presentation/dashboard/smoke/status-source-catalog-smoke.ts index 1d5e7f6edf..21c33299e5 100644 --- a/apps/presentation/dashboard/smoke/status-source-catalog-smoke.ts +++ b/apps/presentation/dashboard/smoke/status-source-catalog-smoke.ts @@ -133,6 +133,7 @@ const runtimeEntries = [ const runtimes = runtimeStatusSourceCatalog(runtimeEntries, runtimeUrl, baseHref); assert(runtimes, "an owner-prepared catalog recognizes its current source"); assert(runtimes.sources.every((s) => s.readOnly && s.kind === "runtime" && !s.hostAlias), "runtime readers never acquire SSH or execution authority"); +equal(statusSourceForUrl(runtimes, "/status.json", "http://127.0.0.1:19801/chat/")?.id, "retained", "a relative entry on the prepared runtime stays scoped and read-only"); equal(activeStatusSourceForUrl(runtimes, runtimeEntries[1].status_url, runtimeUrl, baseHref).id, "fresh", "a failed or pending same-Goal source selection does not bounce to the loaded runtime"); equal(runtimeStatusSourceCatalog(undefined, runtimeUrl, baseHref), null, "existing services need no runtime catalog"); equal(runtimeStatusSourceCatalog(runtimeEntries, "http://127.0.0.1:19803/status.json", baseHref), null, "an unrelated service cannot replace source choices"); diff --git a/apps/presentation/dashboard/src/data/status-source-catalog.ts b/apps/presentation/dashboard/src/data/status-source-catalog.ts index ae8acced2e..c380cd5504 100644 --- a/apps/presentation/dashboard/src/data/status-source-catalog.ts +++ b/apps/presentation/dashboard/src/data/status-source-catalog.ts @@ -185,13 +185,16 @@ export function removeStatusSource(catalog: StatusSourceCatalog, sourceIdToRemov export function statusSourceForUrl(catalog: StatusSourceCatalog, statusUrl: string, baseHref: string) { if (!statusUrl.trim()) return localStatusSource; const resolved = resolveFrontstageOpsStatusUrl(statusUrl, baseHref); - if (resolved.source?.isRelative) return localStatusSource; let normalized = statusUrl.trim(); try { normalized = new URL(normalized, baseHref).toString(); } catch { return null; } + if (resolved.source?.isRelative) { + return catalog.sources.find((source) => source.kind === "runtime" && source.statusUrl === normalized) + ?? localStatusSource; + } return catalog.sources.find((source) => source.statusUrl === normalized) ?? null; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 186f762f8e..05f1a25f16 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -2790,7 +2790,7 @@ export function PersonalWorkspacePage({ onOpenDetails={() => setSelection({ kind: "goal", item: selectedGoal })} onSelect={setSelection} onView={setSelectedGoalTab} selectedTodoId={drawerSelection?.kind === "todo" ? drawerSelection.item.todoId : null} board={taskBoard(!settingsOpen && selectedGoalTab === "overview")} results={goalResults(!settingsOpen && selectedGoalTab === "overview")} - historyRefreshRevision={historyRefreshRevision} readSourceUrl={statusSourceControl?.activeSource.kind === "runtime" ? statusSourceControl.activeSource.statusUrl : undefined} proposals={proposals} onPreview={createPreview} />, + historyRefreshRevision={historyRefreshRevision} readSourceUrl={statusSourceControl?.activeSource.kind === "runtime" && statusSourceControl.connectionState !== "loading" && statusSourceControl.connectionState !== "error" ? statusSourceControl.activeSource.statusUrl : undefined} proposals={proposals} onPreview={createPreview} />, tasks: taskBoard(!settingsOpen && selectedGoalTab === "tasks"), files: goalResults(selectedGoalTab === "files" && !settingsOpen), chat: agentWorkActive ? ({ - ...current, - statusUrl: url, - workAgent: "statusUrl" in current && (!current.statusUrl || current.statusUrl === url) ? current.workAgent : undefined, - }), + search: (current) => { + const runtimeSourceChanged = Boolean(nextPayload.local_dashboard_api?.read_only_sources?.length + && "statusUrl" in current && current.statusUrl && current.statusUrl !== url); + return { + ...current, + statusUrl: url, + workAgent: "statusUrl" in current && (!current.statusUrl || current.statusUrl === url) ? current.workAgent : undefined, + // Node/pack ids are source-local even when the Goal ids coincide. + // Back restores the original source's URL and its own selection. + ...(runtimeSourceChanged ? {research_goal: undefined, research_node: undefined, + research_group: undefined, research_analysis: undefined, + research_analysis_goal: undefined, research_pack: undefined} : {}), + }; + }, }); } if (!statusRequestIsCurrent(statusRequestFenceRef.current, request)) return false; diff --git a/docs/development/frontend-delivery.md b/docs/development/frontend-delivery.md index 442537e2c5..14d20f00b5 100644 --- a/docs/development/frontend-delivery.md +++ b/docs/development/frontend-delivery.md @@ -24,6 +24,8 @@ These are read transports, not connected execution Goals or a second registry. The native status and complete validated Explore history stay scoped to the physical source, including identical Goal ids in different containers. An unavailable container fails visibly without replacement; refreshing retries. +Switching prepared readers clears source-local question and analysis selections; +Back restores the previous reader's URL and its own selection. All mutation endpoints are denied. A read-only source does not imply an analysis pack, model execution, adoption or validated benefit; analysis remains unavailable until its existing owner binds the appropriate source pack. diff --git a/examples/personal-workspace-browser/evidence/runtime-source-workspace/desktop.png b/examples/personal-workspace-browser/evidence/runtime-source-workspace/desktop.png index c3cce58df9..f9c9a1e357 100644 Binary files a/examples/personal-workspace-browser/evidence/runtime-source-workspace/desktop.png and b/examples/personal-workspace-browser/evidence/runtime-source-workspace/desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/runtime-source-workspace/phone.png b/examples/personal-workspace-browser/evidence/runtime-source-workspace/phone.png index 2baac52c5b..ac6fb17a38 100644 Binary files a/examples/personal-workspace-browser/evidence/runtime-source-workspace/phone.png and b/examples/personal-workspace-browser/evidence/runtime-source-workspace/phone.png differ diff --git a/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs b/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs index 066644af33..256a4e799b 100644 --- a/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs @@ -56,7 +56,7 @@ try { const errors = []; page.on("pageerror", error => errors.push(String(error))); const url = new URL("/chat/", catalog[0].status_url); - url.search = new URLSearchParams({ goalId: "same-goal", view: "overview", statusUrl: catalog[0].status_url }); + url.search = new URLSearchParams({ goalId: "same-goal", view: "overview", statusUrl: process.env.LOOPX_RUNTIME_READER_BEFORE === "1" ? catalog[0].status_url : "/status.json" }); await page.goto(url.toString()); await page.getByRole("heading", { name: "Retained research", exact: true }).waitFor(); if (process.env.LOOPX_RUNTIME_READER_BEFORE === "1") { @@ -69,15 +69,19 @@ try { await page.screenshot({path:resolve(output,"desktop.png")}); await page.getByLabel("研究历程", {exact:true}).getByRole("button", {name:"关系图", exact:true}).click(); assert.equal(await page.locator(".research-history-edges > g > path").count(),2); + await page.locator(".research-history-node").filter({hasText:"Retained route 0"}).click(); + assert.equal(new URL(page.url()).searchParams.get("research_node"),"route0"); await page.getByLabel("选择控制面来源", {exact:true}).click(); await page.getByRole("option", {name:"Fresh",exact:true}).click(); await page.getByRole("heading",{name:"Fresh research",exact:true}).waitFor(); + assert.equal(new URL(page.url()).searchParams.get("research_node"),null,"a source switch must not rebind the same node id in another physical source"); await page.locator(".research-history-coverage").filter({hasText:"92 / 92"}).waitFor(); await page.getByLabel("研究历程", {exact:true}).getByRole("button", {name:"关系图",exact:true}).click(); await page.locator(".research-history-node").filter({hasText:"Fresh route 0"}).waitFor(); assert.equal(await page.locator(".research-history-node").filter({hasText:"Retained route"}).count(),0); await page.goBack(); await page.getByRole("heading",{name:"Retained research",exact:true}).waitFor(); + assert.equal(new URL(page.url()).searchParams.get("research_node"),"route0","Back restores the original source-local selection"); await page.goForward(); await page.getByRole("heading",{name:"Fresh research",exact:true}).waitFor(); await page.reload();