Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-20
23 changes: 23 additions & 0 deletions openspec/changes/fix-sidebar-refresh-request-ownership/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
## Context

见 proposal.md。现有每工作区代次已能拒绝直接返回的旧请求,但后台刷新在检查后仍等待无工作区列表,且后台摘要请求无条件推进代次。

## Goals / Non-Goals

**Goals:** 明确完整请求的共享生命周期、最终提交检查和加载状态清理资格。

**Non-Goals:** 不调整置顶数据、排序、展开批次或后端分页协议,也不移入主工作区的其他未提交改动。

## Decisions

- 保存正在进行的全量请求及代次;后台刷新复用对应 Promise。单纯比较代次无法阻止后台摘要抢占全量请求,因此在发起后台请求前处理全量优先级。
- 无工作区列表等待结束后,再按代次筛选所有工作区结果;筛选与提交之间不再等待异步操作。
- 请求清理以本轮仍有效的请求集合为依据,而非本轮新增加载指示的集合。这样已有空工作区的激活状态也会正确结束。
- 测试解析并执行实际 Sidebar 回调,仅模拟 React 状态和 API 边界,用延迟 Promise 构造竞争顺序;不另抄一份请求算法。
- 合入 44e0d894 时,早发摘要先按缓存置顶数增加请求名额;本轮置顶数增加且原请求仍有效、仍有更多历史时补请求。已被较新全量加载替代的摘要不得再发补请求。保留主分支的历史追加、Map 批次展开和真实目录排序控制器。

## Risks / Trade-offs

- 共享请求失败影响本轮后台刷新 → 保留缓存,在 finally 按请求身份移除记录,并测试后续重试。
- 用户连续发起两次全量请求 → 旧请求清理必须检查记录身份,不能删除较新请求。
- 单测不挂载浏览器 UI → 断言真实调用路径更新的列表、总数、完整标记和加载集合,视觉布局保持原样。
24 changes: 24 additions & 0 deletions openspec/changes/fix-sidebar-refresh-request-ownership/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## Why

PR #64 提前发起侧栏会话请求后,摘要请求仍可能跨越后续等待覆盖较新的全量列表;定时刷新也可能抢走用户展开请求的提交资格。需要在合并前明确每个工作区请求的所有权,保留早发请求带来的响应速度。

## What Changes

- 用户全量加载进行中时,后台刷新复用同一请求,不发起会使它失效的摘要请求。
- 所有异步等待完成后才检查请求代次,旧结果不能更改会话、总数或全量标记。
- 只有当前请求能清理对应加载状态,包括重新激活已加载的空工作区。
- 用真实 Sidebar 回调和可控异步完成顺序添加回归测试。

## Capabilities

### New Capabilities

- `sidebar-refresh-request-ownership`: 侧栏并发列表请求的提交和加载状态所有权。

### Modified Capabilities

无。

## Impact

仅影响 WebUI Sidebar 的工作区会话加载和定向 JS 测试;不修改 API、置顶规则、会话排序或展开展示方式。
Original file line number Diff line number Diff line change
@@ -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** 工作区退出加载状态并展示空列表状态
14 changes: 14 additions & 0 deletions openspec/changes/fix-sidebar-refresh-request-ownership/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
## 1. 请求所有权修复

- [x] 1.1 添加真实 Sidebar 回调时序回归,并确认原 PR 在旧摘要覆盖、全量请求被抢占、空工作区激活清理场景失败。
- [x] 1.2 实现全量请求共享与身份清理,验证定时刷新不抢占、失败可重试及连续全量请求的清理隔离。
- [x] 1.3 在全部等待之后筛选有效结果并按当前请求清理加载状态,验证列表、总数、完整标记及空工作区状态。

## 2. 验证与交付

- [x] 2.1 将新回归加入 JS 测试入口,运行相关定向测试、严格 OpenSpec 校验和 git diff --check,确认只提交本次修复文件。

## 3. 合入最新主分支

