From f8b6b73d163211c16300e893c6b01971129e9b82 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:52:05 +0800 Subject: [PATCH] fix(workspace): preserve scoped research navigation Restore saved chapter focus when returning to Research in the same Goal. Keep the matching bookmark when normalizing the status source and clear the previous Goal's selection when navigating to another Goal or reader. Cover source normalization, tab changes, and browser history with the packaged research reader. Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../smoke/research-analysis-read-smoke.mjs | 3 +- ...esearch-evidence-version-browser-smoke.mjs | 52 +++++++++++++++++-- .../goal-research-analysis.tsx | 4 +- .../goal-research-history.tsx | 2 +- .../research-case-study.tsx | 7 +-- apps/presentation/dashboard/src/router.tsx | 31 +++++++---- .../dashboard/src/views/dashboard-page.tsx | 52 ++++++++++++------- 7 files changed, 111 insertions(+), 40 deletions(-) diff --git a/apps/presentation/dashboard/smoke/research-analysis-read-smoke.mjs b/apps/presentation/dashboard/smoke/research-analysis-read-smoke.mjs index 5eb933f06f..cbdfab2824 100644 --- a/apps/presentation/dashboard/smoke/research-analysis-read-smoke.mjs +++ b/apps/presentation/dashboard/smoke/research-analysis-read-smoke.mjs @@ -20,7 +20,7 @@ const study = caseFixture.analyses[0].case_study; const studyNavigation = packId => ({url: new URL('http://127.0.0.1/chat/'), goalId: 'child', packId, namespace: null, follow: () => {throw Error('read navigated');}}); const renderedStudy = renderToStaticMarkup(createElement(ResearchCaseStudy, { - study, zh: false, historical: true, scope: caseFixture.analyses[0].input.scope, + study, zh: false, historical: true, active: true, scope: caseFixture.analyses[0].input.scope, navigation: studyNavigation('saved-pack'), onCitation: () => {throw Error('read executed a citation');}, })); @@ -35,6 +35,7 @@ const casePack = {pack_id: "one", title: "First study", scope: caseFixture.analy case_study: study, case_study_summary: {title: study.title}, applicability: "current"}; const secondCase = {...casePack, pack_id: "two", title: "Second study"}; const renderCase = packs => renderToStaticMarkup(createElement(ResearchCaseReading, { + active: true, analysis: {zh: false, pack: casePack, snapshot: {catalog: {packs}}, location: {open: false}, studyNavigation}, })); assert.ok(!renderCase([casePack]).includes(" new Promise(resolvePaint => requestAnimationFrame(() => requestAnimationFrame(resolvePaint)))); assert.equal(await citation.count(), 0, "Late original response cannot reopen the previous evidence"); - console.log("research evidence version: saved and late original excerpts isolated"); + + // Open a saved chapter through the same workspace router used by the tabs + // and parent links. Browser Back must keep the previous visit's position. + localizedReady = false; + const child = JSON.parse(await readFile(resolve(import.meta.dirname, "../src/data/fixtures/case-study-child.json"), "utf8")); + const parent = {goal_id: child.parent_goal_id, display_name: "Benchmark", events: [], analyses: []}; + const study = await start("study", child, parent); + const studyPack = (await (await fetch(new URL(analysisPath + "?" + new URLSearchParams({goal_id: child.goal_id}), study))).json()).pack; + const chapter = studyPack.case_study.sections[1]; + const address = new URL("/chat/", study); + address.search = new URLSearchParams({goalId: child.goal_id, view: "research", research_goal: child.goal_id, + research_reading: "case", research_analysis_goal: child.goal_id, research_pack: studyPack.pack_id}); + address.hash = "case-section-" + studyPack.pack_id + "-" + chapter.id; + const atChapter = () => page.waitForFunction(id => document.activeElement?.id === id, address.hash.slice(1)); + await page.goto(address.href); + await atChapter(); + const tabs = page.locator(".personal-goal-tabs"); + await tabs.getByRole("button", {name: "任务", exact: true}).click(); + await page.waitForURL(url => url.searchParams.get("view") === "tasks"); + assert.equal(new URL(page.url()).hash, address.hash); + await tabs.getByRole("button", {name: "研究", exact: true}).click(); + await atChapter(); + await page.locator("button.research-parent-goal").click(); + await page.waitForURL(url => url.searchParams.get("goalId") === parent.goal_id); + function assertParentAddress() { + const url = new URL(page.url()); + assert.equal(url.hash, ""); + assert.equal([...url.searchParams.keys()].some(key => key.startsWith("research_")), false, + "The parent's address belongs to its own research"); + } + assertParentAddress(); + await page.goBack(); + await atChapter(); + assert.equal(new URL(page.url()).searchParams.get("research_pack"), studyPack.pack_id); + await page.reload(); + await atChapter(); + await page.goForward(); + await page.waitForURL(url => url.searchParams.get("goalId") === parent.goal_id); + assertParentAddress(); + console.log("research evidence version: saved excerpts isolated and chapter navigation restored"); } finally { releaseOriginal?.(); await browser?.close(); @@ -110,12 +149,15 @@ try { await rm(root, {recursive: true, force: true}); } -async function start(name, capture) { - const fixture = resolve(root, name + ".json"); - await writeFile(fixture, JSON.stringify(capture)); +async function start(name, ...captures) { + const fixtures = await Promise.all(captures.map(async (capture, index) => { + const path = resolve(root, name + "-" + index + ".json"); + await writeFile(path, JSON.stringify(capture)); + return path; + })); const child = spawn(resolveTestPython({repoRoot: repo}), [ "-u", "examples/personal-workspace-browser/research-history-backend.py", - "--fixture", fixture, "--workspace-directory", "--assets-dir", assets, + ...fixtures.flatMap(path => ["--fixture", path]), "--workspace-directory", "--assets-dir", assets, ], {cwd: repo, env: {...process.env, PYTHONPATH: repo}, stdio: ["ignore", "pipe", "pipe"]}); servers.push(child); let error = ""; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx index 9fcced13eb..1e55e0f8d5 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx @@ -396,7 +396,7 @@ export function ResearchReportSummary({analysis: a}: {analysis: Controller}) { onExpand={origin => {a.trigger.current = origin; a.show();}} onCitation={a.readCitation}/> : null; } -export function ResearchCaseReading({analysis: a, onHistory}: {analysis: Controller; onHistory: () => void}) { +export function ResearchCaseReading({analysis: a, active, onHistory}: {analysis: Controller; active: boolean; onHistory: () => void}) { const packs = (a.snapshot?.catalog.packs ?? []).filter(p => p.case_study_summary); const p = a.pack; const firstStudy = packs[0]?.pack_id; @@ -410,7 +410,7 @@ export function ResearchCaseReading({analysis: a, onHistory}: {analysis: Control {packs.map(pack => )} : null} - {p?.case_study ? : !a.loading && !a.readError ?

