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
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,9 @@
.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 { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; 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-trail-main { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.research-trail-list button > svg { color: var(--pw-muted); }
.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; }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {exploreFindingCopy} from "./explore-finding-copy";
import {CircleHelp} from "lucide-react";
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";
Expand Down Expand Up @@ -40,7 +40,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedDay, da
unstructured: "决策未结构化", unstructuredHint: "结论未按 writeback 结构记录,无法拆出依据与决策。",
density: "记录密度", densityHint: "柱高为事件数;浅红为无记录空档", ticks: "转向 / 继续",
routes: "研究路线", routeHint: "仅由显式关联或父子分组相连", singles: "未关联研究项",
trail: "决策轨迹", more: "显示更早记录", moreQuestions: "显示更多问题", records: "条决策记录", latestRecord: "最近记录", unknownTime: "时间未知", all: "全部时段",
trail: "决策轨迹", more: "显示更早记录", moreQuestions: "显示更多问题", records: "条决策记录", unknownTime: "时间未知", all: "全部时段",
span: "记录跨度", start: "起点", end: "末端", replan: "转向", continue: "继续", stop: "停止", wait: "等待", no_change: "无变化", ask_human: "请人决定",
...findingCopy.labels, unlabeled: "无结论", phaseNote: "按记录时间三等分,仅为阅读分组,不是预声明的评估窗口。",
noTime: "没有可用的记录时间,无法放上时间轴;下方记录仍可完整阅读。",
Expand All @@ -50,7 +50,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 earlier records", moreQuestions: "Show more questions", records: "decision records", latestRecord: "Most recent record", unknownTime: "Unknown time", all: "Whole history",
trail: "Decision history", more: "Show earlier records", moreQuestions: "Show more questions", 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",
...findingCopy.labels, 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.",
Expand Down Expand Up @@ -229,20 +229,26 @@ function TrailQuestion({group, copy, label, timeLabel, selectedIds, onSelect}: {
{exceptions.map(({status, count}) => <span className={tone(status)} key={status}>{label(status)} {count}</span>)}
</div>
</header>
<ol className="research-trail-list">{group.items.slice(0, limit).map((item, index) => {
<ol className="research-trail-list">{group.items.slice(0, limit).map(item => {
const decision = item.sections!.decision!;
// Preview source text, never a generated title. Complete lists live in
// the same exact-finding reader used by every other history selection.
const previewKey = decision.changed.length ? "changed" : decision.stopped.length ? "stopped" : "retained";
return <li key={item.event.event_id}>
<button type="button" data-research-decision={item.event.event_id}
aria-label={`${label(decision.outcome)} · ${item.at === null ? copy.unknownTime : timeLabel(item.at)} · ${item.event.title}`}
aria-label={`${label(decision.outcome)} · ${item.at === null ? copy.unknownTime : timeLabel(item.at)} · ${label(item.event.status)} · ${item.event.title}`}
className={selectedIds.includes(item.event.node_id) ? "is-selected" : ""}
onClick={event => {event.currentTarget.focus({preventScroll: true}); onSelect(item.event.node_id, item.event.result_id);}}>
<span className="research-trail-meta"><b className={`research-outcome is-${decision.outcome}`}>{label(decision.outcome)}</b>
<time dateTime={item.at === null ? undefined : item.event.recorded_at}>{item.at === null ? copy.unknownTime : timeLabel(item.at)}</time>
<span>{label(item.event.status)}</span>{index === 0 && item.at !== null ? <span>{copy.latestRecord}</span> : null}
<span className="research-trail-main">
<span className="research-trail-meta"><b className={`research-outcome is-${decision.outcome}`}>{label(decision.outcome)}</b>
<time dateTime={item.at === null ? undefined : item.event.recorded_at}>{item.at === null ? copy.unknownTime : timeLabel(item.at)}</time>
<span>{label(item.event.status)}</span>
</span>
{decision[previewKey].length ? <span className="research-trail-change"><em>{label(previewKey)}</em>{decision[previewKey].join(";")}</span> : null}
<span className="research-trail-meta">{(["changed", "stopped", "retained"] as const).map(key => decision[key].length
? <span key={key}>{label(key)} {decision[key].length}</span> : null)}</span>
</span>
{decision.changed.length ? <span className="research-trail-change"><em>{copy.changed}</em>{decision.changed.join(";")}</span> : null}
{decision.stopped.length ? <span className="research-trail-change"><em>{copy.stopped}</em>{decision.stopped.join(";")}</span> : null}
{decision.retained.length ? <span className="research-trail-change"><em>{copy.retained}</em>{decision.retained.join(";")}</span> : null}
<ChevronRight size={16} aria-hidden/>
</button>
</li>;
})}</ol>
Expand Down
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.
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.
111 changes: 111 additions & 0 deletions examples/personal-workspace-browser/research-history.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,120 @@ runpy.run_path(sys.argv[0], run_name="__main__")
} catch (failure) {await close(); throw failure;}
}

// 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) {
const directory = await mkdtemp(resolve(tmpdir(), "loopx-decision-reading-"));
const fixture = resolve(directory, "history.json");
execFileSync(resolveTestPython(), ["-c", `
import json, sys
from pathlib import Path
from loopx.capabilities.explore.result_log import build_explore_node_event, build_explore_finding_event
goal = 'synthetic-decision-reading'
events = []
for question in range(3):
node = f'question-{question}'
title = ['对照能否区分修复机制与额外运行时间,并支持下一轮选择?',
'Did the fixed-budget comparison support a different candidate?',
'新的证据是否改变了原问题的判断,哪些失败与未知仍需保留?'][question]
events.append(build_explore_node_event(goal_id=goal, node_id=node, title=title,
recorded_at='2026-01-01T00:00:00Z'))
for record in range(5):
changed = [] if question == 1 and record == 4 else [f'改用对照方案 {record},先比较相同预算下的有效结果,再决定是否进入下一轮。' * 3]
stopped = [f'STOP {index}: 停止将局部测量直接当作原始目标收益;同一来源中的负结果仍保留。' * 3 for index in range(2)]
retained = [f'RETAIN {index}: 保留原始目标、固定预算、历史失败与未知原因,不能从代理改进推断完整任务通过。' * 3 for index in range(3)]
interpretation = 'Outcome: ' + ('no_change' if not changed else 'replan')
for key, values in [('changed', changed), ('stopped', stopped), ('retained', retained)]:
if values: interpretation += ' ' + key + ': ' + ' '.join('- ' + value for value in values)
summary = 'Input revision: synthetic-v1 Applicability: current Observation: OBSERVATION: 对照未检验完整任务收益。 Interpretation: ' + interpretation
assert len(summary) < 2000
events.append(build_explore_finding_event(goal_id=goal, node_id=node,
finding_id=f'decision-{question}-{record}', title=f'Comparison {question}, decision {record}',
summary=summary, status=['confirmed', 'refuted', 'tentative'][record % 3],
recorded_at=f'2026-01-0{question + 1}T0{record}:00:00Z'))
Path(sys.argv[1]).write_text(json.dumps({'goal_id': goal, 'display_name': 'Synthetic decision reading', 'events': events}, ensure_ascii=False))
`, fixture], {cwd: repoRoot});
let owner, page;
try {
owner = await startHistoryOwner(fixture);
page = await browser.newPage({viewport: {width: 1280, height: 900}});
await page.addInitScript(() => {if (!localStorage.getItem('loopx-pw-locale')) localStorage.setItem('loopx-pw-locale', 'zh-CN');});
await page.goto(`${owner.base}/chat/?goalId=${owner.goal_id}&view=research&statusUrl=%2Fstatus.json`, {waitUntil: 'domcontentloaded'});
const history = page.locator('.research-history');
await history.getByRole('status').filter({hasText: '18 / 18'}).waitFor();
const trail = history.locator('.research-trail');
const group = trail.locator('.research-trail-question').first();
assert.equal(await trail.locator('.research-trail-question').count(), 3);
assert.equal(await group.locator('[data-research-decision]').count(), 3);
assert.match(await group.locator('header').innerText(), /已反驳 2.*暂定结论 1|暂定结论 1.*已反驳 2/s);
await page.screenshot({path: resolve(outputDir, 'decision-trail-first-desktop.png'), animations: 'disabled'});
await trail.evaluate(e => e.scrollIntoView({block: 'start'}));
await page.screenshot({path: resolve(outputDir, 'decision-trail-desktop.png'), animations: 'disabled'});
await trail.locator('.research-trail-question').last().evaluate(e => e.scrollIntoView({block: 'start'}));
await page.screenshot({path: resolve(outputDir, 'decision-trail-lower-desktop.png'), animations: 'disabled'});
await page.setViewportSize({width: 390, height: 844});
await page.emulateMedia({reducedMotion: 'reduce'});
await trail.evaluate(e => e.scrollIntoView({block: 'start'}));
await page.screenshot({path: resolve(outputDir, 'decision-trail-phone.png'), animations: 'disabled'});
const rowsHeight = await group.locator('.research-trail-list').evaluate(e => e.getBoundingClientRect().height);
await writeFile(resolve(outputDir, 'decision-trail-reading.json'), JSON.stringify({rowsHeight}, null, 2));
assert(rowsHeight <= 500, `Three recent decisions must be comparable within one phone screen, rather than three repeated documents (${rowsHeight}px)`);
for (const locale of ['zh-CN', 'en']) {
await page.evaluate(value => localStorage.setItem('loopx-pw-locale', value), locale);
await page.reload({waitUntil: 'domcontentloaded'});
await history.getByRole('status').filter({hasText: '18 / 18'}).waitFor();
const row = group.locator('[data-research-decision]').first();
assert.match(await row.innerText(), locale === 'zh-CN' ? /停止 2.*保留 3/s : /Stopped 2.*Retained 3/s);
await row.scrollIntoViewIfNeeded();
await row.focus(); await row.press('Enter');
const detail = history.locator('.research-history-detail');
await detail.waitFor();
assert.equal(new URL(page.url()).searchParams.get('research_finding'), 'decision-2-4');
const original = detail.locator('.research-recorded-finding > .research-finding-sections');
assert.match(await original.innerText(), /OBSERVATION: 对照未检验完整任务收益/);
for (const text of ['STOP 0:', 'STOP 1:', 'RETAIN 0:', 'RETAIN 1:', 'RETAIN 2:']) assert((await original.innerText()).includes(text));
assert.equal(await original.locator('li').count(), 6, 'The exact reader keeps every changed, stopped and retained item');
if (locale === 'zh-CN') {
await page.screenshot({path: resolve(outputDir, 'decision-trail-reader-phone.png'), animations: 'disabled'});
await original.locator('dd').last().scrollIntoViewIfNeeded();
await page.screenshot({path: resolve(outputDir, 'decision-trail-reader-tail-phone.png'), animations: 'disabled'});
}
await detail.press('Escape');
assert(await row.evaluate(e => e === document.activeElement), 'Escape restores the exact decision control');
const returned = await row.boundingBox();
assert(returned.y >= 0 && returned.y + returned.height <= 844, 'Return brings the original decision back into the reading viewport');
const noChange = trail.locator('.research-trail-question').nth(1).locator('[data-research-decision]').first();
await noChange.click();
assert.equal(new URL(page.url()).searchParams.get('research_finding'), 'decision-1-4');
assert.match(await original.innerText(), /STOP 0:/, 'A decision with no changed list still has complete stopped and retained evidence');
await detail.press('Escape');
await group.getByRole('button', {name: locale === 'zh-CN' ? '显示更早记录 · 2' : 'Show earlier records · 2', exact: true}).click();
assert.equal(await group.locator('[data-research-decision]').count(), 5, 'Earlier decisions stay reachable');
const oldest = group.locator('[data-research-decision]').last();
await oldest.click();
assert.equal(new URL(page.url()).searchParams.get('research_finding'), 'decision-2-0');
await detail.press('Escape');
assert(await oldest.evaluate(e => e === document.activeElement));
if (locale === 'en') {
await trail.evaluate(e => e.scrollIntoView({block: 'start'}));
await page.screenshot({path: resolve(outputDir, 'decision-trail-english-phone.png'), animations: 'disabled'});
}
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
}
const readback = await (await page.request.get(`${owner.base}/fixture/readback`)).json();
assert.equal(readback.source_unchanged, true);
assert.equal(readback.mutation_attempts, 0, 'Reading and returning cannot perform a write');
} finally {
await page?.close();
await owner?.close();
await rm(directory, {recursive: true, force: true});
}
}

export const researchHistoryScenario = {
id: "research-history",
async run({browser, collectCoverage, url}) {
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.
const nativeOwner = await startHistoryOwner(resolve(repoRoot, "apps/presentation/dashboard/src/data/fixtures/research-native-readback.json"));
Expand Down
Loading