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 f60c846b50..896bc27610 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 @@ -298,6 +298,24 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re select(parent?.selection ?? {nodes: [], day: null}, false); }; const resultsAnchor = useRef(null), reportAnchor = useRef(null), historyAnchor = useRef(null); + const reportEntries = useRef(new Set()); + const reportScope = JSON.stringify([goalId, readSourceUrl ?? "", analysis.pack?.pack_id ?? analysis.location.packId]); + useLayoutEffect(() => { + if (!active || !analysis.location.open || reportEntries.current.has(reportScope)) return; + // A record/finding link owns its focus, even while history is still loading. + if (selection.nodes.length || selection.day !== null) {reportEntries.current.add(reportScope); return;} + if (analysis.loading && !analysis.pack && !analysis.readError) return; + const target = analysis.panel.current; + if (!target) return; + // Resolve a saved report link once per reading context. Explicit reopening + // keeps its existing action focus; reads and tab restoration must not rearm it. + reportEntries.current.add(reportScope); + // A user may have selected a source while this report was loading. + // URL restoration must not take focus from that active control. + if (document.activeElement && document.activeElement !== document.body && document.activeElement !== target) return; + target.focus({preventScroll: true}); + target.scrollIntoView({block: "start"}); + }, [active, reportScope, analysis.location.open, analysis.loading, analysis.pack, analysis.readError, analysis.panel, selection.nodes.length, selection.day]); const readingSelection = Boolean(selected || selectedMembers?.length); const selectedFinding = selected?.findings.find(finding => finding.result_id === selection.finding) ?? (!selection.finding ? selected?.findings.at(-1) : undefined); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-workspace-panels.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-workspace-panels.tsx index 5a7813e13e..ad775e751a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-workspace-panels.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-workspace-panels.tsx @@ -9,15 +9,21 @@ export function GoalWorkspacePanels({ activeTab, panels, scrollRef }: { }) { const [visited, setVisited] = useState(() => new Set([activeTab])); const positions = useRef>>({}); + const currentTab = useRef(activeTab); useEffect(() => setVisited(current => current.has(activeTab) ? current : new Set([...current, activeTab])), [activeTab]); useLayoutEffect(() => { + currentTab.current = activeTab; + const scroll = scrollRef.current; + if (scroll) scroll.scrollTop = positions.current[activeTab] ?? 0; + }, [activeTab, scrollRef]); + useEffect(() => { + // The ancestor's scroll ref is ready after layout effects finish mounting. const scroll = scrollRef.current; if (!scroll) return; - scroll.scrollTop = positions.current[activeTab] ?? 0; - const remember = () => { positions.current[activeTab] = scroll.scrollTop; }; + const remember = () => { positions.current[currentTab.current] = scroll.scrollTop; }; scroll.addEventListener("scroll", remember, { passive: true }); return () => scroll.removeEventListener("scroll", remember); - }, [activeTab, scrollRef]); + }, [scrollRef]); return (Object.keys(panels) as WorkspaceGoalTab[]).map(tab => visited.has(tab) || tab === activeTab ? : null); diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/deep-report-entry-after-desktop.png b/examples/personal-workspace-browser/evidence/research-finding-reader/deep-report-entry-after-desktop.png new file mode 100644 index 0000000000..d21c8db4a5 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/deep-report-entry-after-desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/deep-report-entry-after-mobile.png b/examples/personal-workspace-browser/evidence/research-finding-reader/deep-report-entry-after-mobile.png new file mode 100644 index 0000000000..01fc3bd3c1 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/deep-report-entry-after-mobile.png differ diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/deep-report-entry-before-desktop.png b/examples/personal-workspace-browser/evidence/research-finding-reader/deep-report-entry-before-desktop.png new file mode 100644 index 0000000000..ce2538db33 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/deep-report-entry-before-desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/deep-report-entry-before-mobile.png b/examples/personal-workspace-browser/evidence/research-finding-reader/deep-report-entry-before-mobile.png new file mode 100644 index 0000000000..61cb22d6a8 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/deep-report-entry-before-mobile.png differ diff --git a/examples/personal-workspace-browser/research-history.mjs b/examples/personal-workspace-browser/research-history.mjs index 12dde0804b..b751e770cb 100644 --- a/examples/personal-workspace-browser/research-history.mjs +++ b/examples/personal-workspace-browser/research-history.mjs @@ -245,6 +245,34 @@ export const researchHistoryScenario = { assert.equal(await synthesisPage.getByRole('region', {name: '研究汇报', exact: true}).count(), 1, 'One reading focus when expanded'); assert.match(await analysis.innerText(), /竞争解释.*随机波动/s); const savedOverview = await analysis.locator('.research-synthesis-summary').innerText(); + // Saved links and the first return from another view preserve reading. + // Exercise a fresh panel mount at each width, then repeat the round trip. + for (const viewport of [{width: 390, height: 844}, {width: 1280, height: 900}]) { + await synthesisPage.setViewportSize(viewport); + await synthesisPage.reload({waitUntil: 'domcontentloaded'}); + await analysis.locator('.research-synthesis-summary').waitFor(); + await synthesisPage.screenshot({path: resolve(outputDir, `research-deep-link-${viewport.width}.png`), animations: 'disabled'}); + assert(await analysis.locator('.research-synthesis-summary').evaluate(e => { + const box = e.getBoundingClientRect(); + return box.top >= 0 && box.bottom <= innerHeight - 48; + }), 'A saved deep-report link reveals the actual account in the first viewport'); + const laterFinding = analysis.locator('.research-report-finding').last(); + for (let visit = 0; visit < 2; visit++) { + const readingTop = await laterFinding.evaluate(async e => { + e.scrollIntoView({block: 'center'}); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + return e.getBoundingClientRect().top; + }); + await synthesisPage.getByRole('button', {name: '任务', exact: true}).click(); + await synthesisPage.getByRole('button', {name: '研究', exact: true}).click(); + // The existing panel entrance animation must settle before measuring. + await synthesisPage.waitForFunction(top => { + const finding = document.querySelector('.personal-goal-view-panel:not([hidden]) .research-report-finding:last-child'); + return finding && Math.abs(finding.getBoundingClientRect().top - top) < 3; + }, readingTop); + } + } + await synthesisPage.setViewportSize({width: 390, height: 844}); assert(await analysis.locator('.research-report-tabs').evaluate(tabs => { const overview = tabs.parentElement.querySelector('.research-synthesis-summary'); return Boolean(overview && (tabs.compareDocumentPosition(overview) & Node.DOCUMENT_POSITION_FOLLOWING));