From b22308096f26ad9dc5f8812b2caa115665b6e903 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:03:35 +0800 Subject: [PATCH 1/3] Highlight research items with records in the selected period Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../goal-research-timeline.tsx | 12 +++++----- .../research-history.mjs | 15 +++++++++++++ .../presentation/explore_timeline.ts | 22 +++++++++++++++---- .../control_plane_ts/explore_history.test.ts | 4 ++++ 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx index 2d079e122f..370599cbf0 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx @@ -35,7 +35,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId }, [timeline.start, timeline.end]); const findingCopy = exploreFindingCopy(zh); const copy: Copy = zh ? { - early: "前段", middle: "中段", late: "后段", item: "研究项", items: "研究项", question: "个问题", questions: "个问题", + early: "前段", middle: "中段", late: "后段", item: "个有记录的研究项", items: "个有记录的研究项", question: "个问题", questions: "个问题", decisions: "显式决策", none: "未记录显式决策", changed: "变化", stopped: "停止", retained: "保留", unstructured: "决策未结构化", unstructuredHint: "结论未按 writeback 结构记录,无法拆出依据与决策。", density: "记录密度", densityHint: "柱高为事件数;浅红为无记录空档", ticks: "转向 / 继续", @@ -46,7 +46,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId noTime: "没有可用的记录时间,无法放上时间轴;下方记录仍可完整阅读。", whole: "全程", } : { - early: "Early", middle: "Middle", late: "Late", item: "Research item", items: "Research items", question: "question", questions: "questions", + early: "Early", middle: "Middle", late: "Late", item: "research item with records", items: "research items with records", question: "question", questions: "questions", decisions: "Explicit decisions", none: "No explicit decision recorded", changed: "Changed", stopped: "Stopped", retained: "Retained", unstructured: "Unstructured decisions", unstructuredHint: "Findings were not recorded in writeback sections; evidence and decisions cannot be separated.", density: "Recording density", densityHint: "Bar height is events; pale red is a recording gap", ticks: "Replan / continue", @@ -62,6 +62,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId const span = Math.max(1, end - start); const pct = (at: number) => `${((at - start) / span) * 100}%`; const inPhase = (at: number | null) => phase === null || exploreTimelinePhaseAt(timeline.phases, at) === phase; + const nodeInPhase = (item: TimelineNode) => phase === null || item.recordedPhases.has(phase); const label = (key: string) => copy[key] ?? key; const hours = (from: number, to: number) => { const value = (to - from) / 3_600_000; return value >= 48 ? `${(value / 24).toFixed(1)} ${zh ? "天" : "d"}` : `${value.toFixed(1)} ${zh ? "小时" : "h"}`; }; const maxEvents = Math.max(1, ...timeline.bins.map(bin => bin.events)); @@ -73,7 +74,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId const marker = (item: TimelineNode, style: CSSProperties, extra = "") => { const title = `${item.node.latest.title} · ${label(item.label || "unlabeled")}${item.first !== null ? ` · ${timeLabel(item.first)}` : ""}`; return ; }); const trail = groupExploreTimelineDecisions(timeline, phase); @@ -124,6 +125,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId

{findingCopy.meaning}

{!timeline.structuredCount ?

{copy.unstructuredHint}

: null}

{copy.routeHint}{zh ? "。" : ". "}{zh ? "相邻点合拢,数字为研究项数,一次展开原始时间与依据。" : "Nearby points group; the number counts research items. Select once for original times and sources."}

+

{zh ? "选中时段后,高亮其中有记录的研究项。各项位置仍按首次记录时间。" : "Selecting a period highlights items with records in it. Each item stays at its first recorded time."}

{zh ? "未关联问题按首次记录 UTC 日期收纳,不推断路线或因果。决策轨迹按研究问题归组,组内保留各轮显式决策,最近记录在前;同名问题不会自动合并。" : "Unlinked questions group by first-record UTC date, without inferring a route or cause. Decision history groups explicit decisions by question, newest record first; matching titles do not merge questions."}

