mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
1 parent
7cdbc41caa
commit
8537fbd1b2
6 files changed
+133
-15
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user