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 58702688d6..cc02cde652 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
@@ -190,14 +190,21 @@
.research-trail { border-top: 1px solid var(--pw-line); padding-top: 20px; }
.research-trail h3 { display: flex; align-items: baseline; gap: 10px; margin: 0 0 12px; font-size: var(--text-lead); font-weight: 600; }
.research-trail h3 small { color: var(--pw-muted); font-size: var(--text-caption); font-weight: 400; }
-.research-trail-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 0; padding: 0; list-style: none; }
-.research-history .research-trail-list button { display: flex; flex-direction: column; align-items: stretch; gap: 8px; width: 100%; height: 100%; padding: 16px; border: 1px solid var(--pw-line); border-radius: 10px; background: var(--pw-bg); color: var(--pw-text); font: inherit; font-size: var(--text-caption); line-height: var(--leading-prose); text-align: left; cursor: pointer; transition: border-color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard); }
-.research-history .research-trail-list button:hover { border-color: var(--pw-line-strong); background: var(--pw-hover); }
-.research-history .research-trail-list button.is-selected { border-color: var(--pw-text); outline: 1px solid var(--pw-text); }
-.research-trail-list strong { font-size: var(--text-body); line-height: var(--leading-body); font-weight: 600; display: block; overflow-wrap: anywhere; }
-.research-trail-list span:not(.research-trail-meta) { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
+.research-trail-groups { margin: 0; padding: 0; list-style: none; }
+.research-trail-question { padding-block: 20px; border-bottom: 1px solid var(--pw-line); }
+.research-trail-question:first-child { padding-top: 0; }
+.research-trail-question h4 { margin: 0 0 8px; font-size: var(--text-lead); line-height: var(--leading-prose); font-weight: 600; overflow-wrap: anywhere; }
+.research-trail-question > header { margin-bottom: 16px; }
+.research-trail-list { display: grid; gap: 0; margin: 0 0 0 8px; padding: 0 0 0 16px; border-left: 1px solid var(--pw-line); list-style: none; }
+.research-trail-list > li { position: relative; min-width: 0; }
+.research-trail-list > li::before { content: ""; position: absolute; top: 23px; left: -20px; width: 7px; height: 7px; border-radius: 50%; background: var(--pw-muted); box-shadow: 0 0 0 4px var(--pw-bg); }
+.research-history .research-trail-list button { display: flex; flex-direction: column; align-items: stretch; gap: 6px; width: 100%; min-height: 44px; padding: 12px; border: 0; border-radius: 6px; background: none; color: var(--pw-text); font: inherit; font-size: var(--text-body); line-height: var(--leading-prose); text-align: left; cursor: pointer; transition: background-color var(--motion-fast) var(--ease-standard); }
+.research-history .research-trail-list button:hover { background: var(--pw-hover); }
+.research-history .research-trail-list button.is-selected { outline: 1px solid var(--pw-line-strong); }
+.research-trail-change { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
.research-trail-list .is-muted, .research-finding-sections .is-muted { color: var(--pw-muted); }
-.research-trail-meta { display: flex; align-items: center; gap: 8px; color: var(--pw-muted); }
+.research-trail-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; color: var(--pw-muted); font-size: var(--text-caption); }
+.research-trail-question > .research-link { margin: 8px 0 0 36px; min-height: 44px; }
.research-outcome { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: var(--text-caption); font-weight: 600; background: var(--pw-blue-soft); color: var(--pw-blue-ink); }
.research-outcome.is-replan { background: var(--pw-tone); color: var(--pw-orbit); }
.research-outcome.is-stop, .research-outcome.is-ask_human { background: var(--pw-amber-bg); color: var(--pw-text); }
@@ -216,8 +223,7 @@
.research-tick-key.is-stop, .research-tick-key.is-wait, .research-tick-key.is-ask_human { background: var(--pw-amber); }
.research-decision-ticks .is-no_change, .research-tick-key.is-no_change { background: var(--pw-muted); }
@media (max-width: 640px) { .research-unlinked-days { grid-template-columns: repeat(2, minmax(0, 1fr)); } .research-route-caption { grid-template-columns: minmax(0, 1fr); } .research-finding-sections { grid-template-columns: minmax(0, 1fr); gap: 4px; } .research-finding-sections dd { margin-bottom: 12px; } }
-@media (max-width: 1200px) { .research-trail-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
-@media (max-width: 900px) { .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 (max-width: 900px) { .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. */
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 ffb4354ba0..9bd6c24157 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
@@ -1,7 +1,7 @@
import {CircleHelp} from "lucide-react";
import {useLayoutEffect, useRef, useState, type CSSProperties} from "react";
-import type {ExploreTimeline, TimelineFinding, TimelineNode, TimelinePhase} from "../../../../../../loopx/control_plane/presentation/explore_timeline.js";
-import {exploreTimelinePhaseAt, groupExploreTimelinePoints, selectExploreTimelineDecisions} from "../../../../../../loopx/control_plane/presentation/explore_timeline.js";
+import type {ExploreTimeline, TimelineNode, TimelinePhase} from "../../../../../../loopx/control_plane/presentation/explore_timeline.js";
+import {exploreTimelinePhaseAt, groupExploreTimelinePoints, groupExploreTimelineDecisions} from "../../../../../../loopx/control_plane/presentation/explore_timeline.js";
import type {groupUnlinkedHistoryQuestions} from "../../../../../../loopx/control_plane/presentation/explore_history.js";
import {ContextHelp} from "./context-help";
@@ -38,7 +38,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedDay, da
unstructured: "决策未结构化", unstructuredHint: "结论未按 writeback 结构记录,无法拆出依据与决策。",
density: "记录密度", densityHint: "柱高为事件数;浅红为无记录空档", ticks: "转向 / 继续",
routes: "研究路线", routeHint: "仅由显式关联或父子分组相连", singles: "未关联研究项",
- trail: "决策轨迹", more: "显示更多决策", all: "全部时段",
+ trail: "决策轨迹", more: "显示更早记录", moreQuestions: "显示更多问题", records: "条决策记录", latestRecord: "最近记录", unknownTime: "时间未知", all: "全部时段",
span: "记录跨度", start: "起点", end: "末端", replan: "转向", continue: "继续", stop: "停止", wait: "等待", no_change: "无变化", ask_human: "请人决定",
confirmed: "来源记为确认", refuted: "已反驳", tentative: "待检查", unlabeled: "无结论", phaseNote: "按记录时间三等分,仅为阅读分组,不是预声明的评估窗口。",
noTime: "没有可用的记录时间,无法放上时间轴;下方记录仍可完整阅读。",
@@ -48,7 +48,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedDay, da
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",
routes: "Research routes", routeHint: "Connected only by recorded relationships or parent grouping", singles: "Unlinked research items",
- trail: "Decision history", more: "Show more decisions", all: "Whole history",
+ trail: "Decision history", more: "Show earlier records", moreQuestions: "Show more questions", records: "decision records", latestRecord: "Most recent record", unknownTime: "Unknown time", all: "Whole history",
span: "Recorded span", start: "Start", end: "End", replan: "Replan", continue: "Continue", stop: "Stop", wait: "Wait", no_change: "No change", ask_human: "Ask human",
confirmed: "Recorded confirmation", refuted: "Refuted", tentative: "Tentative", unlabeled: "No finding", phaseNote: "Equal thirds of record time, for reading only; not a pre-declared evaluation window.",
noTime: "No usable record time; the history cannot be placed on a time axis. All records remain readable below.",
@@ -82,7 +82,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedDay, da
className={`research-timeline-marker is-cluster${selected ? " is-selected" : ""}${group.items.some(item => inPhase(item.first)) ? "" : " is-dimmed"}`}
style={{left: pct(group.at)}} onClick={() => onSelectMany(ids)}>{ids.length} ;
});
- const trail = selectExploreTimelineDecisions(timeline, phase);
+ const trail = groupExploreTimelineDecisions(timeline, phase);
const decisionOutcomes = [...new Set(timeline.decisions.map(item => item.sections!.decision!.outcome))];
const groupedIds = new Set(dayGroups.flatMap(group => group.nodes.map(node => node.id)));
const swarm = timeline.singles.filter(id => !groupedIds.has(id)).map(id => timeline.nodes.get(id)!).filter(item => item.first !== null);
@@ -112,7 +112,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedDay, da
{copy.phaseNote}
{copy.densityHint}{zh ? "。" : ". "}{zh ? "无记录空档不代表研究停止。" : "A recording gap does not establish that research stopped."}
{!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 ? "未关联问题按首次记录 UTC 日期收纳,不推断路线或因果。决策轨迹保留各轮显式决策,最新记录在前。" : "Unlinked questions group by first-record UTC date, without inferring a route or cause. Decision history retains each explicit decision, newest record first."}
+ {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 ? onPhase(null)}>{copy.all} : null}
{LABELS.map(key => {label(key)} )}
@@ -193,23 +193,57 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedDay, da
{copy.trail}
{!timeline.decisions.length ? {timeline.structuredCount ? copy.none : copy.unstructured}
- : }
+ : }
;
}
-function TrailList({items, copy, label, timeLabel, selectedIds, onSelect}: {items: TimelineFinding[]; copy: Copy; label: (key: string) => string; timeLabel: (value: number) => string; selectedIds: readonly string[]; onSelect: (id: string, finding?: string) => void}) {
+function TrailList({groups, copy, label, timeLabel, selectedIds, onSelect}: {
+ groups: ReturnType; copy: Copy; label: (key: string) => string;
+ timeLabel: (value: number) => string; selectedIds: readonly string[]; onSelect: (id: string, finding?: string) => void;
+}) {
const [limit, setLimit] = useState(3);
return <>
- {items.slice(0, limit).map(item => {
+ {groups.slice(0, limit).map(group => )}
+ {groups.length > limit ? setLimit(value => value + 6)}>{copy.moreQuestions} · {groups.length - limit} : null}
+ >;
+}
+
+function TrailQuestion({group, copy, label, timeLabel, selectedIds, onSelect}: {
+ group: ReturnType[number]; copy: Copy; label: (key: string) => string;
+ timeLabel: (value: number) => string; selectedIds: readonly string[]; onSelect: (id: string, finding?: string) => void;
+}) {
+ const [limit, setLimit] = useState(3);
+ const knownTimes = group.items.flatMap(item => item.at === null ? [] : [item.at]);
+ const exceptions = LABELS.filter(status => status !== "confirmed")
+ .map(status => ({status, count: group.items.filter(item => item.event.status === status).length})).filter(item => item.count);
+ return
+
+ {group.title}
+ {group.items.length} {copy.records}
+ {knownTimes.length ? {timeLabel(knownTimes.at(-1)!)}{knownTimes.length > 1 ? ` — ${timeLabel(knownTimes[0]!)}` : ""} : null}
+ {knownTimes.length < group.items.length ? {copy.unknownTime} · {group.items.length - knownTimes.length} : null}
+ {exceptions.map(({status, count}) => {label(status)} {count} )}
+
+
+ {group.items.slice(0, limit).map((item, index) => {
const decision = item.sections!.decision!;
return
- onSelect(item.event.node_id, item.event.result_id)}>
- {label(decision.outcome)} {item.at !== null ? timeLabel(item.at) : ""} {label(item.event.status)}
- {item.event.title}
- {copy.changed} {decision.changed.join(";") || "—"}
- ;
+ {event.currentTarget.focus({preventScroll: true}); onSelect(item.event.node_id, item.event.result_id);}}>
+ {label(decision.outcome)}
+ {item.at === null ? copy.unknownTime : timeLabel(item.at)}
+ {label(item.event.status)} {index === 0 && item.at !== null ? {copy.latestRecord} : null}
+
+ {decision.changed.length ? {copy.changed} {decision.changed.join(";")} : null}
+ {decision.stopped.length ? {copy.stopped} {decision.stopped.join(";")} : null}
+ {decision.retained.length ? {copy.retained} {decision.retained.join(";")} : null}
+
+ ;
})}
- {items.length > limit ? setLimit(value => value + 6)}>{copy.more} · {items.length - limit} : null}
- >;
+ {group.items.length > limit ? setLimit(value => value + 6)}>{copy.more} · {group.items.length - limit} : null}
+ ;
}
diff --git a/docs/development/assets/decision-history/after-desktop.png b/docs/development/assets/decision-history/after-desktop.png
new file mode 100644
index 0000000000..0d5e993e1e
Binary files /dev/null and b/docs/development/assets/decision-history/after-desktop.png differ
diff --git a/docs/development/assets/decision-history/after-phone.png b/docs/development/assets/decision-history/after-phone.png
new file mode 100644
index 0000000000..7689f5d851
Binary files /dev/null and b/docs/development/assets/decision-history/after-phone.png differ
diff --git a/docs/development/assets/decision-history/before-desktop.png b/docs/development/assets/decision-history/before-desktop.png
new file mode 100644
index 0000000000..af8e386eab
Binary files /dev/null and b/docs/development/assets/decision-history/before-desktop.png differ
diff --git a/docs/development/assets/decision-history/first-viewport.png b/docs/development/assets/decision-history/first-viewport.png
new file mode 100644
index 0000000000..d3f0a40ac5
Binary files /dev/null and b/docs/development/assets/decision-history/first-viewport.png differ
diff --git a/docs/development/assets/decision-history/record-desktop.png b/docs/development/assets/decision-history/record-desktop.png
new file mode 100644
index 0000000000..49425895df
Binary files /dev/null and b/docs/development/assets/decision-history/record-desktop.png differ
diff --git a/docs/development/assets/decision-history/record-phone.png b/docs/development/assets/decision-history/record-phone.png
new file mode 100644
index 0000000000..2f857d411b
Binary files /dev/null and b/docs/development/assets/decision-history/record-phone.png differ
diff --git a/docs/development/design.md b/docs/development/design.md
index 2891fd5e3f..addb43d1c4 100644
--- a/docs/development/design.md
+++ b/docs/development/design.md
@@ -193,6 +193,14 @@ 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.
+Decision history groups records by their recorded question identity, with the
+question title once above its successive decisions. Matching titles alone do
+not establish shared identity or decision revision. Show recent records first,
+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.
+
Route captions distinguish start and end and wrap as readable entries. If a
long title is clamped in that list, one activation opens its complete original
in the existing reader; keyboard return restores the exact entry. Keep view,
diff --git a/examples/personal-workspace-browser/research-finding-reader.mjs b/examples/personal-workspace-browser/research-finding-reader.mjs
index 003bbc7658..76cefea054 100644
--- a/examples/personal-workspace-browser/research-finding-reader.mjs
+++ b/examples/personal-workspace-browser/research-finding-reader.mjs
@@ -20,13 +20,20 @@ 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: - 保留负结果与原始版本'
+ outcome='replan' if index%3==0 else 'wait' if index%3==2 else 'continue'
+ change=f'第 {index+1} 轮:'+['核对相同输入下的成本,补齐缺失观测后再判断收益。', '保留已确认的质量回退,转向检查输入差异。', '等待独立测量;当前证据仍不足以确定下一步。'][index%3]
+ summary=f'Input revision: input:{"a"*64} Applicability: 固定合成样本;不是生产评测。 Observation: {observation} Interpretation: Outcome: {outcome} changed: - {change} 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'))
+for index in range(4):
+ node=f'independent-{index}'
+ events.append(build_explore_node_event(goal_id=goal,node_id=node,title='合成研究:质量、延迟和未知成本应如何一起判断?',recorded_at='2026-01-01T00:00:00Z'))
+ events.append(build_explore_finding_event(goal_id=goal,node_id=node,finding_id=f'other-{index}',title='同名独立问题的记录',status='refuted',
+ summary='Input revision: synthetic Applicability: independent sample Observation: no valid comparison Interpretation: Outcome: stop stopped: - 当前样本无法支持比较结论',recorded_at=f'2026-01-01T00:{index+1:02}:00Z'))
Path(sys.argv[1]).write_text(json.dumps({'goal_id':goal,'display_name':'合成研究 · 结论阅读','events':events},ensure_ascii=False))
`, path], {cwd: repoRoot});
}
@@ -106,9 +113,15 @@ export const researchFindingReaderScenario = {
// belong to one question. Opening it must not substitute the latest one.
for (const width of [1440, 390]) {
await page.setViewportSize({width, height: 900});
- const decisions = history.locator(".research-trail-list button");
+ const group = history.locator(".research-trail-question").first();
+ assert.equal(await group.locator("h4").innerText(), "合成研究:质量、延迟和未知成本应如何一起判断?");
+ assert.match(await group.locator("header").innerText(), /20 条决策记录.*已反驳 3.*待检查 4/s);
+ const decisions = group.locator(".research-trail-list button");
+ if (width === 1440) assert.equal(await decisions.count(), 3, "A question starts with three records, not repeated title cards");
+ await group.scrollIntoViewIfNeeded();
+ await page.screenshot({path: resolve(outputDir, `research-decision-groups-${width}.png`), animations: "disabled"});
const older = decisions.nth(1);
- assert.match(await older.innerText(), /比较 19:/);
+ assert.match(await older.getAttribute("aria-label"), /比较 19:/);
await older.focus();
await page.keyboard.press("Enter");
await picker.waitFor();
@@ -124,10 +137,12 @@ export const researchFindingReaderScenario = {
assert.equal(await picker.inputValue(), "finding-18", "Reload retains the decision's exact finding");
await reader.press("Escape");
- await history.getByRole("button", {name: /显示更多决策/}).click();
+ await group.getByRole("button", {name: /显示更早记录/}).click();
const expandedCount = await decisions.count();
const earlier = decisions.nth(7);
- assert.match(await earlier.innerText(), /比较 13:/);
+ assert.match(await earlier.getAttribute("aria-label"), /比较 13:/);
+ assert((await group.evaluate(element => element.scrollWidth - element.clientWidth)) <= 1, "Grouped records fit narrow screens");
+ await page.screenshot({path: resolve(outputDir, `research-decision-expanded-${width}.png`), animations: "disabled"});
await earlier.tap();
await picker.waitFor();
assert.equal(await picker.inputValue(), "finding-12");
@@ -142,6 +157,18 @@ export const researchFindingReaderScenario = {
await reader.press("Escape");
}
+ const questionGroups = history.locator(".research-trail-question");
+ assert.equal(await questionGroups.count(), 3, "Default paging counts questions, not raw decisions");
+ assert.equal(await questionGroups.nth(0).locator("h4").innerText(), await questionGroups.nth(1).locator("h4").innerText(),
+ "Identical titles from different question IDs remain separate groups");
+ await history.getByRole("button", {name: /显示更多问题/}).click();
+ assert.equal(await questionGroups.count(), 5, "Every independent question remains reachable");
+ assert.match(await questionGroups.last().innerText(), /1 条决策记录.*已反驳 1.*停止/s);
+ await history.getByRole("button", {name: /^中段 ·/}).click();
+ assert.equal(await questionGroups.count(), 1, "Time filtering applies before grouping");
+ assert.match(await questionGroups.first().locator("header").innerText(), /8 条决策记录/);
+ await history.getByRole("button", {name: "全部时段", exact: true}).click();
+
await page.goto(url.replace("research_node=question", "research_node=blocked"));
await reader.waitFor();
assert.match(await reader.innerText(), /无法读取评测来源,当前结果未知/);
diff --git a/loopx/control_plane/presentation/explore_timeline.ts b/loopx/control_plane/presentation/explore_timeline.ts
index edd7791f28..2ce278af99 100644
--- a/loopx/control_plane/presentation/explore_timeline.ts
+++ b/loopx/control_plane/presentation/explore_timeline.ts
@@ -96,6 +96,24 @@ export function selectExploreTimelineDecisions(timeline: ExploreTimeline, phase:
.sort((a, b) => (b.at ?? -Infinity) - (a.at ?? -Infinity));
}
+/** Question containment only: matching titles never establish a shared decision.
+ * Filter before grouping so counts and records describe the selected time range.
+ * Preserve every event, including revisions, ties and unknown record times. */
+export function groupExploreTimelineDecisions(timeline: ExploreTimeline, phase: number | null) {
+ const groups = new Map();
+ for (const item of selectExploreTimelineDecisions(timeline, phase)) {
+ const nodeId = item.event.node_id;
+ const id = nodeId ? `node:${nodeId}` : `event:${item.event.event_id}`;
+ let group = groups.get(id);
+ if (!group) {
+ group = {id, title: timeline.nodes.get(nodeId)?.node.latest.title || item.event.title, items: []};
+ groups.set(id, group);
+ }
+ group.items.push(item);
+ }
+ return [...groups.values()];
+}
+
/** Routes are connected components of recorded typed edges and parent grouping only. */
function routesOf(nodes: readonly ExploreHistoryNode[], edges: readonly ExploreHistoryEvent[]) {
const known = new Set(nodes.map(node => node.id));
diff --git a/tests/control_plane_ts/explore_history.test.ts b/tests/control_plane_ts/explore_history.test.ts
index ebf549b138..b3139a1afa 100644
--- a/tests/control_plane_ts/explore_history.test.ts
+++ b/tests/control_plane_ts/explore_history.test.ts
@@ -177,3 +177,25 @@ test("three unchanged research checkpoints retain their actual findings and miss
assert.equal(view.edges.length, 0, "the real solver recorded no typed relationships; chronology cannot supply them");
}
});
+
+test("decision history groups by declared question, never title, after time filtering", async () => {
+ const {buildExploreTimeline, groupExploreTimelineDecisions} = await import("../../loopx/control_plane/presentation/explore_timeline.ts");
+ const question = (id: string) => event({event_id: id, result_id: id, title: "Same question"});
+ const decision = (id: string, node: string, at: string, status = "tentative") => event({event_id: id,
+ event_kind: "finding", result_id: id, node_id: node, title: "Same question", status, recorded_at: at,
+ summary: "Input revision: fixed Applicability: synthetic Observation: evidence Interpretation: Outcome: continue changed: - inspect evidence"});
+ const rows = [question("a"), question("b"), decision("old", "a", "2026-01-01T01:00:00Z", "refuted"),
+ decision("other", "b", "2026-01-01T02:00:00Z"), decision("latest", "a", "2026-01-01T20:00:00Z"),
+ decision("backfill", "a", "2026-01-01T03:00:00Z"), decision("unknown", "a", "unknown"),
+ decision("unlinked-one", "", "unknown"), decision("unlinked-two", "", "unknown")];
+ const view = projectExploreHistory(rows, goal), timeline = buildExploreTimeline(view.nodes, view.edges, view.events);
+ const groups = groupExploreTimelineDecisions(timeline, null);
+ assert.deepEqual(groups.map(group => group.items.map(item => item.event.event_id)),
+ [["latest", "backfill", "old", "unknown"], ["other"], ["unlinked-two"], ["unlinked-one"]]);
+ assert.equal(groups[0]!.title, groups[1]!.title, "Identical wording does not merge distinct questions");
+ assert.equal(groups[0]!.items[2]!.event.status, "refuted", "Historical negative evidence survives grouping");
+ assert.deepEqual(groupExploreTimelineDecisions(timeline, 0).map(group => group.items.map(item => item.event.event_id)),
+ [["backfill", "old"], ["other"]], "Filter first; group ordering and counts reflect the selected interval");
+ assert.deepEqual(groupExploreTimelineDecisions(timeline, 1), []);
+ assert.equal(view.nodes.find(node => node.id === "a")!.latest.status, "open", "Presentation never changes the state owner");
+});