Skip to content

fix(web-shell): persist collapsed session group sections across reload - #6878

Merged
samuelhsin merged 7 commits into
QwenLM:mainfrom
samuelhsin:fix/web-shell-persist-collapsed-session-groups
Jul 15, 2026
Merged

fix(web-shell): persist collapsed session group sections across reload#6878
samuelhsin merged 7 commits into
QwenLM:mainfrom
samuelhsin:fix/web-shell-persist-collapsed-session-groups

Conversation

@samuelhsin

@samuelhsin samuelhsin commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Persist collapsed session-organization section ids (named groups / Ungrouped / color buckets) in localStorage so a full page reload no longer re-expands every section. Key: qwen-code-web-shell-collapsed-session-groups, same qwen-code-web-shell-* namespace as sidebar width / sidebar collapsed / theme.

Restore on mount, write back on toggle. Skip auto-collapse on the first catalog sync so restored expand/collapse is not wiped on remount; brand-new sections that appear later in the same session still start collapsed.

Why it's needed

Issue #6870: collapsing named groups (or Ungrouped) works for the current page, but refresh restores every group to expanded. Desktop already persists (craft-)collapsed-session-groups; Web Shell was missing the same preference.

Reviewer Test Plan

How to verify

  1. Open Web Shell with session_organization enabled and at least one named group.
  2. Collapse a named group (and optionally Ungrouped).
  3. Hard-refresh the page.
  4. Expect: collapsed sections stay collapsed; expand again + refresh stays expanded.

Automated:

  • cd packages/web-shell && npm test -- client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx
  • cd packages/web-shell && npx playwright test client/e2e/web-shell.collapsed-groups-persist.spec.ts

Evidence (Before & After)

Before: collapse Backend → reload → Backend reopens.
After: collapse Backend → reload → Backend stays collapsed.

Collapsed session groups survive reload

Storyboard captioned in the GIF: expand → collapse Backend → reload → still collapsed.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Local: packages/web-shell vitest + Playwright against npm run dev with mock daemon routes.

Risk & Scope

  • Main risk or tradeoff: local-only preference; no cross-device sync. First catalog sync no longer auto-collapses every section on mount (intentional so restored state wins).
  • Not validated / out of scope: Desktop Electron path (already has persistence); daemon schema / organization APIs.
  • Breaking changes / migration notes: none. New localStorage key only.

Linked Issues

Fixes #6870

中文说明

这个 PR 做什么

把会话组织分区(命名分组 / Ungrouped / 颜色桶)的收合状态写进 localStorage,全页刷新后不再全部展开。key:qwen-code-web-shell-collapsed-session-groups,与侧栏宽度等现有 qwen-code-web-shell-* 命名空间一致。

挂载时恢复,toggle 时写回。首次 catalog 同步跳过自动收合,避免 remount 冲掉已恢复状态;同一次会话里中途新出现的 section 仍默认收合。

为什么需要

Issue #6870:收合命名分组在当前页有效,刷新后全部重新展开。Desktop 已有同类持久化;Web Shell 补齐。

Reviewer 验证

  1. 启用 session_organization,收合命名分组后硬刷新。
  2. 预期:收合保持;再展开后刷新仍展开。

证据见上方 GIF。风险:仅本地偏好;不涉及 daemon。

Store collapsed section ids in localStorage using the existing
qwen-code-web-shell-* key namespace, and skip the first catalog sync
auto-collapse so restored expand/collapse state survives remount.

Fixes QwenLM#6870

Co-authored-by: Cursor <cursoragent@cursor.com>
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR! (re-run)

Template: all required headings present and match the template exactly — What this PR does, Why it's needed, Reviewer Test Plan (with How to verify, Evidence, Tested on), Risk & Scope, Linked Issues, and Chinese translation. ✓

Problem: real bug with clear reproduction. Issue #6870 describes collapsed session groups resetting to expanded on every page reload. Desktop already persists this state (collapsedSessionGroups); Web Shell was missing the same capability. The PR includes a demo GIF showing before/after behavior.

Direction: aligned. CHANGELOG shows session groups landed in #6350 and custom colors in #6752 — persisting collapsed state is the natural next step. Local-only persistence, no daemon changes, consistent with the existing qwen-code-web-shell-* localStorage key namespace (sidebar-width, sidebar-collapsed).

Size: not applicable — only packages/web-shell/ files touched. Production code: ~237 lines (collapsedSessionSections.ts = 117, WebShellSidebar.tsx ≈ 53, WorkspaceSection.tsx ≈ 12, mockDaemon.ts ≈ 55). Tests: ~668 lines. Well within scope.

Approach: minimal and well-targeted. The collapsedSessionSections.ts module provides shared localStorage helpers with a clean owner-based write protocol (replaceOwnedCollapsedSessionSectionIds) so primary sidebar and per-workspace sections don't clobber each other. The dual-latch mechanism (groupsCatalogReady + sessionsCatalogReady) is the right solution for the non-obvious timing problem — the latch only settles on successful catalog loads, preventing a failed request from consuming it and auto-collapsing restored expansions. The demo-capture scripts from the original PR have been removed (the /tmp symlink race concern is moot).

