From 86e907ceedeb7c61755186cfa1c89ee55f6934d1 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:16:43 +0800 Subject: [PATCH 1/2] Show elapsed record time on the research timeline Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../goal-research-history.css | 9 ++++- .../goal-research-timeline.tsx | 37 ++++++++++++++----- .../research-history.mjs | 3 ++ .../research-real-benchmark.mjs | 18 ++++++++- .../presentation/explore_timeline.ts | 21 +++++++++++ .../control_plane_ts/explore_history.test.ts | 13 +++++++ 6 files changed, 88 insertions(+), 13 deletions(-) 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 e3beb3fd7b..918ba08970 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 @@ -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; } 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..2c44255843 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 @@ -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"; @@ -21,6 +21,21 @@ 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 seconds = Math.floor(offset / 1_000); + const units: [number, string][] = zh + ? [[86_400, " 天"], [3_600, " 小时"], [60, " 分"], [1, " 秒"]] + : [[86_400, "d"], [3_600, "h"], [60, "m"], [1, "s"]]; + const parts = []; + for (const [size, unit] of units) { + const count = Math.floor(seconds / size); + if (count) parts.push(`${count}${unit}`); + seconds %= size; + } + 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(null); const [plotWidth, setPlotWidth] = useState(0); @@ -38,7 +53,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: "请人决定", @@ -49,7 +64,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", @@ -59,17 +74,15 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId }; const {start, end} = timeline; if (start === null || end === null) return

{copy.noTime}

; - 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