diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index c111eb79ee..c614484ea3 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -865,6 +865,7 @@ export function PersonalWorkspacePage({ const [lifecycleBusyGoalIds, setLifecycleBusyGoalIds] = useState>(() => new Set()); const [quickCompletingTodoIds, setQuickCompletingTodoIds] = useState>(() => new Set()); const [refreshState, setRefreshState] = useState<"idle" | "loading" | "done" | "error">("idle"); + const refreshSequenceRef = useRef(0); const [historyRefreshRevision, setHistoryRefreshRevision] = useState(0); const [sessionProposalIds, setSessionProposalIds] = useState([]); const [managerChannelProposalIds, setManagerChannelProposalIds] = useState([]); @@ -1935,6 +1936,7 @@ export function PersonalWorkspacePage({ async function refreshWorkspace() { if (!callbacks.onRefresh || refreshState === "loading") return; + const sequence = ++refreshSequenceRef.current; setRefreshState("loading"); try { await callbacks.onRefresh(); @@ -1943,7 +1945,9 @@ export function PersonalWorkspacePage({ } catch { setRefreshState("error"); } - window.setTimeout(() => setRefreshState("idle"), 1800); + window.setTimeout(() => { + if (refreshSequenceRef.current === sequence) setRefreshState("idle"); + }, 1800); } const settingsPage = settingsOpen ? ( diff --git a/examples/personal-workspace-browser/blocked-reason-readback.mjs b/examples/personal-workspace-browser/blocked-reason-readback.mjs index 6ea771f0e2..9bffabf017 100644 --- a/examples/personal-workspace-browser/blocked-reason-readback.mjs +++ b/examples/personal-workspace-browser/blocked-reason-readback.mjs @@ -104,6 +104,7 @@ export const blockedReasonReadbackScenario = { "--resume-when", `todo_done:${authority.cyclic}`, "--successor-todo-id", authority.independent); let writes = 0; let omitReason = false; + let holdStatusRead = null; const context = await openWorkspacePage({ newPage: (options) => browser.newPage({ ...options, locale }) }, url, { collectCoverage, viewport: mobile ? { width: 390, height: 844 } : { width: 1512, height: 982 }, isMobile: mobile, beforeGoto: async (_api, page) => { @@ -122,6 +123,9 @@ export const blockedReasonReadbackScenario = { }; omit(payload); } + const heldRead = holdStatusRead; + holdStatusRead = null; + if (heldRead) await heldRead; await route.fulfill({ response, json: payload }); }); await page.route("**/api/chat/todo/detail?*", async route => { @@ -192,8 +196,22 @@ export const blockedReasonReadbackScenario = { await refresh(); await cause.getByText(CHANGED_REASON, { exact: true }).waitFor({ timeout: 8_000 }); assert.doesNotMatch(await cause.innerText(), new RegExp(REASON)); - omitReason = true; await refresh(); + omitReason = true; + let releaseRead; + holdStatusRead = new Promise(resolveRead => { releaseRead = resolveRead; }); + const pendingRefresh = refresh(); + try { + await page.locator(".personal-refresh-control.is-loading").waitFor(); + // Cross the previous refresh's 1.8s feedback lifetime while + // the new real status response is held at the transport. + await page.waitForTimeout(1900); + assert.equal(await page.locator(".personal-refresh-control.is-loading").count(), 1, + "An older feedback timer cannot clear a newer in-flight refresh"); + } finally { + releaseRead(); + await pendingRefresh; + } await cause.getByText(locale === "en" ? /No reason was recorded/ : /未记录原因/).waitFor(); omitReason = false; await authority.cli("todo", "update", "--goal-id", "goal-a", "--role", "agent", "--todo-id", authority.with_reason,