Moving on to code review. 🔍

中文说明

感谢贡献!(重新审查)

模板:所有必需标题与模板完全匹配——What this PR doesWhy it's neededReviewer Test Plan(含 How to verifyEvidenceTested on)、Risk & ScopeLinked Issues 和中文翻译。✓

问题:真实 bug,有清晰复现。Issue #6870 描述折叠的 session 分组在页面刷新后重新展开。Desktop 端已有相同的持久化能力(collapsedSessionGroups);Web Shell 补齐。PR 包含演示 GIF 展示 before/after 行为。

方向:对齐。CHANGELOG 显示 session groups 在 #6350 上线,自定义颜色在 #6752 加入——持久化折叠状态是自然的后续。仅本地持久化,无 daemon 改动,和已有的 qwen-code-web-shell-* localStorage key 命名空间一致。

规模:不适用——仅涉及 packages/web-shell/ 文件。生产代码约 237 行(collapsedSessionSections.ts = 117,WebShellSidebar.tsx ≈ 53,WorkspaceSection.tsx ≈ 12,mockDaemon.ts ≈ 55)。测试约 668 行。规模合理。

方案:最小化且目标明确。collapsedSessionSections.ts 模块提供共享的 localStorage helper,采用基于 owner 的写入协议(replaceOwnedCollapsedSessionSectionIds),使主侧栏和各工作区的折叠状态互不覆盖。双 latch 机制(groupsCatalogReady + sessionsCatalogReady)正确处理了不直观的时序问题——latch 仅在 catalog 成功加载后落定,防止失败请求消费 latch 导致恢复的展开状态被自动折叠。原始 PR 的演示脚本已移除(/tmp 符号链接竞争问题不再适用)。

进入代码审查 🔍

Qwen Code · qwen3.7-max

Reviewed at 5bed4533ef7dec64e4dcbc431f48e5abc7370f17 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Code Review (re-run)

Independent proposal: persist collapsed section IDs in localStorage under the qwen-code-web-shell-* namespace, initialize from storage on mount, write back on change, and handle the initial-catalog-sync carefully to avoid wiping restored state. Use namespace-prefixed IDs for workspace-scoped sections to prevent cross-owner clobbering. This is exactly what the PR does — the implementation matches the straightforward approach.

No correctness issues, no security concerns, no regressions found.

