diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css index f395110017..e49d0bc268 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.css @@ -231,12 +231,20 @@ .research-finding-steps { display: flex; flex-shrink: 0; align-items: center; gap: 8px; font-size: var(--text-body); } .research-history .research-finding-steps button { display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-bg); color: var(--pw-text); cursor: pointer; } .research-finding-steps button:disabled { color: var(--pw-muted); cursor: not-allowed; } -.research-history-detail .research-finding-exceptions { font-size: var(--text-body); color: var(--pw-muted); margin: 8px 0 16px; } -.research-recorded-finding > h4 { font-size: var(--text-lead); line-height: var(--leading-prose); margin: 8px 0; } +.research-history-detail .research-finding-exceptions { font-size: var(--text-body); color: var(--pw-muted); margin: 0; } +.research-recorded-finding > h3 { max-width: none; font-size: var(--text-heading); line-height: var(--leading-heading); margin: 8px 0; } +.research-reader-states { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 12px; } +.research-reader-question { margin-block: 4px 12px; font-size: var(--text-body); } +.research-reader-question > summary { display: flex; align-items: center; gap: 8px; min-height: 44px; cursor: pointer; color: var(--pw-muted); } +.research-reader-question > summary::before { content: "▸"; flex-shrink: 0; } +.research-reader-question[open] > summary::before { content: "▾"; } +.research-reader-question > summary::-webkit-details-marker { display: none; } +.research-reader-question:not([open]) > summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.research-reader-question[open] > summary { color: var(--pw-text); font-size: var(--text-heading); line-height: var(--leading-heading); font-weight: 600; } +.research-reader-question > p { font-size: var(--text-lead); line-height: var(--leading-prose); } @media (max-width: 640px) { - .research-history .research-finding-navigation { flex-wrap: wrap; gap: 8px; } - .research-finding-navigation label { flex-basis: 100%; } - .research-finding-steps { margin-left: auto; } + .research-history .research-finding-navigation { gap: 8px; } + .research-finding-steps { gap: 4px; } .research-history-context > summary { padding-inline: 16px; } } .research-analysis-scope { font-size: var(--text-body); line-height: var(--leading-body); color: var(--pw-muted); overflow-wrap: anywhere; } 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 3998c9ec81..6f4a8c2f88 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 @@ -74,6 +74,8 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re const [loaded, setLoaded] = useState(0); const [revision, setRevision] = useState(0); const [selection, setSelection] = useState(() => selectedFromUrl(goalId)); + // Pinning the displayed finding must not collapse a newly opened question. + const [questionExpanded, setQuestionExpanded] = useState(() => !selectedFromUrl(goalId).finding); const [recordsOpen, setRecordsOpen] = useState(false); const records = useRef(null); const [lens, setLens] = useState(() => viewFromUrl(goalId).lens); @@ -139,14 +141,14 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re : null}; }; const findingMeta = (finding: ExploreHistoryEvent) => {timeLabel(finding.recorded_at)} · {stateLabel(finding.status)}{finding.confidence !== null ? ` · ${zh ? "来源自报置信度" : "Source-reported confidence"} ${finding.confidence}` : ""}; - const findingElement = (finding: ExploreHistoryEvent, title: string) => { + const findingElement = (finding: ExploreHistoryEvent) => { const sections = findingSections(finding); const content = <>{findingBody(finding)}{sections || finding.evidence_refs.length ?
{zh ? "来源与适用范围" : "Sources and applicability"}{finding.evidence_refs.length ? ` · ${finding.evidence_refs.length}` : ""} {sections ?
{sectionCopy.applicability}
{sections.applicability}
{sectionCopy.inputRevision}
{sections.inputRevision}
: null} {finding.evidence_refs.map(ref => {ref})}
{zh ? "完整原文" : "Full original text"}

{originals.get(finding.event_id)?.title ?? finding.title}

{originals.get(finding.event_id)?.summary ?? finding.summary}

: null}; return
- {finding.title !== title ?

{finding.title}

: null}{findingMeta(finding)}{content} +

