fix(web-shell): include channel sessions in release dialog

The release dialog inherited the sidebar's default source filter, which
excludes sourceType: "channel". A workspace filled by idle channel
sessions therefore opened an empty recovery list.

Query the workspace catalog without a source filter for this dialog and
keep the default, channel, and qwen-live rows. The sidebar and every
other useScopedSessions caller keep the default filter.

Add hook/dialog regressions and verify banner -> release -> create with
a real sourceType: "channel" session filling a one-session workspace.
This commit is contained in:
Shaojin Wen committed 2026-11-07 19:42:58 +08:00
1 parent 7cdbc41caa
commit 8537fbd1b2
6 files changed
+133 -15

No files matched your search

+16 -3
View File
@@ -97,6 +97,12 @@ falls back to the workspace daemon client when it does not. That fallback is
required because the banner is most useful on the empty new-session screen,
where no session client has been created yet.
The dialog cannot reuse the sidebar's default source filter: that filter
excludes `sourceType: "channel"`, which is exactly the population that can fill
the cap. The recovery list queries the workspace catalog without a source
filter and keeps the default, channel, and qwen-live rows, preserving the old
live-session behavior while exposing the channel sessions.
The banner is cleared when the session or workspace changes, or when the user
dismisses it. This path is a fallback for the remaining 503 cases (for example,
every slot is held by active work); it does not replace automatic reclamation.
@@ -132,9 +138,15 @@ every slot is held by active work); it does not replace automatic reclamation.
release from the empty new-session screen uses the workspace daemon client.
- `packages/web-shell/client/components/SessionLimitBanner.test.tsx`: the
banner exposes release and dismiss actions.
- `packages/web-shell/client/hooks/useScopedSessions.test.tsx`: the release
query can omit the source filter; the sidebar default still applies it.
- `packages/web-shell/client/components/dialogs/ReleaseSessionDialog.test.tsx`:
channel sessions are listed, qwen-live keeps its existing place, and agent
sessions stay out.
- Manual browser run on the built Web Shell against a daemon with
`--max-sessions 1`: submit a prompt, confirm the banner, open the release
dialog, release the idle session, and create a session successfully.
`--max-sessions 1`, filled by a real `sourceType: "channel"` session: submit
a prompt, confirm the banner, release the channel session, and create a
session successfully.
## 9. Acceptance Criteria
@@ -142,7 +154,8 @@ every slot is held by active work); it does not replace automatic reclamation.
channel event pump is subscribed.
2. A non-channel session with a live subscriber is not closed by the reaper.
3. A create rejected by the workspace cap produces a visible release action.
4. Releasing an idle session frees a slot and the next create succeeds.
4. Releasing an idle session, including a workspace filled only by channel
sessions, frees a slot and the next create succeeds.
5. Existing reaper tests and web-shell type checks pass.
## 10. Follow-ups
@@ -88,6 +88,11 @@ workspace 创建失败时,`dispatchActionError` 现在把 `session_limit_excee
客户端。这个回退是必要的:横幅最常出现在空白的新会话页,此时还没有创建任何
会话客户端。
对话框不能复用侧栏默认的 source 过滤:该过滤会排除 `sourceType: "channel"`,
而这正是能占满名额的会话群体。恢复列表不带 source 过滤地查询工作区目录,再保留
default、channel、qwen-live 三类行;这既保留原有 live 会话行为,也暴露出渠道
会话。
会话或工作区变化、或用户关闭横幅时,横幅清除。这条路径只作为剩余 503 场景的
兜底(例如所有名额都被活跃工作占用),不替代自动回收。
@@ -122,15 +127,20 @@ workspace 创建失败时,`dispatchActionError` 现在把 `session_limit_excee
释放时走工作区 daemon 客户端。
- `packages/web-shell/client/components/SessionLimitBanner.test.tsx`:横幅暴露
释放和关闭两个动作。
- 在构建版 Web Shell 上对 `--max-sessions 1` 的 daemon 做真实浏览器验证:
提交 prompt 出现横幅、打开释放对话框、释放空闲会话、再次创建成功。
- `packages/web-shell/client/hooks/useScopedSessions.test.tsx`:释放查询可以
省略 source 过滤;侧栏默认仍应用过滤。
- `packages/web-shell/client/components/dialogs/ReleaseSessionDialog.test.tsx`:
渠道会话会被列出,qwen-live 保留原有位置,agent 会话被排除。
- 在构建版 Web Shell 上对 `--max-sessions 1` 的 daemon 做真实浏览器验证:用
真实 `sourceType: "channel"` 会话占满名额,提交 prompt 出现横幅、释放该
渠道会话、再次创建成功。
## 9. 验收标准
1. 渠道会话空闲超过 `sessionIdleTimeoutMs` 后,即使渠道事件泵仍订阅,也会被关闭。
2. 带 live 订阅者的非渠道会话不会被 reaper 关闭。
3. 因工作区上限被拒绝的创建会显示可见的释放入口。
4. 释放一条空闲会话后名额释放,下一次创建成功。
4. 释放一条空闲会话(包括工作区只被渠道会话占满的情况)后名额释放,下一次创建成功。
5. 现有 reaper 测试和 web-shell 类型检查通过。
## 10. 后续工作
@@ -128,6 +128,7 @@ describe('ReleaseSessionDialog selection', () => {
expect(scopedSessionsOptions).toEqual({
autoLoad: true,
maxAgeMs: 1_000,
sourceType: null,
});
// The dialog opens with no highlight at all; Enter has nothing to act on.
expect(rows().some(isCursor)).toBe(false);
@@ -220,6 +221,52 @@ describe('ReleaseSessionDialog selection', () => {
expect(dangerButton().disabled).toBe(true);
});
it('lists channel sessions without dropping existing live sources', async () => {
sessions.splice(
0,
sessions.length,
{
sessionId: 'channel-1',
workspaceCwd: '/work/repo',
displayName: 'Channel session',
sourceType: 'channel',
clientCount: 1,
updatedAt: '2026-01-01T00:00:00Z',
},
{
sessionId: 'live-1',
workspaceCwd: '/work/repo',
displayName: 'Live session',
sourceType: 'qwen-live',
clientCount: 1,
updatedAt: '2026-01-01T00:00:00Z',
},
{
sessionId: 'agent-1',
workspaceCwd: '/work/repo',
displayName: 'Agent session',
sourceType: 'agent',
clientCount: 1,
updatedAt: '2026-01-01T00:00:00Z',
},
);
releaseSessionMock.mockClear();
mount();
expect(rows()).toHaveLength(2);
expect(rows()[0].textContent).toContain('Channel session');
expect(rows()[1].textContent).toContain('Live session');
act(() => {
rows()[0].dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(dangerButton().disabled).toBe(false);
await act(async () => {
dangerButton().click();
await Promise.resolve();
});
expect(releaseSessionMock).toHaveBeenCalledWith('channel-1');
});
it('clears the confirmed target and disarms release when the filter changes', () => {
mount();
@@ -21,6 +21,11 @@ interface ReleaseSessionDialogProps {
const LIST_ID = 'release-session-list';
const optionId = (index: number) => `${LIST_ID}-opt-${index}`;
// Channel sessions consume the workspace cap but are hidden from the normal
// sidebar query; the release dialog must include them to free a slot. The
// default sidebar filter also admits qwen-live, so preserve that here.
const RELEASABLE_SOURCE_TYPES = new Set(['default', 'channel', 'qwen-live']);
export function ReleaseSessionDialog({
onReleased,
onError,
@@ -37,6 +42,7 @@ export function ReleaseSessionDialog({
} = useScopedSessions(workspaceCwd, {
autoLoad: true,
maxAgeMs: 1_000,
sourceType: null,
});
const currentSessionId = connection.sessionId;
const [deleting, setDeleting] = useState(false);
@@ -57,8 +63,12 @@ export function ReleaseSessionDialog({
if (sessionsError) setMessage(sessionsError.message);
}, [sessionsError]);
const releaseCandidates = sessions.filter(
(s) =>
s.sourceType === undefined || RELEASABLE_SOURCE_TYPES.has(s.sourceType),
);
const filtered = filterQuery
? sessions.filter((s) => {
? releaseCandidates.filter((s) => {
const q = filterQuery.toLowerCase();
return (
(s.displayName || '').toLowerCase().includes(q) ||
@@ -66,7 +76,7 @@ export function ReleaseSessionDialog({
sessionMatchesGitQuery(s, q)
);
})
: sessions;
: releaseCandidates;
const confirmRow = (index: number) => {
const session = filtered[index];
@@ -42,8 +42,17 @@ let root: Root | null = null;
let container: HTMLDivElement | null = null;
let scopedResult: ReturnType<typeof useScopedSessions> | undefined;
function Probe({ cwd }: { cwd?: string }) {
const result = useScopedSessions(cwd, { autoLoad: true });
function Probe({
cwd,
sourceType,
}: {
cwd?: string;
sourceType?: string | null;
}) {
const result = useScopedSessions(cwd, {
autoLoad: true,
...(sourceType !== undefined ? { sourceType } : {}),
});
scopedResult = result;
const { sessions, deleteSessions, releaseSession } = result;
return (
@@ -67,8 +76,8 @@ function Probe({ cwd }: { cwd?: string }) {
);
}
function render(cwd?: string) {
act(() => root!.render(<Probe cwd={cwd} />));
function render(cwd?: string, sourceType?: string | null) {
act(() => root!.render(<Probe cwd={cwd} sourceType={sourceType} />));
}
beforeEach(() => {
@@ -175,6 +184,28 @@ describe('useScopedSessions', () => {
expect(primaryDeleteSessions).not.toHaveBeenCalled();
});
it('omits the source filter when the release dialog asks for all sources', async () => {
listWorkspaceSessionsPage.mockResolvedValue({
sessions: [
{
sessionId: 'channel',
workspaceCwd: '/all-sources',
sourceType: 'channel',
},
] satisfies DaemonSessionSummary[],
});
render('/all-sources', null);
await act(async () => {
await listWorkspaceSessionsPage.mock.results[0]?.value;
});
expect(listWorkspaceSessionsPage).toHaveBeenCalledWith({});
expect(
container!.querySelector('[data-testid="sessions"]')?.textContent,
).toBe('channel');
});
it('releases a scoped session without invalidating the primary catalog', async () => {
listWorkspaceSessionsPage.mockResolvedValue({ sessions: [] });
primaryReleaseSession.mockResolvedValue(undefined);
@@ -19,6 +19,8 @@ interface ScopedSessionsOptions {
view?: 'organized';
group?: string;
pollIntervalMs?: number;
/** `null` queries every source type; omitted keeps the sidebar default. */
sourceType?: string | null;
}
export function useScopedSessions(
@@ -35,7 +37,12 @@ export function useScopedSessions(
view,
group,
pollIntervalMs,
sourceType: sourceTypeOption,
} = options;
const sourceType =
sourceTypeOption === null
? undefined
: (sourceTypeOption ?? WEB_SHELL_SESSION_SOURCE_TYPE);
const primary = useWebShellSessions({
autoLoad,
enabled: enabled && !workspaceCwd,
@@ -44,7 +51,7 @@ export function useScopedSessions(
archiveState,
view,
group,
sourceType: WEB_SHELL_SESSION_SOURCE_TYPE,
...(sourceType !== undefined ? { sourceType } : {}),
...(pollIntervalMs !== undefined ? { pollIntervalMs } : {}),
});
const primaryDeleteSession = primary.deleteSession;
@@ -63,11 +70,11 @@ export function useScopedSessions(
...(archiveState !== undefined ? { archiveState } : {}),
...(view !== undefined ? { view } : {}),
...(group !== undefined ? { group } : {}),
sourceType: WEB_SHELL_SESSION_SOURCE_TYPE,
...(sourceType !== undefined ? { sourceType } : {}),
},
}
: undefined,
[archiveState, group, pageSize, view, workspaceCwd],
[archiveState, group, pageSize, sourceType, view, workspaceCwd],
);
const scoped = useSessionCatalogQuery(workspace.client, query, {
autoLoad,