diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.css index 4a42826ef9..639a1043b2 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.css @@ -58,3 +58,11 @@ @keyframes analysis-request-spin { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { .analysis-spin { animation: none; } } @media (max-width: 640px) { .research-analysis { padding: 16px; } .analysis-episode > summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; } .analysis-episode-verdicts { grid-column: 1; grid-row: 2; flex-direction: column; gap: 0; } .analysis-episode > summary > svg { grid-column: 2; grid-row: 1 / 3; } .analysis-episode-context { grid-template-columns: 1fr; gap: 12px; } .analysis-method dl { grid-template-columns: 1fr; gap: 2px; } .analysis-method dd { margin-bottom: 8px; } .analysis-pack-choice { flex-direction: column; align-items: stretch; } } + +/* Readback has one status surface, shared by the summary and its detail. */ +.analysis-read-error,.analysis-read-empty{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-block:12px;color:var(--pw-muted)} +.analysis-read-error>button,.analysis-read-empty>button{display:inline-flex;align-items:center;gap:4px;min-height:44px;border:1px solid var(--pw-line);border-radius:6px;padding:8px 12px;color:var(--pw-text);background:var(--pw-bg);font:inherit;cursor:pointer} +.analysis-read-error>button:disabled,.analysis-read-empty>button:disabled{opacity:.5;cursor:default} +.analysis-read-loading{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;padding-block:16px} +.analysis-read-loading>span{height:5rem;border-radius:6px;background:var(--pw-line)} +@media(max-width:640px){.analysis-read-loading{grid-template-columns:repeat(2,minmax(0,1fr))}} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx index 03642fe064..5393c8747d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx @@ -37,10 +37,15 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean const sourcePanel = useRef(null); const sourceTrigger = useRef(null); const [location, setLocation] = useState(() => locationFor(goalId)); - const [snapshot, setSnapshot] = useState(null); + const sourceScope = JSON.stringify([goalId, outputLanguage, readSourceUrl ?? ""]); + const [readState, setReadState] = useState<{scope: string; packId: string | null; snapshot: Snapshot | null; error: string; loading: boolean}>( + {scope: sourceScope, packId: location.packId, snapshot: null, error: "", loading: true}); + // Scope changes hide old data synchronously, before effects start the next read. + const snapshot = readState.scope !== sourceScope ? null : readState.packId === location.packId + ? readState.snapshot : readState.snapshot ? {...readState.snapshot, pack: null} : null; + const readError = readState.scope === sourceScope && readState.packId === location.packId ? readState.error : ""; + const loading = readState.scope !== sourceScope || readState.packId !== location.packId || readState.loading; const [error, setError] = useState(""); - const [readError, setReadError] = useState(""); - const [loading, setLoading] = useState(true); const [pending, setPending] = useState(false); const [cancelPending, setCancelPending] = useState(false); const panel = useRef(null); @@ -48,51 +53,61 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean const generation = useRef(0); const readSequence = useRef(0); const submitting = useRef(false); - const scope = useRef({goalId, packId: location.packId, outputLanguage}); - scope.current = {goalId, packId: location.packId, outputLanguage}; + const scope = useRef({goalId, packId: location.packId, outputLanguage, sourceScope}); + scope.current = {goalId, packId: location.packId, outputLanguage, sourceScope}; const read = useCallback(async (packId: string | null = location.packId, signal?: AbortSignal) => { const current = generation.current, sequence = ++readSequence.current; + const isCurrent = () => current === generation.current && sequence === readSequence.current + && scope.current.sourceScope === sourceScope && scope.current.packId === packId && !signal?.aborted; + setReadState(previous => ({scope: sourceScope, packId, snapshot: previous.scope === sourceScope + ? previous.packId === packId ? previous.snapshot : previous.snapshot ? {...previous.snapshot, pack: null} : null : null, + error: previous.scope === sourceScope && previous.packId === packId ? previous.error : "", loading: true})); const query = new URLSearchParams({goal_id: goalId, output_language: outputLanguage, ...(packId ? {pack_id: packId} : {})}); - let value: Snapshot; - try {value = await requestJson(sourceReadApiUrl(readSourceUrl, `${endpoint}?${query}`), {signal});} - catch (e) {if (current === generation.current && sequence === readSequence.current && !signal?.aborted) setReadError(errorText(e)); throw e;} - if (!value?.ok || !Array.isArray(value.catalog?.packs) || (value.pack && (value.pack.pack_id !== (packId ?? value.catalog.default_pack_id) - || value.pack.config.output_language !== outputLanguage || (value.pack.result && value.pack.result.output_language !== outputLanguage)))) { - const failure = new Error(zh ? "分析来源读回不一致。" : "Analysis source readback does not match."); - if (current === generation.current && sequence === readSequence.current && !signal?.aborted) setReadError(failure.message); - throw failure; + try { + const value = await requestJson(sourceReadApiUrl(readSourceUrl, `${endpoint}?${query}`), {signal}); + if (!value?.ok || !Array.isArray(value.catalog?.packs) || (value.pack && (value.pack.pack_id !== (packId ?? value.catalog.default_pack_id) + || value.pack.config.output_language !== outputLanguage || (value.pack.result && value.pack.result.output_language !== outputLanguage)))) { + throw new Error(zh ? "分析来源读回不一致。" : "Analysis source readback does not match."); + } + if (isCurrent()) { + setReadState({scope: sourceScope, packId, snapshot: value, error: "", loading: false}); + if (value.pack?.job?.status === "succeeded" || value.pack?.job?.status === "cancelled") setError(""); + } + return value; + } catch (e) { + if (isCurrent()) setReadState(previous => ({...previous, error: errorText(e), loading: false})); + throw e; } - if (current === generation.current && sequence === readSequence.current && scope.current.goalId === goalId && scope.current.packId === packId && scope.current.outputLanguage === outputLanguage && !signal?.aborted) {setSnapshot(value); setReadError(""); if (value.pack?.job?.status === "succeeded" || value.pack?.job?.status === "cancelled") setError("");} - return value; - }, [goalId, location.packId, zh, outputLanguage, readSourceUrl]); + }, [goalId, location.packId, zh, outputLanguage, readSourceUrl, sourceScope]); const applyLocation = useCallback((next: typeof location) => { // Selection and browser history share one source transition. Invalidate // pending reads/actions before a new scope can display the old snapshot. if (scope.current.packId !== next.packId) { generation.current++; readSequence.current++; submitting.current = false; citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false); - setPending(false); setCancelPending(false); setError(""); setReadError(""); - setSnapshot(previous => previous?.pack?.pack_id === next.packId ? previous : previous ? {...previous, pack: null} : null); + setPending(false); setCancelPending(false); setError(""); + setReadState(previous => ({scope: sourceScope, packId: next.packId, snapshot: previous.scope === sourceScope && previous.snapshot + ? previous.snapshot.pack?.pack_id === next.packId ? previous.snapshot : {...previous.snapshot, pack: null} : null, error: "", loading: true})); } - scope.current = {goalId, packId: next.packId, outputLanguage}; + scope.current = {goalId, packId: next.packId, outputLanguage, sourceScope}; setLocation(next); - }, [goalId, outputLanguage]); + }, [goalId, outputLanguage, sourceScope]); useEffect(() => { generation.current++; submitting.current = false; - applyLocation(locationFor(goalId)); setSnapshot(null); setError(""); setReadError(""); setPending(false); setCancelPending(false); + applyLocation(locationFor(goalId)); + setReadState({scope: sourceScope, packId: locationFor(goalId).packId, snapshot: null, error: "", loading: true}); + setError(""); setPending(false); setCancelPending(false); const pop = () => applyLocation(locationFor(goalId)); window.addEventListener("popstate", pop); return () => {generation.current++; citeGeneration.current++; window.removeEventListener("popstate", pop);}; - }, [goalId, applyLocation, readSourceUrl]); + }, [goalId, applyLocation, readSourceUrl, sourceScope]); useEffect(() => { if (!active) return; - const controller = new AbortController(); setLoading(true); - void read(location.packId, controller.signal).catch(() => undefined).finally(() => {if (!controller.signal.aborted) setLoading(false);}); + const controller = new AbortController(); + void read(location.packId, controller.signal).catch(() => undefined); return () => controller.abort(); }, [active, read, location.packId]); - // A language change is an analysis-scope change, not a model invocation. - // Hide the prior language synchronously, before the next effect/read finishes. - const currentSnapshot = snapshot?.pack && snapshot.pack.config.output_language !== outputLanguage ? null : snapshot; + const currentSnapshot = snapshot; const pack = currentSnapshot?.pack ?? null; useEffect(() => {citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false);}, [goalId, readSourceUrl, snapshot?.pack?.pack_id, snapshot?.pack?.analysis_key]); useEffect(() => { @@ -165,7 +180,7 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean finally {if (current === citeGeneration.current) setCitationBusy(false);} }; const closeCitation = () => {citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false); requestAnimationFrame(() => sourceTrigger.current?.focus());}; - return {readOnly: Boolean(readSourceUrl) && currentSnapshot?.execution_enabled !== true, citation, citationBusy, citationError, sourcePanel, readCitation, closeCitation, goalId, zh, location, snapshot: currentSnapshot, pack, readError, error: error || readError, loading, pending, cancelPending, recordedRunning, panel, trigger, read, show, execute, cancel, + return {readOnly: Boolean(readSourceUrl) && currentSnapshot?.execution_enabled !== true, citation, citationBusy, citationError, sourcePanel, readCitation, closeCitation, goalId, zh, location, snapshot: currentSnapshot, pack, readError, error, loading, pending, cancelPending, recordedRunning, panel, trigger, read, show, execute, cancel, close: () => {citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false); navigate(false); requestAnimationFrame(() => trigger.current?.focus());}, select: (id: string) => navigate(location.open, id, true)}; } type Controller = ReturnType; @@ -186,7 +201,7 @@ export function ResearchAnalysisButton({analysis: a}: {analysis: Controller}) { // The action keeps its separate-usage consequence visible. Further method // explanation is optional; cached results and unavailable states are reads. const readOnly = unavailable || a.pack?.result || a.pending || a.recordedRunning; - return
{a.pack ?

{a.zh ? "分析请求设置(固定):" : "Fixed analysis request: "}{a.pack.config.model} / {a.pack.config.effort} / {a.pack.config.service_tier}

: null} @@ -232,17 +247,29 @@ function AnalysisContinuation({pack: p, analysis: a}: {pack: Pack; analysis: Con ; } +function AnalysisReadState({analysis: a}: {analysis: Controller}) { + if (a.readError) return
+ {a.pack ? (a.zh ? "刷新失败,保留上次结果。" : "Refresh failed. Showing the last result.") + : (a.zh ? "暂时无法读取分析。" : "Analysis could not be read.")} + +

{a.readError}

+
; + if (!a.pack && a.loading && (!a.snapshot || a.snapshot.catalog.packs.length > 0)) return
+ {Array.from({length: 6}, (_, i) => )}
; + if (!a.pack) return
{reasonCopy(a.snapshot?.catalog.reason_code, a.zh)} +
; + return !a.pack.result && a.pack.availability !== "available" ?

{reasonCopy(a.pack.reason_code, a.zh)}

: null; +} + export function ResearchAnalysisStatus({analysis: a}: {analysis: Controller}) { - const p = a.pack; - if (a.readOnly && !p) return

{a.zh ? "此运行尚未接入深度分析。" : "Deep analysis is not connected for this runtime."}

; - if (!p) return

{a.loading ? (a.zh ? "正在核对可分析来源;浏览不调用 Agent。" : "Checking analysis sources. Browsing never invokes an Agent.") : reasonCopy(a.snapshot?.catalog.reason_code, a.zh)}

; - return !p.result && p.availability !== "available" ?

{reasonCopy(p.reason_code, a.zh)}

: null; + return a.location.open ? null : ; } export function ResearchProgressSummary({analysis: a}: {analysis: Controller}) { const packs = a.snapshot?.catalog.packs ?? []; if (!a.pack && !packs.length) return null; - return ; + return ; } export function ResearchAnalysisPanel({analysis: a}: {analysis: Controller}) { @@ -260,8 +287,7 @@ export function ResearchAnalysisPanel({analysis: a}: {analysis: Controller}) {

{zh ? "轨迹深度分析" : "Trajectory analysis"}

{p?.title ?? (zh ? "只分析已授权的固定轨迹,不改变研究历史。" : "Analyze an authorized fixed trajectory without changing its history.")}

{result ? : null}
{a.error ?

{a.error}

: null} {a.snapshot?.catalog.unreadable_pack_ids?.length && !p?.continuation_unavailable ?

{zh ? "部分历史分析暂不可读" : "Some historical analyses cannot be read"} · {a.snapshot.catalog.unreadable_pack_ids.length}

: null} - {a.loading && !p ?

{zh ? "正在核对可分析来源…" : "Checking authorized sources…"}

: null} - {!a.loading && !p ?

{reasonCopy(a.snapshot?.catalog.reason_code, zh)}

: null} + {p ? <>
{p.episode_count} {zh ? "个研究片段" : "research episodes"} · {p.selection.exhaustive ? (zh ? "固定范围" : "Fixed scope") : (zh ? "定向抽样" : "Purposive sample")}

{p.selection.exhaustive ? (zh ? "覆盖此固定分析范围;不等于覆盖全部研究。" : "Covers this fixed analysis scope, not necessarily the whole research history.") : (zh ? "定向抽样,仅分析选定研究片段;范围外或未捕获的工作未纳入,不代表全程。" : "Purposive sample of selected episodes; work outside this scope or not captured is excluded. It does not represent the whole run.")}

@@ -269,7 +295,7 @@ export function ResearchAnalysisPanel({analysis: a}: {analysis: Controller}) {

{zh ? "下方仍显示当前研究 Goal 的已记录历史。" : "The current research Goal’s recorded history remains below."}

- {!available ?

{reasonCopy(p.reason_code, zh)}

: null} + {p.result && !available ?

{reasonCopy(p.reason_code, zh)}

: null} {!result || a.pending || a.recordedRunning ?
{a.pending || a.recordedRunning ? <>

{a.pending ? : null}{a.pending ? (zh ? "正在处理分析请求…" : "Processing analysis request…") : (zh ? "上次记录仍待分析结果;当前执行状态尚未核验。" : "The last receipt awaits a result; current execution is unverified.")}

: result ? diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-progress.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-progress.tsx index 7e01ad33be..fa171e9962 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-progress.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-progress.tsx @@ -83,7 +83,7 @@ function NativeResults({native, zh, onCitation}: {native: ResearchProgressPack[" ; } -export function ResearchProgress({pack: p, packs, selectedId, error, zh, onSelect, onCitation}: {pack: ResearchProgressPack | null; packs: Pick[]; selectedId: string; error: string; zh: boolean; onSelect: (id: string) => void; onCitation: (citation: AssessmentCitation, origin?: HTMLButtonElement) => void}) { +export function ResearchProgress({pack: p, packs, selectedId, zh, onSelect, onCitation}: {pack: ResearchProgressPack | null; packs: Pick[]; selectedId: string; zh: boolean; onSelect: (id: string) => void; onCitation: (citation: AssessmentCitation, origin?: HTMLButtonElement) => void}) { const measured = p?.solver_usage, usage = measured?.projection, result = p?.result; const number = (n: number) => new Intl.NumberFormat(zh ? "zh-CN" : "en", {maximumFractionDigits: 1}).format(n); const percent = (n: number) => new Intl.NumberFormat(zh ? "zh-CN" : "en", {style: "percent", maximumFractionDigits: 0}).format(n); @@ -98,9 +98,6 @@ export function ResearchProgress({pack: p, packs, selectedId, error, zh, onSelec

{p?.selection.exhaustive ? (zh ? "仅覆盖声明的固定分析范围,不等于覆盖全部研究。" : "Covers the declared fixed analysis scope, not necessarily the whole study.") : p?.selection.population_episodes == null ? (zh ? "定向抽样,全程覆盖未知。" : "Purposive sample; whole-run coverage is unknown.") : (zh ? "定向抽样,不代表全程。" : "Purposive sample, not the whole run.")}

{zh ? "原生测量缺失时保持未采集;分析器判读不能补成实际收益。完整取样规则与版本在“来源与用量”中。" : "Missing native measurements remain uncaptured; judgments cannot supply realized utility. Full selection rules and revisions are under Sources and usage."}

{p ?

{number(p.episode_count)} {zh ? "个片段" : "episodes"} · {p.selection.exhaustive ? (zh ? "固定范围" : "Fixed scope") : (zh ? "定向抽样" : "Purposive sample")}{result ? (zh ? " · 未校准" : " · Uncalibrated") : ""}

: null}
{packs.length > 1 ? : null} - {error ?

{p - ? (zh ? "无法核对本次来源;已显示的值仅为上次读回。" : "The source could not be refreshed. Displayed values are from the last readback.") - : (zh ? "所选来源暂不可读,未显示其他范围的结果。" : "The selected source could not be read. No result from another scope is shown.")} {error}

: null} {p ? <> {p.applicability === "historical" ?

{zh ? "历史分析:来源或方法已变化,不能当作当前判断。" : "Historical analysis: the source or method changed; these are not current judgments."}

: null} {p.native_outcomes ? : null} @@ -178,6 +175,6 @@ export function ResearchProgress({pack: p, packs, selectedId, error, zh, onSelec
{zh ? "覆盖边界" : "Coverage limits"}
{zh ? "仅本次完整捕获;继承前缀、设置、原生评测、操作者及后续未完成轮不包含。金额与全研究总成本未知。" : "This complete capture only. Inherited prefixes, setup, native evaluation, operator work and later incomplete Turns are excluded. Dollar and total study costs are unknown."}
: null} - : !error ?

{zh ? "正在核对此范围的分析来源…" : "Checking analysis sources for this scope…"}

: null} + : null} ; } diff --git a/docs/development/design.md b/docs/development/design.md index c8b1fec8a1..373bc5f7e8 100644 --- a/docs/development/design.md +++ b/docs/development/design.md @@ -661,7 +661,12 @@ observation"). Reduced motion stops the arc; the ring and core still read live. turning a read-only source into a setup task. A known empty state and an unobserved source remain different states. - **Loading** holds the final geometry with a quiet skeleton; a region does - not collapse to one spinner and then jump. + not collapse to one spinner and then jump. An initial read is not evidence of + an empty or disconnected source. Bind loading, errors and results to the same + Goal/source/language/selection; a delayed response cannot borrow another + scope's content. Keep one read-status surface across summary and detail, + with retry available where the user encountered the failure. A failed refresh + may retain the same scope's last result, visibly marked as such. - **Region failure** is one announced line (icon, plain cause, how to retry) sized to its content. Reserve banners for a whole-surface failure. - **Unknown** stays visible as "—" with its reason; it never becomes zero or diff --git a/examples/personal-workspace-browser/evidence/research-read-state/before-desktop.png b/examples/personal-workspace-browser/evidence/research-read-state/before-desktop.png new file mode 100644 index 0000000000..ed751d9cf0 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-read-state/before-desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/research-read-state/failure-desktop.png b/examples/personal-workspace-browser/evidence/research-read-state/failure-desktop.png new file mode 100644 index 0000000000..304cfec8ec Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-read-state/failure-desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/research-read-state/pending-desktop.png b/examples/personal-workspace-browser/evidence/research-read-state/pending-desktop.png new file mode 100644 index 0000000000..30d4552fdf Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-read-state/pending-desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/research-read-state/retry-phone.png b/examples/personal-workspace-browser/evidence/research-read-state/retry-phone.png new file mode 100644 index 0000000000..537b5c6f07 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/research-read-state/retry-phone.png differ 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 96acfdb619..de9769327b 100644 --- a/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs @@ -38,7 +38,7 @@ class Source(GoalSource): events += [build_explore_edge_event(goal_id=self.goal_id,from_node='route'+str(i),to_node='route'+str(i+1),edge_type='depends_on',recorded_at='2026-01-01T23:%02d:00Z'%(i*30)) for i in range(2)] return events assets=pathlib.Path(sys.argv[2]) -servers=[RuntimeWorkspaceServer(Source(n),assets) for n in ('Retained','Fresh','Distinct')] +servers=[RuntimeWorkspaceServer(Source(n),assets,analysis_runtime_root=pathlib.Path(sys.argv[1]).parent / "analysis") for n in ('Retained','Fresh','Distinct')] catalog=[dict(id=s.source.id,label=s.source.label,status_url=s.status_url) for s in servers] for s in servers: s.catalog=catalog @@ -61,12 +61,47 @@ try { page.on("pageerror", error => errors.push(String(error))); const url = new URL("/chat/", catalog[0].status_url); url.search = new URLSearchParams({ goalId: "same-goal", view: "overview", statusUrl: process.env.LOOPX_RUNTIME_READER_BEFORE === "1" ? catalog[0].status_url : "/status.json" }); + let holdAnalysis = true, failAnalysis = false, releaseAnalysis, readStarted; + let analysisWrites = 0; + await page.route("**/api/chat/research-analysis?*", async route => { + if (route.request().method() !== "GET") {analysisWrites++; return route.abort();} + if (holdAnalysis) {readStarted(); await new Promise(resolve => {releaseAnalysis = resolve;});} + if (failAnalysis) return route.fulfill({status:503, json:{error:"Synthetic analysis transport failure"}}); + return route.continue(); + }); await page.goto(url.toString()); await page.getByRole("heading", { name: "Retained research", exact: true }).waitFor(); if (process.env.LOOPX_RUNTIME_READER_BEFORE === "1") { await page.screenshot({path:resolve(output,"before-desktop.png")}); } else { + const initialRead = new Promise(resolve => {readStarted = resolve;}); await page.locator(".personal-goal-tabs").getByRole("button", {name:"研究", exact:true}).click(); + await initialRead; + await page.locator(".research-history-coverage").filter({hasText:"92 / 92"}).waitFor(); + const history = page.getByRole("region", {name:"研究历程",exact:true}); + await page.screenshot({path:resolve(output,"analysis-pending-desktop.png")}); + assert.doesNotMatch(await history.innerText(),/尚未接入深度分析|尚无可分析/, + "A pending read does not prove an analysis connection is absent"); + await history.getByRole("status",{name:"正在读取分析"}).waitFor(); + failAnalysis = true; holdAnalysis = false; releaseAnalysis(); + const failure = history.getByRole("alert"); + await failure.waitFor(); + assert.equal(await history.locator(".research-analysis").count(),0,"Retry is available without opening the analysis panel"); + await page.screenshot({path:resolve(output,"analysis-failure-desktop.png")}); + const retryRead = new Promise(resolve => {readStarted = resolve;}); + holdAnalysis = true; + await failure.getByRole("button",{name:"重试读取",exact:true}).click(); + await retryRead; + assert.equal(await failure.getByRole("button",{name:"重试读取",exact:true}).isDisabled(),true); + assert.doesNotMatch(await history.innerText(),/尚未接入深度分析|尚无可分析/); + await page.setViewportSize({width:390,height:844}); + await page.screenshot({path:resolve(output,"analysis-retry-phone.png")}); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false); + failAnalysis = false; holdAnalysis = false; releaseAnalysis(); + await failure.waitFor({state:"hidden"}); + await history.locator(".analysis-read-loading").waitFor({state:"hidden"}); + assert.equal(analysisWrites,0,"Read recovery never starts an analysis"); + await page.setViewportSize({width:1512,height:982}); await page.locator(".research-history-coverage").filter({hasText:"92 / 92"}).waitFor(); assert.equal(new URL(new URL(page.url()).searchParams.get("statusUrl")).searchParams.get("source_namespace"),new URL(catalog[0].status_url).searchParams.get("source_namespace"),"the initial relative entry binds its physical source before subsequent navigation"); assert.equal(await page.getByText("运行来源 · 只读", {exact:true}).count(), 1); @@ -156,6 +191,7 @@ try { await page.setViewportSize({width:390,height:844}); await page.screenshot({path:resolve(output,"phone.png")}); assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false); + assert.equal(analysisWrites,0); assert.deepEqual(errors,[]); } console.log("runtime source workspace browser smoke: ok");