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 b074ecba8a..f127f12cdc 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 @@ -87,7 +87,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re const opener = useRef(null); const readerParent = useRef<{selection: Selection; nodeId: string; opener: HTMLElement | null} | null>(null); const focusPending = useRef(Boolean(selection.nodes.length || selection.day !== null)); - const focusReturn = useRef<{nodeId?: string; element: HTMLElement | null} | null>(null); + const focusReturn = useRef<{nodeId?: string; eventId?: string; element: HTMLElement | null} | null>(null); const copy = zh ? { title: "研究历程", loading: "正在读取完整记录", reading: "读取中", refresh: "刷新记录", all: "回到全图", scope: "Explore 已记录历史", coverage: "其他来源覆盖、尝试发生时序尚未核验", @@ -158,19 +158,6 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re }; const updateRecords = (events: readonly ExploreHistoryEvent[]) =>
    {events.map(event =>
  1. {stateLabel(event.status)} · {event.title}

    {event.summary ? findingBody(event) : null}
    {event.parent_id ? {copy.group}: {event.parent_id} : null}{event.evidence_refs.map(ref => {ref})}{event.run_id ? {event.run_id} : null}
  2. )}
; - useEffect(() => { - const pop = () => { - readerParent.current = null; - focusReturn.current = null; - focusPending.current = true; - const next = selectedFromUrl(goalId); - setSelection(next); - setQuestionExpanded(!next.finding); - const view = viewFromUrl(goalId); setLens(view.lens); setPhase(view.phase); - }; - window.addEventListener("popstate", pop); - return () => window.removeEventListener("popstate", pop); - }, [goalId]); useEffect(() => { if (!active) return; let current = true; @@ -250,19 +237,38 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re const positions = new Map(layout.map(item => [item.node.id, item])); const selectedId = selection.nodes.length === 1 ? selection.nodes[0]! : ""; const selected = value?.nodes.find(node => node.id === selectedId); + const selectedFinding = selected?.findings.find(finding => finding.result_id === selection.finding) + ?? (!selection.finding ? selected?.findings.at(-1) : undefined); const selectedGroup = selection.day !== null ? grouped.groups.find(group => group.day === selection.day) : undefined; const selectedMembers = selectedGroup?.nodes ?? (selection.nodes.length > 1 ? value?.nodes.filter(node => selection.nodes.includes(node.id)) : undefined); + useEffect(() => { + const pop = () => { + readerParent.current = null; + const next = selectedFromUrl(goalId); + const returning = !next.nodes.length && next.day === null && Boolean(selection.nodes.length || selection.day !== null); + focusReturn.current = returning ? {eventId: selectedFinding?.event_id, element: opener.current} : null; + focusPending.current = !returning; + setSelection(next); + setQuestionExpanded(!next.finding); + const view = viewFromUrl(goalId); setLens(view.lens); setPhase(view.phase); + }; + window.addEventListener("popstate", pop); + return () => window.removeEventListener("popstate", pop); + }, [goalId, selection, selectedFinding?.event_id]); useLayoutEffect(() => { if (!active) return; if (focusReturn.current) { - const {nodeId, element} = focusReturn.current; + const {nodeId, eventId, element} = focusReturn.current; // The full-history view moves while reading. Resolve its remounted // control by the local return key rather than losing keyboard position. - const returnKey = element?.dataset.researchReturn; + const origin = !element?.dataset.researchDecision || element.dataset.researchDecision === eventId ? element : null; + const returnKey = origin?.dataset.researchReturn; const target = nodeId ? detail.current?.querySelector(`[data-research-node="${CSS.escape(nodeId)}"] button`) - : element?.isConnected ? element : returnKey ? graph.current?.querySelector(`[data-research-return="${CSS.escape(returnKey)}"]`) : null; + : (origin?.isConnected ? origin : null) + ?? (returnKey ? graph.current?.querySelector(`[data-research-return="${CSS.escape(returnKey)}"]`) : null) + ?? (eventId ? graph.current?.querySelector(`[data-research-decision="${CSS.escape(eventId)}"]`) : null); focusReturn.current = null; - if (target?.isConnected) {target.focus({preventScroll: true}); target.scrollIntoView({block: "nearest"});} + if (target?.isConnected) {target.focus({preventScroll: true}); target.scrollIntoView({block: "center"});} else graph.current?.focus(); return; } @@ -298,7 +304,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re const parent = readerParent.current; readerParent.current = null; focusPending.current = false; - focusReturn.current = {nodeId: parent?.nodeId, element: opener.current}; + focusReturn.current = {nodeId: parent?.nodeId, eventId: selectedFinding?.event_id, element: opener.current}; if (parent) opener.current = parent.opener; select(parent?.selection ?? {nodes: [], day: null}, false); }; @@ -322,8 +328,6 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re 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); const selectedEdges = selected ? value?.edges.filter(edge => edge.from_node === selected.id || edge.to_node === selected.id) ?? [] : []; const selectedContextCitations = [...new Map((originalDisplay ? [] : currentDisplay?.fields ?? []) .filter(field => field.event_id === selected?.latest.event_id || field.event_id === selectedFinding?.event_id) @@ -354,7 +358,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re : null; const historyView = !value ? null : lens === "timeline" && timeline ?
- select({nodes: [], day})} phase={phase} onPhase={next => chooseView(lens, next)} onSelect={(id, finding) => choose(id, true, finding)} onSelectMany={nodes => select({nodes, day: null})} timeLabel={fullTime} dayLabel={dayTime}/> + select({nodes: [], day})} phase={phase} onPhase={next => chooseView(lens, next)} onSelect={(id, finding) => choose(id, true, finding)} onSelectMany={nodes => select({nodes, day: null})} timeLabel={fullTime} dayLabel={dayTime}/>
:

{copy.trail}

{!timeline.decisions.length ?

{timeline.structuredCount ? copy.none : copy.unstructured}

- : } + : }
; } -function TrailList({groups, copy, label, timeLabel, selectedIds, onSelect}: { +function TrailList({groups, copy, label, timeLabel, selectedEventId, onSelect}: { groups: ReturnType; copy: Copy; label: (key: string) => string; - timeLabel: (value: number) => string; selectedIds: readonly string[]; onSelect: (id: string, finding?: string) => void; + timeLabel: (value: number) => string; selectedEventId?: string; onSelect: (id: string, finding?: string) => void; }) { const [limit, setLimit] = useState(3); + const selectedIndex = groups.findIndex(group => group.items.some(item => item.event.event_id === selectedEventId)); + // A bookmarked finding also restores its place in the folded history. + // Keep that range open when the reader closes, without expanding every row. + useLayoutEffect(() => {setLimit(value => Math.max(value, selectedIndex + 1));}, [selectedIndex]); + const visible = Math.max(limit, selectedIndex + 1); return <> -
    {groups.slice(0, limit).map(group => )}
- {groups.length > limit ? : null} +
    {groups.slice(0, visible).map(group => )}
+ {groups.length > visible ? : null} ; } -function TrailQuestion({group, copy, label, timeLabel, selectedIds, onSelect}: { +function TrailQuestion({group, copy, label, timeLabel, selectedEventId, onSelect}: { group: ReturnType[number]; copy: Copy; label: (key: string) => string; - timeLabel: (value: number) => string; selectedIds: readonly string[]; onSelect: (id: string, finding?: string) => void; + timeLabel: (value: number) => string; selectedEventId?: string; onSelect: (id: string, finding?: string) => void; }) { const [limit, setLimit] = useState(3); + const selectedIndex = group.items.findIndex(item => item.event.event_id === selectedEventId); + useLayoutEffect(() => {setLimit(value => Math.max(value, selectedIndex + 1));}, [selectedIndex]); + const visible = Math.max(limit, selectedIndex + 1); const knownTimes = group.items.flatMap(item => item.at === null ? [] : [item.at]); const exceptions = LABELS.filter(status => status !== "confirmed") .map(status => ({status, count: group.items.filter(item => item.event.status === status).length})).filter(item => item.count); @@ -229,7 +237,7 @@ function TrailQuestion({group, copy, label, timeLabel, selectedIds, onSelect}: { {exceptions.map(({status, count}) => {label(status)} {count})}
-
    {group.items.slice(0, limit).map(item => { +
      {group.items.slice(0, visible).map(item => { const decision = item.sections!.decision!; // Preview source text, never a generated title. Complete lists live in // the same exact-finding reader used by every other history selection. @@ -237,7 +245,7 @@ function TrailQuestion({group, copy, label, timeLabel, selectedIds, onSelect}: { return
    1. ; })}
    - {group.items.length > limit ? : null} + {group.items.length > visible ? : null} ; } diff --git a/docs/development/assets/runtime-source-analysis/history-return/before-return-phone.png b/docs/development/assets/runtime-source-analysis/history-return/before-return-phone.png new file mode 100644 index 0000000000..cfa5d43dae Binary files /dev/null and b/docs/development/assets/runtime-source-analysis/history-return/before-return-phone.png differ diff --git a/docs/development/assets/runtime-source-analysis/history-return/default-desktop.png b/docs/development/assets/runtime-source-analysis/history-return/default-desktop.png new file mode 100644 index 0000000000..179df7fd8c Binary files /dev/null and b/docs/development/assets/runtime-source-analysis/history-return/default-desktop.png differ diff --git a/docs/development/assets/runtime-source-analysis/history-return/default-phone.png b/docs/development/assets/runtime-source-analysis/history-return/default-phone.png new file mode 100644 index 0000000000..5323f0439c Binary files /dev/null and b/docs/development/assets/runtime-source-analysis/history-return/default-phone.png differ diff --git a/docs/development/assets/runtime-source-analysis/history-return/older-question-phone.png b/docs/development/assets/runtime-source-analysis/history-return/older-question-phone.png new file mode 100644 index 0000000000..e49cadc9c7 Binary files /dev/null and b/docs/development/assets/runtime-source-analysis/history-return/older-question-phone.png differ diff --git a/docs/development/assets/runtime-source-analysis/history-return/reader-desktop.png b/docs/development/assets/runtime-source-analysis/history-return/reader-desktop.png new file mode 100644 index 0000000000..6430c57537 Binary files /dev/null and b/docs/development/assets/runtime-source-analysis/history-return/reader-desktop.png differ diff --git a/docs/development/assets/runtime-source-analysis/history-return/reader-phone.png b/docs/development/assets/runtime-source-analysis/history-return/reader-phone.png new file mode 100644 index 0000000000..cecd56ec0e Binary files /dev/null and b/docs/development/assets/runtime-source-analysis/history-return/reader-phone.png differ diff --git a/docs/development/assets/runtime-source-analysis/history-return/return-desktop.png b/docs/development/assets/runtime-source-analysis/history-return/return-desktop.png new file mode 100644 index 0000000000..0e1823bb3f Binary files /dev/null and b/docs/development/assets/runtime-source-analysis/history-return/return-desktop.png differ diff --git a/docs/development/assets/runtime-source-analysis/history-return/return-english-phone.png b/docs/development/assets/runtime-source-analysis/history-return/return-english-phone.png new file mode 100644 index 0000000000..0f2244e909 Binary files /dev/null and b/docs/development/assets/runtime-source-analysis/history-return/return-english-phone.png differ diff --git a/docs/development/assets/runtime-source-analysis/history-return/return-phone.png b/docs/development/assets/runtime-source-analysis/history-return/return-phone.png new file mode 100644 index 0000000000..5514341858 Binary files /dev/null and b/docs/development/assets/runtime-source-analysis/history-return/return-phone.png differ diff --git a/examples/personal-workspace-browser/research-history.mjs b/examples/personal-workspace-browser/research-history.mjs index 86fdf9498e..8f4ea680ec 100644 --- a/examples/personal-workspace-browser/research-history.mjs +++ b/examples/personal-workspace-browser/research-history.mjs @@ -139,11 +139,12 @@ from pathlib import Path from loopx.capabilities.explore.result_log import build_explore_node_event, build_explore_finding_event goal = 'synthetic-decision-reading' events = [] -for question in range(3): +for question in range(4): node = f'question-{question}' title = ['对照能否区分修复机制与额外运行时间,并支持下一轮选择?', 'Did the fixed-budget comparison support a different candidate?', - '新的证据是否改变了原问题的判断,哪些失败与未知仍需保留?'][question] + '新的证据是否改变了原问题的判断,哪些失败与未知仍需保留?', + '较早的问题还有哪些反例需要保留?'][question] events.append(build_explore_node_event(goal_id=goal, node_id=node, title=title, recorded_at='2026-01-01T00:00:00Z')) for record in range(5): @@ -158,7 +159,7 @@ for question in range(3): events.append(build_explore_finding_event(goal_id=goal, node_id=node, finding_id=f'decision-{question}-{record}', title=f'Comparison {question}, decision {record}', summary=summary, status=['confirmed', 'refuted', 'tentative'][record % 3], - recorded_at=f'2026-01-0{question + 1}T0{record}:00:00Z')) + recorded_at=f'2025-12-31T0{record}:00:00Z' if question == 3 else f'2026-01-0{question + 1}T0{record}:00:00Z')) Path(sys.argv[1]).write_text(json.dumps({'goal_id': goal, 'display_name': 'Synthetic decision reading', 'events': events}, ensure_ascii=False)) `, fixture], {cwd: repoRoot}); let owner, page; @@ -168,9 +169,16 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id': goal, 'display_name': 'Synth await page.addInitScript(() => {if (!localStorage.getItem('loopx-pw-locale')) localStorage.setItem('loopx-pw-locale', 'zh-CN');}); await page.goto(`${owner.base}/chat/?goalId=${owner.goal_id}&view=research&statusUrl=%2Fstatus.json`, {waitUntil: 'domcontentloaded'}); const history = page.locator('.research-history'); - await history.getByRole('status').filter({hasText: '18 / 18'}).waitFor(); + await history.getByRole('status').filter({hasText: '24 / 24'}).waitFor(); const trail = history.locator('.research-trail'); const group = trail.locator('.research-trail-question').first(); + const assertReturn = async control => { + assert(await control.evaluate(e => e === document.activeElement), 'Return restores the exact finding control'); + assert(await control.evaluate(e => { + const r = e.getBoundingClientRect(); + return [r.top + 4, r.bottom - 4].every(y => e.contains(document.elementFromPoint(r.x + r.width / 2, y))); + }), 'The returned entry is readable above the footer and below the sticky header'); + }; assert.equal(await trail.locator('.research-trail-question').count(), 3); assert.equal(await group.locator('[data-research-decision]').count(), 3); assert.match(await group.locator('header').innerText(), /已反驳 2.*暂定结论 1|暂定结论 1.*已反驳 2/s); @@ -189,7 +197,7 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id': goal, 'display_name': 'Synth for (const locale of ['zh-CN', 'en']) { await page.evaluate(value => localStorage.setItem('loopx-pw-locale', value), locale); await page.reload({waitUntil: 'domcontentloaded'}); - await history.getByRole('status').filter({hasText: '18 / 18'}).waitFor(); + await history.getByRole('status').filter({hasText: '24 / 24'}).waitFor(); const row = group.locator('[data-research-decision]').first(); assert.match(await row.innerText(), locale === 'zh-CN' ? /停止 2.*保留 3/s : /Stopped 2.*Retained 3/s); await row.scrollIntoViewIfNeeded(); @@ -207,7 +215,7 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id': goal, 'display_name': 'Synth await page.screenshot({path: resolve(outputDir, 'decision-trail-reader-tail-phone.png'), animations: 'disabled'}); } await detail.press('Escape'); - assert(await row.evaluate(e => e === document.activeElement), 'Escape restores the exact decision control'); + await assertReturn(row); const returned = await row.boundingBox(); assert(returned.y >= 0 && returned.y + returned.height <= 844, 'Return brings the original decision back into the reading viewport'); const noChange = trail.locator('.research-trail-question').nth(1).locator('[data-research-decision]').first(); @@ -220,14 +228,67 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id': goal, 'display_name': 'Synth const oldest = group.locator('[data-research-decision]').last(); await oldest.click(); assert.equal(new URL(page.url()).searchParams.get('research_finding'), 'decision-2-0'); + await page.reload({waitUntil: 'domcontentloaded'}); + await history.getByRole('status').filter({hasText: '24 / 24'}).waitFor(); + await detail.waitFor(); + assert.equal(new URL(page.url()).searchParams.get('research_finding'), 'decision-2-0', 'Reload keeps the exact historical finding'); + await page.screenshot({path: resolve(outputDir, `decision-return-reader-${locale}.png`), animations: 'disabled'}); + await detail.press('Escape'); + await page.screenshot({path: resolve(outputDir, `decision-return-list-${locale}.png`), animations: 'disabled'}); + assert.equal(await group.locator('[data-research-decision]').count(), 5, 'Returning after reload reveals the historical decision, not only the latest three'); + await assertReturn(oldest); + const historicalReturn = await oldest.boundingBox(); + assert(historicalReturn.y >= 0 && historicalReturn.y + historicalReturn.height <= 844, 'The reloaded historical entry returns to the reading viewport'); + await page.goBack(); await detail.waitFor(); + assert.equal(new URL(page.url()).searchParams.get('research_finding'), 'decision-2-0'); + await page.goForward(); await detail.waitFor({state: 'hidden'}); + await assertReturn(oldest); + await oldest.click(); await detail.waitFor(); + await page.goBack(); await detail.waitFor({state: 'hidden'}); + await assertReturn(oldest); + await oldest.click(); await detail.waitFor(); + await detail.press('Escape'); + const previousDecision = group.locator('[data-research-decision]').nth(3); + await previousDecision.click(); await detail.waitFor(); + await page.goBack(); await detail.waitFor({state: 'hidden'}); + await assertReturn(previousDecision); + await page.goBack(); await detail.waitFor(); + assert.equal(new URL(page.url()).searchParams.get('research_finding'), 'decision-2-0'); + await detail.press('Escape'); + await assertReturn(oldest); + await page.goBack(); await detail.waitFor(); + await page.goBack(); await detail.waitFor({state: 'hidden'}); + await assertReturn(oldest); + await trail.getByRole('button', {name: locale === 'zh-CN' ? '显示更多问题 · 1' : 'Show more questions · 1', exact: true}).click(); + const earlierQuestion = trail.locator('.research-trail-question').last(); + await earlierQuestion.getByRole('button', {name: locale === 'zh-CN' ? '显示更早记录 · 2' : 'Show earlier records · 2', exact: true}).click(); + const earlierDecision = earlierQuestion.locator('[data-research-decision]').last(); + await earlierDecision.click(); await page.reload({waitUntil: 'domcontentloaded'}); + await history.getByRole('status').filter({hasText: '24 / 24'}).waitFor(); + await detail.waitFor(); + assert.equal(new URL(page.url()).searchParams.get('research_finding'), 'decision-3-0'); await detail.press('Escape'); - assert(await oldest.evaluate(e => e === document.activeElement)); + assert.equal(await trail.locator('.research-trail-question').count(), 4, 'A bookmarked finding also reveals its older question'); + assert.equal(await earlierQuestion.locator('[data-research-decision]').count(), 5); + await assertReturn(earlierDecision); + await page.screenshot({path: resolve(outputDir, `decision-return-earlier-question-${locale}.png`), animations: 'disabled'}); if (locale === 'en') { await trail.evaluate(e => e.scrollIntoView({block: 'start'})); await page.screenshot({path: resolve(outputDir, 'decision-trail-english-phone.png'), animations: 'disabled'}); } assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); } + await page.setViewportSize({width: 1280, height: 900}); + const desktopOrigin = trail.locator('.research-trail-question').last().locator('[data-research-decision]').last(); + await desktopOrigin.click(); await page.reload({waitUntil: 'domcontentloaded'}); + await history.getByRole('status').filter({hasText: '24 / 24'}).waitFor(); + const desktopReader = history.locator('.research-history-detail'); + await desktopReader.waitFor(); + assert.equal(new URL(page.url()).searchParams.get('research_finding'), 'decision-3-0'); + await page.screenshot({path: resolve(outputDir, 'decision-return-reader-desktop.png'), animations: 'disabled'}); + await desktopReader.press('Escape'); + await assertReturn(desktopOrigin); + await page.screenshot({path: resolve(outputDir, 'decision-return-list-desktop.png'), animations: 'disabled'}); const readback = await (await page.request.get(`${owner.base}/fixture/readback`)).json(); assert.equal(readback.source_unchanged, true); assert.equal(readback.mutation_attempts, 0, 'Reading and returning cannot perform a write');