- [x] 3.1 合入 44e0d894,保留置顶补足五条、历史追加、分批展开和目录排序控制器,并用真实回调验证早发摘要后的置顶补请求不会抢占全量加载。
- [x] 3.2 更新测试 fixture 使用真实目录排序控制器,运行隔离工作区完整 pnpm test、OpenSpec 严格校验和 diff --check,再提交 merge commit。
159 changes: 127 additions & 32 deletions web/src/components/Sidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,11 @@ import {
import {
normalizeWorkspaceSessionListResponse,
retainUnrefreshedSidebarSessions,
settleSidebarWorkspacePage,
sidebarWorkspacePageIsCurrent,
sidebarWorkspaceSessionListQuery,
workspaceHasCachedSidebarSessions,
workspaceNeedsInitialSidebarLoad,
} from '../lib/sidebarWorkspaceSessions.js';
import {
opencodePreviewTargets,
Expand Down Expand Up @@ -1906,12 +1909,15 @@ 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());
const pendingFullWorkspaceLoadsRef = 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());
Expand Down Expand Up @@ -2446,30 +2452,42 @@ export function Sidebar({
const wantFull = full || sessionFullyLoadedWorkspacesRef.current.has(workspaceHash);
const appendNewSessions = wantFull && !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);
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,
pinnedIds: pinnedByWorkspaceRef.current.get(workspaceHash),
})),
);
}
return normalizeWorkspaceSessionListResponse(
await api.listWorkspaceSessions(workspaceHash, sidebarWorkspaceSessionListQuery({
full: wantFull,
pinnedIds: pinnedByWorkspaceRef.current.get(workspaceHash),
})),
);
})();
if (wantFull) {
pendingFullWorkspaceLoadsRef.current.set(workspaceHash, { sequence, promise: pagePromise });
}
try {
const payload = await pagePromise;
if (workspaceSessionLoadSeqRef.current.get(workspaceHash) !== sequence) return;
applyWorkspaceSessionList(workspace, payload, { appendNewSessions });
} catch {
/* 鉴权失败不致命 */
} finally {
if (pendingFullWorkspaceLoadsRef.current.get(workspaceHash)?.sequence === sequence) {
pendingFullWorkspaceLoadsRef.current.delete(workspaceHash);
}
if (workspaceSessionLoadSeqRef.current.get(workspaceHash) === sequence) {
setSessionWorkspaceLoading([workspaceHash], false);
}
Expand Down Expand Up @@ -2584,7 +2602,59 @@ export function Sidebar({
opencodePreviewProbedRef.current.add(w.hash);
refreshOpencodeImportPreview(w).catch(() => {});
});
setSessionWorkspaceLoading(earlyVisibleWorkspaceHashes, true);
const loadWorkspacePage = async (workspace, query) => {
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,
query,
),
),
};
};
// 会话行是侧边栏的主体,不能等待置顶等辅助数据后才开始请求。
const startWorkspacePageLoad = (workspace) => {
const pendingFull = pendingFullWorkspaceLoadsRef.current.get(workspace.hash);
if (pendingFull && sidebarWorkspacePageIsCurrent(
workspaceSessionLoadSeqRef.current.get(workspace.hash), pendingFull.sequence,
)) {
// 用户的全量请求尚未完成时,后台摘要不能推进代次使它失效。
return {
sequence: pendingFull.sequence,
query: {},
result: settleSidebarWorkspacePage(pendingFull.promise.then((page) => ({ workspace, ...page }))),
};
}
const sequence = (workspaceSessionLoadSeqRef.current.get(workspace.hash) || 0) + 1;
workspaceSessionLoadSeqRef.current.set(workspace.hash, sequence);
const query = sidebarWorkspaceSessionListQuery({
full: sessionFullyLoadedWorkspacesRef.current.has(workspace.hash),
pinnedIds: pinnedByWorkspaceRef.current.get(workspace.hash),
});
return {
sequence,
query,
result: settleSidebarWorkspacePage(loadWorkspacePage(workspace, query)),
};
};
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([
Expand Down Expand Up @@ -2648,27 +2718,46 @@ 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 = [];
let settledPages = [];
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 };
settledPages = await Promise.all(visibleWorkspaces.map(async (workspace) => {
let request = earlyWorkspacePages.get(workspace.hash)
|| startWorkspacePageLoad(workspace);
let result = await request.result;
const neededQuery = sidebarWorkspaceSessionListQuery({
pinnedIds: nextPinnedMap.get(workspace.hash),
});
// 先用缓存置顶数启动请求;本轮发现更多置顶项时补足普通五条。
// 补请求也必须尊重较新的用户全量请求,不能重新抢走它的代次。
if (result.ok && result.page.hasMore && request.query.limit !== undefined
&& neededQuery.limit > request.query.limit && sidebarWorkspacePageIsCurrent(
workspaceSessionLoadSeqRef.current.get(workspace.hash), request.sequence,
)) {
request = startWorkspacePageLoad(workspace);
result = await request.result;
}
const wantFull = sessionFullyLoadedWorkspacesRef.current.has(w.hash);
const payload = normalizeWorkspaceSessionListResponse(
await api.listWorkspaceSessions(w.hash, sidebarWorkspaceSessionListQuery({
full: wantFull,
pinnedIds: nextPinnedMap.get(w.hash),
})),
);
return { workspace: w, ...payload };
return { workspace, sequence: request.sequence, result };
}));
// 所有等待结束后再检查代次,不能让这里较慢的请求造成旧摘要回写。
const noWorkspaceRaw = await noWorkspaceListPromise;
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 noWorkspaceIncoming = (Array.isArray(noWorkspaceRaw) ? noWorkspaceRaw : [])
.filter(isNoWorkspaceSession)
.map(normalizeNoWorkspaceSession);
Expand All @@ -2693,7 +2782,7 @@ export function Sidebar({
...noWorkspaceIncoming,
];
setSessions((prev) => retainUnrefreshedSidebarSessions(prev, incoming, {
refreshedWorkspaceHashes: visibleWorkspaceHashes,
refreshedWorkspaceHashes,
pinnedByWorkspace: pinnedByWorkspaceRef.current,
refreshNoWorkspace: true,
}));
Expand All @@ -2707,8 +2796,14 @@ export function Sidebar({
}
catch { /* 鉴权失败不致命 */ }
finally {
setSessionWorkspacesLoaded(visibleWorkspaceHashes, true);
setSessionWorkspaceLoading(visibleWorkspaceHashes, false);
setSessionWorkspacesLoaded(refreshedWorkspaceHashes, true);
// 激活已加载空工作区也会设置 loading;只按本轮当前请求清理,
// 既不能遗漏已有标记,也不能清掉较新全量请求正在使用的标记。
setSessionWorkspaceLoading(settledPages
.filter(({ workspace, sequence }) => sidebarWorkspacePageIsCurrent(
workspaceSessionLoadSeqRef.current.get(workspace.hash), sequence,
))
.map(({ workspace }) => workspace.hash), false);
}
} finally {
refreshingRef.current = false;
Expand Down
1 change: 1 addition & 0 deletions web/src/lib/runTests.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,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';
Expand Down
Loading
Loading