{a.zh ? "选择一份解读,阅读问题背景、策略差异、关键转折与证据。" : "Choose a study to read its background, approaches, turning points and evidence."}

: null} ; 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 5c30414da6..5876a80b50 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 @@ -514,7 +514,7 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist onSelect={strategies.choose} onClose={() => strategies.choose(null)}/>; } if (caseReading && (hasCases || awaitingRequestedCase)) { - return setCaseMode(false)}/>; + return setCaseMode(false)}/>; } return
{hasCases ? : null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.tsx b/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.tsx index 536e5d6982..047f2e7cea 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/research-case-study.tsx @@ -52,8 +52,8 @@ function focusChapter(chapter: HTMLElement) { } /** A document's author controls the composition; the host owns navigation and evidence. */ -export function ResearchCaseStudy({study, zh, historical, scope, navigation, onCitation}: { - study: TrajectoryCaseStudy; zh: boolean; historical: boolean; +export function ResearchCaseStudy({study, zh, historical, active, scope, navigation, onCitation}: { + study: TrajectoryCaseStudy; zh: boolean; historical: boolean; active: boolean; scope: {case_id: string; run_id: string; revision: string; window: string}; navigation: {url: URL; goalId: string; packId: string; namespace: string | null; follow: (href: URL) => void}; onCitation: (c: AssessmentCitation, origin?: HTMLButtonElement) => void; @@ -62,6 +62,7 @@ export function ResearchCaseStudy({study, zh, historical, scope, navigation, onC const {url, goalId, packId, namespace, follow} = navigation; const prefix = `${caseChapterHashPrefix.slice(1)}${namespace ? namespace + '-' : ''}${packId}-`; useEffect(() => { + if (!active) return; // The URL can arrive before the saved study is mounted, or while history // switches to another Goal/version. Only restore a visible matching chapter. let frame = 0; @@ -84,7 +85,7 @@ export function ResearchCaseStudy({study, zh, historical, scope, navigation, onC window.removeEventListener('popstate', restore); window.removeEventListener('hashchange', restore); }; - }, [goalId, packId, prefix]); + }, [active, goalId, packId, prefix]); const citationLinks = (citations: AssessmentCitation[]) =>
    {citations.map((c, i) =>