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 c96c5fe65d..71c53e5f87 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 @@ -34,11 +34,6 @@ .research-detail-kind { display: flex; align-items: center; gap: 6px; font-size: var(--text-caption); color: var(--pw-muted); } .research-recorded-finding { border-top: 1px solid var(--pw-line); padding-block: 10px; } .research-recorded-finding small { display: block; color: var(--pw-muted); } -.research-recorded-finding > summary { cursor: pointer; padding-block: 8px; } -.research-finding-summary { display: inline-block; vertical-align: top; width: calc(100% - 24px); } -.research-finding-summary strong { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } -.research-finding-summary small:only-child { color: var(--pw-text); font-size: var(--text-body); } -.research-recorded-finding[open] > summary strong { display: block; overflow: visible; } .research-node-provenance { margin-top: 20px; padding-top: 12px; border-top: 1px solid var(--pw-line); } .research-node-provenance > summary, .research-finding-sources > summary { cursor: pointer; min-height: 44px; align-content: center; } .research-question-index { list-style: none; padding: 0; margin: 0; } @@ -48,7 +43,7 @@ .research-question-index button span { color: var(--pw-muted); font-size: var(--text-caption); } .research-question-index button .research-history-error { color: var(--pw-red); } .research-question-index button:hover strong { text-decoration: underline; text-underline-offset: 3px; } -.research-history-detail code { display: block; font-family: var(--font-mono); font-size: var(--text-caption); overflow-wrap: anywhere; } +.research-history-detail code { display: block; font-family: var(--font-mono); font-size: var(--text-body); overflow-wrap: anywhere; } .research-history-records { padding-left: 18px; } .research-history-records li { padding: 6px 0; } .research-history-records time { display: block; font-size: var(--text-caption); color: var(--pw-muted); } @@ -165,3 +160,27 @@ @media (max-width: 640px) { .research-unlinked-days { grid-template-columns: repeat(2, minmax(0, 1fr)); } .research-finding-sections { grid-template-columns: minmax(0, 1fr); gap: 4px; } .research-finding-sections dd { margin-bottom: 12px; } } @media (max-width: 900px) { .research-phases { grid-template-columns: repeat(3, minmax(260px, 1fr)); overflow-x: auto; padding: 2px; scroll-snap-type: x proximity; } .research-phase { scroll-snap-align: start; } .research-trail-list { grid-template-columns: 1fr; } .research-timeline-grid { grid-template-columns: minmax(0, 1fr); } .research-row-label { flex-direction: row; flex-wrap: wrap; gap: 8px; } .research-row-plot { margin-bottom: 8px; } } @media (prefers-reduced-motion: reduce) { .research-history .research-timeline-marker:hover::after { transform: translate(-50%, -50%); } } + +/* A selected question is a reading surface; the whole history stays reachable. */ +.research-history-layout.has-selection > .research-history-detail { border-top: 0; } +.research-history-context { border-top: 1px solid var(--pw-line); min-width: 0; } +.research-history-context > summary { min-height: 44px; padding: 12px 24px; font-size: var(--text-body); cursor: pointer; } +.research-history-context > .research-progress { margin: 16px 24px; } +.research-history .research-finding-navigation { display: flex; align-items: flex-end; gap: 16px; margin-top: 16px; min-width: 0; } +.research-finding-navigation label { display: grid; flex: 1; min-width: 0; gap: 8px; font-size: var(--text-body); } +.research-finding-navigation select { width: 100%; min-width: 0; min-height: 44px; padding: 8px 12px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-bg); color: var(--pw-text); font: inherit; } +.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; } +@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-context > summary { padding-inline: 16px; } +} +.research-analysis-scoped-action { display: grid; gap: 6px; max-width: 28rem; min-width: 0; } +.research-analysis-scope { font-size: var(--text-body); line-height: var(--leading-body); color: var(--pw-muted); overflow-wrap: anywhere; } +.research-reader-location { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 4px 16px; } +.research-history .research-reader-location .research-reader-back, .research-history-detail .research-reader-location p { margin: 0; } 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 7a61742f2a..23bf4e9011 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 @@ -1,6 +1,6 @@ -import {useEffect, useId, useLayoutEffect, useMemo, useRef, useState} from "react"; +import {Fragment, useEffect, useId, useLayoutEffect, useMemo, useRef, useState} from "react"; import {defaultParseSearch} from "@tanstack/react-router"; -import {ArrowLeft, Circle, FlaskConical, GitBranch, RefreshCw} from "lucide-react"; +import {ArrowLeft, ArrowRight, Circle, FlaskConical, GitBranch, RefreshCw} from "lucide-react"; import {ResearchAnalysisButton, ResearchAnalysisPanel, ResearchAnalysisStatus, ResearchCitationPanel, ResearchProgressSummary, useResearchAnalysis} from "./goal-research-analysis"; import {buildExploreTimeline, parseExploreFindingSections} from "../../../../../../loopx/control_plane/presentation/explore_timeline.js"; import {GoalResearchTimeline} from "./goal-research-timeline"; @@ -11,7 +11,7 @@ import {groupUnlinkedHistoryQuestions, layoutExploreHistory, parseExploreHistory import "./goal-research-history.css"; import {ExploreMeasurements, formatExploreMeasurementValue} from "./explore-measurements"; -type Selection = {nodes: string[]; day: string | null}; +type Selection = {nodes: string[]; day: string | null; finding?: string}; type Lens = "timeline" | "graph"; type Read = {events: ExploreHistoryEvent[]; total: number | null; complete: boolean}; const selectedFromUrl = (goal: string): Selection => { @@ -23,7 +23,7 @@ const selectedFromUrl = (goal: string): Selection => { const search = defaultParseSearch(window.location.search); const decoded = "research_node" in search ? search.research_node : undefined; const nodes = Array.isArray(decoded) && decoded.every(id => typeof id === "string") ? decoded : query.getAll("research_node"); - return {nodes: day === null ? [...new Set(nodes.filter(Boolean))] : [], day}; + return {nodes: day === null ? [...new Set(nodes.filter(Boolean))] : [], day, finding: query.get("research_finding") || undefined}; }; const viewFromUrl = (goal: string) => { @@ -96,7 +96,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re
{finding.summary}
}{execution ?{sections.inputRevision}{ref})}{ref})}
+ {sections ? {finding.summary}
{copy.group}: {event.parent_id} : null}{event.evidence_refs.map(ref => {ref})}{event.run_id ? {event.run_id} : null}{copy.scope} · {copy.time}{zh ? "。" : ". "}{copy.coverage}{zh ? "。" : "."}
{copy.interpretation}{zh ? "。" : "."}
- {lens === "graph" ?{zh ? "按来源分组,实线为已记录关联,虚线仅表示分组;排列不代表时间或进展。未关联问题按首次记录 UTC 日期收纳。" : "Grouped by source. Solid lines are recorded relationships; dotted lines only group nodes. Position does not imply time or progress. Unlinked questions are collected by first-record UTC date."}
: null} -{zh ? "合拢仅为避免遮挡,不表示关联或因果;选择一次可读全部原始记录与依据。" : "Nearby points group to avoid overlap, without implying a relationship or causality. Select once to read every original record and source."}
-{copy.time}{value?.startAt ? <> · — > : null} · {busy ? `${copy.reading} ${loaded}${read.total ? ` / ${read.total}` : ""}` - : read.total === null ? (zh ? "来源尚未读到" : "Source has not been read") - : `${read.events.length} / ${read.total} ${copy.records}${read.complete ? "" : ` · ${copy.partial}`}`}
{copy.failure} {error || history.error}
: null} - {!busy && !error && read.total === 0 ?{zh ? "这个 Goal 尚无已记录的研究历程。" : "This Goal has no recorded research history yet."}
: null} -{copy.scope} · {copy.time}{zh ? "。" : ". "}{copy.coverage}{zh ? "。" : "."}
{copy.interpretation}{zh ? "。" : "."}
+ {lens === "graph" ?{zh ? "按来源分组,实线为已记录关联,虚线仅表示分组;排列不代表时间或进展。未关联问题按首次记录 UTC 日期收纳。" : "Grouped by source. Solid lines are recorded relationships; dotted lines only group nodes. Position does not imply time or progress. Unlinked questions are collected by first-record UTC date."}
: null} +{zh ? "合拢仅为避免遮挡,不表示关联或因果;选择一次可读全部原始记录与依据。" : "Nearby points group to avoid overlap, without implying a relationship or causality. Select once to read every original record and source."}
+{copy.time}{value?.startAt ? <> · — > : null} · {busy ? `${copy.reading} ${loaded}${read.total ? ` / ${read.total}` : ""}` + : read.total === null ? (zh ? "来源尚未读到" : "Source has not been read") + : `${read.events.length} / ${read.total} ${copy.records}${read.complete ? "" : ` · ${copy.partial}`}`}
{copy.failure} {error || history.error}
: null} + {!busy && !error && read.total === 0 ?{zh ? "这个 Goal 尚无已记录的研究历程。" : "This Goal has no recorded research history yet."}
: null} +{selected.latest.blocked_reason}
: null} - {selected.findings.length ? selected.findings.map(finding => findingElement(finding, selected.latest.title, selected.findings.length > 1)) : <>{selected.latest.summary ?{selected.latest.summary}
: null}{zh ? "此节点没有附属结论记录。" : "No finding is attached to this node."}
>} + {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."}
} + > : <>{selected.latest.summary ?{selected.latest.summary}
: null}{zh ? "此节点没有附属结论记录。" : "No finding is attached to this node."}
>}{selected.latest.summary}
: null} {measurementSummary(selected.id)} @@ -334,6 +377,10 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re )}{copy.lost}
: null} {value?.events.length && !value.nodes.length ?{zh ? "尚未记录节点结构;可展开下面的已读记录。" : "Node structure has not been recorded; loaded records remain available below."}
: null} diff --git a/apps/presentation/dashboard/src/router.tsx b/apps/presentation/dashboard/src/router.tsx index a7aa2199a7..2891fc36fd 100644 --- a/apps/presentation/dashboard/src/router.tsx +++ b/apps/presentation/dashboard/src/router.tsx @@ -35,6 +35,7 @@ const searchSchema = z.object({ research_phase: z.unknown().optional(), research_goal: z.unknown().optional(), research_node: z.unknown().optional(), + research_finding: z.unknown().optional(), research_group: z.unknown().optional(), research_pack: z.unknown().optional(), research_analysis: z.unknown().optional(), diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index bc4eeb3205..3b28368563 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -3427,7 +3427,7 @@ export function DashboardPage() { workAgent: "statusUrl" in current && (!current.statusUrl || current.statusUrl === url) ? current.workAgent : undefined, // Node/pack ids are source-local even when the Goal ids coincide. // Back restores the original source's URL and its own selection. - ...(runtimeSourceChanged ? {research_lens: undefined, research_phase: undefined, research_goal: undefined, research_node: undefined, + ...(runtimeSourceChanged ? {research_lens: undefined, research_phase: undefined, research_goal: undefined, research_node: undefined, research_finding: undefined, research_group: undefined, research_analysis: undefined, research_analysis_goal: undefined, research_pack: undefined} : {}), }; diff --git a/docs/development/design.md b/docs/development/design.md index cfeea2e8cc..5972a3504c 100644 --- a/docs/development/design.md +++ b/docs/development/design.md @@ -187,6 +187,16 @@ Saved findings lead with readable observations, interpretations and decisions. Put references, applicability, revisions and full original text behind one expansion. Clamp repeated titles only in the selection list, never the selected finding's full content. Do not invent short titles or infer findings from prose. +A selected question is a focused document, not another full history stacked below +the chart. Show one finding at a time with its declared status and full text; +keep all findings selectable and failed or tentative counts visible. Preserve +the exact finding on reload and Back. Offer the whole recorded history in place +and through the existing return action, without letting it bury the chosen text. + +选中问题后聚焦阅读一条结论,完整正文与记录状态保留;全部结论可直接切换,反驳和 +待检查计数仍可见。刷新与返回保留所选结论,全景可原位展开或通过原入口返回, +不再把整张图和全部折叠标题堆在所选正文之前。 + Reading a citation opens only the requested original, preserving the explicit open or closed state of the full analysis and returning to the exact citation. diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index fd6aca15f8..b478158fa5 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -71,6 +71,7 @@ import { managedGoalResultsScenario } from "./personal-workspace-browser/managed import { researchResultsScenario } from "./personal-workspace-browser/research-results.mjs"; import { researchModeScenario } from "./personal-workspace-browser/research-mode.mjs"; import { researchHistoryScenario } from "./personal-workspace-browser/research-history.mjs"; +import { researchFindingReaderScenario } from "./personal-workspace-browser/research-finding-reader.mjs"; import { researchMeasurementsScenario } from "./personal-workspace-browser/research-measurements.mjs"; import { loopxModeScenario } from "./personal-workspace-browser/loopx-mode.mjs"; import { progressiveLoadingScenario } from "./personal-workspace-browser/progressive-loading.mjs"; @@ -125,6 +126,7 @@ scenarioCatalog.push(attentionUserActionScenario); scenarioCatalog.push(researchResultsScenario); scenarioCatalog.push(researchHistoryScenario); scenarioCatalog.push(researchMeasurementsScenario); +scenarioCatalog.push(researchFindingReaderScenario); scenarioCatalog.push(prReviewAgentOrderScenario); scenarioCatalog.push(taskInspectorReturnScenario); scenarioCatalog.push(replanCadenceScenario); diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/README.md b/examples/personal-workspace-browser/evidence/research-finding-reader/README.md new file mode 100644 index 0000000000..e24b44411e --- /dev/null +++ b/examples/personal-workspace-browser/evidence/research-finding-reader/README.md @@ -0,0 +1,32 @@ +# Focused finding reader + +Synthetic research data rendered by the packaged UI and the production history +HTTP/SQLite owner; no model, solver or live Goal is involved. + +Before: `01f61ee9f3b993bfafc11419ea1842e722906957`. +After UI digest (ordered TSX, CSS, router, dashboard source paths plus bytes): +`92f2654591aa2773c3258bde107d691bc4186b833047b51cd6834831e197f6e7`. +Fixture SHA-256: `2972f2b28582a5ee990f439cde19841756c3d4fa97733dbbcd6919195ad0ffc2`. + +The same 24 events contain 20 long findings, three refuted and four tentative, +a blocked source and a single-finding case. The old selected question presents +20 collapsed document headings after the full timeline. The new reader starts +with one complete finding; all 20 remain directly selectable. The negative and +tentative counts stay visible. Returning restores the whole recorded history. + +Desktop captures are 1440 × 900; narrow captures are 390 × 900. First-viewport +images retain the whole workspace frame. Source captures inspect the same +expanded original on both widths, with complete identities and normal reading +text. These images qualify presentation over disposable synthetic state, not +scientific effectiveness, model interpretation or an installed App. + +Run the `research-finding-reader` packaged browser scenario for exact selection, +reload/Back, complete original, unknown-link recovery, keyboard and effect-free +readback. The existing research-history, research-measurements and runtime-source +scenarios retain history, values and source-isolation coverage. + +The two analysis-scope captures add a presentation-only synthetic analysis +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. diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/after-desktop.png b/examples/personal-workspace-browser/evidence/research-finding-reader/after-desktop.png new file mode 100644 index 0000000000..34d32660ba Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/after-desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/after-first-viewport.png b/examples/personal-workspace-browser/evidence/research-finding-reader/after-first-viewport.png new file mode 100644 index 0000000000..315cb353fa Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/after-first-viewport.png differ diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/after-phone.png b/examples/personal-workspace-browser/evidence/research-finding-reader/after-phone.png new file mode 100644 index 0000000000..a4ae50aefb Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/after-phone.png differ diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/after-source-desktop.png b/examples/personal-workspace-browser/evidence/research-finding-reader/after-source-desktop.png new file mode 100644 index 0000000000..83583791fe Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/after-source-desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/after-source-phone.png b/examples/personal-workspace-browser/evidence/research-finding-reader/after-source-phone.png new file mode 100644 index 0000000000..8c793f4918 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/after-source-phone.png differ diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/analysis-scope-desktop.png b/examples/personal-workspace-browser/evidence/research-finding-reader/analysis-scope-desktop.png new file mode 100644 index 0000000000..419c2e905c Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/analysis-scope-desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/analysis-scope-phone.png b/examples/personal-workspace-browser/evidence/research-finding-reader/analysis-scope-phone.png new file mode 100644 index 0000000000..9c1ff2a707 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/analysis-scope-phone.png differ diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/before-desktop.png b/examples/personal-workspace-browser/evidence/research-finding-reader/before-desktop.png new file mode 100644 index 0000000000..825df5b176 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/before-desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/before-first-viewport.png b/examples/personal-workspace-browser/evidence/research-finding-reader/before-first-viewport.png new file mode 100644 index 0000000000..0b5424f066 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/before-first-viewport.png differ diff --git a/examples/personal-workspace-browser/evidence/research-finding-reader/before-phone.png b/examples/personal-workspace-browser/evidence/research-finding-reader/before-phone.png new file mode 100644 index 0000000000..8a2179b1ce Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-finding-reader/before-phone.png differ diff --git a/examples/personal-workspace-browser/research-finding-reader.mjs b/examples/personal-workspace-browser/research-finding-reader.mjs new file mode 100644 index 0000000000..4fe97f5479 --- /dev/null +++ b/examples/personal-workspace-browser/research-finding-reader.mjs @@ -0,0 +1,163 @@ +import assert from "node:assert/strict"; +import {execFileSync} from "node:child_process"; +import {mkdtemp, rm} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {resolve} from "node:path"; +import {resolveTestPython} from "../../scripts/test-python.mjs"; +import {outputDir, repoRoot} from "./fixture.mjs"; +import {analysisReadback, startHistoryOwner} from "./research-history.mjs"; + +// Public synthetic documents through the production history HTTP/SQLite owner. +// No model, solver, scientific score or live Goal is involved. +export function writeFindingReaderFixture(path) { + execFileSync(resolveTestPython(), ["-c", ` +import json, sys +from pathlib import Path +from loopx.capabilities.explore.result_log import build_explore_node_event, build_explore_finding_event +goal='synthetic-finding-reading' +events=[build_explore_node_event(goal_id=goal,node_id='question',title='合成研究:质量、延迟和未知成本应如何一起判断?',recorded_at='2026-01-01T00:00:00Z')] +for index in range(20): + status='refuted' if index%7==0 else 'tentative' if index%4==0 else 'confirmed' + observation=f'观测 {index+1}:质量下降而延迟改善,仍不能据此断言完整收益。' + observation+=' 保留失败候选与完整成本未知;下一步需要在相同条件下核对。'*3 + summary=f'Input revision: input:{"a"*64} Applicability: 固定合成样本;不是生产评测。 Observation: {observation} Interpretation: Outcome: continue changed: - 继续核对完整成本 stopped: - 不把局部改善当整体收益 retained: - 保留负结果与原始版本' + events.append(build_explore_finding_event(goal_id=goal,node_id='question',finding_id=f'finding-{index}', + title=f'比较 {index+1}:质量、延迟与采用版本的核对 · quality, latency and retained candidate', + status=status,summary=summary,evidence_refs=['result:synthetic-'+str(index)],recorded_at=f'2026-01-01T{index+1:02}:00:00Z')) +events.append(build_explore_node_event(goal_id=goal,node_id='blocked',title='来源暂不可用的研究问题',status='blocked',blocked_reason='无法读取评测来源,当前结果未知。',recorded_at='2026-01-02T00:00:00Z')) +events.append(build_explore_node_event(goal_id=goal,node_id='single',title='仅有一条结论的问题',recorded_at='2026-01-02T01:00:00Z')) +events.append(build_explore_finding_event(goal_id=goal,node_id='single',finding_id='single-finding',title='原始观测',summary='一条完整的原始结论。',status='tentative',recorded_at='2026-01-02T01:00:00Z')) +Path(sys.argv[1]).write_text(json.dumps({'goal_id':goal,'display_name':'合成研究 · 结论阅读','events':events},ensure_ascii=False)) +`, path], {cwd: repoRoot}); +} + +export const researchFindingReaderScenario = { + id: "research-finding-reader", + async run({browser, collectCoverage}) { + const temporary = await mkdtemp(resolve(tmpdir(), "loopx-finding-reading-")); + let owner, page; + try { + const fixture = resolve(temporary, "capture.json"); + writeFindingReaderFixture(fixture); + owner = await startHistoryOwner(fixture); + page = await browser.newPage({viewport: {width: 1440, height: 900}, hasTouch: true}); + if (collectCoverage) await page.coverage.startJSCoverage({resetOnNavigation: false}); + const url = new URL("/chat/?goalId=synthetic-finding-reading&view=research&research_goal=synthetic-finding-reading&research_node=question&statusUrl=%2Fstatus.json", owner.base).href; + await page.goto(url, {waitUntil: "domcontentloaded"}); + const history = page.locator(".research-history"); + const reader = history.locator(".research-history-detail"); + const article = reader.locator(".research-recorded-finding"); + const picker = reader.getByRole("combobox", {name: "选择问题结论", exact: true}); + await picker.waitFor(); + await page.getByText("开启研究模式", {exact: true}).waitFor(); + assert.equal(await picker.locator("option").count(), 20, "Every finding remains directly selectable"); + assert.equal(await article.count(), 1, "A dense question presents one full document, not twenty stacked documents"); + assert.equal(await picker.inputValue(), "finding-19"); + await page.waitForURL(/research_finding=finding-19/); + assert.match(await reader.locator(".research-finding-exceptions").innerText(), /已反驳 3.*待检查 4/); + assert.equal(await history.locator(".research-history-canvas").isVisible(), false, "The selected text leads; history is available on demand"); + await page.screenshot({path: resolve(outputDir, "research-finding-reader-desktop.png"), animations: "disabled"}); + await page.locator(".personal-channel-scroll").evaluate(element => element.scrollTop = 0); + await page.screenshot({path: resolve(outputDir, "research-finding-reader-first-viewport.png"), animations: "disabled"}); + + await picker.selectOption("finding-0"); + assert.match(await article.innerText(), /已反驳/); + assert.match(await article.innerText(), /观测 1:质量下降而延迟改善/); + const completeObservation = await article.locator(".research-finding-sections dd").first().innerText(); + assert.equal(completeObservation.split("保留失败候选").length - 1, 3, "Selected long prose is complete"); + await page.reload({waitUntil: "domcontentloaded"}); + await picker.waitFor(); + assert.equal(await picker.inputValue(), "finding-0", "Reload keeps the exact finding, not the latest one"); + await reader.getByRole("button", {name: "下一条结论", exact: true}).click(); + assert.equal(await picker.inputValue(), "finding-1"); + await page.goBack(); + await page.waitForFunction(() => new URL(location.href).searchParams.get("research_finding") === "finding-0"); + assert.equal(await picker.inputValue(), "finding-0", "Back restores the prior finding"); + + for (const width of [1440, 390]) { + await page.setViewportSize({width, height: 900}); + const sources = article.locator(".research-finding-sources"); + await sources.locator(":scope > summary").click(); + const original = sources.locator(":scope > details"); + if (!(await original.evaluate(element => element.open))) await original.locator(":scope > summary").click(); + assert.match(await sources.innerText(), /Input revision: input:a{64}/, "The writer's complete original remains reachable"); + assert.match(await sources.innerText(), /result:synthetic-0/); + const geometry = await reader.evaluate(element => ({font: parseFloat(getComputedStyle(element.querySelector(".research-finding-sections dd")).fontSize), + sourceFont: parseFloat(getComputedStyle(element.querySelector(".research-finding-sources code")).fontSize), overflow: element.scrollWidth - element.clientWidth, maxHeight: getComputedStyle(element).maxHeight})); + assert(geometry.font >= 16 && geometry.sourceFont >= 14 && geometry.overflow <= 1 && geometry.maxHeight === "none", "Expanded content is normal reading text without clipping or a fixed-height text wall"); + await page.screenshot({path: resolve(outputDir, `research-finding-reader-detail-${width}.png`), animations: "disabled"}); + await sources.locator(":scope > summary").click(); + } + await reader.getByRole("button", {name: "下一条结论", exact: true}).focus(); + await page.keyboard.press("Enter"); + assert.equal(await picker.inputValue(), "finding-1", "The complete collection works by keyboard"); + await reader.getByRole("button", {name: "上一条结论", exact: true}).tap(); + assert.equal(await picker.inputValue(), "finding-0"); + await reader.getByRole("button", {name: "下一条结论", exact: true}).tap(); + assert.equal(await picker.inputValue(), "finding-1", "Narrow touch navigation keeps the selected record identity"); + await page.screenshot({path: resolve(outputDir, "research-finding-reader-phone.png"), animations: "disabled"}); + await history.locator(".research-history-context > summary").click(); + assert.equal(await history.locator(".research-history-canvas").isVisible(), true); + await reader.getByRole("button", {name: "回到全图", exact: true}).click(); + assert.equal(await reader.count(), 0, "Returning restores the original full history"); + assert.equal(new URL(page.url()).searchParams.has("research_finding"), false); + + await page.goto(url.replace("research_node=question", "research_node=blocked")); + await reader.waitFor(); + assert.match(await reader.innerText(), /无法读取评测来源,当前结果未知/); + assert.equal(await article.count(), 0, "No finding is invented for an unavailable source"); + await page.goto(url + "&research_finding=absent"); + await picker.waitFor(); + assert.equal(await article.count(), 0, "An unknown explicit finding does not silently open a different record"); + assert.match(await reader.innerText(), /找不到所选结论/); + await picker.selectOption("finding-4"); + assert.match(await article.innerText(), /待检查/); + await page.evaluate(() => localStorage.setItem("loopx-pw-locale", "en")); + await page.reload({waitUntil: "domcontentloaded"}); + await reader.getByRole("combobox", {name: "Choose a question finding", exact: true}).waitFor(); + assert.match(await article.innerText(), /Tentative/); + assert.match(await article.innerText(), /观测 5/, "Changing interface language reads the original; it does not rewrite it"); + await page.goto(url.replace("research_node=question", "research_node=single")); + await reader.waitFor(); + assert.equal(await article.count(), 1); + assert.equal(await reader.getByRole("combobox").count(), 0, "A single finding needs no selector or disabled navigation"); + assert.match(await article.innerText(), /一条完整的原始结论/); + // Analysis remains scoped by its existing pack owner, not the open node. + // This presentation-only read fixture must expose the actual chosen scope + // before its action, even while whole-history metrics are collapsed. + let analysisWrites = 0; + await page.route("**/api/chat/research-analysis?*", route => { + if (route.request().method() !== "GET") {analysisWrites++; return route.abort();} + const selected = new URL(route.request().url()).searchParams.get("pack_id")?.[0] ?? "a"; + const snapshot = analysisReadback(selected); snapshot.pack.result = null; snapshot.pack.job = null; + snapshot.pack.config.output_language = new URL(route.request().url()).searchParams.get("output_language"); + return route.fulfill({json: snapshot}); + }); + await page.goto(url); + const scope = history.locator(".research-analysis-scope"); + await scope.filter({hasText: /^Analysis scope · Synthetic analysis A$/}).waitFor(); + assert.equal(await history.locator(".research-history-context").evaluate(element => element.open), false); + await history.locator(".research-history-context > summary").click(); + await history.getByRole("combobox", {name: "Evaluation scope", exact: true}).selectOption("b".repeat(64)); + await scope.filter({hasText: /^Analysis scope · Synthetic analysis B$/}).waitFor(); + assert(await scope.evaluate(element => element.scrollWidth <= element.clientWidth + 1), "The chosen analysis scope remains readable in a narrow focused view"); + await history.locator(".research-history-context > summary").click(); + for (const width of [1440, 390]) { + await page.setViewportSize({width, height: 900}); + await page.locator(".personal-channel-scroll").evaluate(element => element.scrollTop = 0); + await page.screenshot({path: resolve(outputDir, `research-finding-reader-scope-${width}.png`), animations: "disabled"}); + } + assert.equal(analysisWrites, 0, "Reading and choosing the displayed scope cannot dispatch analysis"); + const readback = await (await page.request.get(`${owner.base}/fixture/readback`)).json(); + assert(readback.source_unchanged && readback.mutation_attempts === 0, "Reading, switching and recovery have no execution or source-write effect"); + return {coverageEntries: collectCoverage ? await page.coverage.stopJSCoverage() : [], note: "real-owner dense/small/unavailable finding selection, complete original, negative states and exact reload/Back focus"}; + } catch (error) { + if (page && !page.isClosed()) await page.screenshot({path: resolve(outputDir, "research-finding-reader-failed.png")}); + throw error; + } finally { + await page?.close(); + await owner?.close(); + await rm(temporary, {recursive: true, force: true}); + } + }, +}; diff --git a/examples/personal-workspace-browser/research-history.mjs b/examples/personal-workspace-browser/research-history.mjs index d2ecf204f6..10d2f2096b 100644 --- a/examples/personal-workspace-browser/research-history.mjs +++ b/examples/personal-workspace-browser/research-history.mjs @@ -10,7 +10,7 @@ import {fetchWorkspaceBackend, openWorkspacePage} from "./scenario-context.mjs"; // Presentation-only race fixture; the HTTP/process authority is tested in // tests/presentation/test_research_analysis_api.py. No model is dispatched here. -function analysisReadback(selected = "a") { +export function analysisReadback(selected = "a") { const scope = {goal_id: "synthetic-solver", case_id: "synthetic-case", run_id: "synthetic-run", revision: "fixed", window: "fixed synthetic sample"}; const selection = {rule: "Synthetic purpose sample", population_episodes: null, exhaustive: false}; const config = {model: "fixture", effort: "max", service_tier: "unknown"}; @@ -508,8 +508,8 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id':goal,'display_name':'Syntheti await page.screenshot({path:resolve(outputDir, `research-question-index-${viewport.width}.png`), animations:'disabled'}); await detail.locator('[data-research-node="hit-6"] button').click(); assert.equal(await history.locator('.research-history-canvas').evaluate(e=>e.getBoundingClientRect().width),widthBefore,'Reading never squeezes the whole-history graph'); - assert.equal(await detail.locator('details.research-recorded-finding[open]').count(),0); - await detail.locator('details.research-recorded-finding').filter({hasText:'Synthetic negative'}).locator('summary').first().click(); + assert.equal(await detail.locator('.research-recorded-finding').count(),1,'A question reads one complete finding at a time'); + await detail.getByRole('combobox',{name:'选择问题结论',exact:true}).selectOption('negative'); await detail.getByText('实际执行与资源观测', {exact:true}).click(); assert(await detail.getByText('scheduler → mask-selection', {exact:true}).isVisible()); assert(await detail.getByText('未测量', {exact:true}).isVisible(), 'Unknown iterations must not become zero'); diff --git a/examples/personal-workspace-browser/research-measurements.mjs b/examples/personal-workspace-browser/research-measurements.mjs index f645306f44..dda2f0ed26 100644 --- a/examples/personal-workspace-browser/research-measurements.mjs +++ b/examples/personal-workspace-browser/research-measurements.mjs @@ -64,7 +64,6 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id':goal,'display_name':'合成 }; let reader = await open("comparison"); const finding = reader.locator(".research-recorded-finding").last(); - await finding.locator(":scope > summary").click(); const measurements = finding.locator(".explore-measurements"); assert.deepEqual(await measurements.locator(".explore-measurement-reading strong").allTextContents(), ["7", "10", "0", "未测量"]); assert.equal(await measurements.locator(":scope > details").count(), 1, "Related measurements share one source disclosure"); @@ -74,7 +73,7 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id':goal,'display_name':'合成 await help.focus(); await measurements.getByRole("tooltip").waitFor({state: "visible"}); await page.keyboard.press("Escape"); - assert.equal(await finding.evaluate(node => node.open), true, "Help dismissal preserves the selected finding"); + assert.equal(await reader.getByRole("combobox", {name: "选择问题结论", exact: true}).inputValue(), "finding-1", "Help dismissal preserves the selected finding"); await page.screenshot({path: resolve(outputDir, "research-measurements-desktop.png"), animations: "disabled"}); for (const width of [1440, 390]) { @@ -95,7 +94,7 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id':goal,'display_name':'合成 await measurements.getByRole("tooltip").waitFor({state: "visible"}); await source.locator(":scope > summary").tap(); await measurements.getByRole("tooltip").waitFor({state: "hidden"}); - assert.equal(await finding.evaluate(node => node.open), true, "Touch help dismissal preserves the reading context"); + assert.equal(await reader.getByRole("combobox", {name: "选择问题结论", exact: true}).inputValue(), "finding-1", "Touch help dismissal preserves the reading context"); assert.equal(await source.evaluate(node => node.open), true, "The outside source action still runs"); await source.locator(":scope > summary").tap(); await page.screenshot({path: resolve(outputDir, "research-measurements-phone.png"), animations: "disabled"}); diff --git a/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs b/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs index de9769327b..545859d159 100644 --- a/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs @@ -112,10 +112,15 @@ try { assert.equal(await page.locator(".research-history-edges > g > path").count(),2); await page.locator(".research-history-node").filter({hasText:"Retained route 0"}).click(); assert.equal(new URL(page.url()).searchParams.get("research_node"),"route0"); + // Even an unavailable finding in a bookmarked URL belongs to this source. + const findingLink = new URL(page.url()); findingLink.searchParams.set("research_finding", "source-local-finding"); + await page.goto(findingLink.href); + await page.locator(".research-history-detail").waitFor(); await page.getByLabel("选择控制面来源", {exact:true}).click(); await page.getByRole("option", {name:"Fresh",exact:true}).click(); await page.getByRole("heading",{name:"Fresh research",exact:true}).waitFor(); assert.equal(new URL(page.url()).searchParams.get("research_node"),null,"a source switch must not rebind the same node id in another physical source"); + assert.equal(new URL(page.url()).searchParams.get("research_finding"),null,"a source switch clears finding focus alongside node focus"); await page.locator(".research-history-coverage").filter({hasText:"92 / 92"}).waitFor(); await page.getByLabel("研究历程", {exact:true}).getByRole("button", {name:"关系图",exact:true}).click(); await page.locator(".research-history-node").filter({hasText:"Fresh route 0"}).waitFor(); @@ -125,6 +130,7 @@ try { await page.goBack(); await page.getByRole("heading",{name:"Retained research",exact:true}).waitFor(); assert.equal(new URL(page.url()).searchParams.get("research_node"),"route0","Back restores the original source-local selection"); + assert.equal(new URL(page.url()).searchParams.get("research_finding"),"source-local-finding"); await page.goForward(); await page.getByRole("heading",{name:"Fresh research",exact:true}).waitFor(); await page.reload();