Prior critical findings — all resolved at this commit:

  1. Secondary-workspace sections used memory-only Set (the web-shell / session sidebar: collapsed named groups reopen after page refresh #6870 root cause for non-primary workspaces). Fixed: WorkspaceSection now seeds collapsedGroupIds from readWorkspaceCollapsedGroupIds(workspace.id) and writes back via useEffect, using collision-safe namespaced IDs (ws:<workspaceId>|group:<groupId>, ws:<workspaceId>|ungrouped).
  2. /tmp symlink race in demo-capture script. Resolved: the script was removed from the tree entirely (confirmed absent at head).
  3. First-sync latch timing. Hardened: the dual-latch (groupsCatalogReady && sessionsCatalogReady) only settles on successful catalog loads. A failed request can't consume the latch and auto-collapse restored expansions. The prevOrganizationEnabled render-time state sync correctly closes the gate when capabilities arrive late.

Code quality observations (non-blocking):

The replaceOwnedCollapsedSessionSectionIds function is a clean pattern for multi-owner localStorage — each writer owns its IDs and replaces only its own subset, preserving other owners. The isPrimaryCollapsedSectionId / isWorkspaceCollapsedSectionId predicates cleanly partition the ID space. The sessionsCatalogReady derivation using data !== undefined (not !loading) correctly handles the useDaemonResource lifecycle where loading starts false before autoLoad runs.

Test Results

Unit tests (vitest)

 ✓ collapsedSessionSections.test.ts (2 tests) 4ms
 ✓ WebShellSidebar.collapse-persist.test.tsx (9 tests) 446ms
 ✓ WebShellSidebar.workspace-removal.test.tsx (8 tests) 444ms

 Test Files  3 passed (3)
      Tests  19 passed (19)

E2E test (Playwright)

Running 1 test using 1 worker
  1 passed (5.2s)

The test collapses a "Backend" group, verifies localStorage is written (["group:group-backend"]), reloads the page, and confirms the group stays collapsed — exactly the acceptance criteria from #6870.

Build & Static Checks

Typecheck:  ✅ clean (0 errors)
Lint:       ✅ clean (all changed files)
中文说明

代码审查(重新审查)

独立方案:在 localStorageqwen-code-web-shell-* 命名空间下持久化折叠的 section ID,mount 时从 storage 初始化状态,变更时写回,并小心处理首次 catalog 同步以避免覆盖恢复的状态。对工作区作用域的 section 使用命名空间前缀的 ID 防止跨 owner 覆盖。PR 的实现和这个方案完全一致。

无正确性问题、无安全隐患、无回归。

之前的 Critical 问题——在此 commit 均已解决:

  1. 次级工作区使用内存态 Setweb-shell / session sidebar: collapsed named groups reopen after page refresh #6870 对非主工作区的根因)。已修复:WorkspaceSection 通过 readWorkspaceCollapsedGroupIds(workspace.id) 初始化 collapsedGroupIds 并通过 useEffect 写回,使用防冲突的命名空间 ID。
  2. 演示脚本的 /tmp 符号链接竞争。已解决:脚本已从代码树整体移除。
  3. 首同步 latch 时序。已加固:双 latch 仅在 catalog 成功加载后落定。

代码质量:replaceOwnedCollapsedSessionSectionIds 是多 owner localStorage 的干净模式。sessionsCatalogReady 使用 data !== undefined(而非 !loading)正确处理 useDaemonResource 生命周期。

测试结果

单元测试 19/19 通过。Playwright e2e 测试通过——折叠 Backend 分组、验证 localStorage 写入、刷新页面、确认分组保持折叠。类型检查和 lint 全部通过。

Qwen Code · qwen3.7-max

Reviewed at 5bed4533ef7dec64e4dcbc431f48e5abc7370f17 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — Clean across every stage; would merge without hesitation.

This is a well-executed fix for a real UX gap that users hit immediately — collapsed session groups resetting on every page reload. The implementation is exactly what you'd want: 117 lines of clean localStorage helpers with proper owner-based write isolation, a dual-latch mechanism that correctly handles the non-obvious catalog timing edge case, and workspace-scoped persistence that doesn't clobber the primary sidebar's state.

All three critical findings from the earlier review round are resolved at this commit: secondary-workspace persistence works through namespaced IDs, the demo-capture script (and its symlink race) is gone, and the first-sync latch is hardened against failed requests and late-arriving capabilities. The 9 collapse-persist unit tests cover primary persistence, workspace-scoped persistence, cross-workspace isolation, and the initial-catalog-latch behavior. The Playwright e2e test drives the full collapse → verify → reload → confirm cycle. 19 sidebar tests, 1 e2e test, typecheck, and lint all green.

Approving. ✅

中文说明

信心度: 5/5 — 各阶段全部通过,毫不犹豫合并。

这是对真实 UX 缺陷的出色修复——折叠的 session 分组在页面刷新后重置是用户会立即注意到的问题。实现方式正是期望的:117 行干净的 localStorage helper,带有正确的基于 owner 的写入隔离,双 latch 机制正确处理了不直观的 catalog 时序边界情况,工作区作用域的持久化不会干扰主侧栏的状态。

之前审查轮次的三个 Critical 问题在此 commit 均已解决:次级工作区通过命名空间 ID 实现持久化,演示脚本(及其符号链接竞争)已移除,首同步 latch 对失败请求和延迟到达的 capabilities 进行了加固。9 个折叠持久化单测覆盖主要持久化、工作区作用域持久化、跨工作区隔离和初始 catalog latch 行为。Playwright e2e 测试驱动完整的折叠→验证→刷新→确认循环。19 个 sidebar 测试、1 个 e2e 测试、类型检查、lint 全部通过。

批准合并。✅

Qwen Code · qwen3.7-max

Reviewed at 5bed4533ef7dec64e4dcbc431f48e5abc7370f17 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@samuelhsin
samuelhsin enabled auto-merge July 14, 2026 09:55
Crop to the sidebar, caption the four beats (expand → collapse →
reload → still collapsed), and keep Pinned out of the organized
session list mock so the Backend collapse is obvious.

Co-authored-by: Cursor <cursoragent@cursor.com>

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline. Not reviewed: chunk 1, chunk 2, chunk 3 — no agent reported covering these; nobody read them.

— qwen3.7-max via Qwen Code /review

Comment on lines +7 to +8
const COLLAPSED_SESSION_SECTIONS_STORAGE_KEY =
'qwen-code-web-shell-collapsed-session-groups';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The storage key 'qwen-code-web-shell-collapsed-session-groups' is independently hardcoded in three files (this test, the E2E spec, and WebShellSidebar.tsx) instead of being exported/imported from the source module.

Concrete cost: If someone renames the key in WebShellSidebar.tsx (e.g., to add a version suffix for a schema migration), both test files continue passing against a stale key — tests pass while production silently loses all persisted collapse state for every user.

Consider exporting the constant from WebShellSidebar.tsx (or a shared constants module) and importing it in the unit test. The E2E test is harder to couple due to different build contexts, so duplication there is more acceptable.

— qwen3.7-max via Qwen Code /review

// First catalog sync: register ids only. Restored localStorage (or the
// empty default) owns expand/collapse; auto-collapse would otherwise wipe
// expanded sections on every remount because the known set resets.
const isInitialCatalog = knownSessionSectionIdsRef.current.size === 0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The isInitialCatalog heuristic (knownSessionSectionIdsRef.current.size === 0) silently depends on the invariant that nothing else pre-populates knownSessionSectionIdsRef before this effect fires. The comment explains why the initial-catalog skip exists, but not what would break the heuristic.

Failure scenario: A future maintainer adds an initializer that seeds knownSessionSectionIdsRef (e.g., to fix a flicker bug). The size === 0 check becomes false on the first real sync, so all restored-from-localStorage expanded sections get auto-collapsed on every mount — silently, with no error.

Consider strengthening the comment to state the invariant explicitly, or using a dedicated useRef<boolean>(true) flag that is flipped after the first run, decoupling the heuristic from the ref's size.

— qwen3.7-max via Qwen Code /review

Comment on lines +365 to +366
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] No unit test verifies the fallback behavior when localStorage contains corrupt or unexpected data (invalid JSON, non-array values, non-string items). The production code handles all three gracefully, but a regression that removes the guard would go undetected.

