From 4d56f8e81dc165b0bf1c1f95cca7a9af6863c6fb Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:55:26 +0800 Subject: [PATCH] Show the full retained strategy chronology in research Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../goal-research-history.css | 3 -- .../goal-research-history.tsx | 24 ++++----- .../goal-research-workbench.tsx | 2 +- .../goal-source-strategies.css | 8 +++ .../goal-source-strategies.tsx | 51 ++++++++++++++----- docs/development/frontend-delivery.md | 9 +++- ...runtime-source-workspace-browser-smoke.mjs | 29 ++++++++--- 7 files changed, 88 insertions(+), 38 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 918ba08970..e26d7c926a 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 @@ -88,9 +88,6 @@ .research-history-read-error p, .research-history-empty p { margin: 4px 0 0; color: var(--pw-muted); font-size: var(--text-body); line-height: var(--leading-prose); } .research-history-read-error > button, .research-history-empty > button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; flex-shrink: 0; min-height: 44px; padding: 8px 12px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-bg); color: var(--pw-text); font: inherit; font-size: var(--text-body); cursor: pointer; } .research-history-read-error > button:hover, .research-history-empty > button:hover { background: var(--pw-hover); } -.research-source-strategy { margin-block-start: 12px; padding-block-start: 12px; border-block-start: 1px solid var(--pw-line); overflow-wrap: anywhere; } -.research-source-strategy time { margin-inline-start: 8px; color: var(--pw-muted); font-size: var(--text-caption); } -.research-source-strategy strong + time + p { color: var(--pw-text); } .research-history-error { color: var(--pw-red); font-size: var(--text-body); overflow-wrap: anywhere; } .research-history button:focus-visible, .research-history-canvas:focus-visible, .research-history-detail:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: -2px; } @media (max-width: 640px) { diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx index db71a4aa3b..5c30414da6 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx @@ -11,7 +11,6 @@ import {GoalResearchTimeline} from "./goal-research-timeline"; import {requestJson} from "../../data/chat"; import {sourceReadApiUrl} from "../../data/local-status-query"; import {ContextHelp} from "./context-help"; -import type {WorkspaceGoal} from "./personal-workspace-model"; import {groupUnlinkedHistoryQuestions, layoutExploreHistory, mergeExploreHistoryEvents, parseExploreHistoryPage, projectExploreHistory, type ExploreHistoryEvent, type ExploreHistoryNode} from "../../../../../../loopx/control_plane/presentation/explore_history.js"; import "./goal-research-history.css"; import {ExploreMeasurements, formatExploreMeasurementValue} from "./explore-measurements"; @@ -54,9 +53,9 @@ const viewFromUrl = (goal: string) => { /** An effect-free history consumer. Its source is Explore, not raw sessions or * a judge. All recorded updates survive; unsupported analysis stays unavailable. */ -export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHistory = false, active, zh, refreshRevision = 0, readSourceUrl, sourceStrategy, onOpenSourceTasks}: { +export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHistory = false, active, zh, refreshRevision = 0, readSourceUrl, onOpenSourceTasks}: { goalId: string; originalGoalId?: string; preferHistory?: boolean; active: boolean; zh: boolean; refreshRevision?: number; readSourceUrl?: string; - sourceStrategy?: WorkspaceGoal["sourceStrategy"]; onOpenSourceTasks?: () => void; + onOpenSourceTasks?: () => void; }) { const strategies = useGoalStrategyLocation(goalId, readSourceUrl); const analysis = useResearchAnalysis(goalId, active, zh, readSourceUrl); @@ -509,7 +508,7 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist // Keep an explicitly opened study in place during a read failure. A complete // catalogue with no study still uses the ordinary history view. const awaitingRequestedCase = requestedCase === true && (analysis.loading || Boolean(analysis.readError)); - if (hasNativeSource && strategies.selection !== null) { + if (hasNativeSource && strategies.selection !== null && !emptySourceHistory) { return strategies.choose(null)}/>; @@ -536,7 +535,7 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist

{copy.scope} · {copy.time}{zh ? "。" : ". "}{copy.coverage}{zh ? "。" : "."}

{copy.interpretation}{zh ? "。" : "."}

{lens === "graph" ?

{zh ? "按来源分组,实线为已记录关联,虚线仅表示分组;排列不代表时间或进展。未关联问题按首次记录 UTC 日期收纳。" : "Grouped by source. Solid lines are recorded relationships; dotted lines only group nodes. Position does not imply time or progress. Unlinked questions are collected by first-record UTC date."}

: null}

{zh ? "合拢仅为避免遮挡,不表示关联或因果;选择一次可读全部原始记录与依据。" : "Nearby points group to avoid overlap, without implying a relationship or causality. Select once to read every original record and source."}

- {hasNativeSource ? : null}
@@ -568,18 +567,15 @@ export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHist
: null} - {emptySourceHistory ?
-
+ {emptySourceHistory ? <> +

{zh ? "这份来源没有可读的 Explore 记录。" : "This source has no readable Explore records."}

- {sourceStrategy ?
- {zh ? "最近一条策略记录" : "Last recorded strategy"} - -

{sourceStrategy.summary}

- {sourceStrategy.observation && sourceStrategy.observation !== sourceStrategy.summary ?

{sourceStrategy.observation}

: null} -
: null}
{onOpenSourceTasks ? : null} -
: emptyHistory && !emptyWithAnalysis ?
+
+ + : emptyHistory && !emptyWithAnalysis ?
{emptyHistoryMessage}

{zh ? "开启研究模式后,后续研究问题、尝试与证据会在这里形成可回读的历程。" : "Enable research mode to build a readable history of questions, attempts and evidence from later work."}

: null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-workbench.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-workbench.tsx index 42889f9377..f77d905385 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-workbench.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-workbench.tsx @@ -42,7 +42,7 @@ export function GoalResearchWorkbench(props: {goalId: string; active: boolean; z active={props.active} refreshRevision={props.refreshRevision}/> : null} {!props.statusNotice ? <>
setEvidenceOpen(event.currentTarget.open)}> {props.zh ? "阅读已保存的结论" : "Read saved findings"} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-source-strategies.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-source-strategies.css index 48eb64f38c..795559e77d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-source-strategies.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-source-strategies.css @@ -7,6 +7,14 @@ .source-strategies-navigation select { width: 100%; min-width: 0; min-height: 44px; padding: 8px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-bg); color: var(--pw-text); font: inherit; font-size: var(--text-body); text-overflow: ellipsis; } .source-strategies button:disabled { opacity: .5; cursor: default; } .source-strategies-meta { margin-block: 10px 24px; color: var(--pw-muted); font-size: var(--text-body); line-height: var(--leading-prose); } +.source-strategy-chronology { list-style: none; margin: 0; padding: 0; } +.source-strategy-chronology li { position: relative; margin-inline-start: 8px; padding: 0 0 20px 24px; border-inline-start: 1px solid var(--pw-line); } +.source-strategy-chronology li::before { content: ""; position: absolute; inset-inline-start: -4px; top: 20px; width: 7px; height: 7px; border-radius: 50%; background: var(--pw-muted); } +.source-strategy-chronology button { display: block; width: 100%; padding: 12px 16px; border: 1px solid var(--pw-line); border-radius: 8px; background: var(--pw-bg); color: var(--pw-text); text-align: start; font: inherit; font-size: var(--text-lead); line-height: var(--leading-prose); cursor: pointer; overflow-wrap: anywhere; } +.source-strategy-chronology button:hover { background: var(--pw-hover); } +.source-strategy-stamp { display: flex; flex-wrap: wrap; gap: 4px 16px; color: var(--pw-muted); margin-block-end: 8px; font-size: var(--text-body); } +.source-strategy-chronology strong, .source-strategy-observation { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; white-space: pre-wrap; } +.source-strategy-observation { margin-block-start: 8px; font-weight: 400; } .source-strategy-document { margin-block-start: 32px; min-width: 0; overflow-wrap: anywhere; font-size: var(--text-lead); line-height: var(--leading-prose); } .source-strategy-document section { margin-block: 28px; } .source-strategy-document h3 { margin-block: 0 10px; font-size: var(--text-body); font-weight: 600; line-height: var(--leading-prose); } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-source-strategies.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-source-strategies.tsx index 6c14417b93..9615a01a98 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-source-strategies.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-source-strategies.tsx @@ -51,14 +51,16 @@ export function useGoalStrategyLocation(goalId: string, sourceUrl?: string) { return {selection, choose, opener, scope}; } -export function GoalSourceStrategies({goalId, originalGoalId, sourceUrl, zh, selection, onSelect, onClose}: { +export function GoalSourceStrategies({goalId, originalGoalId, sourceUrl, zh, selection, onSelect, onClose, embedded = false}: { goalId: string; originalGoalId: string; sourceUrl?: string; zh: boolean; selection: string; - onSelect: (id: string, replace?: boolean) => void; onClose: () => void; + onSelect: (id: string, replace?: boolean) => void; onClose?: () => void; embedded?: boolean; }) { const [snapshot, setSnapshot] = useState(null); const [busy, setBusy] = useState(true), [failed, setFailed] = useState(false); const [revision, setRevision] = useState(0); const heading = useRef(null); + const overview = useRef(null); + const previousSelection = useRef(null); const namespace = sourceUrl ? new URL(sourceUrl, window.location.href).searchParams.get("source_namespace") : null; useEffect(() => { const controller = new AbortController(); @@ -75,18 +77,30 @@ export function GoalSourceStrategies({goalId, originalGoalId, sourceUrl, zh, sel }).catch(() => {if (current) setFailed(true);}).finally(() => {if (current) setBusy(false);}); return () => {current = false; controller.abort();}; }, [goalId, originalGoalId, sourceUrl, namespace, revision]); - useEffect(() => {heading.current?.focus({preventScroll: true});}, []); useEffect(() => { - if (selection === "all" && snapshot?.items[0]) onSelect(snapshot.items[0].id, true); - }, [selection, snapshot]); + const previous = previousSelection.current; + previousSelection.current = selection; + if (selection === "all" && previous && previous !== "all") { + const entry = overview.current?.querySelector(`[data-strategy-id="${versionId.test(previous) ? previous : ""}"]`); + entry?.focus({preventScroll: true}); entry?.scrollIntoView({block: "nearest"}); + } else if (!embedded || selection !== "all") heading.current?.focus({preventScroll: true}); + }, [selection, embedded]); const items = snapshot?.items ?? []; + // Only recorded time orders this view. It establishes neither execution time + // nor causal relationships; records without a usable time remain readable. + const chronology = [...items].sort((a, b) => { + const left = Date.parse(a.recorded_at), right = Date.parse(b.recorded_at); + return Number.isFinite(left) && Number.isFinite(right) ? left - right + : Number.isFinite(left) ? -1 : Number.isFinite(right) ? 1 : 0; + }); + const dated = chronology.filter(item => Number.isFinite(Date.parse(item.recorded_at))); const index = items.findIndex(item => item.id === selection); const selected = items[index]; const vision = record(selected?.agent_vision), patch = record(vision.vision_patch), path = record(vision.path_delta); const stride = record(vision.stride_decision), probe = record(stride.next_probe), review = record(stride.previous_review); const time = (stamp: string) => Number.isFinite(Date.parse(stamp)) ? new Intl.DateTimeFormat(zh ? "zh-CN" : "en", {dateStyle: "medium", timeStyle: "medium"}).format(new Date(stamp)) - : stamp || (zh ? "时间未记录" : "Time not recorded"); + : (zh ? "时间未记录" : "Time not recorded"); const prose = (label: string, value: unknown) => typeof value === "string" && value ?

{label}

: null; const list = (label: string, value: unknown) => Array.isArray(value) && value.length @@ -96,12 +110,12 @@ export function GoalSourceStrategies({goalId, originalGoalId, sourceUrl, zh, sel const nextLabels = zh ? {scope: "范围", prediction: "预期", adoption_criterion: "采用条件", cost_bound: "成本边界", minimum_evidence: "所需依据"} : {scope: "Scope", prediction: "Prediction", adoption_criterion: "Adoption criterion", cost_bound: "Cost bound", minimum_evidence: "Required evidence"}; return
- + {selection !== "all" || onClose ? : null}

{zh ? "策略历史" : "Strategy history"}

{zh - ? "直接读取原 Goal 保留的全部策略记录,原文保持原语言。它们与 Explore 事件分开;记录中的计划、采用声明和重规划并不证明后续行动或效果。打开和切换记录不会运行模型或实验。" - : "All retained strategy records are read directly from the original Goal, in their original language. They are separate from Explore events. Plans, adoption claims and replans do not establish subsequent actions or benefit. Reading records runs no model or experiment."}

+ ? "直接读取原 Goal 保留的全部策略记录,原文保持原语言,按记录时间(本地)排列。记录空窗不等于没有工作,时间相邻不代表继承或因果。它们与 Explore 事件分开;计划、采用声明和重规划并不证明后续行动或效果。打开和切换记录不会运行模型或实验。" + : "All retained strategy records are read directly from the original Goal, in their original language, ordered by recorded time (local). Gaps do not establish inactivity; adjacency does not establish inheritance or causality. These are separate from Explore events. Plans, adoption claims and replans do not establish subsequent actions or benefit. Reading records runs no model or experiment."}

{failed ?
{snapshot ? (zh ? "刷新失败,当前记录已保留" : "Refresh failed; current records are preserved") @@ -109,15 +123,28 @@ export function GoalSourceStrategies({goalId, originalGoalId, sourceUrl, zh, sel
: null} {busy && !snapshot ?

{zh ? "正在读取全部策略记录…" : "Reading all strategy records…"}

: null} {snapshot ? <> - : null} +

{snapshot.total} {zh ? "份策略记录" : "strategy records"}{dated.length < items.length ? <> · {items.length - dated.length} {zh ? "份时间未记录" : "without a recorded time"} : null}{dated.length ? <> · {zh ? "记录时间(本地)" : "Recorded time (local)"}
+ — : null}

+ {selection === "all" && items.length ?
    + {chronology.map(item => { + const itemVision = record(item.agent_vision), itemPatch = record(itemVision.vision_patch), itemPath = record(itemVision.path_delta); + const summary = typeof itemPatch.vision_summary === "string" && itemPatch.vision_summary; + const observation = typeof itemPath.observed_reality === "string" && itemPath.observed_reality; + return
  1. ; + })} +
: null} {!items.length ?

{zh ? "原 Goal 没有保留策略记录。" : "The original Goal has no retained strategy records."}

: !selected && selection !== "all" ?

{zh ? "原来源中找不到所选版本。可选择其他记录。" : "The selected version is absent from this source. Other records remain readable."}

: null} {selected ?
diff --git a/docs/development/frontend-delivery.md b/docs/development/frontend-delivery.md index 9513df981f..79ab85135a 100644 --- a/docs/development/frontend-delivery.md +++ b/docs/development/frontend-delivery.md @@ -136,13 +136,18 @@ source; the same physical source cannot be linked twice. Removing an aliased link uses its workspace id and current physical namespace. Linked-source research opens native history by default; an authored case reading remains optional. When a complete linked history read has no Explore records, show that limit -alongside any retained strategy and a route to the original Tasks. A failed +alongside the retained strategy chronology and a route to the original Tasks. A failed read still offers recovery, and a linked reader never offers to enable research mode on the original Goal. The **Strategy history** action reads every retained `agent_vision` version through the original Goal's native history, including versions outside the -status window. It preserves original language, negative results and unresolved +status window. Its default overview spans all retained records by recorded time; +it never silently selects the latest version. Sources proven to have no Explore +records show this overview directly. Other sources keep their Explore history +and load strategy history only on request. Unknown times stay visible, and time +adjacency establishes neither inactivity, inheritance nor causal benefit. +It preserves original language, negative results and unresolved questions. The focused reader supports exact-version links, reload, Back/Forward and unavailable-version recovery; missing versions never select the latest one silently. Plans and recorded adoption claims remain distinct from demonstrated diff --git a/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs b/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs index 1171dc630f..6125fc87a0 100644 --- a/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs @@ -37,7 +37,7 @@ class Source(GoalSource): for i in range(23): rows.append(dict(goal_id=self.goal_id,agent_id='source-worker',classification='state_recorded', json_path='native-record-'+str(i)+'.json', - generated_at=(datetime(2026,1,1,tzinfo=timezone.utc)+timedelta(minutes=41*i)).isoformat(), + generated_at='' if i == 7 else (datetime(2026,1,1,tzinfo=timezone.utc)+timedelta(minutes=41*i)).isoformat(), agent_vision=dict(schema_version='goal_vision_replan_contract_v0',agent_id='source-worker', vision_patch=dict(vision_summary=self.id+' original objective '+str(i),acceptance_summary='Compare independently checked outcomes.'), path_delta=dict(prior_assumption='A candidate might improve the original outcome.', @@ -143,12 +143,21 @@ try { await page.screenshot({path:resolve(output,"strategies-before-desktop.png")}); await page.locator(".research-strategy-entry").click(); const strategies = page.getByRole("region",{name:"策略历史",exact:true}); - await strategies.getByText("Retained original objective 22",{exact:true}).waitFor(); + const chronology = strategies.getByRole("list",{name:"全部策略记录",exact:true}); + await chronology.getByText("Retained original objective 22",{exact:true}).waitFor(); + assert.equal(new URL(page.url()).searchParams.get("research_state"),"all","Opening history keeps the whole chronology instead of selecting the latest record"); + assert.equal(await chronology.locator("li").count(),23,"The overview retains every version beyond the status window"); + assert.match(await chronology.locator("li").first().innerText(),/Retained original objective 0/); + assert.match(await chronology.locator("li").last().innerText(),/时间未记录[\s\S]*Retained original objective 7/,"Unknown recorded times stay readable without inventing a date"); + const span = await strategies.locator(".source-strategies-meta").first().locator("time").evaluateAll(elements=>elements.map(element=>element.getAttribute("datetime"))); + assert.equal(Date.parse(span[1])-Date.parse(span[0]),15*3600000+2*60000,"The overview spans the complete fifteen-hour record window"); + const earliestStrategy = await chronology.locator("button").first().getAttribute("data-strategy-id"); + await page.screenshot({path:resolve(output,"strategies-overview-desktop.png")}); + await chronology.getByRole("button").filter({hasText:"Retained original objective 22"}).click(); assert.ok(await strategies.getByText("Retained original objective 22",{exact:true}).evaluate(element=>parseFloat(getComputedStyle(element).fontSize)>=16),"Original prose keeps the reading-body size"); const strategySelect = strategies.getByRole("combobox",{name:"策略记录",exact:true}); assert.equal(await strategySelect.locator("option").count(),23); const newestStrategy = new URL(page.url()).searchParams.get("research_state"); - const earliestStrategy = await strategySelect.locator("option").last().getAttribute("value"); assert.notEqual(newestStrategy,earliestStrategy); await page.screenshot({path:resolve(output,"strategies-desktop.png")}); await strategySelect.selectOption(earliestStrategy); @@ -190,6 +199,9 @@ try { await strategies.getByText("原来源中找不到所选版本。可选择其他记录。",{exact:true}).waitFor(); assert.equal(await strategies.getByRole("article",{name:"策略原文",exact:true}).count(),0,"Missing exact versions never substitute the latest one"); await strategySelect.selectOption(earliestStrategy); + await strategies.getByRole("button",{name:"返回策略总览",exact:true}).click(); + await page.waitForFunction(id=>document.activeElement?.dataset.strategyId===id,earliestStrategy); + await page.screenshot({path:resolve(output,"strategies-overview-phone.png")}); await strategies.getByRole("button",{name:"返回研究历程",exact:true}).focus(); await page.keyboard.press("Enter"); await page.waitForFunction(()=>document.activeElement?.classList.contains("research-strategy-entry")); @@ -255,11 +267,16 @@ try { await page.getByRole("heading",{name:"Distinct research",exact:true}).waitFor(); await page.locator(".personal-goal-tabs").getByRole("button",{name:"研究",exact:true}).click(); await page.getByText("这份来源没有可读的 Explore 记录。",{exact:true}).waitFor(); - await page.locator(".research-strategy-entry").click(); - await strategies.getByText("Distinct original objective 22",{exact:true}).waitFor(); + await chronology.getByText("Distinct original objective 22",{exact:true}).waitFor(); + assert.equal(new URL(page.url()).searchParams.get("research_state"),null,"A proven-empty Explore source shows all original strategies without another interaction"); + assert.equal(await chronology.locator("li").count(),23); assert.equal(await strategies.getByText("Retained original objective 22",{exact:true}).count(),0); assert.equal(await page.locator(".research-history-node").count(),0,"Native strategies do not fabricate Explore nodes"); - await strategies.getByRole("button",{name:"返回研究历程",exact:true}).click(); + await page.screenshot({path:resolve(output,"empty-explore-strategies-desktop.png")}); + await chronology.getByRole("button").filter({hasText:"Distinct original objective 0"}).click(); + await strategies.getByRole("article",{name:"策略原文",exact:true}).getByText("Distinct original objective 0",{exact:true}).waitFor(); + await strategies.getByRole("button",{name:"返回策略总览",exact:true}).click(); + await page.waitForFunction(()=>document.activeElement?.dataset.strategyId); await page.getByLabel("选择控制面来源",{exact:true}).click(); await page.getByRole("option",{name:"Retained",exact:true}).click(); await page.getByRole("button",{name:/Retained research/}).first().waitFor();