Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,13 @@
.research-decision-ticks span.is-replan, .research-tick-key.is-replan { background: var(--pw-orbit); }
.research-tick-key { display: inline-block; width: 3px; height: 10px; border-radius: 1px; background: var(--pw-blue); }
.research-phase-divider { position: absolute; top: -4px; height: 76px; border-left: 1px dashed var(--pw-faint); pointer-events: none; }
.research-axis { position: relative; height: 18px; margin-top: 2px; border-top: 1px solid var(--pw-line); }
.research-axis span { position: absolute; top: 2px; transform: translateX(-50%); color: var(--pw-muted); font-size: var(--text-caption); white-space: nowrap; }
.research-axis { position: relative; height: 22px; margin-top: 2px; border-top: 1px solid var(--pw-line); }
.research-axis time { position: absolute; top: 5px; transform: translateX(-50%); color: var(--pw-muted); font-size: var(--text-caption); white-space: nowrap; }
.research-axis time::before { position: absolute; top: -5px; left: 50%; width: 1px; height: 4px; background: var(--pw-line-strong); content: ""; }
.research-axis .is-start { transform: none; }
.research-axis .is-start::before { left: 0; }
.research-axis .is-end { transform: translateX(-100%); }
.research-axis .is-end::before { left: auto; right: 0; }
.research-route { display: contents; }
.research-route-caption { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 16px; margin: 0 0 4px; min-width: 0; }
.research-route-caption button { display: grid; grid-template-columns: auto minmax(0, 1fr); align-content: start; align-items: baseline; gap: 8px; min-width: 0; min-height: 44px; padding: 4px 0; border: 0; background: transparent; color: inherit; font: inherit; text-align: start; cursor: pointer; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,10 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist
if (next !== lens || nextPhase !== phase) window.history.pushState(null, "", url);
};
const chooseLens = (next: Lens) => chooseView(next, phase);
const fullTime = (at: number) => new Intl.DateTimeFormat(zh ? "zh-CN" : "en", {month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false}).format(new Date(at));
const fullTime = (at: number, precise = false) => new Intl.DateTimeFormat(zh ? "zh-CN" : "en", {
month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false,
second: precise ? "2-digit" : undefined, fractionalSecondDigits: precise ? 3 : undefined,
}).format(new Date(at));
const dayTime = (at: number) => new Intl.DateTimeFormat(zh ? "zh-CN" : "en", {month: "numeric", day: "numeric"}).format(new Date(at));
const positions = new Map(layout.map(item => [item.node.id, item]));
const selectedId = selection.nodes.length === 1 ? selection.nodes[0]! : "";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {exploreFindingCopy} from "./explore-finding-copy";
import {ChevronRight, CircleHelp} from "lucide-react";
import {useLayoutEffect, useRef, useState, type CSSProperties} from "react";
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 {exploreTimelinePhaseAt, exploreTimelineTickOffsets, 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";

Expand All @@ -14,13 +14,29 @@ type Props = {
timeline: ExploreTimeline; zh: boolean; selectedIds: readonly string[]; selectedEventId?: string; phase: number | null;
selectedDay: string | null; dayGroups: ReturnType<typeof groupUnlinkedHistoryQuestions>["groups"]; onGroupSelect: (day: string, button: HTMLButtonElement) => void;
onSelect: (id: string, finding?: string) => void; onSelectMany: (ids: string[], button: HTMLButtonElement) => void; onPhase: (index: number | null) => void;
timeLabel: (value: number) => string; dayLabel: (value: number) => string;
timeLabel: (value: number, precise?: boolean) => string; dayLabel: (value: number) => string;
};

const LABELS = ["confirmed", "refuted", "tentative"] as const;
const tone = (label: string) => LABELS.includes(label as typeof LABELS[number]) ? `is-${label}` : "is-unlabeled";
const isOpen = (item: TimelineNode) => ["open", "exploring", "blocked"].includes(item.node.latest.status);

function elapsedLabel(offset: number, zh: boolean) {
if (offset > 0 && offset < 1_000) return `${offset}${zh ? " 毫秒" : "ms"}`;
let milliseconds = offset;
const units: [number, string][] = zh
? [[86_400_000, " 天"], [3_600_000, " 小时"], [60_000, " 分"]]
: [[86_400_000, "d"], [3_600_000, "h"], [60_000, "m"]];
const parts = [];
for (const [size, unit] of units) {
const count = Math.floor(milliseconds / size);
if (count) parts.push(`${count}${unit}`);
milliseconds %= size;
}
if (milliseconds) parts.push(`${milliseconds / 1_000}${zh ? " 秒" : "s"}`);
return parts.join(" ") || (zh ? "0 秒" : "0s");
}

export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId, selectedDay, dayGroups, onGroupSelect, phase, onSelect, onSelectMany, onPhase, timeLabel, dayLabel}: Props) {
const plot = useRef<HTMLDivElement>(null);
const [plotWidth, setPlotWidth] = useState(0);
Expand All @@ -38,7 +54,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId
early: "前段", middle: "中段", late: "后段", item: "研究项", items: "研究项", question: "个问题", questions: "个问题",
decisions: "显式决策", none: "未记录显式决策", changed: "变化", stopped: "停止", retained: "保留",
unstructured: "决策未结构化", unstructuredHint: "结论未按 writeback 结构记录,无法拆出依据与决策。",
density: "记录密度", densityHint: "柱高为事件数;浅红为无记录空档", ticks: "转向 / 继续",
density: "记录密度", densityHint: "柱高为事件数;浅红为无记录空档", axis: "自首条记录起",
routes: "研究路线", routeHint: "仅由显式关联或父子分组相连", singles: "未关联研究项",
trail: "决策轨迹", more: "显示更早记录", moreQuestions: "显示更多问题", record: "条决策记录", records: "条决策记录", unknownTime: "时间未知", all: "全部时段",
span: "记录跨度", start: "起点", end: "末端", replan: "转向", continue: "继续", stop: "停止", wait: "等待", no_change: "无变化", ask_human: "请人决定",
Expand All @@ -49,7 +65,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId
early: "Early", middle: "Middle", late: "Late", item: "Research item", items: "Research items", 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",
density: "Recording density", densityHint: "Bar height is events; pale red is a recording gap", axis: "Since first record",
routes: "Research routes", routeHint: "Connected only by recorded relationships or parent grouping", singles: "Unlinked research items",
trail: "Decision history", more: "Show earlier records", moreQuestions: "Show more questions", record: "decision record", records: "decision records", 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",
Expand All @@ -59,17 +75,15 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId
};
const {start, end} = timeline;
if (start === null || end === null) return <ContextHelp label={zh ? "记录时间未知" : "Record time unknown"} icon={CircleHelp}><p className="research-history-note">{copy.noTime}</p></ContextHelp>;
const span = Math.max(1, end - start);
const elapsed = end - start;
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 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));
const hasRecordingGaps = timeline.bins.some(bin => !bin.events);
const ticks: number[] = [];
const midnight = new Date(start); midnight.setHours(24, 0, 0, 0);
for (let at = midnight.getTime(); at <= end; at += 86_400_000) ticks.push(at);
const stride = Math.max(1, Math.ceil(ticks.length / 10));
const ticks = exploreTimelineTickOffsets(elapsed, plotWidth);
const marker = (item: TimelineNode, style: CSSProperties, extra = "") => {
const title = `${item.node.latest.title} · ${label(item.label || "unlabeled")}${item.first !== null ? ` · ${timeLabel(item.first)}` : ""}`;
return <button key={item.node.id} type="button" title={title} aria-label={title} aria-pressed={selectedIds.includes(item.node.id)} data-research-nodes={item.node.id}
Expand Down Expand Up @@ -121,6 +135,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId
<div className="research-phase-legend">{LABELS.map(key => <span key={key}><i className={tone(key)} aria-hidden="true"/>{label(key)}</span>)}</div>
<ContextHelp label={zh ? "时间线读法" : "Reading the timeline"}>
<p>{phaseNote}</p><p className="research-density-help"><i className="research-bar-key" aria-hidden/>{copy.densityHint}{zh ? "。" : ". "}{zh ? "无记录空档不代表研究停止。" : "A recording gap does not establish that research stopped."}</p>
<p>{zh ? "横轴从首条记录算起。悬停刻度可查看对应的本地时间。" : "The axis starts at the first record. Hover over a tick for its local time."}</p>
<p>{findingCopy.meaning}</p>
{!timeline.structuredCount ? <p>{copy.unstructuredHint}</p> : null}
<p>{copy.routeHint}{zh ? "。" : ". "}{zh ? "相邻点合拢,数字为研究项数,一次展开原始时间与依据。" : "Nearby points group; the number counts research items. Select once for original times and sources."}</p>
Expand All @@ -130,7 +145,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId
</div>
</div>
<div className="research-timeline-grid">
<div className="research-row-label"><strong>{copy.density}</strong><small>
<div className="research-row-label"><strong>{copy.density}</strong><small>{copy.axis}</small><small>
{hasRecordingGaps ? <span><i className="research-gap-key" aria-hidden="true"/> {zh ? "无记录" : "No records"}</span> : null}
{decisionOutcomes.map(outcome => <span key={outcome}><i className={`research-tick-key is-${outcome}`}/> {label(outcome)}</span>)}
</small></div>
Expand All @@ -141,7 +156,10 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId
title={`${label(item.sections!.decision!.outcome)} · ${item.at === null ? "—" : timeLabel(item.at)}`}
className={`is-${item.sections!.decision!.outcome}${inPhase(item.at) ? "" : " is-dimmed"}`} style={{left: pct(item.at!)}}/>)}</div> : null}
{timeline.phases.slice(1).map(item => <span key={item.index} className="research-phase-divider" style={{left: pct(item.start)}}/>)}
<div className="research-axis">{ticks.map((at, index) => index % stride ? null : <span key={at} style={{left: pct(at)}}>{dayLabel(at)}</span>)}</div>
<div className="research-axis" role="group" aria-label={copy.axis}>{ticks.map(offset => <time
key={offset} dateTime={`PT${offset / 1_000}S`} title={timeLabel(start + offset, true)}
className={offset === 0 ? "is-start" : offset === elapsed ? "is-end" : undefined}
style={{left: pct(start + offset)}}>{elapsedLabel(offset, zh)}</time>)}</div>
</div>
{timeline.routes.length ? <div className="research-row-heading"><strong>{copy.routes}</strong></div> : null}
{timeline.routes.map(route => {
Expand Down
57 changes: 57 additions & 0 deletions examples/personal-workspace-browser/research-history.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,59 @@ runpy.run_path(sys.argv[0], run_name="__main__")
} catch (failure) {await close(); throw failure;}
}

async function readElapsedTimePrecision(browser) {
const directory = await mkdtemp(resolve(tmpdir(), "loopx-recorded-time-"));
let owner, page;
try {
execFileSync(resolveTestPython(), ["-c", `
from datetime import datetime, timedelta, timezone
import json, sys
from pathlib import Path
from loopx.capabilities.explore.result_log import build_explore_node_event

start = datetime(2026, 1, 1, tzinfo=timezone.utc)
for elapsed in (1900, 0, 9, 61002):
goal = f'recorded-time-{elapsed}'
events = [build_explore_node_event(goal_id=goal, node_id=f'record-{index}',
title=f'Record {index + 1}', recorded_at=at.isoformat(timespec='milliseconds'))
for index, at in enumerate((start, start + timedelta(milliseconds=elapsed)))]
Path(sys.argv[1], f'{elapsed}.json').write_text(json.dumps({'goal_id': goal, 'events': events}))
`, directory], {cwd: repoRoot});
owner = await startHistoryOwner([1900, 0, 9, 61002].map(elapsed => resolve(directory, `${elapsed}.json`)));
page = await browser.newPage({viewport: {width: 1440, height: 900}});
await page.addInitScript(() => {
if (!localStorage.getItem("loopx-pw-locale")) localStorage.setItem("loopx-pw-locale", "en");
});
const ticks = page.locator(".research-axis time");
const open = async elapsed => {
const goal = `recorded-time-${elapsed}`;
const query = new URLSearchParams({goalId: goal, view: "research", statusUrl: `/status.json?goal_id=${goal}`});
await page.goto(`${owner.base}/chat/?${query}`, {waitUntil: "domcontentloaded"});
await ticks.first().waitFor();
};
await open(1900);
await ticks.nth(2).waitFor();
assert.deepEqual(await ticks.allTextContents(), ["0s", "1s", "1.9s"], "Distinct recorded times have distinct labels");
assert.equal(await ticks.last().getAttribute("datetime"), "PT1.9S");
assert.equal(new Set(await ticks.evaluateAll(elements => elements.map(element => element.title))).size, 3,
"The absolute clock labels distinguish ticks within the same minute");
await page.setViewportSize({width: 390, height: 844});
await page.waitForFunction(() => document.querySelectorAll(".research-axis time").length === 2);
assert.deepEqual(await ticks.allTextContents(), ["0s", "1.9s"], "The narrow endpoint keeps its recorded precision");
await open(0);
assert.deepEqual(await ticks.allTextContents(), ["0s"], "Coincident records share one tick");
await open(9);
assert.deepEqual(await ticks.allTextContents(), ["0s", "9ms"], "Subsecond spans retain their millisecond label");
await page.evaluate(() => localStorage.setItem("loopx-pw-locale", "zh-CN"));
await open(61002);
assert.equal(await ticks.last().textContent(), "1 分 1.002 秒", "A longer span retains fractional seconds in Chinese");
} finally {
await page?.close();
await owner?.close();
await rm(directory, {recursive: true, force: true});
}
}

// Whole-page comparison pressure: repeated conditions must not bury the next
// decision, while its exact original finding remains one keyboard action away.
async function readDecisionTrail(browser) {
Expand Down Expand Up @@ -302,6 +355,7 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id': goal, 'display_name': 'Synth
export const researchHistoryScenario = {
id: "research-history",
async run({browser, collectCoverage, url}) {
await readElapsedTimePrecision(browser);
await readDecisionTrail(browser);
// The ordinary journey uses the real source verifier and HTTP owner, with
// no intercepted analysis response and no analysis process or Goal writes.
Expand Down Expand Up @@ -979,10 +1033,13 @@ export const researchHistoryScenario = {
assert.equal(await history.locator(".research-history-edges .is-grouping").count(),0);
await page.screenshot({path:resolve(outputDir,'research-history-portfolio-graph.png'),animations:'disabled'});
await history.getByRole('button',{name:'时间线',exact:true}).click();
const beforeRefreshAxis = await history.locator(".research-axis time").allTextContents();
assert(beforeRefreshAxis.length >= 2);
unavailable=true;
await history.getByRole('button',{name:'刷新记录',exact:true}).click();
await history.getByRole('alert').waitFor();
assert.match(await history.textContent(),/76 \/ 76/,'A failed refresh retains the complete real capture');
assert.deepEqual(await history.locator(".research-axis time").allTextContents(), beforeRefreshAxis, "A failed refresh retains the verified time scale");
const readError = history.locator('.research-history-read-error');
assert.match(await readError.innerText(), /刷新失败,当前记录已保留/);
assert.equal(await readError.getByRole('tooltip').isVisible(), false, 'Technical failure detail stays secondary');
Expand Down
Loading
Loading