From a5dd7c362ab410714fdab25cd3735ed161fa6e1a Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:26:19 +0800 Subject: [PATCH 1/2] Lead with the timeline when source research has no saved results Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../features/personal-workspace/goal-research-history.tsx | 8 ++++++-- .../personal-workspace/goal-research-workbench.tsx | 4 +++- .../features/personal-workspace/research-case-study.css | 3 ++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx index 2ecb9d2dbb..b15c774ccc 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx @@ -506,7 +506,10 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist const recordUnit = read.complete && history.canonical && read.events.length > history.canonical.events.length ? (zh ? "条原始记录" : "source rows") : read.total === 1 ? copy.record : copy.records; const emptySourceHistory = hasNativeSource && emptyHistory; - const emptyWithAnalysis = emptyHistory && Boolean(analysis.pack?.result || analysis.pack?.native_outcomes); + const hasSavedResult = Boolean(analysis.pack?.result || analysis.pack?.native_outcomes); + const emptyWithAnalysis = emptyHistory && hasSavedResult; + const historyBeforeAnalysisSummary = hasNativeSource && Boolean(value?.nodes.length) && !hasSavedResult; + const progressSummary = !progressiveOverview && !readingSelection ? : null; const emptyHistoryMessage = zh ? "这个 Goal 尚无研究历程" : "No research history for this Goal yet"; const hasCases = (analysis.snapshot?.catalog.packs ?? []).some(p => p.case_study_summary); // Keep an explicitly opened study in place during a read failure. A complete @@ -587,7 +590,7 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist : null} {!progressiveOverview ? <> - {!readingSelection ? : null} + {!historyBeforeAnalysisSummary ? progressSummary : null} : null} {busy && !read.events.length ?
{copy.loading}… @@ -687,6 +690,7 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist {readingSelection ? : null}{historyView}
: null} + {historyBeforeAnalysisSummary ? progressSummary : null} {(selection.nodes.some(id => !value?.nodes.some(node => node.id === id)) || (selection.day !== null && !selectedGroup)) && read.complete && !busy && !error && !history.error ?

{copy.lost}

: null} {value?.events.length && !value.nodes.length ?

{zh ? "尚未记录节点结构;可展开下面的已读记录。" : "Node structure has not been recorded; loaded records remain available below."}

: null} {value && (value.missingLinks.length || value.unknownTimeCount || value.unknownKindCount) ? diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-workbench.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-workbench.tsx index 5e4cf6ca9a..6870ca1d41 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-workbench.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-workbench.tsx @@ -23,7 +23,9 @@ function ResearchGoalNavigation({goalId, goals, zh, onSelect, directoryUrl, acti const children = tree.children.get(goalId) ?? []; if (!parent && !children.length) return null; return ; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.css b/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.css index bd216c436b..13cc30a62c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.css @@ -2,7 +2,8 @@ .research-goal-navigation { padding-block: 0 12px; } .research-goal-navigation h2 { font-size: 22px; font-weight: 600; margin-block: 16px; } .research-goal-navigation button { font: inherit; color: var(--pw-text); background: none; text-align: start; cursor: pointer; min-height: 44px; } -.research-parent-goal { border: 0; padding: 8px 0; } +.research-parent-goal { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; border: 0; padding: 8px 0; } +.research-parent-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .research-child-heading { display: flex; align-items: baseline; flex-wrap: wrap; column-gap: 12px; } .research-child-heading > span, .research-child-timing { color: var(--pw-muted); font-size: var(--text-caption); } .research-child-goals { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(240px,100%), 1fr)); gap: 16px; } From d1803a3c00579de998288cc6c0827e0e4ab8aba3 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:19:37 +0800 Subject: [PATCH 2/2] Preserve research scope focus when history moves Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../goal-research-history.tsx | 26 ++++++++---- .../research-source-recovery.ts | 41 ++++++++++++++++++- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx index b15c774ccc..bb7a73f2ce 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx @@ -509,7 +509,13 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist const hasSavedResult = Boolean(analysis.pack?.result || analysis.pack?.native_outcomes); const emptyWithAnalysis = emptyHistory && hasSavedResult; const historyBeforeAnalysisSummary = hasNativeSource && Boolean(value?.nodes.length) && !hasSavedResult; - const progressSummary = !progressiveOverview && !readingSelection ? : null; + // Reorder the existing reader in the DOM so visual and keyboard order agree. + // Keeping its identity also preserves a focused scope selector while loading. + const progressSummary = !progressiveOverview && !readingSelection + ? : null; + const analysisDetails = !progressiveOverview ? + + : null; const emptyHistoryMessage = zh ? "这个 Goal 尚无研究历程" : "No research history for this Goal yet"; const hasCases = (analysis.snapshot?.catalog.packs ?? []).some(p => p.case_study_summary); // Keep an explicitly opened study in place during a read failure. A complete @@ -589,15 +595,16 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist
{emptyHistoryMessage}

{zh ? "开启研究模式后,后续研究问题、尝试与证据会在这里形成可回读的历程。" : "Enable research mode to build a readable history of questions, attempts and evidence from later work."}

: null} - {!progressiveOverview ? <> - {!historyBeforeAnalysisSummary ? progressSummary : null} - : null} - {busy && !read.events.length ?
+ {!progressiveOverview ? : null} + {[ + historyBeforeAnalysisSummary ? null : progressSummary, + analysisDetails, + busy && !read.events.length ?
{copy.loading}…
{Array.from({length: 3}, (_, index) => )}
{Array.from({length: 18}, (_, index) => )}
-
: null} - {value?.nodes.length ?
+
: null, + value?.nodes.length ?
{selected ?
{if (event.key === "Escape") {event.stopPropagation(); closeReader();}}}>
@@ -689,8 +696,9 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist {readingSelection ? : null}{historyView} -
: null} - {historyBeforeAnalysisSummary ? progressSummary : null} +
: null, + historyBeforeAnalysisSummary ? progressSummary : null, + ]} {(selection.nodes.some(id => !value?.nodes.some(node => node.id === id)) || (selection.day !== null && !selectedGroup)) && read.complete && !busy && !error && !history.error ?

{copy.lost}

: null} {value?.events.length && !value.nodes.length ?

{zh ? "尚未记录节点结构;可展开下面的已读记录。" : "Node structure has not been recorded; loaded records remain available below."}

: null} {value && (value.missingLinks.length || value.unknownTimeCount || value.unknownKindCount) ? diff --git a/examples/personal-workspace-browser/research-source-recovery.ts b/examples/personal-workspace-browser/research-source-recovery.ts index 95e680ab3c..ecd21709fa 100644 --- a/examples/personal-workspace-browser/research-source-recovery.ts +++ b/examples/personal-workspace-browser/research-source-recovery.ts @@ -10,6 +10,7 @@ import {pathToFileURL} from "node:url"; import type {Browser} from "playwright"; import {launchBrowser, loadPlaywright} from "../dashboard-browser-smoke-support.mjs"; import {resolveTestPython} from "../../scripts/test-python.mjs"; +import {analysisReadback} from "./research-history.mjs"; const root = resolve(import.meta.dirname, "../.."); const output = resolve(root, "output/playwright/research-source-recovery"); @@ -130,12 +131,50 @@ threading.Event().wait() await coverage.filter({hasText:"33 / 33"}).waitFor(); await page.goForward(); await coverage.filter({hasText:"11 / 11"}).waitFor(); + + const empty = analysisReadback("c"); + empty.pack.result = null; + empty.pack.job = null; + const saved = analysisReadback("a"); + const namespace = new URL(catalog[1].status_url).searchParams.get("source_namespace"); + await page.route("**/api/chat/research-analysis?*", async route => { + const query = new URL(route.request().url()).searchParams; + if (query.get("source_namespace") !== namespace) return route.continue(); + const response = query.get("pack_id") === empty.pack.pack_id ? empty : saved; + const language = query.get("output_language"); + const config = {...response.pack.config, ...(language ? {output_language:language} : {})}; + const result = response.pack.result ? {...response.pack.result, output_language:language} : null; + await route.fulfill({json:{...response, execution_enabled:false, pack:{...response.pack, config, result}}}); + }); + await page.reload(); + const progress = page.locator(".research-progress"); + const scope = progress.locator(":scope > header select"); + const savedScope = progress.locator(`.research-progress-scope[data-pack-id="${saved.pack.pack_id}"]`); + const emptyScope = progress.locator(`.research-progress-scope[data-pack-id="${empty.pack.pack_id}"]`); + await savedScope.waitFor(); + const originalSelect = await scope.elementHandle(); + await scope.focus(); + await scope.selectOption(empty.pack.pack_id); + await emptyScope.waitFor(); + assert.equal(await scope.evaluate((select, original) => select === original && select === document.activeElement, originalSelect), true, + "Moving the empty analysis below history keeps the focused selector"); + assert.equal(await progress.evaluate(element => + Boolean(document.querySelector(".research-axis")!.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING)), true); + await scope.selectOption(saved.pack.pack_id); + await savedScope.waitFor(); + assert.equal(await scope.evaluate((select, original) => select === original && select === document.activeElement, originalSelect), true, + "Moving the saved analysis above history keeps the focused selector"); + assert.equal(await progress.evaluate(element => + Boolean(element.compareDocumentPosition(document.querySelector(".research-axis")!) & Node.DOCUMENT_POSITION_FOLLOWING)), true); + await page.keyboard.press("Tab"); + assert.equal(await progress.evaluate(element => element.contains(document.activeElement)), true, + "The next Tab continues within the saved analysis"); assert.deepEqual(writes, [], "Browsing and retry never dispatch work, analysis or source mutations"); assert.deepEqual(errors, []); await page.close(); } for (let i=0;i