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 0c5851f8ab..0b5a88953f 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,19 +5,19 @@ 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"; 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}; }; @@ -285,8 +285,14 @@ 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 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.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; const selectedMembers = selectedGroup?.nodes ?? (selection.nodes.length > 1 ? value?.nodes.filter(node => selection.nodes.includes(node.id)) : undefined); @@ -353,12 +359,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); @@ -366,9 +374,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, @@ -377,8 +385,9 @@ 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) => { + 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()); @@ -409,14 +418,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 c0c2290e7b..610115a35e 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, precise?: boolean) => string; dayLabel: (value: number) => string; }; @@ -51,7 +51,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: "柱高为事件数;浅红为无记录空档", axis: "自首条记录起", @@ -62,7 +62,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", axis: "Since first record", @@ -79,6 +79,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId const span = Math.max(1, elapsed); 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)); @@ -87,7 +88,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); @@ -139,6 +140,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} @@ -206,7 +208,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId {dayGroups.length ? <>
{copy.singles} · {groupedIds.size}
{dayGroups.map(group => )}
: null} {swarm.length ? <> @@ -231,7 +233,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)); @@ -248,7 +250,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); @@ -275,7 +277,7 @@ function TrailQuestion({group, copy, label, timeLabel, selectedEventId, onSelect