Failure scenario: Someone removes the Array.isArray check during a cleanup, and a user whose localStorage contains "not-an-array" from a prior bug gets a crash instead of a graceful fallback.

Suggested change
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
// Consider adding a test:
it('tolerates corrupt localStorage data', async () => {
window.localStorage.setItem(
COLLAPSED_SESSION_SECTIONS_STORAGE_KEY,
'not valid json',
);
renderSidebar();
await flushSidebar();
expect(groupHeader('Backend').getAttribute('aria-expanded')).toBe('true');
});

— qwen3.7-max via Qwen Code /review

Comment on lines +384 to +386
} catch {
// localStorage can be unavailable in private or embedded contexts.
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] No test verifies the component remains functional when localStorage.setItem throws (e.g., private browsing, embedded iframe with storage disabled, quota exceeded).

Failure scenario: A regression removes the try/catch, and the useEffect that calls writeCollapsedSessionSectionIds crashes on toggle, breaking the component.

Suggested change
} catch {
// localStorage can be unavailable in private or embedded contexts.
}
// Consider adding:
it('does not crash when localStorage.setItem throws', async () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota exceeded');
});
renderSidebar();
await flushSidebar();
act(() => click(groupHeader('Backend')));
await flushSidebar();
expect(groupHeader('Backend').getAttribute('aria-expanded')).toBe('false');
});

— qwen3.7-max via Qwen Code /review

Comment on lines +1980 to +1981
if (isInitialCatalog) return;
// Brand-new sections that appear mid-session still start collapsed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The non-initial-catalog path (auto-collapse of brand-new sections appearing mid-session) is not tested. The isInitialCatalog skip is implicitly covered, but the opposite branch — isInitialCatalog === false — has no test.

Failure scenario: A new group is created server-side while the sidebar is mounted. The auto-collapse code path could regress without detection.

Consider adding a test that renders the sidebar, flushes, then updates the mock to include a new group and verifies the new section starts collapsed.

— qwen3.7-max via Qwen Code /review

Export the storage key for unit tests, use an explicit first-catalog
latch instead of size===0, and cover corrupt/disabled storage plus
mid-session auto-collapse of newly appeared sections.

Co-authored-by: Cursor <cursoragent@cursor.com>

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not reviewed: coverage — could not read the agents' transcripts (the CLI did not export QWEN_CODE_PROJECT_DIR / QWEN_CODE_SESSION_ID, so this run cannot find the harness's record of what its agents did), so this run cannot show that any of the diff was read.

[Critical] Secondary-workspace session groups still use the memory-only collapsedGroupIds Set in WorkspaceSection, which is the exact root cause identified by issue #6870. Failure scenario: collapse a named or Ungrouped section under a non-primary workspace and reload the page; WorkspaceSection remounts with an empty Set and reopens it. Share the persisted collapse-state mechanism with this render path, using collision-safe workspace/section IDs as needed.

— Codex $qreview via Qwen Code /review

Comment on lines +1983 to +1986
const isInitialCatalog = awaitingInitialSessionCatalogRef.current;
awaitingInitialSessionCatalogRef.current = false;
for (const id of unseenIds) knownSessionSectionIdsRef.current.add(id);
if (isInitialCatalog) return;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] This one-shot latch flips on the first non-empty derived catalog even though groups and sessions hydrate independently. Failure scenario: if groups arrive first, later initial recent/color:* sections are treated as brand-new and auto-collapsed; if the initial catalog is empty, this effect returns before clearing the latch and the first genuinely new section later remains expanded. Gate the initial sync on explicit settlement/readiness of every catalog source, including empty responses.

— Codex $qreview via Qwen Code /review

