Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -279,3 +279,17 @@
.research-history-loading-chart { gap: 3px; padding-inline: 8px; }
}
@media (prefers-reduced-motion: reduce) { .research-history-loading-phases, .research-history-loading-chart { animation: none; } }

.research-record-comparison { margin-block: 16px; border-block: 1px solid var(--pw-line); padding-block: 12px; }
.research-record-comparison > label { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-block: 12px; }
.research-record-comparison select { min-width: 0; max-width: 100%; min-height: 44px; font: inherit; color: var(--pw-text); background: var(--pw-surface, #fff); border: 1px solid var(--pw-line); border-radius: 6px; padding: 8px; }
.research-comparison-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
.research-comparison-pair > div { min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
.research-comparison-pair pre { white-space: pre-wrap; }
.research-comparison-field { border-block-start: 1px solid var(--pw-line); padding-block: 12px; }
.research-comparison-side, .research-comparison-missing { color: var(--pw-muted); }
.research-comparison-side { display: none; }
@media (max-width: 640px) {
.research-comparison-pair { grid-template-columns: 1fr; gap: 16px; }
.research-comparison-side { display: block; margin-block-end: 4px; }
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {ResearchRecordComparison} from "./research-record-comparison";
import {CircleHelp} from "lucide-react";
import {ContextHelp} from "./context-help";
import {useMemo, useState, type ReactNode} from "react";
Expand Down Expand Up @@ -66,6 +67,7 @@ export function ResearchRecordSearch({goalId, events, displayEvents, renderBody,
{event.from_node || event.to_node ? <><dt>{zh ? "显式关联" : "Recorded link"}</dt><dd><code>{event.from_node} → {event.to_node}</code></dd></> : null}
{event.agent_id ? <><dt>Agent ID</dt><dd><code>{event.agent_id}</code></dd></> : null}
</dl>
<ResearchRecordComparison events={events} selected={event} zh={zh} stateLabel={stateLabel} timeLabel={timeLabel}/>
{event.evidence_refs.map(ref => <code key={ref}>{ref}</code>)}
</details>
</li>;})}</ol>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {useState} from "react";
import type {ExploreHistoryEvent} from "../../../../../../loopx/control_plane/presentation/explore_history.ts";
import {changedResearchFields, researchComparisonFields, researchRecordVersions, type ResearchComparisonField} from "./research-record-versions.ts";
import {ExploreMeasurements} from "./explore-measurements";

const labels: Record<ResearchComparisonField, [string, string]> = {
title: ["Title", "标题"], summary: ["Original text", "原文"], status: ["Recorded status", "记录状态"],
blocked_reason: ["Blocked reason", "受阻原因"], evidence_refs: ["Evidence references", "证据引用"],
measurements: ["Measurements", "测量"], confidence: ["Recorded confidence", "记录置信度"],
run_id: ["Run ID", "运行 ID"], agent_id: ["Agent ID", "Agent ID"], node_kind: ["Node kind", "节点类型"],
node_id: ["Node ID", "问题 ID"], parent_id: ["Source group", "来源分组"],
from_node: ["Link source", "关联起点"], to_node: ["Link target", "关联终点"], edge_type: ["Link type", "关联类型"],
supersedes: ["Supersedes", "取代记录"], execution_observation: ["Execution observation", "执行观察"],
};

/** Requested original-source detail; no analysis, acceptance or write path. */
export function ResearchRecordComparison({events, selected, zh, stateLabel, timeLabel}: {
events: readonly ExploreHistoryEvent[]; selected: ExploreHistoryEvent; zh: boolean;
stateLabel: (status: string) => string; timeLabel: (at: string) => string;
}) {
const versions = researchRecordVersions(events, selected);
const index = versions.findIndex(event => event.event_id === selected.event_id);
const alternatives = versions.filter(event => event.event_id !== selected.event_id);
const [chosen, setChosen] = useState("");
const [showAll, setShowAll] = useState(false);
const [open, setOpen] = useState(false);
const comparison = alternatives.find(event => event.event_id === chosen)
?? versions[index - 1] ?? alternatives[0];
if (!comparison) return null;
const changed = changedResearchFields(selected, comparison);
const value = (event: ExploreHistoryEvent, field: ResearchComparisonField) => {
const raw = event[field];
if (raw === undefined || raw === null || raw === "" || (Array.isArray(raw) && !raw.length)) return <span className="research-comparison-missing">{zh ? "未记录" : "Not recorded"}</span>;
if (field === "measurements") return <ExploreMeasurements measurements={event.measurements ?? []} zh={zh}/>;
if (field === "status") return stateLabel(event.status);
if (Array.isArray(raw)) return <ul>{raw.map((item, i) => <li key={i}><code>{String(item)}</code></li>)}</ul>;
if (typeof raw === "object") return <pre>{JSON.stringify(raw, null, 2)}</pre>;
return String(raw);
};
return <details className="research-record-comparison" open={open} onToggle={event => setOpen(event.currentTarget.open)}>
<summary>{zh ? "比较记录版本" : "Compare recorded versions"} · {versions.length}</summary>
{open ? <>
<label>{zh ? "比较对象" : "Compare with"}<select value={comparison.event_id} onChange={event => setChosen(event.target.value)}>
{alternatives.map(event => <option key={event.event_id} value={event.event_id}>{timeLabel(event.recorded_at)} · {event.title} · {event.event_id}</option>)}
</select></label>
<p>{zh ? "比较原始记录,不代表同条件评测或研究质量变化。" : "Compares original records, not matched evaluations or changes in research quality."}</p>
<div className="research-comparison-pair">
{[selected, comparison].map((event, i) => <div key={event.event_id}><strong>{i === 0 ? (zh ? "所选记录" : "Selected record") : (zh ? "对照记录" : "Comparison record")}</strong><p>{timeLabel(event.recorded_at)}</p><code>{event.event_id}</code></div>)}
</div>
<p role="status">{zh ? `${changed.length} 个字段不同` : `${changed.length} fields differ`}</p>
<label><input type="checkbox" checked={showAll} onChange={event => setShowAll(event.target.checked)}/>{zh ? "包括相同字段" : "Include unchanged fields"}</label>
{(showAll ? researchComparisonFields : changed).map(field => <section key={field} className="research-comparison-field">
<h4>{labels[field][zh ? 1 : 0]}</h4>
<div className="research-comparison-pair">{[selected, comparison].map((event, i) => <div key={event.event_id}>
<span className="research-comparison-side">{i === 0 ? (zh ? "所选记录" : "Selected record") : (zh ? "对照记录" : "Comparison record")}</span>
<div>{value(event, field)}</div>
</div>)}</div>
</section>)}
</> : null}
</details>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import {test} from "node:test";
import {researchRecordVersions, changedResearchFields} from "./research-record-versions.ts";

const record = (overrides = {}) => ({goal_id: "goal", event_kind: "finding", result_id: "result", event_id: "old",
recorded_at: "2026-01-02T00:00:00Z", title: "Observation", summary: "Negative evidence", status: "refuted",
evidence_refs: ["source:old"], confidence: null, ...overrides});

test("versions retain source order and original evidence, independent of dates and search", () => {
const old = Object.freeze(record());
const next = Object.freeze(record({event_id: "new", recorded_at: "2026-01-01T00:00:00Z", summary: "Different scope", status: "confirmed"}));
assert.deepEqual(researchRecordVersions([old, next], next), [old, next]);
assert.deepEqual(changedResearchFields(old, next), ["summary", "status"]);
});

test("same title, supersedes and result id cannot cross Goal or kind boundaries", () => {
const old = record();
const next = record({event_id: "new"});
const foreign = record({event_id: "foreign", goal_id: "other", supersedes: "old"});
const node = record({event_id: "node", event_kind: "node"});
const unrelated = record({event_id: "unrelated", result_id: "different"});
assert.deepEqual(researchRecordVersions([foreign, old, node, unrelated, next], next), [old, next]);
assert.deepEqual(researchRecordVersions([next], next), [next], "Missing history does not synthesize a prior version");
});

test("comparison preserves zero, missing measurements, source changes and original whitespace", () => {
const old = record({measurements: [{metric: "score", value: null, unit: "points"}]});
const next = record({summary: "Negative evidence\n", confidence: 0, evidence_refs: [], measurements: [{metric: "score", value: 0, unit: "points"}]});
assert.deepEqual(changedResearchFields(old, next), ["summary", "evidence_refs", "measurements", "confidence"]);
assert.deepEqual(changedResearchFields(old, record({measurements: [{unit: "points", value: null, metric: "score"}]})), [], "Object serialization order is not a content change");
assert.deepEqual(changedResearchFields(old, record()), ["measurements"]);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type {ExploreHistoryEvent} from "../../../../../../loopx/control_plane/presentation/explore_history.ts";

/** Recorded versions of one result, in source order. Dates and supersedes text
* do not establish lineage across result kinds or Goals. Never use search hits
* as the source: a filtered-out revision remains available for comparison. */
export function researchRecordVersions(events: readonly ExploreHistoryEvent[], selected: ExploreHistoryEvent) {
return events.filter(event => event.goal_id === selected.goal_id
&& event.event_kind === selected.event_kind && event.result_id === selected.result_id);
}

// Local display fields, not a new evidence or lifecycle vocabulary.
export const researchComparisonFields = ["title", "summary", "status", "blocked_reason", "evidence_refs",
"measurements", "confidence", "run_id", "agent_id", "node_kind", "node_id", "parent_id",
"from_node", "to_node", "edge_type", "supersedes", "execution_observation"] as const;
export type ResearchComparisonField = typeof researchComparisonFields[number];

const normalized = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(normalized);
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, normalized(item)]));
return value;
};
export function changedResearchFields(left: ExploreHistoryEvent, right: ExploreHistoryEvent) {
return researchComparisonFields.filter(field => JSON.stringify(normalized(left[field])) !== JSON.stringify(normalized(right[field])));
}
15 changes: 13 additions & 2 deletions examples/personal-workspace-browser/research-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,16 @@ historical revisions. Search covers the loaded snapshot before display paginatio
incomplete reads and failed refreshes stay visible. Clear filters to browse again.
Queries are local to this open reader and reset when it closes or the Goal changes.

For a result with more than one loaded version, expand **Compare recorded versions**.
Choose another version of that same result to read changed original text, recorded
status, evidence references and measurements together. **Include unchanged fields**
restores shared context. Alternatives come from the loaded history, even when the
search or status filter hides them. Missing values remain unrecorded; zero remains
zero. The comparison preserves original record IDs and source order, and does not
claim matched evaluations, improved quality or acceptance. Partial/stale history
retains the reader's coverage warning; refresh to retry. Closing and reopening the
comparison does not change either source record.

Choose **Download matches (JSON)** to keep all matching original records, including
those not yet expanded on screen. The file includes the Goal, export time, literal
filters and loaded/exported counts. `history_complete` and `refresh_failed` preserve
Expand All @@ -135,11 +145,12 @@ failure or refresh the history after a read failure.

The synthetic packaged regression exercises native paged history, an old negative
revision after 75 other findings, status filtering, full/filtered JSON downloads,
partial/stale coverage, interrupted reads/downloads and retry, Goal isolation and
original-version comparison (including unmeasured versus zero), partial/stale
coverage, interrupted reads/downloads and retry, Goal isolation and
desktop/narrow layouts without dispatching an Agent:

```sh
node --experimental-strip-types --test apps/presentation/dashboard/src/features/personal-workspace/research-record-search.test.mjs
node --experimental-strip-types --test apps/presentation/dashboard/src/features/personal-workspace/research-record-search.test.mjs apps/presentation/dashboard/src/features/personal-workspace/research-record-versions.test.mjs
node --experimental-strip-types --test apps/presentation/dashboard/src/features/personal-workspace/research-record-export.test.mjs
LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PERSONAL_WORKSPACE_SCENARIO=research-record-search node examples/personal-workspace-browser-smoke.mjs
```
Expand Down
Loading
Loading