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 @@ -130,9 +130,11 @@
.research-history-controls .research-lens button[aria-pressed="true"] { background: var(--pw-text); color: var(--pw-bg); }
.research-history-canvas.is-timeline { max-height: none; overflow: visible; background: var(--pw-bg); padding: 24px; }
.research-timeline { display: grid; gap: 20px; min-width: 0; }
.research-timeline-guide { display: flex; align-items: center; gap: 4px; color: var(--pw-muted); font-size: var(--text-caption); }
.research-timeline-guide > .research-link { margin-inline-start: auto; }
.research-timeline-summary { display: grid; gap: 8px; min-width: 0; }
.research-timeline-key { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; min-width: 0; }
.research-timeline-key > .research-link { margin-inline-start: auto; min-height: 44px; font-size: var(--text-body); }
.research-phases { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.research-phases.is-single { grid-template-columns: minmax(0, 1fr); }
.research-history .research-phase { display: flex; flex-direction: column; align-items: stretch; gap: 10px; min-width: 0; min-height: 44px; padding: 12px; border: 1px solid var(--pw-line); border-radius: 8px; background: var(--pw-bg); color: var(--pw-text); font: inherit; 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-phase:hover { border-color: var(--pw-line-strong); background: var(--pw-hover); }
.research-history .research-phase.is-active { outline: 2px solid var(--pw-text); outline-offset: -1px; }
Expand All @@ -144,7 +146,7 @@
.research-phase-details dd { margin: 4px 0 0; }
.research-phase-bar { display: flex; gap: 2px; height: 18px; border-radius: 4px; overflow: hidden; background: var(--pw-track); }
.research-phase-bar span { display: flex; align-items: center; min-width: 4px; padding-inline: 6px; overflow: hidden; color: var(--pw-bg); font-size: var(--text-caption); font-weight: 600; white-space: nowrap; }
.research-phase-legend { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: var(--text-caption); color: var(--pw-text); }
.research-phase-legend { display: flex; flex: 1; flex-wrap: wrap; gap: 4px 12px; min-width: 0; font-size: var(--text-caption); color: var(--pw-text); }
.research-phase-legend > span { display: inline-flex; align-items: center; gap: 4px; }
.research-phase-legend i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; background: var(--research-tone); }
.research-trail-list em, .research-finding-sections dt { font-style: normal; font-weight: 600; color: var(--pw-text); margin-right: 6px; }
Expand All @@ -166,7 +168,7 @@
.research-density span.is-gap, .research-gap-key { background: var(--pw-red-bg); }
.research-density span.is-gap { border-top: 0; }
.research-gap-key { display: inline-block; width: 10px; height: 10px; border: 1px solid var(--pw-red); border-radius: 2px; }
.research-bar-key { display: inline-block; width: 10px; height: 10px; background: var(--pw-blue-soft); border-top: 2px solid var(--pw-blue); border-radius: 2px 2px 0 0; }
.research-bar-key { display: inline-block; width: 10px; height: 10px; margin-inline-end: 4px; background: var(--pw-blue-soft); border-top: 2px solid var(--pw-blue); border-radius: 2px 2px 0 0; }
.research-decision-ticks { position: relative; height: 12px; margin-top: 4px; }
.research-decision-ticks span { position: absolute; top: 0; width: 3px; height: 10px; margin-left: -1px; border-radius: 1px; background: var(--pw-blue); }
.research-decision-ticks span.is-replan, .research-tick-key.is-replan { background: var(--pw-orbit); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId
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);
const unknownTime = [...timeline.nodes.values()].filter(item => item.first === null).length;
const phaseNote = timeline.phases.length === 1
? (zh ? "全部记录归入同一阅读分组,仅为阅读组织,不是预声明的评估窗口。" : "All records share one reading group, for reading only; not a pre-declared evaluation window.")
: copy.phaseNote;
const phaseName = (item: TimelinePhase) => timeline.phases.length === 1 ? copy.whole : [copy.early, copy.middle, copy.late][item.index];
const phaseDescription = (item: TimelinePhase) => {
const counts = Object.entries(item.decisions);
Expand All @@ -112,18 +115,22 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedEventId
</button>;
};
return <div className="research-timeline">
<div className="research-timeline-guide"><span>{zh ? "记录时间" : "Record time"}</span><ContextHelp label={zh ? "时间线读法" : "Reading the timeline"}>
<p>{timeline.phases.length === 1 ? (zh ? "全部记录归入同一阅读分组,仅为阅读组织,不是预声明的评估窗口。" : "All records share one reading group, for reading only; not a pre-declared evaluation window.") : copy.phaseNote}</p><p>{copy.densityHint}{zh ? "。" : ". "}{zh ? "无记录空档不代表研究停止。" : "A recording gap does not establish that research stopped."}</p>
<div className="research-timeline-summary">
<div className={`research-phases${timeline.phases.length === 1 ? " is-single" : ""}`} role="group" aria-label={phaseNote}>{timeline.phases.map(phaseCard)}</div>
<div className="research-timeline-key">
<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>{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>
<p>{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."}</p>
<dl className="research-phase-details">{timeline.phases.map(item => <div key={item.index}><dt>{phaseName(item)}</dt><dd>{phaseDescription(item)}</dd></div>)}</dl>
</ContextHelp>{phase !== null ? <button type="button" className="research-link" onClick={() => onPhase(null)}>{copy.all}</button> : null}</div>
<div className="research-phase-legend">{LABELS.map(key => <span key={key}><i className={tone(key)} aria-hidden="true"/>{label(key)}</span>)}<ContextHelp label={findingCopy.help}><p>{findingCopy.meaning}</p></ContextHelp></div>
<div className="research-phases" role="group" aria-label={copy.phaseNote}>{timeline.phases.map(phaseCard)}</div>
</ContextHelp>{phase !== null ? <button type="button" className="research-link" onClick={() => onPhase(null)}>{copy.all}</button> : null}
</div>
</div>
<div className="research-timeline-grid">
<div className="research-row-label"><strong>{copy.density}</strong><small>
<span><i className="research-bar-key" aria-hidden="true"/> {zh ? "柱高=事件数" : "Bar height = events"}</span>
{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 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.
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.
26 changes: 19 additions & 7 deletions examples/personal-workspace-browser/research-history.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1134,22 +1134,30 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id':goal,'display_name':'Syntheti
assert.equal(await gapLegend.locator('i').evaluate(e => getComputedStyle(e).backgroundColor),
await history.locator('.research-density .is-gap').first().evaluate(e => getComputedStyle(e).backgroundColor),
'The visible key identifies the actual recording-gap marks');
const barLegend = history.getByText('柱高=事件数', {exact: true});
assert(await barLegend.isVisible(), 'The bar-height meaning must be visible without opening optional help');
const timelineHelp = history.getByRole('button', {name:'时间线读法', exact:true});
await timelineHelp.focus();
const barLegend = page.locator('.context-help-popover:popover-open .research-density-help');
await barLegend.waitFor();
assert.match(await barLegend.innerText(), /柱高为事件数/);
assert.equal(await barLegend.locator('i').evaluate(e => getComputedStyle(e).borderTopColor),
await history.locator('.research-density span:not(.is-gap)').first().evaluate(e => getComputedStyle(e).borderTopColor),
'The visible bar key matches the real density bars');
'The on-demand bar key matches the real density bars');
await page.keyboard.press('Escape');
assert(await timelineHelp.evaluate(e => e === document.activeElement));
// The default reading must survive a locale switch, not only the zh copy.
await page.evaluate(key => localStorage.setItem(key, 'en'), 'loopx-pw-locale');
await page.reload({waitUntil: 'networkidle'});
const englishHistory = page.getByRole('region', {name: 'Research history', exact: true});
await englishHistory.getByText('Bar height = events', {exact: true}).waitFor()
.catch(error => {throw new Error(`The English reading keeps the bar-height key: ${error.message}`);});
await englishHistory.getByRole('button', {name:'Reading the timeline', exact:true}).focus();
const englishBarLegend = page.locator('.context-help-popover:popover-open .research-density-help');
await englishBarLegend.waitFor();
assert.match(await englishBarLegend.innerText(), /Bar height is events/);
await page.keyboard.press('Escape');
await englishHistory.getByText('No records', {exact: true}).waitFor()
.catch(error => {throw new Error(`The English reading keeps the recording-gap key: ${error.message}`);});
await page.evaluate(key => localStorage.setItem(key, 'zh-CN'), 'loopx-pw-locale');
await page.reload({waitUntil: 'networkidle'});
await history.getByText('柱高=事件数', {exact: true}).waitFor();
await history.getByRole('button', {name:'时间线读法', exact:true}).waitFor();
const target = await history.locator(".research-timeline-marker").first().evaluate(button => {
const box = button.getBoundingClientRect(), dot = getComputedStyle(button, "::after");
const count=button.querySelector('span');
Expand All @@ -1166,7 +1174,11 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id':goal,'display_name':'Syntheti
for (const viewport of [{width: 1280, height: 900}, {width: 390, height: 844}]) {
await page.setViewportSize(viewport);
assert(await gapLegend.isVisible(), 'The recording-gap key remains visible on desktop and narrow layouts');
assert(await barLegend.isVisible(), 'The bar-height key remains visible on desktop and narrow layouts');
await timelineHelp.focus();
await barLegend.waitFor();
assert.match(await barLegend.innerText(), /柱高为事件数/);
await page.keyboard.press('Escape');
assert(await timelineHelp.evaluate(e => e === document.activeElement), 'On-demand reading help returns focus at both widths');
const target = history.locator('.research-route-track [data-research-nodes~="hit-6"]');
await target.scrollIntoViewIfNeeded();
await page.screenshot({path: resolve(outputDir, `research-history-targets-${viewport.width}.png`), animations:'disabled'});
Expand Down
40 changes: 38 additions & 2 deletions examples/personal-workspace-browser/research-route-reading.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import {execFileSync} from "node:child_process";
import {mkdtemp, readFile, rm} from "node:fs/promises";
import {mkdtemp, readFile, rm, writeFile} from "node:fs/promises";
import {tmpdir} from "node:os";
import {resolve} from "node:path";
import {resolveTestPython} from "../../scripts/test-python.mjs";
Expand Down Expand Up @@ -111,7 +111,7 @@ export const researchRouteReadingScenario = {
assert.equal(await caption.count(), 3, "Every recorded route remains in the whole history");
const legend = history.locator(".research-phase-legend");
assert((await legend.innerText()).includes(language === "zh-CN" ? "暂定结论" : "Tentative"));
const findingHelp = legend.getByRole("button", {name: language === "zh-CN" ? "结论状态说明" : "About finding states", exact: true});
const findingHelp = history.getByRole("button", {name: language === "zh-CN" ? "时间线读法" : "Reading the timeline", exact: true});
await findingHelp.focus();
const explanation = page.locator(".context-help-popover:popover-open");
await explanation.waitFor();
Expand Down Expand Up @@ -260,6 +260,42 @@ export const researchRouteReadingScenario = {
assert.equal(analysisWrites, 0, "Reading, language changes and every action state never dispatch a model");
const readback = await (await page.request.get(`${owner.base}/fixture/readback`)).json();
assert(readback.source_unchanged && readback.mutation_attempts === 0, "No source or execution state changes");
// A short capture has one reading group. It must use the available row
// and describe that group consistently to sighted and nonvisual readers.
await owner.close();
const shortFixture = resolve(temporary, "short-capture.json");
const shortCapture = structuredClone(capture);
shortCapture.events = capture.events.filter(event =>
["route-0-start", "finding-route-0-start"].includes(event.result_id));
await writeFile(shortFixture, JSON.stringify(shortCapture));
owner = await startHistoryOwner(shortFixture);
state = "deep";
for (const language of ["zh-CN", "en"]) for (const width of [1440, 390]) {
await page.setViewportSize({width, height: 900});
await page.evaluate(locale => localStorage.setItem("loopx-pw-locale", locale), language);
await page.goto(new URL("/chat/?goalId=synthetic-route-reading&view=research&statusUrl=%2Fstatus.json", owner.base).href);
const group = history.locator(".research-phases");
const card = group.locator("button");
await card.waitFor();
assert.equal(await card.count(), 1, "Short history retains one complete reading group");
assert(Math.abs((await card.boundingBox()).width - (await group.boundingBox()).width) <= 1,
"One group uses the available row instead of leaving two empty columns");
assert.doesNotMatch(await group.getAttribute("aria-label"), /三等分|Equal thirds/);
const help = history.getByRole("button", {name: language === "zh-CN" ? "时间线读法" : "Reading the timeline", exact: true});
await help.tap();
const popover = page.locator(".context-help-popover:popover-open");
await popover.waitFor();
assert.match(await popover.innerText(), language === "zh-CN" ? /同一阅读分组/ : /one reading group/);
assert.match(await popover.innerText(), language === "zh-CN" ? /不表示有检查任务排队/ : /does not mean a check is queued/);
await page.keyboard.press("Escape");
assert(await help.evaluate(element => element === document.activeElement));
await card.tap();
await history.getByRole("button", {name: language === "zh-CN" ? "全部时段" : "Whole history", exact: true}).tap();
assert.equal(await card.getAttribute("aria-pressed"), "false");
}
const shortReadback = await (await page.request.get(`${owner.base}/fixture/readback`)).json();
assert(shortReadback.source_unchanged && shortReadback.mutation_attempts === 0, "Short-history reading remains read-only");
assert.equal(analysisWrites, 0);
return {coverageEntries: collectCoverage ? await page.coverage.stopJSCoverage() : [], note: "complete route endpoint reading, exact keyboard return, reload/Back, bilingual action states on desktop/narrow/390 with zero model effects"};
} catch (error) {
if (page && !page.isClosed()) await page.screenshot({path: resolve(outputDir, "research-route-reading-failed.png")});
Expand Down
Loading