Comment on lines +590 to +593
const sessions =
group === 'pinned'
? scenario.sessions.filter((session) => Boolean(session.isPinned))
: scenario.sessions.filter((session) => !session.isPinned);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The mock treats every query other than group=pinned as non-pinned, but production explicitly returns all sessions for group=all. Failure scenario: the new browser regression can stay green while the real sidebar loses or duplicates pinned sessions between its All and Pinned requests. Mirror the production query modes explicitly and leave all unfiltered.

— Codex $qreview via Qwen Code /review

npx playwright test \
--config playwright.config.ts \
client/e2e/web-shell.collapsed-groups-persist.spec.ts \
> /tmp/qwen-6870-demo.log 2>&1

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] This predictable shared /tmp path is opened with truncating redirection. Failure scenario: another local user pre-creates /tmp/qwen-6870-demo.log as a symlink, then running the capture helper follows it and truncates any target writable by the developer account. Create the log with mktemp in a private temporary directory and remove it with a trap.

— Codex $qreview via Qwen Code /review

Comment on lines +237 to +239
expect(
window.localStorage.getItem(COLLAPSED_SESSION_SECTIONS_STORAGE_KEY),
).toBe(JSON.stringify(['group:group-1']));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Every persistence assertion covers only one named group:* ID. Concrete failure scenario: a refactor that retains only the last toggled ID would keep all current unit and browser tests green, yet collapsing two groups would make the first reopen after reload; recent and color:* restoration are likewise untested. Add a remount case that preserves multiple IDs and at least one non-group section kind, including removal of one ID without losing the other.

— Codex $qreview via Qwen Code /review

Comment on lines +5 to +6
FRAMES_DIR="$ROOT_DIR/docs/assets/web-shell-collapsed-session-groups-frames"
GIF_PATH="$ROOT_DIR/docs/assets/web-shell-collapsed-session-groups.gif"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] A successful capture leaves its generated PNG frame directory under docs/assets, and that path is not ignored. Concrete cost: every run dirties the checkout and a broad stage can accidentally commit all intermediate frames. Use a temporary frame directory with trap-based cleanup, or explicitly clean/ignore the generated directory.

— Codex $qreview via Qwen Code /review

return page.locator('aside[aria-label="Workspace sidebar"]');
}