{finding.title}

{findingMeta(finding)}{content}
; }; 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. )}
; @@ -156,7 +158,9 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re readerParent.current = null; focusReturn.current = null; focusPending.current = true; - setSelection(selectedFromUrl(goalId)); + const next = selectedFromUrl(goalId); + setSelection(next); + setQuestionExpanded(!next.finding); const view = viewFromUrl(goalId); setLens(view.lens); setPhase(view.phase); }; window.addEventListener("popstate", pop); @@ -272,6 +276,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re focusPending.current = true; } setSelection(next); + setQuestionExpanded(!next.finding); const url = new URL(window.location.href); url.searchParams.delete("research_node"); url.searchParams.delete("research_group"); url.searchParams.delete("research_finding"); if (next.nodes.length || next.day !== null) { @@ -440,8 +445,14 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re {selected ?
{if (event.key === "Escape") {event.stopPropagation(); closeReader();}}}>
-

{kindLabel(selected.latest.node_kind)} · {stateLabel(selected.latest.status)}

-

{selected.latest.title}

{displayControl} +
+

{kindLabel(selected.latest.node_kind)} · {stateLabel(selected.latest.status)}

+ {selected.findings.length > 1 && findingExceptions ?

{findingExceptions}

: null} +
+ {selected.findings.length ? selectedFinding?.title !== selected.latest.title ?
setQuestionExpanded(event.currentTarget.open)}> + {selected.latest.title} + {selected.latest.summary ?

{selected.latest.summary}

: null} +
: null :

{selected.latest.title}

}{displayControl} {selected.latest.blocked_reason ?

{selected.latest.blocked_reason}

: null} {selected.findings.length ? <> {selected.findings.length > 1 ?
: null} - {selected.findings.length > 1 && findingExceptions ?

{findingExceptions}

: null} - {selectedFinding ? findingElement(selectedFinding, selected.latest.title) :

{zh ? "此记录中找不到所选结论;可从列表选择其他结论。" : "The selected finding is absent from this snapshot. Other findings remain available."}

} + {selectedFinding ? findingElement(selectedFinding) :

{zh ? "此记录中找不到所选结论;可从列表选择其他结论。" : "The selected finding is absent from this snapshot. Other findings remain available."}

} : <>{selected.latest.summary ?

{selected.latest.summary}

: null}

{zh ? "此节点没有附属结论记录。" : "No finding is attached to this node."}

}
{zh ? "来源与历史" : "Sources and history"} {selectedContextCitations.length ? <>

{zh ? "译文依据" : "Translation context"}

@@ -473,7 +483,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re onClick={event => {void analysis.readCitation(citation, event.currentTarget);}}> {citation.source_id} · {zh ? "行" : "lines"} {citation.first_line}–{citation.last_line} )} : null} - {selected.findings.length && selected.latest.summary ?

{selected.latest.summary}

: null} + {selected.findings.length && selectedFinding?.title === selected.latest.title && selected.latest.summary ?

{selected.latest.summary}

: null} {measurementSummary(selected.id)}

{copy.versions}

