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 (#16537)
* 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. * fix(web-shell): keep live sessions visible in release dialog Expanding the recovery list from the sidebar's default source filter to persisted history can still bury the sessions holding slots: closed channel rows fill the first page before they are reached. Add a liveOnly session-list option on the daemon route and SDK so callers can read the workspace bridge instead of persisted history. The release dialog requests the shared page size with that option and keeps listing default, channel, and qwen-live rows, so closed history can no longer crowd out what can be released. Co-authored-by: Qwen-Coder <[email protected]> * fix(sdk): serve liveOnly session lists over native REST The ACP session/list mapping drops liveOnly, so a DaemonClient configured with an AcpHttpTransport silently returned persisted history for the release dialog's live-only request. Route liveOnly through the native REST transport for both the legacy and workspace-qualified list methods, and cover the real ACP transport with a regression test. Co-authored-by: Qwen-Coder <[email protected]> * test(web-shell): pin the release dialog's source whitelist Live-only listings can include side_task, scheduled_task, and standalone sessions that the server does not hide. Add those rows to the dialog test so widening the whitelist fails, and state the 100-row live-page ceiling next to the page-size request. Co-authored-by: Qwen-Coder <[email protected]> --------- Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
1 parent
f8256e8222
commit
5b44cfad71
16 files changed
+418
-27
No files matched your search
@@ -97,6 +97,14 @@ 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. It also cannot read persisted history page by page, because closed
|
||||
channel sessions can fill the first page and hide the live rows that still hold
|
||||
slots. The recovery list therefore asks the workspace for its live bridge
|
||||
sessions (`liveOnly`), the established sessions consuming the cap, and keeps
|
||||
the default, channel, and qwen-live rows.
|
||||
|
||||
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 +140,19 @@ 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 request live-only sessions without a source filter; the sidebar
|
||||
default still applies the filter.
|
||||
- `packages/web-shell/client/components/dialogs/ReleaseSessionDialog.test.tsx`:
|
||||
channel sessions are listed, qwen-live keeps its existing place, and agent
|
||||
sessions stay out.
|
||||
- `packages/cli/src/serve/multi-workspace-sessions.test.ts`: a live session
|
||||
stays reachable when twenty newer closed channel sessions fill the persisted
|
||||
page.
|
||||
- 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 +160,9 @@ 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 even when newer closed
|
||||
persisted history fills the first catalog page.
|
||||
5. Existing reaper tests and web-shell type checks pass.
|
||||
|
||||
## 10. Follow-ups
|
||||
|
||||
@@ -88,6 +88,12 @@ workspace 创建失败时,`dispatchActionError` 现在把 `session_limit_excee
|
||||
客户端。这个回退是必要的:横幅最常出现在空白的新会话页,此时还没有创建任何
|
||||
会话客户端。
|
||||
|
||||
对话框不能复用侧栏默认的 source 过滤:该过滤会排除 `sourceType: "channel"`,
|
||||
而这正是能占满名额的会话群体。它也不能按持久历史逐页读取:已关闭的渠道会话可能
|
||||
占满首屏,把仍占用名额的活跃行挤出页面。因此恢复列表改为向工作区请求 live
|
||||
bridge 会话(`liveOnly`)——这些是占用名额的已建立会话——再保留 default、
|
||||
channel、qwen-live 三类行。
|
||||
|
||||
会话或工作区变化、或用户关闭横幅时,横幅清除。这条路径只作为剩余 503 场景的
|
||||
兜底(例如所有名额都被活跃工作占用),不替代自动回收。
|
||||
|
||||
@@ -122,15 +128,23 @@ 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 过滤的情况下请求 live-only 会话;侧栏默认仍应用过滤。
|
||||
- `packages/web-shell/client/components/dialogs/ReleaseSessionDialog.test.tsx`:
|
||||
渠道会话会被列出,qwen-live 保留原有位置,agent 会话被排除。
|
||||
- `packages/cli/src/serve/multi-workspace-sessions.test.ts`:20 条更新的已关闭
|
||||
渠道会话占满持久页时,活跃会话仍然可被列出。
|
||||
- 在构建版 Web Shell 上对 `--max-sessions 1` 的 daemon 做真实浏览器验证:用
|
||||
真实 `sourceType: "channel"` 会话占满名额,提交 prompt 出现横幅、释放该
|
||||
渠道会话、再次创建成功。
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
1. 渠道会话空闲超过 `sessionIdleTimeoutMs` 后,即使渠道事件泵仍订阅,也会被关闭。
|
||||
2. 带 live 订阅者的非渠道会话不会被 reaper 关闭。
|
||||
3. 因工作区上限被拒绝的创建会显示可见的释放入口。
|
||||
4. 释放一条空闲会话后名额释放,下一次创建成功。
|
||||
4. 释放一条空闲会话(包括工作区只被渠道会话占满、且更新的已关闭持久历史占满
|
||||
目录首屏的情况)后名额释放,下一次创建成功。
|
||||
5. 现有 reaper 测试和 web-shell 类型检查通过。
|
||||
|
||||
## 10. 后续工作
|
||||
|
||||
@@ -3479,6 +3479,8 @@ curl http://127.0.0.1:4170/workspaces/<workspace-id>/sessions
|
||||
|
||||
When `workspace_qualified_rest_core` is advertised, workspace-scoped session batch operations, group CRUD, and session organization mutation are available under `/workspaces/:workspace/sessions/{delete,archive,unarchive}`, `/workspaces/:workspace/session-groups`, and `/workspaces/:workspace/session/:id/organization`. For an untrusted secondary, group GET remains available; every group, session, and organization mutation remains trust-gated. Workspace-less batch and organization mutation routes remain primary-workspace-only for compatibility.
|
||||
|
||||
The `liveOnly=1` list reads the workspace bridge instead of `chats/`, so closed persisted history cannot crowd live sessions out of the first page. It requires a trusted workspace and does not accept the persisted-only filters: combining it with `view`, `archiveState=archived`, `parentSessionId`, `sourceType`, or `sourceId` returns `400 { code: "invalid_session_live_only_filter" }`. Any value other than `1` returns `400 { code: "invalid_session_live_only" }`.
|
||||
|
||||
Query parameters:
|
||||
|
||||
| Field | Required | Notes |
|
||||
@@ -3488,6 +3490,7 @@ Query parameters:
|
||||
| `size` | no | Page size. Invalid values return `400 { code: "invalid_cursor" }` or the existing page-size validation. |
|
||||
| `view` | no | Omit for the legacy recent list. `organized` opts into server-side pinned/group ordering and adds optional organization fields. Any other value returns `400 { code: "invalid_session_view" }`. |
|
||||
| `group` | no | Only meaningful with `view=organized`. `all` (default), `pinned`, `ungrouped`, or a custom group id. Unknown group ids return `404 { code: "group_not_found" }`. |
|
||||
| `liveOnly` | no | `1` lists only sessions currently live in the workspace bridge, ignoring persisted history. Requires a trusted workspace and rejects the incompatibilities described above. |
|
||||
|
||||
Response:
|
||||
|
||||
|
||||
@@ -6478,6 +6478,88 @@ describe('multi-workspace session dispatch', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('lists live sessions when closed channel history fills the persisted page', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const liveSessionId = '550e8400-e29b-41d4-a716-4466554401a0';
|
||||
await writeStoredSession({
|
||||
sessionId: liveSessionId,
|
||||
cwd: SECONDARY_CWD,
|
||||
timestamp: '2026-07-01T00:00:00.000Z',
|
||||
prompt: 'still live',
|
||||
mtime: new Date('2026-07-01T00:00:00.000Z'),
|
||||
});
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
const timestamp = `2026-07-08T00:${String(index).padStart(2, '0')}:00.000Z`;
|
||||
await writeStoredSession({
|
||||
sessionId: `550e8400-e29b-41d4-a716-4466554402${index
|
||||
.toString(16)
|
||||
.padStart(2, '0')}`,
|
||||
cwd: SECONDARY_CWD,
|
||||
timestamp,
|
||||
prompt: `closed channel ${index}`,
|
||||
mtime: new Date(timestamp),
|
||||
sourceType: 'channel',
|
||||
});
|
||||
}
|
||||
const { app } = makeHarness({
|
||||
secondarySummaries: [
|
||||
makeSummary(liveSessionId, SECONDARY_CWD, {
|
||||
clientCount: 1,
|
||||
updatedAt: '2026-07-01T00:01:00.000Z',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const persistedPage = await request(app)
|
||||
.get('/workspaces/secondary-id/sessions?size=20')
|
||||
.set('Host', host())
|
||||
.expect(200);
|
||||
expect(persistedPage.body.sessions).toHaveLength(20);
|
||||
expect(
|
||||
persistedPage.body.sessions.map(
|
||||
(session: { sessionId: string }) => session.sessionId,
|
||||
),
|
||||
).not.toContain(liveSessionId);
|
||||
|
||||
const livePage = await request(app)
|
||||
.get('/workspaces/secondary-id/sessions?liveOnly=1')
|
||||
.set('Host', host())
|
||||
.expect(200);
|
||||
expect(
|
||||
livePage.body.sessions.map(
|
||||
(session: { sessionId: string }) => session.sessionId,
|
||||
),
|
||||
).toEqual([liveSessionId]);
|
||||
expect(livePage.body.sessions[0]).toMatchObject({
|
||||
clientCount: 1,
|
||||
displayName: liveSessionId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects liveOnly combined with persisted-only filters or untrusted workspaces', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const trusted = makeHarness();
|
||||
const filtered = await request(trusted.app)
|
||||
.get('/workspaces/secondary-id/sessions?liveOnly=1&view=organized')
|
||||
.set('Host', host())
|
||||
.expect(400);
|
||||
expect(filtered.body.code).toBe('invalid_session_live_only_filter');
|
||||
|
||||
const invalid = await request(trusted.app)
|
||||
.get('/workspaces/secondary-id/sessions?liveOnly=0')
|
||||
.set('Host', host())
|
||||
.expect(400);
|
||||
expect(invalid.body.code).toBe('invalid_session_live_only');
|
||||
|
||||
const untrusted = makeHarness({ secondaryTrusted: false });
|
||||
await request(untrusted.app)
|
||||
.get('/workspaces/secondary-id/sessions?liveOnly=1')
|
||||
.set('Host', host())
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
it('exports the selected trusted workspace without falling back or starting ACP', async () => {
|
||||
await withRuntimeDir(async () => {
|
||||
const sessionId = '550e8400-e29b-41d4-a716-446655440280';
|
||||
|
||||
@@ -10111,6 +10111,31 @@ export function registerSessionRoutes(
|
||||
});
|
||||
return;
|
||||
}
|
||||
const rawLiveOnly = req.query['liveOnly'];
|
||||
let liveOnly = false;
|
||||
if (rawLiveOnly !== undefined) {
|
||||
if (rawLiveOnly !== '1') {
|
||||
res.status(400).json({
|
||||
error: '`liveOnly` must be "1"',
|
||||
code: 'invalid_session_live_only',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
view !== undefined ||
|
||||
archiveState === 'archived' ||
|
||||
parentSessionId !== undefined ||
|
||||
parsedSource.sourceType !== undefined
|
||||
) {
|
||||
res.status(400).json({
|
||||
error:
|
||||
'`liveOnly` cannot be combined with organized view, archived state, parent, or source filters',
|
||||
code: 'invalid_session_live_only_filter',
|
||||
});
|
||||
return;
|
||||
}
|
||||
liveOnly = true;
|
||||
}
|
||||
const options = {
|
||||
...(cursor !== undefined ? { cursor } : {}),
|
||||
...(size !== undefined ? { size } : {}),
|
||||
@@ -10133,22 +10158,35 @@ export function registerSessionRoutes(
|
||||
res.once('close', onResponseClosed);
|
||||
if (req.aborted || res.destroyed) onRequestAborted();
|
||||
try {
|
||||
if (liveOnly && !runtime.trusted) {
|
||||
res.status(403).json({
|
||||
error: '`liveOnly` requires a trusted workspace',
|
||||
code: 'untrusted_workspace',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Organized/archived views always need the persisted store: organized
|
||||
// cursors are opaque (non-numeric) and archived-only workspaces have no
|
||||
// active persisted sessions, so the live-only fallback would drop them.
|
||||
// Metadata filters gather the whole workspace
|
||||
// (persisted + live) to filter completely and paginates with an opaque
|
||||
// activity cursor, so the numeric-cursor live fallback can't serve it.
|
||||
// `liveOnly` bypasses the persisted store on purpose: its callers need
|
||||
// the sessions holding slots, not a page of closed history.
|
||||
const usePersisted =
|
||||
readOnlySecondary ||
|
||||
runtime.primary ||
|
||||
view === 'organized' ||
|
||||
archiveState === 'archived' ||
|
||||
parentSessionId !== undefined ||
|
||||
parsedSource.sourceType !== undefined ||
|
||||
(cursor !== undefined && cursor !== ''
|
||||
? isPersistedSessionCursor(cursor)
|
||||
: await hasActivePersistedSessions(runtime, controller.signal));
|
||||
(!liveOnly &&
|
||||
(runtime.primary ||
|
||||
view === 'organized' ||
|
||||
archiveState === 'archived' ||
|
||||
parentSessionId !== undefined ||
|
||||
parsedSource.sourceType !== undefined ||
|
||||
(cursor !== undefined && cursor !== ''
|
||||
? isPersistedSessionCursor(cursor)
|
||||
: await hasActivePersistedSessions(
|
||||
runtime,
|
||||
controller.signal,
|
||||
))));
|
||||
// The live path only reads cursor/size; persisted-only options
|
||||
// (organized view or archived state) would be silently dropped there.
|
||||
// usePersisted already routes those to the persisted path — assert it so
|
||||
|
||||
@@ -3656,9 +3656,15 @@ export class DaemonClient {
|
||||
if (options?.sourceId !== undefined) {
|
||||
query.set('sourceId', options.sourceId);
|
||||
}
|
||||
if (options?.liveOnly === true) {
|
||||
query.set('liveOnly', '1');
|
||||
}
|
||||
// The ACP session/list mapping does not carry liveOnly; force native REST
|
||||
// rather than silently serving persisted history.
|
||||
return await this.jsonRequest<DaemonSessionListPage>(
|
||||
`/workspace/${urlEncode(workspaceCwd)}/sessions?${query.toString()}`,
|
||||
'GET /workspace/sessions',
|
||||
options?.liveOnly === true ? { mode: 'rest' } : {},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9081,9 +9087,16 @@ export class WorkspaceDaemonClient {
|
||||
if (options?.sourceId !== undefined) {
|
||||
query.set('sourceId', options.sourceId);
|
||||
}
|
||||
return await this.get(
|
||||
if (options?.liveOnly === true) {
|
||||
query.set('liveOnly', '1');
|
||||
}
|
||||
// The ACP session/list mapping does not carry liveOnly; force native REST
|
||||
// rather than silently serving persisted history.
|
||||
return await this.client.workspaceJsonRequest<DaemonSessionListPage>(
|
||||
this.workspaceSelector,
|
||||
`/sessions?${query.toString()}`,
|
||||
'GET /workspaces/:workspace/sessions',
|
||||
options?.liveOnly === true ? { mode: 'rest' } : {},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1948,6 +1948,14 @@ export interface DaemonSessionListPageOptions {
|
||||
sourceType?: string;
|
||||
/** Restrict the page to this source identifier. Requires `sourceType`. */
|
||||
sourceId?: string;
|
||||
/**
|
||||
* List the sessions currently live in the workspace bridge instead of
|
||||
* persisted history, so closed rows cannot crowd live sessions out of the
|
||||
* first page. Served over native REST; the ACP transport does not carry it.
|
||||
* Cannot be combined with `view`, archived `archiveState`,
|
||||
* `parentSessionId`, `sourceType`, or `sourceId`.
|
||||
*/
|
||||
liveOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface DaemonSessionListPage {
|
||||
@@ -1967,8 +1975,8 @@ export interface DaemonSessionCatalogWorkspace {
|
||||
export interface DaemonSessionCatalogRequest {
|
||||
/** 1–20 entries; `all` excludes internal workspaces. */
|
||||
workspaces: 'all' | DaemonSessionCatalogWorkspace[];
|
||||
/** Shared filters; pageSize is 1–100, default 20. */
|
||||
options?: Omit<DaemonSessionListPageOptions, 'cursor'>;
|
||||
/** Shared filters; pageSize is 1–100, default 20. `liveOnly` is not shared. */
|
||||
options?: Omit<DaemonSessionListPageOptions, 'cursor' | 'liveOnly'>;
|
||||
includeGroups?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import type { DaemonTransport } from '../../src/daemon/DaemonTransport.js';
|
||||
import type { DaemonSessionAgentsStatus } from '../../src/daemon/index.js';
|
||||
import { negotiateTransport } from '../../src/daemon/negotiateTransport.js';
|
||||
import { AcpHttpTransport } from '../../src/daemon/AcpHttpTransport.js';
|
||||
import {
|
||||
DaemonCapabilityMissingError,
|
||||
DAEMON_REASONING_SELECTIONS,
|
||||
@@ -6631,6 +6632,54 @@ describe('DaemonClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('serializes liveOnly into both workspace session-list clients', async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse(200, {
|
||||
sessions: [{ sessionId: 's-live', workspaceCwd: '/work/a' }],
|
||||
}),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
|
||||
await client.listWorkspaceSessionsPage('/work/a', { liveOnly: true });
|
||||
await client
|
||||
.workspaceByCwd('/work/a')
|
||||
.listWorkspaceSessionsPage({ liveOnly: true });
|
||||
|
||||
expect(calls.map((call) => call.url)).toEqual([
|
||||
'http://daemon/workspace/%2Fwork%2Fa/sessions?size=20&liveOnly=1',
|
||||
'http://daemon/workspaces/%2Fwork%2Fa/sessions?size=20&liveOnly=1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('routes liveOnly through native REST when an ACP transport is configured', async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
jsonResponse(200, { sessions: [] }),
|
||||
);
|
||||
const transport = new AcpHttpTransport('http://daemon', undefined, fetch);
|
||||
const transportFetch = vi
|
||||
.spyOn(transport, 'fetch')
|
||||
.mockRejectedValue(new Error('ACP transport must not serve liveOnly'));
|
||||
const client = new DaemonClient({
|
||||
baseUrl: 'http://daemon',
|
||||
fetch,
|
||||
transport,
|
||||
});
|
||||
|
||||
await client.listWorkspaceSessionsPage('/work/a', {
|
||||
pageSize: 100,
|
||||
liveOnly: true,
|
||||
});
|
||||
await client
|
||||
.workspaceByCwd('/work/a')
|
||||
.listWorkspaceSessionsPage({ pageSize: 100, liveOnly: true });
|
||||
|
||||
expect(transportFetch).not.toHaveBeenCalled();
|
||||
expect(calls.map((call) => call.url)).toEqual([
|
||||
'http://daemon/workspace/%2Fwork%2Fa/sessions?size=100&liveOnly=1',
|
||||
'http://daemon/workspaces/%2Fwork%2Fa/sessions?size=100&liveOnly=1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializes source filters into both workspace session-list clients', async () => {
|
||||
const { fetch, calls } = recordingFetch((request) =>
|
||||
request.url.endsWith('/capabilities')
|
||||
|
||||
@@ -659,7 +659,10 @@ describe('public SDK entry — typed daemon event surface (#4217)', () => {
|
||||
}>();
|
||||
expectTypeOf<DaemonSessionCatalogRequest>().toEqualTypeOf<{
|
||||
workspaces: 'all' | DaemonSessionCatalogWorkspace[];
|
||||
options?: Omit<Public.DaemonSessionListPageOptions, 'cursor'>;
|
||||
options?: Omit<
|
||||
Public.DaemonSessionListPageOptions,
|
||||
'cursor' | 'liveOnly'
|
||||
>;
|
||||
includeGroups?: boolean;
|
||||
}>();
|
||||
expectTypeOf<DaemonSessionCatalogPage>().toEqualTypeOf<{
|
||||
|
||||
@@ -128,6 +128,9 @@ describe('ReleaseSessionDialog selection', () => {
|
||||
expect(scopedSessionsOptions).toEqual({
|
||||
autoLoad: true,
|
||||
maxAgeMs: 1_000,
|
||||
pageSize: 1_000,
|
||||
sourceType: null,
|
||||
liveOnly: true,
|
||||
});
|
||||
// The dialog opens with no highlight at all; Enter has nothing to act on.
|
||||
expect(rows().some(isCursor)).toBe(false);
|
||||
@@ -220,6 +223,76 @@ 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: 'side-task-1',
|
||||
workspaceCwd: '/work/repo',
|
||||
displayName: 'Side task',
|
||||
sourceType: 'side_task',
|
||||
clientCount: 1,
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
sessionId: 'scheduled-task-1',
|
||||
workspaceCwd: '/work/repo',
|
||||
displayName: 'Scheduled task',
|
||||
sourceType: 'scheduled_task',
|
||||
clientCount: 1,
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
sessionId: 'standalone-1',
|
||||
workspaceCwd: '/work/repo',
|
||||
displayName: 'Standalone session',
|
||||
sourceType: 'standalone',
|
||||
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();
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useListboxKeyboard } from '../../hooks/useListboxKeyboard';
|
||||
import { useFilterInput } from '../../hooks/useFilterInput';
|
||||
import { SessionRow } from './SessionRow';
|
||||
import { useScopedSessions } from '../../hooks/useScopedSessions';
|
||||
import { SESSION_LIST_PAGE_SIZE } from '../../constants/sessions';
|
||||
|
||||
interface ReleaseSessionDialogProps {
|
||||
onReleased: (sessionId: string) => void;
|
||||
@@ -21,6 +22,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 +43,12 @@ export function ReleaseSessionDialog({
|
||||
} = useScopedSessions(workspaceCwd, {
|
||||
autoLoad: true,
|
||||
maxAgeMs: 1_000,
|
||||
// The daemon caps the live page at 100 rows. Request the shared max so a
|
||||
// full workspace usually fits; above 100 live rows this dialog does not
|
||||
// follow nextCursor.
|
||||
pageSize: SESSION_LIST_PAGE_SIZE,
|
||||
sourceType: null,
|
||||
liveOnly: true,
|
||||
});
|
||||
const currentSessionId = connection.sessionId;
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
@@ -57,8 +69,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 +82,7 @@ export function ReleaseSessionDialog({
|
||||
sessionMatchesGitQuery(s, q)
|
||||
);
|
||||
})
|
||||
: sessions;
|
||||
: releaseCandidates;
|
||||
|
||||
const confirmRow = (index: number) => {
|
||||
const session = filtered[index];
|
||||
|
||||
@@ -42,8 +42,20 @@ 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,
|
||||
liveOnly,
|
||||
}: {
|
||||
cwd?: string;
|
||||
sourceType?: string | null;
|
||||
liveOnly?: boolean;
|
||||
}) {
|
||||
const result = useScopedSessions(cwd, {
|
||||
autoLoad: true,
|
||||
...(sourceType !== undefined ? { sourceType } : {}),
|
||||
...(liveOnly === true ? { liveOnly: true } : {}),
|
||||
});
|
||||
scopedResult = result;
|
||||
const { sessions, deleteSessions, releaseSession } = result;
|
||||
return (
|
||||
@@ -67,8 +79,12 @@ function Probe({ cwd }: { cwd?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function render(cwd?: string) {
|
||||
act(() => root!.render(<Probe cwd={cwd} />));
|
||||
function render(cwd?: string, sourceType?: string | null, liveOnly?: boolean) {
|
||||
act(() =>
|
||||
root!.render(
|
||||
<Probe cwd={cwd} sourceType={sourceType} liveOnly={liveOnly} />,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -175,6 +191,44 @@ describe('useScopedSessions', () => {
|
||||
expect(primaryDeleteSessions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('omits the source filter and requests live sessions for the release dialog', async () => {
|
||||
listWorkspaceSessionsPage.mockResolvedValue({
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'channel',
|
||||
workspaceCwd: '/all-sources',
|
||||
sourceType: 'channel',
|
||||
},
|
||||
] satisfies DaemonSessionSummary[],
|
||||
});
|
||||
|
||||
render('/all-sources', null, true);
|
||||
await act(async () => {
|
||||
await listWorkspaceSessionsPage.mock.results[0]?.value;
|
||||
});
|
||||
|
||||
expect(listWorkspaceSessionsPage).toHaveBeenCalledWith({ liveOnly: true });
|
||||
expect(
|
||||
container!.querySelector('[data-testid="sessions"]')?.textContent,
|
||||
).toBe('channel');
|
||||
});
|
||||
|
||||
it('forwards liveOnly through the unscoped primary catalog', async () => {
|
||||
workspaceClient.listWorkspaceSessionsPage.mockResolvedValue({
|
||||
sessions: [],
|
||||
});
|
||||
|
||||
render(undefined, null, true);
|
||||
await act(async () => {
|
||||
await workspaceClient.listWorkspaceSessionsPage.mock.results[0]?.value;
|
||||
});
|
||||
|
||||
expect(workspaceClient.listWorkspaceSessionsPage).toHaveBeenCalledWith(
|
||||
'/primary',
|
||||
{ liveOnly: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('releases a scoped session without invalidating the primary catalog', async () => {
|
||||
listWorkspaceSessionsPage.mockResolvedValue({ sessions: [] });
|
||||
primaryReleaseSession.mockResolvedValue(undefined);
|
||||
|
||||
@@ -19,6 +19,10 @@ interface ScopedSessionsOptions {
|
||||
view?: 'organized';
|
||||
group?: string;
|
||||
pollIntervalMs?: number;
|
||||
/** `null` queries every source type; omitted keeps the sidebar default. */
|
||||
sourceType?: string | null;
|
||||
/** List live bridge sessions only, so closed history cannot hide them. */
|
||||
liveOnly?: boolean;
|
||||
}
|
||||
|
||||
export function useScopedSessions(
|
||||
@@ -35,7 +39,13 @@ export function useScopedSessions(
|
||||
view,
|
||||
group,
|
||||
pollIntervalMs,
|
||||
liveOnly,
|
||||
sourceType: sourceTypeOption,
|
||||
} = options;
|
||||
const sourceType =
|
||||
sourceTypeOption === null
|
||||
? undefined
|
||||
: (sourceTypeOption ?? WEB_SHELL_SESSION_SOURCE_TYPE);
|
||||
const primary = useWebShellSessions({
|
||||
autoLoad,
|
||||
enabled: enabled && !workspaceCwd,
|
||||
@@ -44,7 +54,8 @@ export function useScopedSessions(
|
||||
archiveState,
|
||||
view,
|
||||
group,
|
||||
sourceType: WEB_SHELL_SESSION_SOURCE_TYPE,
|
||||
...(sourceType !== undefined ? { sourceType } : {}),
|
||||
...(liveOnly === true ? { liveOnly: true } : {}),
|
||||
...(pollIntervalMs !== undefined ? { pollIntervalMs } : {}),
|
||||
});
|
||||
const primaryDeleteSession = primary.deleteSession;
|
||||
@@ -63,11 +74,12 @@ export function useScopedSessions(
|
||||
...(archiveState !== undefined ? { archiveState } : {}),
|
||||
...(view !== undefined ? { view } : {}),
|
||||
...(group !== undefined ? { group } : {}),
|
||||
sourceType: WEB_SHELL_SESSION_SOURCE_TYPE,
|
||||
...(sourceType !== undefined ? { sourceType } : {}),
|
||||
...(liveOnly === true ? { liveOnly: true } : {}),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
[archiveState, group, pageSize, view, workspaceCwd],
|
||||
[archiveState, group, liveOnly, pageSize, sourceType, view, workspaceCwd],
|
||||
);
|
||||
const scoped = useSessionCatalogQuery(workspace.client, query, {
|
||||
autoLoad,
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface WebShellSessionsOptions extends SessionCatalogHookOptions {
|
||||
sourceType?: string;
|
||||
sourceId?: string;
|
||||
parentSessionId?: string;
|
||||
liveOnly?: boolean;
|
||||
}
|
||||
|
||||
export function useSessionCatalogQuery(
|
||||
@@ -519,6 +520,7 @@ export function useWebShellSessions(options: WebShellSessionsOptions = {}) {
|
||||
sourceType,
|
||||
sourceId,
|
||||
parentSessionId,
|
||||
liveOnly,
|
||||
} = options;
|
||||
const workspace = useWorkspace();
|
||||
const legacy = useSessions({
|
||||
@@ -544,11 +546,13 @@ export function useWebShellSessions(options: WebShellSessionsOptions = {}) {
|
||||
...(sourceType !== undefined ? { sourceType } : {}),
|
||||
...(sourceId !== undefined ? { sourceId } : {}),
|
||||
...(parentSessionId !== undefined ? { parentSessionId } : {}),
|
||||
...(liveOnly === true ? { liveOnly: true } : {}),
|
||||
}),
|
||||
[
|
||||
archiveState,
|
||||
cursor,
|
||||
group,
|
||||
liveOnly,
|
||||
pageSize,
|
||||
parentSessionId,
|
||||
sourceId,
|
||||
|
||||
@@ -170,6 +170,7 @@ describe('SessionCatalogStore', () => {
|
||||
{ parentSessionId: 'parent' },
|
||||
{ sourceType: 'side-task' },
|
||||
{ sourceId: 'source' },
|
||||
{ liveOnly: true },
|
||||
]) {
|
||||
expect(getSessionCatalogQueryKey(base)).not.toBe(
|
||||
getSessionCatalogQueryKey({
|
||||
|
||||
@@ -124,6 +124,7 @@ export function getSessionCatalogQueryKey(query: SessionCatalogQuery): string {
|
||||
options?.parentSessionId ?? null,
|
||||
options?.sourceType ?? null,
|
||||
options?.sourceId ?? null,
|
||||
options?.liveOnly ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user