test('persists collapsed session groups across reload @demo', async ({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This regression is tagged only @demo, while pull-request CI runs the Web Shell suite with --grep @smoke; the full browser job is schedule/manual-only. Failure scenario: collapse persistence breaks on a PR and this test provides no pre-merge signal. Include this stable path in the smoke subset (for example, add @smoke) or run the spec from another PR job.

— Codex $qreview via Qwen Code /review

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unresolved, please confirm: [Critical] WebShellSidebar.tsx:1986 — latch timing depends on whether daemon delivers groups and sessions atomically; cannot determine from static analysis

Not reviewed: chunk 1, chunk 2, chunk 3 — no agent reported covering these; nobody read them.

[Critical] Existing Critical still stands (capture-collapsed-groups-demo.sh:17): predictable shared /tmp path opened with truncating redirection — symlink race (CWE-377) allows a local user to truncate arbitrary files writable by the developer.

— qwen3.7-max via Qwen Code /review

import sys
from pathlib import Path

from PIL import Image, ImageDraw, ImageFont

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] PIL (Pillow) is imported but not declared in any requirements.txt or dependency manifest. Concrete cost: A developer running capture-collapsed-groups-demo.sh on a clean machine gets ModuleNotFoundError: No module named 'PIL' with no indication of what to install.

Suggested change
from PIL import Image, ImageDraw, ImageFont
# Requires: pip install Pillow
from PIL import Image, ImageDraw, ImageFont

— qwen3.7-max via Qwen Code /review

npx playwright test \
--config playwright.config.ts \
client/e2e/web-shell.collapsed-groups-persist.spec.ts \
> /tmp/qwen-6870-demo.log 2>&1

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Log path references issue 6870 instead of this PR (6878). Concrete cost: A future maintainer grepping for related issues will be confused by the mismatch; the path is also not cleaned up after the script finishes.

Suggested change
> /tmp/qwen-6870-demo.log 2>&1
> /tmp/collapsed-groups-demo.log 2>&1

— qwen3.7-max via Qwen Code /review

Wait for groups+sessions catalog settlement before the initial latch,
persist secondary-workspace collapse via shared namespaced localStorage,
and keep primary/workspace writers from clobbering each other.

Co-authored-by: Cursor <cursoragent@cursor.com>

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline.

— qwen3.7-max via Qwen Code /review

Comment on lines +132 to +134
useEffect(() => {
setCollapsedGroupIds(readWorkspaceCollapsedGroupIds(workspace.id));
}, [workspace.id]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The useState initializer already calls readWorkspaceCollapsedGroupIds(workspace.id) to seed the correct data on first render. This useEffect on [workspace.id] then calls the same function again, creating a new Set reference (different object identity). React sees a state change and schedules an unnecessary re-render. The subsequent write effect then fires and writes the same data back to localStorage. On every mount, this produces one redundant read, one redundant render, and one redundant localStorage.setItem.

Concrete cost: when a user has 5-10 workspace folders visible, a page load triggers 5-10 unnecessary localStorage read-parse-filter-stringify-write cycles during mount.

Suggested change
useEffect(() => {
setCollapsedGroupIds(readWorkspaceCollapsedGroupIds(workspace.id));
}, [workspace.id]);
// Skip reload on mount — the useState initializer already hydrated from
// localStorage. Only reload when workspace.id changes after the first render.
const isFirstMount = useRef(true);
useEffect(() => {
if (isFirstMount.current) {
isFirstMount.current = false;
return;
}
setCollapsedGroupIds(readWorkspaceCollapsedGroupIds(workspace.id));
}, [workspace.id]);

— qwen3.7-max via Qwen Code /review

Comment on lines +66 to +68
} catch {
// localStorage can be unavailable in private or embedded contexts.
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When localStorage.setItem throws (quota exceeded, SecurityError), the error is silently swallowed. The React state has already been updated, so the sidebar shows the group as collapsed. On next reload, the group reverts to expanded with zero console warnings — an investigator tracing through the latch logic and initialization would have no signal pointing at "the write silently failed."

Suggested change
} catch {
// localStorage can be unavailable in private or embedded contexts.
}
} catch (err) {
// localStorage can be unavailable in private or embedded contexts.
console.warn('Failed to persist collapsed session state:', err);
}

— qwen3.7-max via Qwen Code /review

Comment on lines +3 to +8
import {
COLLAPSED_SESSION_SECTIONS_STORAGE_KEY,
readWorkspaceCollapsedGroupIds,
replaceOwnedCollapsedSessionSectionIds,
writeWorkspaceCollapsedGroupIds,
} from './collapsedSessionSections';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This test file directly covers only replaceOwnedCollapsedSessionSectionIds and readWorkspaceCollapsedGroupIds/writeWorkspaceCollapsedGroupIds. Six other exports lack direct unit tests, including readCollapsedSessionSectionIds which has multiple defensive branches (corrupt JSON, non-array payloads, non-string array items) that the sidebar integration tests exercise only indirectly.

Concrete cost: a regression in readCollapsedSessionSectionIds's filtering (e.g., accepting non-string items) would not be caught at the unit level. The sidebar integration tests would still pass because they only write well-formed data.

— qwen3.7-max via Qwen Code /review

Comment on lines +116 to 118
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(() =>
readWorkspaceCollapsedGroupIds(workspace.id),
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Unlike the primary sidebar (which has a dedicated 435-line test file WebShellSidebar.collapse-persist.test.tsx), WorkspaceSection has zero tests covering the new localStorage persistence effects (the reload-on-workspace.id-change effect and the write-on-collapsedGroupIds-change effect).

Concrete cost: a refactor could write collapse state under the wrong workspace key after a workspace switch, or the reload effect could race with the write effect — and no test would catch it.

— qwen3.7-max via Qwen Code /review

claude and others added 3 commits July 15, 2026 01:49
…test

The committed GIF, capture script, and frame-assembly helper only served
the PR description's embedded image and were referenced by nothing else
in the repo; the CAPTURE_DEMO branches in the e2e spec were pure
screenshot staging with no assertions. The remaining spec still covers
every acceptance criterion of QwenLM#6870 and keeps its @smoke tag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KuV2hx2ZqEqdRwsJz4KJH
…ssfully

Two paths could consume the first-sync latch against a partial catalog
and then auto-collapse (and persist over) the user's restored expansions:
a failed initial sessions/groups request counted as settled, and a
mid-session organization_enabled flip let the auto-collapse effect run
one commit before the groups gate closed. Errors no longer settle either
readiness gate, and the gate now closes during the flip render itself.

Also drop the WorkspaceSection reload effect and exhaustive-deps
suppression that defended a workspace.id change which cannot happen (the
render site keys the component by workspace id), and import the storage
key in tests from collapsedSessionSections directly instead of
re-exporting it through WebShellSidebar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KuV2hx2ZqEqdRwsJz4KJH

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline. Unresolved, please confirm: [Critical] /tmp symlink race in capture-collapsed-groups-demo.sh (comment 3578704643) — file is not in this PR's diff, cannot verify Not reviewed: chunk 1, chunk 2, chunk 3, chunk 4 — no agent reported covering these; nobody read them.

— qwen3.7-max via Qwen Code /review

Comment on lines +131 to +133
useEffect(() => {
writeWorkspaceCollapsedGroupIds(workspace.id, collapsedGroupIds);
}, [collapsedGroupIds, workspace.id]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] WorkspaceSection's localStorage persistence useEffect has no dedicated test. The primary sidebar has a 437-line collapse-persist test, but this per-workspace write path — which uses workspace-namespaced IDs (ws:<id>|group:<gid>) — is only covered by the helper-level round-trip test in collapsedSessionSections.test.ts, not by a component test.

Failure scenario: A bug in the WorkspaceSection component's useEffect wiring (e.g., wrong workspace.id passed, or the effect not firing on toggle) would leave secondary-workspace collapse state unpersisted. The helper test passes because it tests the function in isolation, but the component integration is unverified.

Suggested change
useEffect(() => {
writeWorkspaceCollapsedGroupIds(workspace.id, collapsedGroupIds);
}, [collapsedGroupIds, workspace.id]);
// Add a test in WorkspaceSection.test.tsx (or extend collapse-persist.test.tsx):
// - Render WorkspaceSection with pre-seeded localStorage entry `ws:<workspaceId>|group:<groupId>`
// - Toggle collapse, verify the round-trip through readWorkspaceCollapsedGroupIds

Comment on lines +563 to +568
const [prevOrganizationEnabled, setPrevOrganizationEnabled] =
useState(organizationEnabled);
if (prevOrganizationEnabled !== organizationEnabled) {
setPrevOrganizationEnabled(organizationEnabled);
setGroupsCatalogReady(!organizationEnabled);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The organizationEnabled transition logic (false→true mid-session) is not tested. All 9 sidebar integration tests initialize with organizationEnabled = true and never change it.

Failure scenario: Capabilities arrive after the sidebar's first render (common when /workspace capabilities land after the flat sessions request). organizationEnabled flips from false to true. The render-time state sync resets groupsCatalogReady to false, then the groups catalog reload sets it back to true. If there's a race between the reset and the auto-collapse effect consuming the first-sync latch against a stale pre-organized catalog, restored collapse state could be overwritten. No test exercises this transition.

Suggested change
const [prevOrganizationEnabled, setPrevOrganizationEnabled] =
useState(organizationEnabled);
if (prevOrganizationEnabled !== organizationEnabled) {
setPrevOrganizationEnabled(organizationEnabled);
setGroupsCatalogReady(!organizationEnabled);
}
// Add a test that:
// 1. Starts with workspace.capabilities = undefined (organizationEnabled=false)
// 2. Renders the sidebar
// 3. Sets workspace.capabilities = organizationCapabilities (flips to true)
// 4. Re-renders and verifies pre-existing localStorage collapse state is still respected

@wenshao

wenshao commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

✅ Local verification — collapsed session groups persist across reload

Built and ran the real tests locally in an isolated worktree at head 5bed4533e (production files byte-identical to the PR; workspace deps resolved to PR‑head @qwen-code/sdk/webui). Web Shell e2e runs against the repo's in‑process mock daemon, so no live model is needed. All checks are green and all three earlier Critical findings are resolved and independently verified — this looks ready to merge.

What I ran

Check Result
vitest — 11 PR unit tests + 2 added secondary‑workspace tests 13 / 13 pass
playwrightweb-shell.collapsed-groups-persist.spec.ts @smoke 1 / 1 pass
vitest — whole packages/web-shell package 1593 pass (see note ¹)
tsc — web‑shell typecheck (resolved to PR‑head sdk/webui) 0 errors
eslint — 7 PR‑changed files clean
Controlled experiment — revert the fix flips RED → fix is load‑bearing

Collapse→reload storyboard

Expand → collapse “Backend” → hard reload → still collapsed ✅ → re‑expand + reload → stays expanded ✅. Ungrouped is untouched throughout.

Earlier Critical findings — all resolved

  1. Secondary‑workspace sections used a memory‑only Set (the web-shell / session sidebar: collapsed named groups reopen after page refresh #6870 root cause). ✅ Fixed in 4a0a7aa85 (refined in dee8fc61a): WorkspaceSection now seeds collapsedGroupIds from localStorage via readWorkspaceCollapsedGroupIds(workspace.id) and writes back on every change, using collision‑safe ids (ws:<workspaceId>|group:<groupId>, ws:<workspaceId>|ungrouped) that don't clobber the primary sidebar's ids.
    • Independently verified live: I wrote a component test (not part of the PR) that drives a non‑primary workspace through collapse → full remount (reload) and asserts it stays collapsed, plus a no‑cross‑workspace‑bleed case. Both pass. Reverting the read back to new Set() makes the reload test fail with expected 'true' to be 'false' — i.e. the group reopens, exactly the web-shell / session sidebar: collapsed named groups reopen after page refresh #6870 bug. So the fix is genuinely load‑bearing on the path you flagged.
  2. /tmp symlink race (CWE‑377) in capture-collapsed-groups-demo.sh. ✅ Resolved in 5899ad246 — the demo‑capture script was removed from the tree entirely (confirmed: no such file at head).
  3. First‑sync latch timing (WebShellSidebar.tsx). ✅ Hardened in 4a0a7aa85/dee8fc61a: a dedicated first‑sync latch is gated on both groupsCatalogReady && sessionsCatalogReady and settles only on successful (non‑error) catalogs, so a failed request can't consume the latch and auto‑collapse restored expansions. Covered by the unit tests “auto‑collapses a brand‑new section that appears mid‑session” and “does not clobber workspace‑scoped collapse ids when primary toggles”.

Checks evidence

Notes

  • ¹ The only red in the full package run is build-artifact.test.ts (8), which just reads the built dist/index.js; after npm run build it passes 8/8. It needs a prior build step (which CI does) — not a PR regression. The PR touches zero build/config files.
  • Tested on macOS. Windows/Linux unchanged (pure localStorage + React lifecycle; no platform code).
  • Optional suggestion (non‑blocking): the secondary‑workspace path currently has unit coverage only at the storage‑helper level. Consider promoting a component/e2e test like the one I used here so the exact web-shell / session sidebar: collapsed named groups reopen after page refresh #6870 regression path stays guarded going forward — happy to open it against the branch if useful.
🇨🇳 中文说明(点击展开)

✅ 本地验证 — 收合的会话分组在刷新后保持收合

隔离 worktree(head 5bed4533e,生产文件与 PR 逐字节一致;workspace 依赖解析到 PR 头的 @qwen-code/sdk/webui 中构建并运行了真实测试。Web Shell 的 e2e 跑在仓库内置的进程内 mock daemon 上,无需真实模型。所有检查全绿,之前的三个 Critical 问题均已修复并经独立验证 —— 可以合并。

运行了什么

检查 结果
vitest — 11 个 PR 单测 + 我额外加的 2 个次级工作区测试 13 / 13 通过
playwrightweb-shell.collapsed-groups-persist.spec.ts @smoke 1 / 1 通过
vitest — 整个 packages/web-shell 1593 通过(见注 ¹)
tsc — web‑shell 类型检查(解析到 PR 头 sdk/webui) 0 错误
eslint — PR 改动的 7 个文件 无告警
对照实验 — 还原该修复 变红 → 说明修复是真正生效的

(截图见上方英文部分:storyboard 展示 展开 → 收合 “Backend” → 硬刷新 → 仍收合 ✅ → 再展开+刷新 → 仍展开 ✅;Ungrouped 全程不受影响。)

之前的 Critical 问题 —— 均已解决

  1. 次级工作区的分区仍用内存态 Setweb-shell / session sidebar: collapsed named groups reopen after page refresh #6870 的根因)。 ✅ 在 4a0a7aa85 修复(dee8fc61a 再打磨):WorkspaceSection 现在通过 readWorkspaceCollapsedGroupIds(workspace.id)localStorage 初始化收合状态并在每次变更时写回,使用不会与主侧栏冲突的命名空间 id(ws:<workspaceId>|group:<groupId>ws:<workspaceId>|ungrouped)。
    • 已独立现场验证: 我写了一个组件测试(不属于本 PR),驱动一个 非主 工作区完成 收合 → 完整重挂载(刷新)并断言仍收合,另加一个“不跨工作区串味”的用例,二者均通过。把读取还原成 new Set() 后,刷新用例失败(expected 'true' to be 'false'),即分组重新展开 —— 正是 web-shell / session sidebar: collapsed named groups reopen after page refresh #6870 的 bug。说明修复在你指出的路径上确实生效。
  2. capture-collapsed-groups-demo.sh/tmp 符号链接竞争(CWE‑377)。 ✅ 在 5899ad246 解决 —— 该演示脚本已整体从代码树移除(已确认 head 不存在此文件)。
  3. 首个同步的 latch 时序(WebShellSidebar.tsx)。 ✅ 在 4a0a7aa85/dee8fc61a 加固:专用的首同步 latch 需同时满足 groupsCatalogReady && sessionsCatalogReady,且仅在成功(非报错)的 catalog 上落定,失败请求无法消费 latch 从而不会把已恢复的展开状态自动收合。由“会话中途新出现的分区自动收合”“主侧栏 toggle 不会覆盖工作区作用域的收合 id”这两个单测覆盖。

备注

  • ¹ 整包运行里唯一的红是 build-artifact.test.ts(8 个),它只是读取已构建的 dist/index.js;执行 npm run build8/8 通过。它需要先构建(CI 会做)—— 不是 PR 回归。本 PR 未改动任何 build/config 文件。
  • 在 macOS 上测试。Windows/Linux 不受影响(纯 localStorage + React 生命周期,无平台相关代码)。
  • 可选建议(不阻塞): 次级工作区路径目前只有存储辅助函数层的单测。建议把我这里用的组件/e2e 测试沉淀进来,长期守住 web-shell / session sidebar: collapsed named groups reopen after page refresh #6870 这条回归路径 —— 如有需要我可以基于该分支提一个 PR。

@wenshao

wenshao commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@samuelhsin
samuelhsin added this pull request to the merge queue Jul 15, 2026
Merged via the queue into QwenLM:main with commit c8290f8 Jul 15, 2026
67 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

web-shell / session sidebar: collapsed named groups reopen after page refresh

4 participants