{selected.latest.evidence_refs.length ? selected.latest.evidence_refs.map(ref => {ref}) :

{zh ? "产物版本与来源引用未记录。" : "Artifact version and source references were not recorded."}

} {selectedEdges.length ? <>

{copy.links}

    {selectedEdges.map(edge =>
  • {positions.get(edge.from_node)?.node.latest.title ?? (zh ? "来源节点未记录" : "Source node not recorded")} → {relationLabel(edge.edge_type)} → {positions.get(edge.to_node)?.node.latest.title ?? (zh ? "目标节点未记录" : "Target node not recorded")}{edge.summary ? ` — ${edge.summary}` : ""}
  • )}
: null} diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/README.md b/examples/personal-workspace-browser/evidence/research-finding-reader/README.md index e24b44411e..61fe1ccf62 100644 --- a/examples/personal-workspace-browser/evidence/research-finding-reader/README.md +++ b/examples/personal-workspace-browser/evidence/research-finding-reader/README.md @@ -30,3 +30,31 @@ catalog over the same real history backend. They verify that the current pack's name stays beside the action while history metrics are collapsed, and changes with the existing scope selector. They do not qualify analysis authorization or model execution; the regression rejects any non-read analysis request. + +## Parent context and narrow reading + +Packaged Chat UI, production read-only Explore HTTP owner, synthetic long +questions and three mixed-state findings. No real research material appears +in these captures. The analysis fixture is synthetic; this is layout and +navigation evidence, not model-quality or installed-product qualification. + +Before: `920f18809d906b840325c4f40013e9224e4a3a2e`. After: this PR's reader +implementation. Desktop: 1200 × 888; narrow: 390 × 844. Both views use the same +fixture and selected finding, with the reader scrolled to its beginning. + +| View | Before | After | +| --- | --- | --- | +| Desktop | ![Desktop before](context-before-desktop.png) | ![Desktop after](context-after-desktop.png) | +| Narrow | ![Narrow before](context-before-phone.png) | ![Narrow after](context-after-phone.png) | + +Direct finding selection starts with its complete result and keeps the parent +question optional. Opening a question from the route still expands its complete +title immediately. The same full parent context remains available from a finding: + +![Expanded parent context](context-after-context-phone.png) + +The existing `research-route-reading` scenario verifies both entry intents, +readable finding text, complete titles, mixed states, keyboard return and +reload/Back in Chinese and English at 1440, 768 and 390 pixels. The broader +`research-history` scenario also covers unavailable sources, recovery, empty +history and saved-analysis readback. Neither reader dispatches a model. diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/context-after-context-phone.png b/examples/personal-workspace-browser/evidence/research-finding-reader/context-after-context-phone.png new file mode 100644 index 0000000000..f2f1804d3b Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/context-after-context-phone.png differ diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/context-after-desktop.png b/examples/personal-workspace-browser/evidence/research-finding-reader/context-after-desktop.png new file mode 100644 index 0000000000..2d52390cbe Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/context-after-desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/context-after-phone.png b/examples/personal-workspace-browser/evidence/research-finding-reader/context-after-phone.png new file mode 100644 index 0000000000..f8e58aaeda Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/context-after-phone.png differ diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/context-before-desktop.png b/examples/personal-workspace-browser/evidence/research-finding-reader/context-before-desktop.png new file mode 100644 index 0000000000..2a0d8b86ea Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/context-before-desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/context-before-phone.png b/examples/personal-workspace-browser/evidence/research-finding-reader/context-before-phone.png new file mode 100644 index 0000000000..b18347fd87 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/context-before-phone.png differ diff --git a/examples/personal-workspace-browser/research-history.mjs b/examples/personal-workspace-browser/research-history.mjs index e5a3c730d3..55fe13ac43 100644 --- a/examples/personal-workspace-browser/research-history.mjs +++ b/examples/personal-workspace-browser/research-history.mjs @@ -754,7 +754,7 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id':goal,'display_name':'Syntheti assert(await detail.getByText('0.2 / 30', {exact:true}).isVisible()); await page.screenshot({path:resolve(outputDir, `research-execution-${viewport.width}.png`), animations:'disabled'}); assert.deepEqual(selectedNodes(),['hit-6']); - await detail.getByRole('heading',{name:'Synthetic hit target 6',exact:true}).waitFor(); + await detail.getByText('Synthetic hit target 6',{exact:true}).waitFor(); await detail.press('Escape'); assert.deepEqual(selectedNodes(),ids,'Escape returns to the original group'); assert(await detail.locator('[data-research-node="hit-6"] button').evaluate(e=>e===document.activeElement)); diff --git a/examples/personal-workspace-browser/research-route-reading.mjs b/examples/personal-workspace-browser/research-route-reading.mjs index 26c2fac472..c22fb66202 100644 --- a/examples/personal-workspace-browser/research-route-reading.mjs +++ b/examples/personal-workspace-browser/research-route-reading.mjs @@ -28,6 +28,11 @@ for route in range(3): events.append(build_explore_finding_event(goal_id=goal,node_id=node,finding_id=f'finding-{node}',title='固定合成记录', summary='完整观测:局部质量下降,延迟改善;完整成本和独立验证仍未知。',status='refuted' if route==2 else 'tentative', recorded_at=f'2026-01-0{route+1}T{1 if endpoint=="start" else 13:02}:00:00Z')) + if route==0 and endpoint=='end': + for index,status in enumerate(('confirmed','refuted')): + events.append(build_explore_finding_event(goal_id=goal,node_id=node,finding_id=f'finding-{node}-{index}', + title='相同输入下的候选取舍与后续验证',summary='本地比较保留正结果和回退;独立收益及完整成本仍未知。', + status=status,recorded_at=f'2026-01-01T13:00:0{index+1}Z')) Path(sys.argv[1]).write_text(json.dumps({'goal_id':goal,'display_name':'合成研究 · 路线阅读','events':events},ensure_ascii=False)) `, path], {cwd: repoRoot}); } @@ -109,8 +114,23 @@ export const researchRouteReadingScenario = { const fullTitle = capture.events.find(event => event.result_id === `route-0-${endpoint ? "end" : "start"}` && event.event_kind === "node").title; await page.keyboard.press("Enter"); await reader.waitFor(); - assert.equal(await reader.locator("h3").first().innerText(), fullTitle, "One activation reveals the complete original title"); - assert(await reader.locator("h3").evaluate(element => element.scrollHeight <= element.clientHeight + 1), "Selected text is not clipped"); + const question = reader.getByText(fullTitle, {exact: true}); + assert.equal(await question.innerText(), fullTitle, "One route activation reveals the complete original question"); + assert(await question.evaluate(element => element.scrollHeight <= element.clientHeight + 1 && element.scrollWidth <= element.clientWidth + 1), "Requested question text is not clipped"); + if (endpoint) { + await reader.locator("select").selectOption("finding-route-0-end"); + const body = reader.getByText("完整观测:局部质量下降,延迟改善;完整成本和独立验证仍未知。", {exact: true}).first(); + const box = await body.boundingBox(); + const reading = await history.evaluate(element => {const bounds = element.closest(".personal-channel-scroll")?.getBoundingClientRect(); return {top: Math.max(0, bounds?.top ?? 0), bottom: Math.min(innerHeight, bounds?.bottom ?? innerHeight)};}); + const lineHeight = await body.evaluate(element => parseFloat(getComputedStyle(element).lineHeight)); + assert(box.y >= reading.top - 1 && box.y + Math.min(box.height, lineHeight * 2) <= reading.bottom + 1, "The selected finding begins with readable content inside the reading viewport"); + assert(await body.evaluate(element => parseFloat(getComputedStyle(element).fontSize) >= 16), "Density retains readable finding text"); + assert.match(await reader.locator(".research-finding-exceptions").innerText(), /1/); + const context = reader.getByText(fullTitle, {exact: true}); + await context.click(); + assert(await context.evaluate(element => element.scrollHeight <= element.clientHeight + 1 && element.scrollWidth <= element.clientWidth + 1), "Full parent context remains one optional activation away"); + await context.click(); + } if (endpoint && language === "zh-CN" && width !== 768) await page.screenshot({path: resolve(outputDir, `research-route-reading-detail-${width}.png`), animations: "disabled"}); await page.keyboard.press("Escape"); assert.equal(await reader.count(), 0); @@ -142,7 +162,7 @@ export const researchRouteReadingScenario = { await reader.getByRole("button", {name: "Whole graph", exact: true}).click(); await page.goBack(); await reader.waitFor(); - assert.match(await reader.locator("h3").first().innerText(), /末端研究/); + assert.match(await reader.locator(".research-reader-question").innerText(), /末端研究/); assert.equal(analysisWrites, 0, "Reading, language changes and every action state never dispatch a model"); const readback = await (await page.request.get(`${owner.base}/fixture/readback`)).json(); assert(readback.source_unchanged && readback.mutation_attempts === 0, "No source or execution state changes");