From 77a39e3d19cc951fda4c7f844035fcef1d283347 Mon Sep 17 00:00:00 2001 From: Trae User Date: Sat, 19 Sep 2026 16:15:16 +0800 Subject: [PATCH 1/2] fix: stabilize sidebar session loading --- web/src/components/Sidebar.jsx | 95 +++++++++++++++----- web/src/lib/sidebarWorkspaceSessions.js | 17 ++++ web/src/lib/sidebarWorkspaceSessions.test.js | 38 +++++++- 3 files changed, 126 insertions(+), 24 deletions(-) diff --git a/web/src/components/Sidebar.jsx b/web/src/components/Sidebar.jsx index c4b6dd7d..7168f62d 100644 --- a/web/src/components/Sidebar.jsx +++ b/web/src/components/Sidebar.jsx @@ -93,8 +93,11 @@ import { import { normalizeWorkspaceSessionListResponse, retainUnrefreshedSidebarSessions, + settleSidebarWorkspacePage, + sidebarWorkspacePageIsCurrent, sidebarWorkspaceSessionListQuery, workspaceHasCachedSidebarSessions, + workspaceNeedsInitialSidebarLoad, } from '../lib/sidebarWorkspaceSessions.js'; import { opencodePreviewTargets, @@ -1879,12 +1882,14 @@ export function Sidebar({ const expandedRef = useRef(new Set()); const sessionLoadedWorkspacesRef = useRef(new Set()); const sessionFullyLoadedWorkspacesRef = useRef(new Set()); + const sessionsRef = useRef([]); const workspaceSessionLoadSeqRef = useRef(new Map()); // 已经探过 opencode 导入预览的 workspace。探测结果近乎静态,没必要每轮 // 对全部 workspace 重问一遍(见 lib/sidebarAuxiliaryFetch.js)。 const opencodePreviewProbedRef = useRef(new Set()); sessionLoadedWorkspacesRef.current = sessionLoadedWorkspaces; sessionFullyLoadedWorkspacesRef.current = sessionFullyLoadedWorkspaces; + sessionsRef.current = sessions; const workspaceCollapseAllRef = useRef(false); const userCollapsedWorkspacesRef = useRef(new Set()); const sessionListDisclosureCompactRef = useRef(new Set()); @@ -2407,7 +2412,12 @@ export function Sidebar({ || { hash: workspaceHash }; const wantFull = full || sessionFullyLoadedWorkspacesRef.current.has(workspaceHash); const cached = silent || sessionLoadedWorkspacesRef.current.has(workspaceHash); - if (!cached) setSessionWorkspaceLoading([workspaceHash], true); + if (workspaceNeedsInitialSidebarLoad({ + hasCachedSessions: workspaceHasCachedSidebarSessions(sessionsRef.current, workspaceHash), + hasLoaded: cached, + })) { + setSessionWorkspaceLoading([workspaceHash], true); + } const sequence = (workspaceSessionLoadSeqRef.current.get(workspaceHash) || 0) + 1; workspaceSessionLoadSeqRef.current.set(workspaceHash, sequence); @@ -2542,7 +2552,45 @@ export function Sidebar({ opencodePreviewProbedRef.current.add(w.hash); refreshOpencodeImportPreview(w).catch(() => {}); }); - setSessionWorkspaceLoading(earlyVisibleWorkspaceHashes, true); + const loadWorkspacePage = async (workspace) => { + if (workspace.hash === '__local__') { + const list = await api.listSessions(); + return { + workspace, + ...normalizeWorkspaceSessionListResponse( + (Array.isArray(list) ? list : []).filter((session) => !isNoWorkspaceSession(session)), + ), + }; + } + return { + workspace, + ...normalizeWorkspaceSessionListResponse( + await api.listWorkspaceSessions( + workspace.hash, + sidebarWorkspaceSessionListQuery({ + full: sessionFullyLoadedWorkspacesRef.current.has(workspace.hash), + }), + ), + ), + }; + }; + // 会话行是侧边栏的主体,不能等待置顶等辅助数据后才开始请求。 + const startWorkspacePageLoad = (workspace) => { + const sequence = (workspaceSessionLoadSeqRef.current.get(workspace.hash) || 0) + 1; + workspaceSessionLoadSeqRef.current.set(workspace.hash, sequence); + return { + sequence, + result: settleSidebarWorkspacePage(loadWorkspacePage(workspace)), + }; + }; + const earlyVisibleWorkspaces = withActive.filter((workspace) => ( + earlyVisibleWorkspaceHashes.includes(workspace.hash) + )); + const earlyWorkspacePages = new Map(earlyVisibleWorkspaces.map((workspace) => [ + workspace.hash, + startWorkspacePageLoad(workspace), + ])); + const noWorkspaceListPromise = api.listSessions().catch(() => null); const pinnedTargets = new Set(pinnedRefreshTargets(withActive, earlyVisibleWorkspaceHashes)); const [pinnedPairs, noWorkspacePinnedIds] = await Promise.all([ @@ -2606,23 +2654,30 @@ export function Sidebar({ .map((w) => w.hash) .filter((hash) => hash && !visibleWorkspaceHashSet.has(hash)); setSessionWorkspaceLoading(hiddenWorkspaceHashes, false); - setSessionWorkspaceLoading(visibleWorkspaceHashes, true); + const initiallyLoadingWorkspaceHashes = visibleWorkspaceHashes.filter((hash) => ( + workspaceNeedsInitialSidebarLoad({ + hasCachedSessions: workspaceHasCachedSidebarSessions(sessionsRef.current, hash), + hasLoaded: sessionLoadedWorkspacesRef.current.has(hash), + }) + )); + setSessionWorkspaceLoading(initiallyLoadingWorkspaceHashes, true); + let refreshedWorkspaceHashes = []; try { - const noWorkspaceListPromise = api.listSessions().catch(() => null); - const perWorkspace = await Promise.all(visibleWorkspaces.map(async (w) => { - if (w.hash === '__local__') { - const list = await api.listSessions(); - const payload = normalizeWorkspaceSessionListResponse( - (Array.isArray(list) ? list : []).filter((session) => !isNoWorkspaceSession(session)), - ); - return { workspace: w, ...payload }; - } - const wantFull = sessionFullyLoadedWorkspacesRef.current.has(w.hash); - const payload = normalizeWorkspaceSessionListResponse( - await api.listWorkspaceSessions(w.hash, sidebarWorkspaceSessionListQuery({ full: wantFull })), - ); - return { workspace: w, ...payload }; + const settledPages = await Promise.all(visibleWorkspaces.map(async (workspace) => { + const request = earlyWorkspacePages.get(workspace.hash) + || startWorkspacePageLoad(workspace); + return { workspace, sequence: request.sequence, result: await request.result }; })); + const perWorkspace = settledPages.flatMap(({ workspace, sequence, result }) => { + if (!result.ok) return []; + if (sequence !== undefined && !sidebarWorkspacePageIsCurrent( + workspaceSessionLoadSeqRef.current.get(workspace.hash), sequence, + )) return []; + return [result.page]; + }); + refreshedWorkspaceHashes = perWorkspace + .map((item) => item.workspace?.hash) + .filter(Boolean); const noWorkspaceRaw = await noWorkspaceListPromise; const noWorkspaceIncoming = (Array.isArray(noWorkspaceRaw) ? noWorkspaceRaw : []) .filter(isNoWorkspaceSession) @@ -2648,7 +2703,7 @@ export function Sidebar({ ...noWorkspaceIncoming, ]; setSessions((prev) => retainUnrefreshedSidebarSessions(prev, incoming, { - refreshedWorkspaceHashes: visibleWorkspaceHashes, + refreshedWorkspaceHashes, pinnedByWorkspace: pinnedByWorkspaceRef.current, refreshNoWorkspace: true, })); @@ -2662,8 +2717,8 @@ export function Sidebar({ } catch { /* 鉴权失败不致命 */ } finally { - setSessionWorkspacesLoaded(visibleWorkspaceHashes, true); - setSessionWorkspaceLoading(visibleWorkspaceHashes, false); + setSessionWorkspacesLoaded(refreshedWorkspaceHashes, true); + setSessionWorkspaceLoading(initiallyLoadingWorkspaceHashes, false); } } finally { refreshingRef.current = false; diff --git a/web/src/lib/sidebarWorkspaceSessions.js b/web/src/lib/sidebarWorkspaceSessions.js index 3a2d35cd..9c64bf92 100644 --- a/web/src/lib/sidebarWorkspaceSessions.js +++ b/web/src/lib/sidebarWorkspaceSessions.js @@ -57,6 +57,23 @@ export function sidebarWorkspaceSessionListQuery({ full = false } = {}) { return { limit: SIDEBAR_SESSION_COLLAPSE_LIMIT }; } +// 已缓存的会话或已经完成过一次加载的空工作区都不应在后台刷新时重新显示 +// 「加载中」。只有首次打开且没有任何可展示数据时才显示该状态。 +export function workspaceNeedsInitialSidebarLoad({ hasCachedSessions = false, hasLoaded = false } = {}) { + return !hasCachedSessions && !hasLoaded; +} + +export function settleSidebarWorkspacePage(promise) { + return Promise.resolve(promise).then( + (page) => ({ ok: true, page }), + (error) => ({ ok: false, error }), + ); +} + +export function sidebarWorkspacePageIsCurrent(currentSequence, requestSequence) { + return Number(currentSequence) === Number(requestSequence); +} + export function retainUnrefreshedSidebarSessions( previousSessions = [], incomingSessions = [], diff --git a/web/src/lib/sidebarWorkspaceSessions.test.js b/web/src/lib/sidebarWorkspaceSessions.test.js index ec781ba2..095608bb 100644 --- a/web/src/lib/sidebarWorkspaceSessions.test.js +++ b/web/src/lib/sidebarWorkspaceSessions.test.js @@ -6,18 +6,23 @@ import { import { normalizeWorkspaceSessionListResponse, retainUnrefreshedSidebarSessions, + settleSidebarWorkspacePage, + sidebarWorkspacePageIsCurrent, sidebarWorkspaceSessionListQuery, workspaceHasCachedSidebarSessions, + workspaceNeedsInitialSidebarLoad, } from './sidebarWorkspaceSessions.js'; +const pendingTests = []; + function test(name, fn) { - try { - fn(); + const run = Promise.resolve().then(fn).then(() => { console.log(`[pass] ${name}`); - } catch (error) { + }, (error) => { console.error(`[fail] ${name}`); throw error; - } + }); + pendingTests.push(run); } // 触发场景:后端对无 limit 的请求仍回裸数组(老客户端兼容路径),对带 limit @@ -92,6 +97,29 @@ test('sidebar workspace compact query asks for five rows', () => { assert.deepEqual(sidebarWorkspaceSessionListQuery({ full: true }), {}); }); +test('workspaceNeedsInitialSidebarLoad only reports true before any workspace data exists', () => { + assert.equal(workspaceNeedsInitialSidebarLoad(), true); + assert.equal(workspaceNeedsInitialSidebarLoad({ hasCachedSessions: true }), false); + assert.equal(workspaceNeedsInitialSidebarLoad({ hasLoaded: true }), false); +}); + +test('settleSidebarWorkspacePage observes success and failure immediately', async () => { + assert.deepEqual(await settleSidebarWorkspacePage(Promise.resolve({ sessions: [] })), { + ok: true, + page: { sessions: [] }, + }); + const failure = new Error('failed'); + assert.deepEqual(await settleSidebarWorkspacePage(Promise.reject(failure)), { + ok: false, + error: failure, + }); +}); + +test('sidebarWorkspacePageIsCurrent rejects stale refresh generations', () => { + assert.equal(sidebarWorkspacePageIsCurrent(3, 3), true); + assert.equal(sidebarWorkspacePageIsCurrent(4, 3), false); +}); + test('retainUnrefreshedSidebarSessions keeps collapsed workspaces and pinned extras', () => { const previous = [ { id: 'keep-collapsed', workspace_hash: 'w2' }, @@ -149,3 +177,5 @@ test('sidebarSessionProjection uses reported total when the compact page is shor assert.equal(result.hiddenCount, 7); assert.deepEqual(result.visibleSessions.map((session) => session.id), ['0', '1', '2', '3', '4']); }); + +await Promise.all(pendingTests); From 4644f1be020a59c59d95ab75411d9921e3c67d63 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Sun, 20 Sep 2026 04:27:16 +0800 Subject: [PATCH 2/2] Fix sidebar refresh request ownership races --- .../.openspec.yaml | 2 + .../design.md | 22 ++ .../proposal.md | 24 ++ .../sidebar-refresh-request-ownership/spec.md | 37 +++ .../tasks.md | 9 + web/src/components/Sidebar.jsx | 46 ++- web/src/lib/runTests.js | 1 + web/src/lib/sidebarWorkspaceRefresh.test.js | 266 ++++++++++++++++++ 8 files changed, 397 insertions(+), 10 deletions(-) create mode 100644 openspec/changes/fix-sidebar-refresh-request-ownership/.openspec.yaml create mode 100644 openspec/changes/fix-sidebar-refresh-request-ownership/design.md create mode 100644 openspec/changes/fix-sidebar-refresh-request-ownership/proposal.md create mode 100644 openspec/changes/fix-sidebar-refresh-request-ownership/specs/sidebar-refresh-request-ownership/spec.md create mode 100644 openspec/changes/fix-sidebar-refresh-request-ownership/tasks.md create mode 100644 web/src/lib/sidebarWorkspaceRefresh.test.js diff --git a/openspec/changes/fix-sidebar-refresh-request-ownership/.openspec.yaml b/openspec/changes/fix-sidebar-refresh-request-ownership/.openspec.yaml new file mode 100644 index 00000000..cbd245e4 --- /dev/null +++ b/openspec/changes/fix-sidebar-refresh-request-ownership/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-20 diff --git a/openspec/changes/fix-sidebar-refresh-request-ownership/design.md b/openspec/changes/fix-sidebar-refresh-request-ownership/design.md new file mode 100644 index 00000000..6ceb2b83 --- /dev/null +++ b/openspec/changes/fix-sidebar-refresh-request-ownership/design.md @@ -0,0 +1,22 @@ +## Context + +见 proposal.md。现有每工作区代次已能拒绝直接返回的旧请求,但后台刷新在检查后仍等待无工作区列表,且后台摘要请求无条件推进代次。 + +## Goals / Non-Goals + +**Goals:** 明确完整请求的共享生命周期、最终提交检查和加载状态清理资格。 + +**Non-Goals:** 不调整置顶数据、排序、展开批次或后端分页协议,也不移入主工作区的其他未提交改动。 + +## Decisions + +- 保存正在进行的全量请求及代次;后台刷新复用对应 Promise。单纯比较代次无法阻止后台摘要抢占全量请求,因此在发起后台请求前处理全量优先级。 +- 无工作区列表等待结束后,再按代次筛选所有工作区结果;筛选与提交之间不再等待异步操作。 +- 请求清理以本轮仍有效的请求集合为依据,而非本轮新增加载指示的集合。这样已有空工作区的激活状态也会正确结束。 +- 测试解析并执行实际 Sidebar 回调,仅模拟 React 状态和 API 边界,用延迟 Promise 构造竞争顺序;不另抄一份请求算法。 + +## Risks / Trade-offs + +- 共享请求失败影响本轮后台刷新 → 保留缓存,在 finally 按请求身份移除记录,并测试后续重试。 +- 用户连续发起两次全量请求 → 旧请求清理必须检查记录身份,不能删除较新请求。 +- 单测不挂载浏览器 UI → 断言真实调用路径更新的列表、总数、完整标记和加载集合,视觉布局保持原样。 diff --git a/openspec/changes/fix-sidebar-refresh-request-ownership/proposal.md b/openspec/changes/fix-sidebar-refresh-request-ownership/proposal.md new file mode 100644 index 00000000..103485ff --- /dev/null +++ b/openspec/changes/fix-sidebar-refresh-request-ownership/proposal.md @@ -0,0 +1,24 @@ +## Why + +PR #64 提前发起侧栏会话请求后,摘要请求仍可能跨越后续等待覆盖较新的全量列表;定时刷新也可能抢走用户展开请求的提交资格。需要在合并前明确每个工作区请求的所有权,保留早发请求带来的响应速度。 + +## What Changes + +- 用户全量加载进行中时,后台刷新复用同一请求,不发起会使它失效的摘要请求。 +- 所有异步等待完成后才检查请求代次,旧结果不能更改会话、总数或全量标记。 +- 只有当前请求能清理对应加载状态,包括重新激活已加载的空工作区。 +- 用真实 Sidebar 回调和可控异步完成顺序添加回归测试。 + +## Capabilities + +### New Capabilities + +- `sidebar-refresh-request-ownership`: 侧栏并发列表请求的提交和加载状态所有权。 + +### Modified Capabilities + +无。 + +## Impact + +仅影响 WebUI Sidebar 的工作区会话加载和定向 JS 测试;不修改 API、置顶规则、会话排序或展开展示方式。 diff --git a/openspec/changes/fix-sidebar-refresh-request-ownership/specs/sidebar-refresh-request-ownership/spec.md b/openspec/changes/fix-sidebar-refresh-request-ownership/specs/sidebar-refresh-request-ownership/spec.md new file mode 100644 index 00000000..60a8780d --- /dev/null +++ b/openspec/changes/fix-sidebar-refresh-request-ownership/specs/sidebar-refresh-request-ownership/spec.md @@ -0,0 +1,37 @@ +## Purpose + +确保侧栏在用户展开会话列表、定时刷新和工作区切换交错时,始终保留最新有效的完整列表。请求结束后正确退出加载状态,同时保留摘要请求与辅助请求并行发起的响应速度。 + +## ADDED Requirements + +### Requirement: 最新结果拥有提交资格 + +侧栏 SHALL 在本轮所有等待结束后验证工作区请求是否仍有效;过时结果不得修改会话列表、总数、完整加载标记或较新请求的加载状态。 + +#### Scenario: 摘要已返回但无工作区请求仍在等待 + +- **WHEN** 摘要结果返回后仍在等待无工作区列表,用户的全量加载随后完成 +- **THEN** 后续摘要刷新完成时保留全量列表、总数和完整加载标记 + +### Requirement: 后台刷新保留正在进行的全量加载 + +侧栏 SHALL 复用同一工作区正在进行的全量加载,不用后台摘要请求使用户的全量加载失效;失败请求结束后允许后续请求正常重试。 + +#### Scenario: 用户展开时遇到定时刷新 + +- **WHEN** 用户已请求完整会话列表且结果尚未返回,定时刷新开始 +- **THEN** 不新增摘要请求,完整结果返回后两条调用路径均保留完整列表 + +#### Scenario: 共享全量请求失败 + +- **WHEN** 用户全量请求被后台刷新复用但请求失败 +- **THEN** 已有列表保持不变,后续全量加载可以重新请求并成功显示 + +### Requirement: 当前请求负责结束加载状态 + +侧栏 SHALL 在当前工作区请求成功或失败结束后清理对应的显式加载状态;过时请求结束不得清理较新请求的加载状态。 + +#### Scenario: 重新激活已加载的空工作区 + +- **WHEN** 用户重新激活已成功加载过且没有会话的工作区,本轮请求结束 +- **THEN** 工作区退出加载状态并展示空列表状态 diff --git a/openspec/changes/fix-sidebar-refresh-request-ownership/tasks.md b/openspec/changes/fix-sidebar-refresh-request-ownership/tasks.md new file mode 100644 index 00000000..95230c21 --- /dev/null +++ b/openspec/changes/fix-sidebar-refresh-request-ownership/tasks.md @@ -0,0 +1,9 @@ +## 1. 请求所有权修复 + +- [x] 1.1 添加真实 Sidebar 回调时序回归,并确认原 PR 在旧摘要覆盖、全量请求被抢占、空工作区激活清理场景失败。 +- [x] 1.2 实现全量请求共享与身份清理,验证定时刷新不抢占、失败可重试及连续全量请求的清理隔离。 +- [x] 1.3 在全部等待之后筛选有效结果并按当前请求清理加载状态,验证列表、总数、完整标记及空工作区状态。 + +## 2. 验证与交付 + +- [x] 2.1 将新回归加入 JS 测试入口,运行相关定向测试、严格 OpenSpec 校验和 git diff --check,确认只提交本次修复文件。 diff --git a/web/src/components/Sidebar.jsx b/web/src/components/Sidebar.jsx index 7168f62d..67e5ed33 100644 --- a/web/src/components/Sidebar.jsx +++ b/web/src/components/Sidebar.jsx @@ -1884,6 +1884,7 @@ export function Sidebar({ const sessionFullyLoadedWorkspacesRef = useRef(new Set()); const sessionsRef = useRef([]); const workspaceSessionLoadSeqRef = useRef(new Map()); + const pendingFullWorkspaceLoadsRef = useRef(new Map()); // 已经探过 opencode 导入预览的 workspace。探测结果近乎静态,没必要每轮 // 对全部 workspace 重问一遍(见 lib/sidebarAuxiliaryFetch.js)。 const opencodePreviewProbedRef = useRef(new Set()); @@ -2421,23 +2422,30 @@ export function Sidebar({ const sequence = (workspaceSessionLoadSeqRef.current.get(workspaceHash) || 0) + 1; workspaceSessionLoadSeqRef.current.set(workspaceHash, sequence); - try { - let payload; + const pagePromise = (async () => { if (workspaceHash === '__local__') { const list = await api.listSessions(); - payload = normalizeWorkspaceSessionListResponse( + return normalizeWorkspaceSessionListResponse( (Array.isArray(list) ? list : []).filter((session) => !isNoWorkspaceSession(session)), ); - } else { - payload = normalizeWorkspaceSessionListResponse( - await api.listWorkspaceSessions(workspaceHash, sidebarWorkspaceSessionListQuery({ full: wantFull })), - ); } + return normalizeWorkspaceSessionListResponse( + await api.listWorkspaceSessions(workspaceHash, sidebarWorkspaceSessionListQuery({ full: wantFull })), + ); + })(); + if (wantFull) { + pendingFullWorkspaceLoadsRef.current.set(workspaceHash, { sequence, promise: pagePromise }); + } + try { + const payload = await pagePromise; if (workspaceSessionLoadSeqRef.current.get(workspaceHash) !== sequence) return; applyWorkspaceSessionList(workspace, payload); } catch { /* 鉴权失败不致命 */ } finally { + if (pendingFullWorkspaceLoadsRef.current.get(workspaceHash)?.sequence === sequence) { + pendingFullWorkspaceLoadsRef.current.delete(workspaceHash); + } if (workspaceSessionLoadSeqRef.current.get(workspaceHash) === sequence) { setSessionWorkspaceLoading([workspaceHash], false); } @@ -2576,6 +2584,16 @@ export function Sidebar({ }; // 会话行是侧边栏的主体,不能等待置顶等辅助数据后才开始请求。 const startWorkspacePageLoad = (workspace) => { + const pendingFull = pendingFullWorkspaceLoadsRef.current.get(workspace.hash); + if (pendingFull && sidebarWorkspacePageIsCurrent( + workspaceSessionLoadSeqRef.current.get(workspace.hash), pendingFull.sequence, + )) { + // 用户的全量请求尚未完成时,后台摘要不能推进代次使它失效。 + return { + sequence: pendingFull.sequence, + result: settleSidebarWorkspacePage(pendingFull.promise.then((page) => ({ workspace, ...page }))), + }; + } const sequence = (workspaceSessionLoadSeqRef.current.get(workspace.hash) || 0) + 1; workspaceSessionLoadSeqRef.current.set(workspace.hash, sequence); return { @@ -2662,12 +2680,15 @@ export function Sidebar({ )); setSessionWorkspaceLoading(initiallyLoadingWorkspaceHashes, true); let refreshedWorkspaceHashes = []; + let settledPages = []; try { - const settledPages = await Promise.all(visibleWorkspaces.map(async (workspace) => { + settledPages = await Promise.all(visibleWorkspaces.map(async (workspace) => { const request = earlyWorkspacePages.get(workspace.hash) || startWorkspacePageLoad(workspace); return { workspace, sequence: request.sequence, result: await request.result }; })); + // 所有等待结束后再检查代次,不能让这里较慢的请求造成旧摘要回写。 + const noWorkspaceRaw = await noWorkspaceListPromise; const perWorkspace = settledPages.flatMap(({ workspace, sequence, result }) => { if (!result.ok) return []; if (sequence !== undefined && !sidebarWorkspacePageIsCurrent( @@ -2678,7 +2699,6 @@ export function Sidebar({ refreshedWorkspaceHashes = perWorkspace .map((item) => item.workspace?.hash) .filter(Boolean); - const noWorkspaceRaw = await noWorkspaceListPromise; const noWorkspaceIncoming = (Array.isArray(noWorkspaceRaw) ? noWorkspaceRaw : []) .filter(isNoWorkspaceSession) .map(normalizeNoWorkspaceSession); @@ -2718,7 +2738,13 @@ export function Sidebar({ catch { /* 鉴权失败不致命 */ } finally { setSessionWorkspacesLoaded(refreshedWorkspaceHashes, true); - setSessionWorkspaceLoading(initiallyLoadingWorkspaceHashes, false); + // 激活已加载空工作区也会设置 loading;只按本轮当前请求清理, + // 既不能遗漏已有标记,也不能清掉较新全量请求正在使用的标记。 + setSessionWorkspaceLoading(settledPages + .filter(({ workspace, sequence }) => sidebarWorkspacePageIsCurrent( + workspaceSessionLoadSeqRef.current.get(workspace.hash), sequence, + )) + .map(({ workspace }) => workspace.hash), false); } } finally { refreshingRef.current = false; diff --git a/web/src/lib/runTests.js b/web/src/lib/runTests.js index 92c8aab9..cb1059be 100644 --- a/web/src/lib/runTests.js +++ b/web/src/lib/runTests.js @@ -230,6 +230,7 @@ import './sidebarSessionRevealArchitecture.test.js'; import './sidebarCustomSectionArchitecture.test.js'; import './sidebarSessions.test.js'; import './sidebarWorkspaceSessions.test.js'; +import './sidebarWorkspaceRefresh.test.js'; import './sidebarAuxiliaryFetch.test.js'; import './sidebarFullTitle.test.js'; import './sidebarTitleMarquee.test.js'; diff --git a/web/src/lib/sidebarWorkspaceRefresh.test.js b/web/src/lib/sidebarWorkspaceRefresh.test.js new file mode 100644 index 00000000..6931f42b --- /dev/null +++ b/web/src/lib/sidebarWorkspaceRefresh.test.js @@ -0,0 +1,266 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import { setImmediate as nextTask } from 'node:timers/promises'; +import { parseSync } from '@babel/core'; +import * as workspaceSessions from './sidebarWorkspaceSessions.js'; +import * as sidebarSessions from './sidebarSessions.js'; +import * as pinnedSessions from './pinnedSessions.js'; +import * as auxiliaryFetch from './sidebarAuxiliaryFetch.js'; +import { applyStatusUpdate } from './sessionStatus.js'; + +// Execute the production callbacks, including the await boundaries and state +// setters. Testing only the sequence helper cannot expose a check before a later +// await, or a background request invalidating a pending user full-list request. +const source = readFileSync(new URL('../components/Sidebar.jsx', import.meta.url), 'utf8'); +const ast = parseSync(source, { configFile: false, babelrc: false, parserOpts: { plugins: ['jsx'] } }); +const component = ast.program.body.map((node) => node.declaration || node) + .find((node) => node.id?.name === 'Sidebar'); +assert.ok(component, 'Missing production Sidebar'); +const declarations = component.body.body.flatMap((statement) => statement.declarations || []); + +function installCallback(context, name) { + const declaration = declarations.find((node) => node.id.name === name); + assert.equal(declaration?.init.callee?.name, 'useCallback', `Missing production callback ${name}`); + const callback = declaration.init.arguments[0]; + vm.runInContext(`${name} = (${source.slice(callback.start, callback.end)})`, context); +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +function sessions(count, prefix = 'session') { + return Array.from({ length: count }, (_, i) => ({ + id: `${prefix}-${i}`, workspace_hash: 'w', title: `${prefix} ${i}`, + updated_at: new Date(Date.UTC(2026, 8, 20, 0, count - i)).toISOString(), + })); +} + +const compactPage = () => ({ sessions: sessions(5), total: 20, has_more: true }); + +function fixture({ initialSessions = sessions(5), loaded = true, noWorkspace, pinned } = {}) { + const workspace = { hash: 'w', cwd: '/fixture', active: true }; + const state = { + sessions: initialSessions, + loading: new Set(), loaded: new Set(loaded ? ['w'] : []), fullyLoaded: new Set(), + totals: new Map(), statuses: new Map(), workspaces: [workspace], + }; + const requests = []; + const context = vm.createContext({ + Map, Set, Array, Promise, setTimeout, + ...workspaceSessions, ...sidebarSessions, ...pinnedSessions, ...auxiliaryFetch, + applyStatusUpdate, + workspaces: state.workspaces, activeWorkspaceHash: 'w', + revealTarget: { noWorkspace: false, workspaceHash: 'w' }, + api: { + listWorkspaces: async () => [workspace], + listSessions: () => noWorkspace?.promise || Promise.resolve([]), + listWorkspaceSessions: (hash, query) => { + const request = { hash, query, ...deferred() }; + requests.push(request); + return request.promise; + }, + getPinnedSessions: () => pinned?.promise || Promise.resolve({ session_ids: [] }), + getNoWorkspacePinnedSessions: async () => ({ session_ids: [] }), + getPinnedSessionOrder: async () => ({ items: [] }), + }, + hasDesktopBridge: () => false, + desktopTaskbarBadge: { retainWorkspaces() {}, replaceScope() {} }, + desktopTaskbarBadgeAvailable: () => false, + connection: { subscribeWorkspaceStatus() {} }, + refreshOpencodeImportPreview: async () => {}, + syncRetainedSessionIds() {}, cancelSessionSelection() {}, onOpenHome() {}, + setExpandedSessionLists() {}, + setPinnedMap: (value) => { context.pinnedByWorkspaceRef.current = value; }, + setPinnedOrder: (value) => { context.pinnedOrderItemsRef.current = value; }, + updateExpanded: (updater) => { context.expandedRef.current = updater(context.expandedRef.current); }, + setActiveWorkspaceHash: (value) => { context.activeWorkspaceHash = value; }, + }); + const refs = { + sessionsRef: state.sessions, + sessionLoadedWorkspacesRef: state.loaded, + sessionFullyLoadedWorkspacesRef: state.fullyLoaded, + workspaceSessionLoadSeqRef: new Map(), + pendingFullWorkspaceLoadsRef: new Map(), + refreshingRef: false, pendingRefreshHashRef: '', + expandedRef: new Set(['w']), + workspaceCollapseAllRef: false, userCollapsedWorkspacesRef: new Set(), + sessionListDisclosureCompactRef: new Set(), + opencodePreviewProbedRef: new Set(), + pinnedByWorkspaceRef: new Map(), pinnedOrderItemsRef: [], + }; + for (const [name, current] of Object.entries(refs)) context[name] = { current }; + for (const [setter, key, ref] of [ + ['setSessions', 'sessions', 'sessionsRef'], + ['setSessionLoadingWorkspaces', 'loading'], + ['setSessionLoadedWorkspaces', 'loaded', 'sessionLoadedWorkspacesRef'], + ['setSessionFullyLoadedWorkspaces', 'fullyLoaded', 'sessionFullyLoadedWorkspacesRef'], + ['setSessionListTotals', 'totals'], + ['setStatusBySession', 'statuses'], + ['setWorkspaces', 'workspaces'], + ]) { + context[setter] = (update) => { + state[key] = typeof update === 'function' ? update(state[key]) : update; + if (ref) context[ref].current = state[key]; + if (key === 'workspaces') context.workspaces = state[key]; + }; + } + for (const name of ['isNoWorkspaceSession', 'normalizeNoWorkspaceSession', 'normalizeWorkspaceSession']) { + const node = ast.program.body.find((item) => item.type === 'FunctionDeclaration' && item.id.name === name); + assert.ok(node, `Missing production helper ${name}`); + vm.runInContext(source.slice(node.start, node.end), context); + } + for (const name of [ + 'setSessionWorkspaceLoading', 'setSessionWorkspacesLoaded', 'markWorkspaceSessionsFullyLoaded', + 'applyWorkspaceSessionList', 'loadWorkspaceSessions', 'refresh', 'onActivate', + ]) installCallback(context, name); + return { context, state, requests, workspace }; +} + +const tests = []; +function test(name, run) { tests.push({ name, run }); } + +test('an old compact result cannot overwrite a full load while waiting for no-workspace sessions', async () => { + const noWorkspace = deferred(); + const { context, state, requests } = fixture({ noWorkspace }); + const refresh = context.refresh(); + await nextTask(); + assert.equal(requests.length, 1); + assert.equal(requests[0].query.limit, 5); + requests[0].resolve({ ...compactPage(), total: 99 }); + await nextTask(); + const full = context.loadWorkspaceSessions('w', { full: true }); + requests[1].resolve(sessions(20)); + await full; + assert.equal(state.sessions.length, 20, 'user full load commits before the old refresh resumes'); + noWorkspace.resolve([]); + await refresh; + assert.equal(state.sessions.length, 20); + assert.equal(state.totals.get('w'), 20); + assert.equal(state.fullyLoaded.has('w'), true); +}); + +test('a periodic refresh reuses a pending user full load instead of superseding it with five rows', async () => { + const { context, state, requests } = fixture(); + const full = context.loadWorkspaceSessions('w', { full: true }); + const refresh = context.refresh(); + await nextTask(); + // Resolve a compact request too on the unfixed PR, exposing the resulting + // five-row list without hanging the regression test. + requests.slice(1).forEach((request) => request.resolve(compactPage())); + requests[0].resolve(sessions(20)); + await Promise.all([full, refresh]); + assert.equal(state.sessions.length, 20); + assert.equal(state.fullyLoaded.has('w'), true); + assert.equal(requests.length, 1, 'background refresh must share the full request'); +}); + +test('reactivating an already loaded empty workspace exits loading after the current request settles', async () => { + const { context, state, requests, workspace } = fixture({ initialSessions: [] }); + const productionRefresh = context.refresh; + let refresh; + context.refresh = (...args) => { refresh = productionRefresh(...args); return refresh; }; + await context.onActivate(workspace); + await nextTask(); + assert.equal(state.loading.has('w'), true); + assert.equal(requests.length, 1); + requests[0].resolve([]); + await refresh; + assert.equal(state.loading.has('w'), false); + assert.equal(state.loaded.has('w'), true); + assert.equal(state.sessions.length, 0); +}); + +test('an old refresh cannot clear the loading indicator of a newer pending full request', async () => { + const { context, state, requests } = fixture({ initialSessions: [], loaded: false }); + const refresh = context.refresh(); + await nextTask(); + const full = context.loadWorkspaceSessions('w', { full: true }); + requests[0].resolve(compactPage()); + await refresh; + assert.equal(state.loading.has('w'), true, 'new full request still owns loading'); + requests[1].resolve(sessions(20)); + await full; + assert.equal(state.loading.has('w'), false); + assert.equal(state.sessions.length, 20); +}); + +test('a failed current request also clears activation loading for an already loaded empty workspace', async () => { + const { context, state, requests, workspace } = fixture({ initialSessions: [] }); + const productionRefresh = context.refresh; + let refresh; + context.refresh = (...args) => { refresh = productionRefresh(...args); return refresh; }; + await context.onActivate(workspace); + await nextTask(); + assert.equal(requests.length, 1); + requests[0].reject(new Error('unavailable')); + await refresh; + assert.equal(state.loading.has('w'), false); + assert.equal(state.loaded.has('w'), true); + assert.equal(state.sessions.length, 0); +}); + +test('a failed shared full request retains cached rows and permits a later retry', async () => { + const { context, state, requests } = fixture(); + const full = context.loadWorkspaceSessions('w', { full: true }); + const refresh = context.refresh(); + await nextTask(); + requests.slice(1).forEach((request) => request.reject(new Error('unavailable'))); + requests[0].reject(new Error('unavailable')); + await Promise.all([full, refresh]); + assert.equal(state.sessions.length, 5); + assert.equal(state.fullyLoaded.has('w'), false); + assert.equal(state.loading.has('w'), false); + const beforeRetry = requests.length; + const retry = context.loadWorkspaceSessions('w', { full: true }); + assert.equal(requests.length, beforeRetry + 1); + requests.at(-1).resolve(sessions(20)); + await retry; + assert.equal(state.sessions.length, 20); + assert.equal(state.fullyLoaded.has('w'), true); +}); + +test('settling an earlier full request does not remove a later full request from refresh sharing', async () => { + const { context, state, requests } = fixture(); + const first = context.loadWorkspaceSessions('w', { full: true }); + const second = context.loadWorkspaceSessions('w', { full: true }); + requests[0].resolve(sessions(10)); + await first; + assert.equal(state.sessions.length, 5, 'superseded full result is discarded'); + const refresh = context.refresh(); + await nextTask(); + requests.slice(2).forEach((request) => request.resolve(compactPage())); + requests[1].resolve(sessions(20)); + await Promise.all([second, refresh]); + assert.equal(state.sessions.length, 20); + assert.equal(requests.length, 2, 'refresh shares the second full request'); +}); + +test('visible session requests still start before slow pinned metadata resolves', async () => { + const pinned = deferred(); + const { context, state, requests } = fixture({ pinned }); + const refresh = context.refresh(); + await nextTask(); + assert.equal(requests.length, 1, 'retain the early request benefit of PR #64'); + requests[0].resolve(compactPage()); + pinned.resolve({ session_ids: [] }); + await refresh; + assert.equal(state.sessions.length, 5); + assert.equal(state.totals.get('w'), 20); +}); + +let failures = 0; +for (const { name, run } of tests) { + try { + await run(); + console.log(`[pass] ${name}`); + } catch (error) { + failures += 1; + console.error(`[fail] ${name}\n${error.stack}`); + } +} +assert.equal(failures, 0, `${failures} sidebar request timing regressions`);