From 6f6688ffa316897a79489c0717e39e1b3e73b1e9 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Mon, 26 Oct 2026 22:57:57 +0800 Subject: [PATCH 1/4] fix(dashboard): collapse empty thirds on short Explore histories When all Explore records fall within a sub-6h window, the time-anchored reading model split them into three equal phases, leaving two empty early/middle/late cards (observed on EdgeBench runs whose imported log spanned minutes). Below the threshold the timeline now renders one whole-history reading group; long spans keep the three thirds. Adds a control-plane unit test covering both spans. Signed-off-by: huangruiteng --- .../goal-research-timeline.tsx | 6 +++-- .../presentation/explore_timeline.ts | 11 +++++++++- .../control_plane_ts/explore_history.test.ts | 22 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) 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 6d097e5981..adad9dbf19 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 @@ -44,6 +44,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedDay, da span: "记录跨度", start: "起点", end: "末端", replan: "转向", continue: "继续", stop: "停止", wait: "等待", no_change: "无变化", ask_human: "请人决定", ...findingCopy.labels, unlabeled: "无结论", phaseNote: "按记录时间三等分,仅为阅读分组,不是预声明的评估窗口。", noTime: "没有可用的记录时间,无法放上时间轴;下方记录仍可完整阅读。", + whole: "全程", } : { early: "Early", middle: "Middle", late: "Late", items: "Research items", findings: "Findings", decisions: "Explicit decisions", none: "No explicit decision recorded", changed: "Changed", stopped: "Stopped", retained: "Retained", @@ -54,6 +55,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedDay, da 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.", + whole: "Whole run", }; const {start, end} = timeline; if (start === null || end === null) return

{copy.noTime}

; @@ -89,7 +91,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedDay, da 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 phaseName = (item: TimelinePhase) => [copy.early, copy.middle, copy.late][item.index]; + 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); return [ @@ -111,7 +113,7 @@ export function GoalResearchTimeline({timeline, zh, selectedIds, selectedDay, da }; return
{zh ? "记录时间" : "Record time"} -

{copy.phaseNote}

{copy.densityHint}{zh ? "。" : ". "}{zh ? "无记录空档不代表研究停止。" : "A recording gap does not establish that research stopped."}

+

{timeline.phases.length === 1 ? (zh ? "全部记录归入同一阅读分组,仅为阅读组织,不是预声明的评估窗口。" : "All records share one reading group, for reading only; not a pre-declared evaluation window.") : copy.phaseNote}

{copy.densityHint}{zh ? "。" : ". "}{zh ? "无记录空档不代表研究停止。" : "A recording gap does not establish that research stopped."}

{!timeline.structuredCount ?

{copy.unstructuredHint}

: null}

{copy.routeHint}{zh ? "。" : ". "}{zh ? "相邻点合拢,数字为研究项数,一次展开原始时间与依据。" : "Nearby points group; the number counts research items. Select once for original times and sources."}

{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."}

diff --git a/loopx/control_plane/presentation/explore_timeline.ts b/loopx/control_plane/presentation/explore_timeline.ts index 2ce278af99..8a2d651299 100644 --- a/loopx/control_plane/presentation/explore_timeline.ts +++ b/loopx/control_plane/presentation/explore_timeline.ts @@ -156,7 +156,11 @@ export function buildExploreTimeline(nodes: readonly ExploreHistoryNode[], edges const span = start !== null && end !== null ? Math.max(1, end - start) : 0; const binCount = span ? Math.max(1, Math.min(targetBins, Math.ceil(span / 3_600_000))) : 0; const bins: TimelineBin[] = Array.from({length: binCount}, (_, index) => ({start: start! + span * index / binCount, end: start! + span * (index + 1) / binCount, events: 0, nodes: 0, labels: {}, decisions: {}})); - const phases: TimelinePhase[] = span ? [0, 1, 2].map(index => ({index, start: start! + span * index / 3, end: start! + span * (index + 1) / 3, nodes: 0, findings: 0, labels: {}, decisions: {}, lastDecision: null})) : []; + // Thirds only when the recorded span is long enough; short runs (all + // records within minutes) get one whole-history group instead of empty + // early/middle/late cards. + const phaseCount = span >= MIN_THIRDS_SPAN_MS ? 3 : 1; + const phases: TimelinePhase[] = span ? Array.from({length: phaseCount}, (_, index) => ({index, start: start! + span * index / phaseCount, end: start! + span * (index + 1) / phaseCount, nodes: 0, findings: 0, labels: {}, decisions: {}, lastDecision: null})) : []; const slot = (items: T[], at: number) => items[Math.min(items.length - 1, Math.floor((at - start!) / span * items.length))]!; for (const event of events) { const at = time(event.recorded_at); if (at !== null && bins.length) slot(bins, at).events++; } for (const item of timelineNodes.values()) if (item.first !== null && bins.length) { slot(bins, item.first).nodes++; slot(phases, item.first).nodes++; } @@ -172,3 +176,8 @@ export function buildExploreTimeline(nodes: readonly ExploreHistoryNode[], edges } return {start, end, bins, phases, nodes: timelineNodes, routes, singles, edges, findings, decisions, structuredCount: findings.filter(item => item.sections).length}; } + +/** Minimum record-time span where thirds are meaningful. Below it a single + * "whole history" reading group avoids empty early/middle/late cards. */ +export const MIN_THIRDS_SPAN_MS = 6 * 60 * 60_000; +export const WHOLE_HISTORY_PHASE = "whole"; diff --git a/tests/control_plane_ts/explore_history.test.ts b/tests/control_plane_ts/explore_history.test.ts index b3139a1afa..c3d22f9b82 100644 --- a/tests/control_plane_ts/explore_history.test.ts +++ b/tests/control_plane_ts/explore_history.test.ts @@ -109,6 +109,28 @@ test("time-anchored reading keeps record-time thirds, routes and explicit decisi assert.equal(parseExploreFindingSections("free-form finding text"), null, "unstructured findings stay raw prose"); }); +test("short record span collapses thirds into one whole-history reading group", async () => { + const {buildExploreTimeline, MIN_THIRDS_SPAN_MS} = await import("../../loopx/control_plane/presentation/explore_timeline.ts"); + // All records within one minute: thirds would create two empty reading groups. + const rows = [ + event({event_id: "n1", result_id: "q1", title: "First", recorded_at: "2026-01-01T00:00:00Z"}), + event({event_id: "n2", result_id: "q2", title: "Second", recorded_at: "2026-01-01T00:00:30Z"}), + event({event_id: "f1", event_kind: "finding", result_id: "f1", node_id: "q1", status: "confirmed", recorded_at: "2026-01-01T00:00:45Z"}), + ]; + const timeline = buildExploreTimeline(projectExploreHistory(rows, goal).nodes, [], projectExploreHistory(rows, goal).events); + assert.equal(timeline.phases.length, 1, "a sub-6h span reads as one whole-history group"); + assert.equal(timeline.phases[0]!.nodes, 2); + assert.equal(timeline.phases[0]!.findings, 1); + // Long spans keep the three reading groups. + const long = [ + event({event_id: "l1", result_id: "lq1", recorded_at: "2026-01-01T00:00:00Z"}), + event({event_id: "l2", result_id: "lq2", recorded_at: "2026-01-01T12:00:00Z"}), + ]; + const longTimeline = buildExploreTimeline(projectExploreHistory(long, goal).nodes, [], projectExploreHistory(long, goal).events); + assert.equal(longTimeline.phases.length, 3); + assert.ok(MIN_THIRDS_SPAN_MS > 0); +}); + test("phase reading retains successive decisions for one question, including a late backfill", async () => { const {buildExploreTimeline, exploreTimelinePhaseAt, selectExploreTimelineDecisions} = await import("../../loopx/control_plane/presentation/explore_timeline.ts"); const finding = (id: string, at: string, outcome: string) => event({event_id: id, event_kind: "finding", result_id: id, node_id: "route", recorded_at: at, status: "tentative", From 006065e6bf0507c538165aa923076ed9f4c6dc60 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Tue, 27 Oct 2026 16:16:48 +0800 Subject: [PATCH 2/4] fix(dashboard): readable empty vitals and dev status proxy for extension research - Unmeasured Goal vitals (Agent time, cost, last activity, needs-you at zero) rendered bare em dashes with click affordance; they now show their own explanatory value (not measured / nothing waiting / no activity) as muted, non-interactive cells instead. - The manager brief hid the queued-Goal count entirely when no verified run was present; it now shows a separate queued-only note so Goals handed to host app threads stay visible. - Research results treated a missing optional presentation index (404/410) as a read failure and surfaced the dev server's HTML as a JSON SyntaxError; a missing index now reads as no published research. - The dashboard dev proxy forwards the status server's extension and periodic-report paths, so the research workspace works over the vite port instead of returning the SPA HTML. Signed-off-by: huangruiteng --- .../personal-workspace/goal-masthead.css | 6 +++++ .../personal-workspace/goal-masthead.tsx | 25 +++++++++++-------- .../goal-research-results.tsx | 9 +++++-- .../src/features/personal-workspace/i18n.tsx | 2 ++ .../personal-workspace/manager-brief.tsx | 2 +- apps/presentation/dashboard/vite.config.ts | 18 +++++++------ 6 files changed, 40 insertions(+), 22 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.css index 9749cde40f..65aa75ba37 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.css @@ -98,3 +98,9 @@ .goal-masthead-title > button:focus-visible { outline: 2px solid var(--pw-blue); outline-offset: 2px; } .personal-channel-title .goal-masthead-context { white-space: nowrap; } } + +/* Unmeasured vitals are readable facts, not actionable values: muted text, + no hover affordance, and the en dash never replaces the explanation. */ +.goal-vital.is-empty { cursor: default; } +.goal-vital.is-empty:hover { background: transparent; } +.goal-vital.is-empty .goal-vital-value { color: var(--pw-faint); font-size: var(--text-body); font-weight: 500; letter-spacing: 0; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.tsx index 733d30bad4..1be316a35e 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.tsx @@ -140,11 +140,11 @@ export type GoalVitalRegion = "overview" | "tasks" | "attention" | "usage"; const ribbonOrder = ["done", "running_observed", "needs_you", "open", "waiting_dependency", "blocked", "deferred"] as const; -function Vital({ children, extra, help, label, onClick, sub, tone, valueKey }: { - children: ReactNode; extra?: ReactNode; help?: ReactNode; label: string; onClick: () => void; sub?: ReactNode; tone?: string; valueKey?: string; +function Vital({ children, empty, extra, help, label, onClick, sub, tone, valueKey }: { + children: ReactNode; empty?: boolean; extra?: ReactNode; help?: ReactNode; label: string; onClick: () => void; sub?: ReactNode; tone?: string; valueKey?: string; }) { return ( -
{busy ?

{zh ? "正在核验研究结果…" : "Verifying research results…"}

: null} - {error ?

{zh ? "无法核验研究结果;旧内容已清除。" : "Cannot verify research results; previous content was cleared."} {error}

: null} + {error && !indexMissing ?

{zh ? "无法核验研究结果;旧内容已清除。" : "Cannot verify research results; previous content was cleared."} {error}

: null} {!busy && !error && !results.length ?

{zh ? "本 Goal 暂无可读取的公开研究成果。" : "No readable public research results for this Goal yet."}

: null} {results.map(result => = { "brief.runningReading": "正在读取执行状态…", "brief.runningModeUnrecognized": "没有可确认的执行 · {count} 个 Goal 执行方式未知", "brief.alsoQueued": "另有 {count} 个已安排", + "brief.queuedOnly": "{count} 个已安排 · 由宿主 App 线程负责", "brief.claimed": "宿主已领取", "brief.claimedEmpty": "没有交给宿主的工作", "brief.completed": "最近完成", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/manager-brief.tsx b/apps/presentation/dashboard/src/features/personal-workspace/manager-brief.tsx index 094754f508..9c6a320f97 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/manager-brief.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/manager-brief.tsx @@ -89,7 +89,7 @@ export function ManagerBrief({ goals, onSelectGoal, operations = [], onSelectOpe title={t("brief.operationDeliveryPending")} /> : null} presentGoalActivity(goal).live)} onSelectGoal={onSelectGoal} rows={running.map((goal) => ({ goal, key: goal.goalId, meta: , text: goal.title }))} - title={t("brief.running")} total={running.length && queued ? t("brief.alsoQueued", { count: queued }) : null} /> + title={t("brief.running")} total={queued ? (running.length ? t("brief.alsoQueued", { count: queued }) : t("brief.queuedOnly", { count: queued })) : null} /> {claimed.length ? ({ goal, key: goal.goalId, meta: , text: goal.title }))} title={t("brief.claimed")} /> : null} diff --git a/apps/presentation/dashboard/vite.config.ts b/apps/presentation/dashboard/vite.config.ts index 13df2c01ef..dc2328fd91 100644 --- a/apps/presentation/dashboard/vite.config.ts +++ b/apps/presentation/dashboard/vite.config.ts @@ -2,6 +2,11 @@ import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import { defineConfig, type ProxyOptions } from "vite"; +const statusTarget = "http://127.0.0.1:8766"; +const statusProxy: ProxyOptions = { + target: statusTarget, + changeOrigin: false, +}; const chatTarget = "http://127.0.0.1:8767"; const chatProxy: ProxyOptions = { target: chatTarget, @@ -23,14 +28,11 @@ export default defineConfig({ host: "127.0.0.1", port: 5173, proxy: { - "/periodic-report-workspace": { - target: "http://127.0.0.1:8766", - changeOrigin: false, - }, - "/status.json": { - target: "http://127.0.0.1:8766", - changeOrigin: false, - }, + "/periodic-report-workspace": statusProxy, + "/periodic-report-workspace-projection": statusProxy, + "/extension-presentation-surfaces": statusProxy, + "/extension-projection": statusProxy, + "/status.json": statusProxy, "/ssh-hosts": chatProxy, "/api/chat": chatProxy, "/api/actions": chatProxy, From c8d679679dac1e7b8c22da888740dafcdf1f4a02 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Tue, 27 Oct 2026 20:30:40 +0800 Subject: [PATCH 3/4] fix(dashboard): preserve unknown needs-you coverage Signed-off-by: huangruiteng --- .../src/features/personal-workspace/goal-masthead.tsx | 6 ++++-- examples/personal-workspace-browser/goal-masthead.mjs | 8 +++++--- .../personal-workspace-browser/navigation-sorting.mjs | 4 ++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.tsx index 1be316a35e..9c93b9d070 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-masthead.tsx @@ -190,6 +190,8 @@ export function GoalVitals({ goal, items, onFocusRegion, userTodos, digest: shar const windowLabel = digest.usage.window === "7d" ? t("vitals.window7d") : digest.usage.window === "24h" ? t("vitals.window24h") : t("vitals.notMeasured"); const needs = digest.needsYou; + const needsEmpty = needs.availability === "ok" && needs.coverage === "complete" + && needs.blocking === 0 && needs.optional === 0; const last = digest.lastActivity; const lastTime = last ? relativeTime(last.at, locale) : null; const stateLabel = finishing ? t("vitals.stopping") : t(activity.labelKey as WorkspaceMessageKey); @@ -215,14 +217,14 @@ export function GoalVitals({ goal, items, onFocusRegion, userTodos, digest: shar }> {work.total === null ? work.open : {work.done}/{work.total}} - onFocusRegion("attention")} valueKey={`${goal.goalId}:${needs.availability}:${needs.availability === "ok" ? needs.blocking : ""}`} + onFocusRegion("attention")} valueKey={`${goal.goalId}:${needs.availability}:${needs.availability === "ok" ? needs.blocking : ""}`} tone={needs.availability === "ok" && needs.blocking !== null && needs.blocking > 0 ? "danger" : needs.availability === "ok" ? "recede" : undefined} help={needs.availability !== "ok" || needs.coverage !== "complete" ?

{needs.availability === "ok" ? t("vitals.needsYouPartial", {count: needs.listed}) : t("vitals.unavailable")}

: undefined} sub={needs.availability !== "ok" ? null : [ needs.oldestWaitingSince ? t("vitals.needsYouOldest", { time: relativeTime(needs.oldestWaitingSince, locale) ?? "" }) : null, needs.optional ? t("vitals.needsYouOptional", { count: needs.optional }) : null, ].filter(Boolean).join(" · ")}> - {needs.availability !== "ok" ? t("vitals.unavailable") : needs.blocking === null ? t("vitals.needsYouNone") + {needs.availability !== "ok" ? t("vitals.unavailable") : needs.blocking === null ? "—" : needsEmpty ? t("vitals.needsYouNone") : <>{needs.blocking > 0 ? : null}{needs.blocking}}
onFocusRegion("usage")} sub={digest.usage.agentTimeMs === null ? undefined : windowLabel} diff --git a/examples/personal-workspace-browser/goal-masthead.mjs b/examples/personal-workspace-browser/goal-masthead.mjs index 626ca2cdce..0d7bd04ff5 100644 --- a/examples/personal-workspace-browser/goal-masthead.mjs +++ b/examples/personal-workspace-browser/goal-masthead.mjs @@ -186,7 +186,7 @@ export const goalMastheadScenario = { await page.getByTestId("goal-vitals").waitFor({ state: "visible", timeout: 15_000 }); assert.equal(await page.locator(".personal-channel-title h1").innerText(), title, "The stopped Goal stays selected across reload"); assert.equal((await vital(page, "状态")).value, "已停止", "Without an observed turn the Goal reads stopped"); - assert.equal((await vital(page, "等你决定")).value, "0", "A stopped Goal leaves active attention"); + assert.equal((await vital(page, "等你决定")).value, "暂无等待", "A stopped Goal with complete empty attention names the empty state"); await page.locator(".goal-lifecycle-trigger", { hasText: "恢复" }).click(); assert.equal(await page.getByRole("dialog", { name: "恢复这个 Goal?" }).count(), 0, "Resume uses the owner's reviewed preview, not a second confirmation"); @@ -220,9 +220,11 @@ export const goalMastheadScenario = { await openGoal(quiet.page, title); const agentTime = await vital(quiet.page, "Agent 用时"); const cost = await vital(quiet.page, "成本"); - assert.deepEqual([agentTime.value, cost.value, cost.sub], ["—", "—", ""], "Absent usage is unknown, never $0.00"); + assert.deepEqual([agentTime.value, cost.value, cost.sub], ["未采集", "未采集", ""], "Absent usage names the unmeasured state, never $0.00"); + assert.deepEqual(await Promise.all([agentTime.cell, cost.cell].map(cell => cell.getAttribute("aria-disabled"))), ["true", "true"], "Unmeasured usage has no navigation affordance"); const last = await vital(quiet.page, "最近活动"); - assert.deepEqual([last.value, last.sub], ["—", ""], "A claim or Todo write is not activity"); + assert.deepEqual([last.value, last.sub], ["暂无观测到的活动", ""], "A claim or Todo write is not activity"); + assert.equal(await last.cell.getAttribute("aria-disabled"), "true", "Missing activity has no navigation affordance"); assert.match(await readVitalHelp(quiet.page, cost.cell), /未采集/); assert.match(await readVitalHelp(quiet.page, last.cell), /暂无观测到的活动/); assert.notEqual((await vital(quiet.page, "状态")).value, "执行中"); diff --git a/examples/personal-workspace-browser/navigation-sorting.mjs b/examples/personal-workspace-browser/navigation-sorting.mjs index d818319f0e..f86dffc5fc 100644 --- a/examples/personal-workspace-browser/navigation-sorting.mjs +++ b/examples/personal-workspace-browser/navigation-sorting.mjs @@ -179,8 +179,8 @@ export const navigationSortingScenario = { const usageVitals = page.getByTestId("goal-vitals").locator(".goal-vital-cell") .filter({ has: page.locator(".goal-vital-label", { hasText: /^(Agent 用时|成本)$/ }) }); const unknownUsage = await usageVitals.locator(".goal-vital-value").allTextContents(); - if (unknownUsage.length !== 2 || unknownUsage.some(value => value !== "—")) { - throw new Error(`Unknown usage became a zero or disappeared: ${unknownUsage}`); + if (unknownUsage.length !== 2 || unknownUsage.some(value => value !== "未采集")) { + throw new Error(`Unknown usage lost its explicit unmeasured state: ${unknownUsage}`); } for (const label of ["Agent 用时", "成本"]) { const cell = usageVitals.filter({ has: page.getByText(label, { exact: true }) }); From ed6baf49bae6262872ae7310f7976f959184e005 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:55:41 +0800 Subject: [PATCH 4/4] fix(dashboard): render readable research state for missing indexes Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../goal-research-results.tsx | 12 +++--- .../research-results.mjs | 39 +++++++++++++++++-- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-results.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-results.tsx index f1aebb8df3..572300ed5b 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-results.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-results.tsx @@ -19,6 +19,9 @@ export function GoalResearchResults({goalId, api, zh, hideEmpty = false}: {goalI void (async () => { try { const response = await fetch(api.indexUrl, {cache: "no-store"}); + // This optional index may be absent. Keep the normal empty-state path; + // other HTTP and decoding failures must retain their error and retry. + if (response.status === 404 || response.status === 410) return; if (!response.ok) throw new Error(`HTTP ${response.status}`); const collection = parsePresentationSurfaceCollectionResponse(await response.json()); const surfaces = collection.items.filter(surface => surface.goal_id === goalId @@ -39,12 +42,7 @@ export function GoalResearchResults({goalId, api, zh, hideEmpty = false}: {goalI return () => {current = false;}; }, [goalId, api.indexUrl, api.detailUrl, revision]); - // The research index is an optional extension. A missing index (404/410) - // means "no published research for this Goal", not a read failure; old - // content in that state is simply empty rather than an HTML error string. - const indexMissing = error.includes("HTTP 404") || error.includes("HTTP 410"); - - if (hideEmpty && !busy && !results.length && (indexMissing || !error)) return null; + if (hideEmpty && !busy && !error && !results.length) return null; return

{zh ? "研究成果" : "Research results"}

@@ -52,7 +50,7 @@ export function GoalResearchResults({goalId, api, zh, hideEmpty = false}: {goalI
{busy ?

{zh ? "正在核验研究结果…" : "Verifying research results…"}

: null} - {error && !indexMissing ?

{zh ? "无法核验研究结果;旧内容已清除。" : "Cannot verify research results; previous content was cleared."} {error}

: null} + {error ?

{zh ? "无法核验研究结果;旧内容已清除。" : "Cannot verify research results; previous content was cleared."} {error}

: null} {!busy && !error && !results.length ?

{zh ? "本 Goal 暂无可读取的公开研究成果。" : "No readable public research results for this Goal yet."}

: null} {results.map(result => { indexes++; if (nativeUrl && mode === "ready") return route.continue(); + if (["404", "410", "500"].includes(mode)) return route.fulfill({status: Number(mode), json: {ok: false}}); + if (mode === "html") return route.fulfill({contentType: "text/html", body: "HTTP 404 is diagnostic text, not the response status"}); if (mode === "malformed") return route.fulfill({json: {ok: true}}); const items = mode === "disabled" ? [] : [{...surface, visibility: mode === "owner-only" ? "owner-only" : "public-safe"}]; return route.fulfill({json: {ok: true, presentation_surfaces: { @@ -95,6 +97,28 @@ export const researchResultsScenario = { mode = "disabled"; await refresh.click(); await results.getByText("本 Goal 暂无可读取的公开研究成果。", {exact: true}).waitFor(); + for (const status of ["404", "410"]) { + mode = status; + await refresh.click(); + await results.getByText("本 Goal 暂无可读取的公开研究成果。", {exact: true}).waitFor(); + assert.equal(await results.getByRole("alert").count(), 0, "A missing optional index keeps readable empty-state content"); + await refresh.focus(); + await page.keyboard.press("Enter"); + await results.getByText("本 Goal 暂无可读取的公开研究成果。", {exact: true}).waitFor(); + assert.equal(await results.getByText("123456789.1234567891 SYN", {exact: true}).count(), 0); + } + mode = "500"; + await refresh.click(); + await results.getByRole("alert").filter({hasText: "HTTP 500"}).waitFor(); + assert.equal(await results.getByText("本 Goal 暂无可读取的公开研究成果。", {exact: true}).count(), 0); + mode = "html"; + await refresh.click(); + await results.getByRole("alert").waitFor(); + assert.equal(await results.getByText("本 Goal 暂无可读取的公开研究成果。", {exact: true}).count(), 0, + "Diagnostic text mentioning HTTP 404 is not an absent index"); + mode = "disabled"; + await refresh.click(); + await results.getByText("本 Goal 暂无可读取的公开研究成果。", {exact: true}).waitFor(); const priorReads = reads; mode = "owner-only"; await refresh.click(); @@ -112,16 +136,25 @@ export const researchResultsScenario = { await page.getByRole("navigation", {name: "Goal 视图"}).getByRole("button", {name: "成果", exact: true}).click(); await results.getByText("本 Goal 暂无可读取的公开研究成果。", {exact: true}).waitFor(); assert.equal(await results.getByText("123456789.1234567891 SYN", {exact: true}).count(), 0); + mode = "404"; + await refresh.click(); + await results.getByText("本 Goal 暂无可读取的公开研究成果。", {exact: true}).waitFor(); + await page.locator(".personal-goal-link", {hasText: "Product Release"}).click(); + await page.getByRole("button", {name: "成果", exact: true}).click(); + await results.getByText("本 Goal 暂无可读取的公开研究成果。", {exact: true}).waitFor(); assert.equal(api.turnRequests.length, 0, "Reading research must not invoke a model"); + mode = "ready"; await page.evaluate(() => localStorage.setItem("loopx-pw-locale", "en")); await page.reload({waitUntil: "networkidle"}); await page.locator(".personal-goal-link", {hasText: "Product Release"}).click(); await page.getByRole("button", {name: "Files", exact: true}).click(); const english = page.getByRole("region", {name: "Research results", exact: true}); await english.getByText("123456789.1234567891 SYN", {exact: true}).waitFor(); - mode = "disabled"; - await english.getByRole("button", {name: "Refresh", exact: true}).click(); - await english.getByText("No readable public research results for this Goal yet.", {exact: true}).waitFor(); + for (const status of ["disabled", "404", "410"]) { + mode = status; + await english.getByRole("button", {name: "Refresh", exact: true}).click(); + await english.getByText("No readable public research results for this Goal yet.", {exact: true}).waitFor(); + } assert.equal(await english.getByText("123456789.1234567891 SYN", {exact: true}).count(), 0); assert.equal(api.turnRequests.length, 0, "Localized research refresh must not invoke a model"); return {note: `Packaged results consume Goal-scoped public research, preserve uncertainty, and clear stale/disabled/wrong-Goal contents; ${nativeUrl ? "real isolated Core publication/HTTP, scripted Goal directory and corruption cases" : "scripted API fixture"}`,