{timeline.phases.map(item =>
{phaseName(item)}
{phaseDescription(item)}
)}
{phase !== null ? : null} @@ -188,7 +190,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId {dayGroups.length ? <>
{copy.singles} · {groupedIds.size}
{dayGroups.map(group => )}
: null} {swarm.length ? <> diff --git a/examples/personal-workspace-browser/research-history.mjs b/examples/personal-workspace-browser/research-history.mjs index bbaaac230a..9d9914bae6 100644 --- a/examples/personal-workspace-browser/research-history.mjs +++ b/examples/personal-workspace-browser/research-history.mjs @@ -170,6 +170,20 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id': goal, 'display_name': 'Synth 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: '24 / 24'}).waitFor(); + const readLatePhase = async (locale, size) => { + const late = history.getByRole('button', {name: locale === 'zh-CN' ? /^后段 ·/ : /^Late ·/}); + await late.focus(); + await late.press('Enter'); + assert.equal(await late.getAttribute('aria-pressed'), 'true'); + await page.screenshot({path: resolve(outputDir, 'recorded-phase-' + size + '-' + locale + '.png'), animations: 'disabled'}); + assert.equal(await history.locator('[data-research-nodes~="question-2"]:not(.is-dimmed)').count(), 1, + 'A question with a late record stays highlighted at its earlier first record'); + assert.equal(await history.locator('[data-research-nodes~="question-3"].is-dimmed').count(), 1, + 'A question with no records in the selected phase stays dimmed'); + assert.match(await late.getAttribute('aria-label'), locale === 'zh-CN' ? /2 个有记录的研究项/ : /2 research items with records/); + await late.press('Enter'); + }; + await readLatePhase('zh-CN', 'desktop'); const trail = history.locator('.research-trail'); const group = trail.locator('.research-trail-question').first(); const assertReturn = async control => { @@ -198,6 +212,7 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id': goal, 'display_name': 'Synth await page.evaluate(value => localStorage.setItem('loopx-pw-locale', value), locale); await page.reload({waitUntil: 'domcontentloaded'}); await history.getByRole('status').filter({hasText: '24 / 24'}).waitFor(); + await readLatePhase(locale, 'phone'); 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(); diff --git a/loopx/control_plane/presentation/explore_timeline.ts b/loopx/control_plane/presentation/explore_timeline.ts index 8a2d651299..055ff4cc29 100644 --- a/loopx/control_plane/presentation/explore_timeline.ts +++ b/loopx/control_plane/presentation/explore_timeline.ts @@ -52,7 +52,7 @@ export type TimelineFinding = {event: ExploreHistoryEvent; at: number | null; se export type TimelineBin = {start: number; end: number; events: number; nodes: number; labels: Record; decisions: Record}; export type TimelinePhase = {index: number; start: number; end: number; nodes: number; findings: number; labels: Record; decisions: Record; lastDecision: TimelineFinding | null}; export type TimelineRoute = {id: string; nodeIds: string[]; first: number | null; last: number | null}; -export type TimelineNode = {node: ExploreHistoryNode; first: number | null; last: number | null; label: string; routeId: string | null}; +export type TimelineNode = {node: ExploreHistoryNode; first: number | null; last: number | null; label: string; routeId: string | null; recordedPhases: Set}; export type ExploreTimeline = { start: number | null; end: number | null; bins: TimelineBin[]; phases: TimelinePhase[]; @@ -138,7 +138,7 @@ export function buildExploreTimeline(nodes: readonly ExploreHistoryNode[], edges for (const node of nodes) { const stamps = [...node.updates, ...node.findings].map(event => time(event.recorded_at)).filter((at): at is number => at !== null); const label = node.findings.at(-1)?.status ?? ""; - timelineNodes.set(node.id, {node, first: stamps.length ? Math.min(...stamps) : null, last: stamps.length ? Math.max(...stamps) : null, label, routeId: null}); + timelineNodes.set(node.id, {node, first: stamps.length ? Math.min(...stamps) : null, last: stamps.length ? Math.max(...stamps) : null, label, routeId: null, recordedPhases: new Set()}); } const routes: TimelineRoute[] = []; const singles: string[] = []; @@ -162,8 +162,22 @@ export function buildExploreTimeline(nodes: readonly ExploreHistoryNode[], edges const phaseCount = span >= MIN_THIRDS_SPAN_MS ? 3 : 1; const phases: TimelinePhase[] = span ? Array.from({length: phaseCount}, (_, index) => ({index, start: start! + span * index / phaseCount, end: start! + span * (index + 1) / phaseCount, nodes: 0, findings: 0, labels: {}, decisions: {}, lastDecision: null})) : []; const slot = (items: T[], at: number) => items[Math.min(items.length - 1, Math.floor((at - start!) / span * items.length))]!; - for (const event of events) { const at = time(event.recorded_at); if (at !== null && bins.length) slot(bins, at).events++; } - for (const item of timelineNodes.values()) if (item.first !== null && bins.length) { slot(bins, item.first).nodes++; slot(phases, item.first).nodes++; } + for (const event of events) { + const at = time(event.recorded_at); + if (at === null || !bins.length) continue; + slot(bins, at).events++; + // Count each recorded item once per phase, including later findings on + // earlier items. Edges alone do not make an item active in that phase. + const node = event.event_kind === "node" ? timelineNodes.get(event.result_id) + : event.event_kind === "finding" ? timelineNodes.get(event.node_id) : undefined; + if (!node) continue; + const phase = slot(phases, at); + if (!node.recordedPhases.has(phase.index)) { + node.recordedPhases.add(phase.index); + phase.nodes++; + } + } + for (const item of timelineNodes.values()) if (item.first !== null && bins.length) slot(bins, item.first).nodes++; for (const item of findings) { if (item.at === null || !bins.length) continue; const bin = slot(bins, item.at), phase = slot(phases, item.at); diff --git a/tests/control_plane_ts/explore_history.test.ts b/tests/control_plane_ts/explore_history.test.ts index afd50c6416..a41d305b7c 100644 --- a/tests/control_plane_ts/explore_history.test.ts +++ b/tests/control_plane_ts/explore_history.test.ts @@ -157,6 +157,10 @@ test("phase reading retains successive decisions for one question, including a l const view = projectExploreHistory(rows, goal), timeline = buildExploreTimeline(view.nodes, view.edges, view.events); assert.deepEqual(selectExploreTimelineDecisions(timeline, 0).map(item => item.event.event_id), ["early-stop", "backfill", "early"]); assert.deepEqual(selectExploreTimelineDecisions(timeline, null).map(item => item.sections!.decision!.outcome), ["wait", "stop", "no_change", "continue"]); + assert.deepEqual(timeline.phases.map(phase => phase.nodes), [1, 0, 1], "A late finding keeps the original question active without adding a new one"); + const original = timeline.nodes.get("route")!; + assert.deepEqual([...original.recordedPhases], [0, 2]); + assert.equal(original.first, timeline.start, "Later activity does not move the question on the axis"); assert.equal(timeline.phases[0]!.lastDecision!.event.event_id, "early-stop"); assert.equal(exploreTimelinePhaseAt(timeline.phases, timeline.phases[1]!.start), 1, "a boundary belongs to one reading group"); assert.equal(exploreTimelinePhaseAt(timeline.phases, timeline.end), 2); From 372b90510189029aa07e8c6ff8e9d0ed3e99bf4d Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:40:52 +0800 Subject: [PATCH 2/3] Open findings from the selected research period Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../personal-workspace/goal-research-history.tsx | 16 ++++++++++------ docs/development/design.md | 3 +++ .../research-history.mjs | 13 +++++++++++++ .../presentation/explore_timeline.ts | 15 +++++++++++++++ tests/control_plane_ts/explore_history.test.ts | 5 ++++- 5 files changed, 45 insertions(+), 7 deletions(-) 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 6991d16bf6..dda9ff3132 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 @@ -5,13 +5,13 @@ import {Fragment, useEffect, useId, useLayoutEffect, useMemo, useRef, useState} import {defaultParseSearch} from "@tanstack/react-router"; import {ResearchRecordSearch} from "./research-record-browser"; import {ResearchAnalysisButton, ResearchAnalysisPanel, ResearchAnalysisStatus, ResearchCitationPanel, ResearchProgressSummary, ResearchReportSummary, ResearchCaseReading, useResearchAnalysis} from "./goal-research-analysis"; -import {buildExploreTimeline, parseExploreFindingSections, type TimelineFinding} from "../../../../../../loopx/control_plane/presentation/explore_timeline.js"; +import {buildExploreTimeline, latestExploreFindingInPhase, parseExploreFindingSections, type TimelineFinding} from "../../../../../../loopx/control_plane/presentation/explore_timeline.js"; import {GoalResearchTimeline} from "./goal-research-timeline"; import {requestJson} from "../../data/chat"; import {sourceReadApiUrl} from "../../data/local-status-query"; import {ContextHelp} from "./context-help"; import type {WorkspaceGoal} from "./personal-workspace-model"; -import {groupUnlinkedHistoryQuestions, layoutExploreHistory, mergeExploreHistoryEvents, parseExploreHistoryPage, projectExploreHistory, type ExploreHistoryEvent} from "../../../../../../loopx/control_plane/presentation/explore_history.js"; +import {groupUnlinkedHistoryQuestions, layoutExploreHistory, mergeExploreHistoryEvents, parseExploreHistoryPage, projectExploreHistory, type ExploreHistoryEvent, type ExploreHistoryNode} from "../../../../../../loopx/control_plane/presentation/explore_history.js"; import "./goal-research-history.css"; import {ExploreMeasurements, formatExploreMeasurementValue} from "./explore-measurements"; import {projectTrajectoryDisplay, trajectoryDisplaySnapshotText} from "../../../../../../loopx/control_plane/capabilities/trajectory_assessment_display.js"; @@ -280,8 +280,12 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist 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 defaultFinding = (node: ExploreHistoryNode) => ( + lens === "timeline" && phase !== null && timeline ? latestExploreFindingInPhase(timeline, node.id, phase) : undefined + ) ?? node.findings.at(-1); + const selectedFinding = selected && (selection.finding + ? selected.findings.find(finding => finding.result_id === selection.finding) + : defaultFinding(selected)); const hasSeparateQuestion = Boolean(selected?.findings.length && selectedFinding?.title !== selected.latest.title); 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); @@ -372,8 +376,8 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist collectionOpener.current = null; select(collection ?? {nodes: [], day: null}, false); }; - const chooseQuestion = (node: NonNullable["nodes"][number], parent: CollectionSelection) => { - select({nodes: [node.id], day: null, finding: node.findings.at(-1)?.result_id, collection: parent}); + const chooseQuestion = (node: ExploreHistoryNode, parent: CollectionSelection) => { + select({nodes: [node.id], day: null, finding: defaultFinding(node)?.result_id, collection: parent}); }; const resultsAnchor = useRef(null), reportAnchor = useRef(null), historyAnchor = useRef(null); const reportEntries = useRef(new Set()); diff --git a/docs/development/design.md b/docs/development/design.md index 6d50d68d3c..0ca5fbd5e7 100644 --- a/docs/development/design.md +++ b/docs/development/design.md @@ -221,6 +221,9 @@ with their outcome, change and recorded status; retain stopped/retained evidence and expose earlier records in place. Counts and date spans describe the selected time range. Keep negative and tentative record counts visible when older rows are folded, and open each row's exact finding in the existing reader. +When opening a question from a selected timeline period, prefer its last +readable finding recorded in that period. An explicitly chosen finding, the +other findings and revisions in source history stay readable. In the loaded-record list, a node and its unambiguously linked finding share an entry after a complete read. Show the finding status beside the node's lifecycle diff --git a/examples/personal-workspace-browser/research-history.mjs b/examples/personal-workspace-browser/research-history.mjs index 9d9914bae6..b5519f34b4 100644 --- a/examples/personal-workspace-browser/research-history.mjs +++ b/examples/personal-workspace-browser/research-history.mjs @@ -184,6 +184,19 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id': goal, 'display_name': 'Synth await late.press('Enter'); }; await readLatePhase('zh-CN', 'desktop'); + const middle = history.getByRole('button', {name: /^中段 ·/}); + await middle.press('Enter'); + await history.locator('.research-timeline [data-research-nodes~="question-1"]').click(); + const phaseDetail = history.locator('.research-history-detail'); + await phaseDetail.locator('[data-research-node="question-1"] > button').click(); + const phaseFinding = phaseDetail.getByRole('combobox', {name: '选择问题结论'}); + assert.equal(await phaseFinding.inputValue(), 'decision-1-2', 'Opening a highlighted question starts with the last finding recorded in the selected period'); + await page.screenshot({path: resolve(outputDir, 'recorded-phase-finding-desktop.png'), animations: 'disabled'}); + await phaseFinding.selectOption('decision-1-4'); + assert.equal(new URL(page.url()).searchParams.get('research_finding'), 'decision-1-4', 'An explicit finding outside the selected period remains readable'); + await phaseDetail.press('Escape'); + await phaseDetail.press('Escape'); + await middle.press('Enter'); const trail = history.locator('.research-trail'); const group = trail.locator('.research-trail-question').first(); const assertReturn = async control => { diff --git a/loopx/control_plane/presentation/explore_timeline.ts b/loopx/control_plane/presentation/explore_timeline.ts index 055ff4cc29..de234dc2c6 100644 --- a/loopx/control_plane/presentation/explore_timeline.ts +++ b/loopx/control_plane/presentation/explore_timeline.ts @@ -89,6 +89,21 @@ export function exploreTimelinePhaseAt(phases: readonly TimelinePhase[], at: num return index < 0 ? null : index; } +/** Choose from the question's readable findings. Superseded revisions remain + * in source history; a later backfill does not become the latest record time. */ +export function latestExploreFindingInPhase(timeline: ExploreTimeline, nodeId: string, phase: number): ExploreHistoryEvent | undefined { + let latest: ExploreHistoryEvent | undefined; + let latestAt = -Infinity; + for (const finding of timeline.nodes.get(nodeId)?.node.findings ?? []) { + const at = time(finding.recorded_at); + if (at !== null && at >= latestAt && exploreTimelinePhaseAt(timeline.phases, at) === phase) { + latest = finding; + latestAt = at; + } + } + return latest; +} + /** Keep every recorded decision, including repeated attempts and historical backfills. */ export function selectExploreTimelineDecisions(timeline: ExploreTimeline, phase: number | null): TimelineFinding[] { return [...timeline.decisions].reverse() diff --git a/tests/control_plane_ts/explore_history.test.ts b/tests/control_plane_ts/explore_history.test.ts index a41d305b7c..2c8f69c8ab 100644 --- a/tests/control_plane_ts/explore_history.test.ts +++ b/tests/control_plane_ts/explore_history.test.ts @@ -148,7 +148,7 @@ test("short record span collapses thirds into one whole-history reading group", }); test("phase reading retains successive decisions for one question, including a late backfill", async () => { - const {buildExploreTimeline, exploreTimelinePhaseAt, selectExploreTimelineDecisions} = await import("../../loopx/control_plane/presentation/explore_timeline.ts"); + const {buildExploreTimeline, exploreTimelinePhaseAt, latestExploreFindingInPhase, selectExploreTimelineDecisions} = await import("../../loopx/control_plane/presentation/explore_timeline.ts"); const finding = (id: string, at: string, outcome: string) => event({event_id: id, event_kind: "finding", result_id: id, node_id: "route", recorded_at: at, status: "tentative", summary: `Input revision: v1 Applicability: this input Observation: measured Interpretation: Outcome: ${outcome} changed: - ${id}`}); const rows = [event(), finding("early", "2026-01-01T01:00:00Z", "continue"), @@ -165,6 +165,9 @@ test("phase reading retains successive decisions for one question, including a l assert.equal(exploreTimelinePhaseAt(timeline.phases, timeline.phases[1]!.start), 1, "a boundary belongs to one reading group"); assert.equal(exploreTimelinePhaseAt(timeline.phases, timeline.end), 2); assert.equal(exploreTimelinePhaseAt(timeline.phases, null), null, "missing record time remains unknown"); + assert.equal(latestExploreFindingInPhase(timeline, "route", 0)?.event_id, "early-stop", "The default is the last recorded finding in this period, not the last append"); + assert.equal(latestExploreFindingInPhase(timeline, "route", 1), undefined, "Do not infer a finding for an empty period"); + assert.equal(latestExploreFindingInPhase(timeline, "route", 2)?.event_id, "late", "The last endpoint remains included"); assert.equal(view.nodes[0]!.latest.status, "open", "display-time sorting cannot rewrite canonical node state"); }); From 7a66d1c50f2c5e77dd0ebfd2e302c251527cbeb4 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:38:59 +0800 Subject: [PATCH 3/3] Preserve original research finding versions across period reading Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../goal-research-history.tsx | 44 +++++---- .../goal-research-timeline.tsx | 8 +- apps/presentation/dashboard/src/router.tsx | 1 + .../dashboard/src/views/dashboard-page.tsx | 2 +- docs/development/design.md | 4 +- .../research-finding-reader.mjs | 30 +++--- .../research-history.mjs | 95 ++++++++++++++++++- .../presentation/explore_timeline.ts | 8 +- .../control_plane_ts/explore_history.test.ts | 14 +++ 9 files changed, 161 insertions(+), 45 deletions(-) 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 88bfca2b83..d3e644f892 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 @@ -17,7 +17,7 @@ import {ExploreMeasurements, formatExploreMeasurementValue} from "./explore-meas import {projectTrajectoryDisplay, trajectoryDisplaySnapshotText} from "../../../../../../loopx/control_plane/capabilities/trajectory_assessment_display.js"; type CollectionSelection = {nodes: string[]; day: string | null}; -type Selection = CollectionSelection & {finding?: string; collection?: CollectionSelection; question?: boolean; returnKey?: string}; +type Selection = CollectionSelection & {finding?: string; event?: string; collection?: CollectionSelection; question?: boolean; returnKey?: string}; type Lens = "timeline" | "graph"; type Read = {events: ExploreHistoryEvent[]; total: number | null; complete: boolean}; const selectedFromUrl = (goal: string): Selection => { @@ -37,7 +37,7 @@ const selectedFromUrl = (goal: string): Selection => { const collection = nodes.length === 1 && (parentDay !== null || (parentNodes.length > 1 && parentNodes.includes(nodes[0]!))) ? {nodes: parentDay === null ? parentNodes : [], day: parentDay} : undefined; const finding = query.get("research_finding") || undefined; - return {nodes, day, finding, collection, question: query.get("research_question") === "1" || !finding, + return {nodes, day, finding, event: query.get("research_event") || undefined, collection, question: query.get("research_question") === "1" || !finding, returnKey: query.get("research_return") || undefined}; }; @@ -282,11 +282,13 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist 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 findingVersions = value?.events.filter(event => event.event_kind === "finding" && event.node_id === selectedId) ?? []; const defaultFinding = (node: ExploreHistoryNode) => ( lens === "timeline" && phase !== null && timeline ? latestExploreFindingInPhase(timeline, node.id, phase) : undefined ) ?? node.findings.at(-1); - const selectedFinding = selected && (selection.finding - ? selected.findings.find(finding => finding.result_id === selection.finding) + const selectedFinding = selected && (selection.event + ? findingVersions.find(finding => finding.event_id === selection.event && (!selection.finding || finding.result_id === selection.finding)) + : selection.finding ? selected.findings.find(finding => finding.result_id === selection.finding) : defaultFinding(selected)); const hasSeparateQuestion = Boolean(selected?.findings.length && selectedFinding?.title !== selected.latest.title); const selectedGroup = selection.day !== null ? grouped.groups.find(group => group.day === selection.day) : undefined; @@ -354,12 +356,14 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist setSelection(next); const url = new URL(window.location.href); url.searchParams.delete("research_node"); url.searchParams.delete("research_group"); url.searchParams.delete("research_finding"); + url.searchParams.delete("research_event"); url.searchParams.delete("research_parent_node"); url.searchParams.delete("research_parent_group"); url.searchParams.delete("research_question"); url.searchParams.delete("research_return"); if (next.nodes.length || next.day !== null) { if (next.day !== null) url.searchParams.set("research_group", next.day); else url.searchParams.set("research_node", next.nodes.length === 1 ? next.nodes[0]! : JSON.stringify(next.nodes)); if (next.finding) url.searchParams.set("research_finding", next.finding); + if (next.event) url.searchParams.set("research_event", next.event); if (next.question) url.searchParams.set("research_question", "1"); if (next.returnKey) url.searchParams.set("research_return", next.returnKey); if (next.collection?.day !== null && next.collection?.day !== undefined) url.searchParams.set("research_parent_group", next.collection.day); @@ -367,9 +371,9 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist url.searchParams.set("research_goal", goalId); } else url.searchParams.set("research_goal", goalId); - if (selection.finding !== next.finding || selection.question !== next.question || selection.returnKey !== next.returnKey || selection.day !== next.day || selection.nodes.length !== next.nodes.length || selection.nodes.some((id, index) => id !== next.nodes[index])) window.history.pushState(null, "", url); + if (selection.finding !== next.finding || selection.event !== next.event || selection.question !== next.question || selection.returnKey !== next.returnKey || selection.day !== next.day || selection.nodes.length !== next.nodes.length || selection.nodes.some((id, index) => id !== next.nodes[index])) window.history.pushState(null, "", url); }; - const choose = (id: string, focus = true, finding?: string, returnKey?: string) => {collectionOpener.current = null; select({nodes: id ? [id] : [], day: null, finding, returnKey}, focus);}; + const choose = (id: string, focus = true, finding?: string, returnKey?: string, event?: string) => {collectionOpener.current = null; select({nodes: id ? [id] : [], day: null, finding, event, returnKey}, focus);}; const closeReader = () => { focusPending.current = false; focusReturn.current = {nodeId: collection ? selectedId : undefined, eventId: selectedFinding?.event_id, @@ -379,7 +383,8 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist select(collection ?? {nodes: [], day: null}, false); }; const chooseQuestion = (node: ExploreHistoryNode, parent: CollectionSelection) => { - select({nodes: [node.id], day: null, finding: defaultFinding(node)?.result_id, collection: parent}); + const finding = defaultFinding(node); + select({nodes: [node.id], day: null, finding: finding?.result_id, event: finding?.event_id, collection: parent}); }; const resultsAnchor = useRef(null), reportAnchor = useRef(null), historyAnchor = useRef(null); const reportEntries = useRef(new Set()); @@ -410,14 +415,16 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist // Pin the first displayed finding to its stable identity. New source records // must not move a reader, and reload/Back must restore that same evidence. useEffect(() => { - if (!active || selection.finding || !selectedFinding || !selected) return; + if (!active || selection.event || !selectedFinding || !selected) return; const finding = selectedFinding.result_id; - setSelection(previous => ({...previous, finding})); + const event = selectedFinding.event_id; + setSelection(previous => ({...previous, finding, event})); const url = new URL(window.location.href); url.searchParams.set("research_finding", finding); + url.searchParams.set("research_event", event); if (questionExpanded) url.searchParams.set("research_question", "1"); window.history.replaceState(null, "", url); - }, [active, selection.finding, selectedFinding, selected, questionExpanded]); + }, [active, selection.event, selectedFinding, selected, questionExpanded]); const displayControl = display ?
; })} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx index 370599cbf0..6f41a3173d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-timeline.tsx @@ -13,7 +13,7 @@ type Copy = Record; type Props = { timeline: ExploreTimeline; zh: boolean; selectedIds: readonly string[]; selectedEventId?: string; phase: number | null; selectedDay: string | null; dayGroups: ReturnType["groups"]; onGroupSelect: (day: string, button: HTMLButtonElement) => void; - onSelect: (id: string, finding?: string) => void; onSelectMany: (ids: string[], button: HTMLButtonElement) => void; onPhase: (index: number | null) => void; + onSelect: (id: string, finding?: string, event?: string) => void; onSelectMany: (ids: string[], button: HTMLButtonElement) => void; onPhase: (index: number | null) => void; timeLabel: (value: number) => string; dayLabel: (value: number) => string; }; @@ -215,7 +215,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId function TrailList({groups, copy, label, timeLabel, selectedEventId, onSelect}: { groups: ReturnType; copy: Copy; label: (key: string) => string; - timeLabel: (value: number) => string; selectedEventId?: string; onSelect: (id: string, finding?: string) => void; + timeLabel: (value: number) => string; selectedEventId?: string; onSelect: Props["onSelect"]; }) { const [limit, setLimit] = useState(3); const selectedIndex = groups.findIndex(group => group.items.some(item => item.event.event_id === selectedEventId)); @@ -232,7 +232,7 @@ function TrailList({groups, copy, label, timeLabel, selectedEventId, onSelect}: function TrailQuestion({group, copy, label, timeLabel, selectedEventId, onSelect}: { group: ReturnType[number]; copy: Copy; label: (key: string) => string; - timeLabel: (value: number) => string; selectedEventId?: string; onSelect: (id: string, finding?: string) => void; + timeLabel: (value: number) => string; selectedEventId?: string; onSelect: Props["onSelect"]; }) { const [limit, setLimit] = useState(3); const selectedIndex = group.items.findIndex(item => item.event.event_id === selectedEventId); @@ -259,7 +259,7 @@ function TrailQuestion({group, copy, label, timeLabel, selectedEventId, onSelect