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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions docs/design/web-shell-session-overview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Web Shell session overview

## Problem

The overview ranks blocked sessions first, but permission requests, questions,
and running turns share the same spinner. Session IDs occupy a full column,
while long titles compete with workspace, branch, and action columns. Clicking
a title opens the session; clicking elsewhere in the same row selects it.
The sidebar already offers a richer session details popover, but the overview
uses only its Git-specific variant.

## Design

- Keep the existing session catalog, live-state subscriptions, workspace
identities, pagination, and mutation capability checks.
- Use a two-line session cell: title first, then workspace, branch, and PR.
Move the complete ID and path into the shared details popover.
- Show distinct permission, question, running, and idle states in the table.
Keep a compact active/attention cue beside the pinned title so narrow
layouts do not hide the row's state behind the pinned actions.
Add an all/needs-attention/running/idle filter and keep workspace selection
visible in the toolbar. Search titles, IDs, branches, and PR numbers.
- Reuse the sidebar details popover on title hover. Provide an explicit
details button for keyboard and touch users. Keep PR/issue links and ID
copying inside the popover, with events isolated from row navigation.
Only one overview details popover is open at a time. Long values wrap in
an internally scrolling surface; keyboard focus stays visible when it opens.
When hover replaces focused details, move focus to the incoming title
before opening its preview. Ordinary hover preserves an external input's
focus, including when portals live in a ShadowRoot, and Tab continues from
the row after the preview closes.
- Make the popover status agree with the overview's derived live state,
including older daemons that provide pending approvals via status reports.
- Open a session when its row or title is clicked. Checkboxes exclusively
control selection; existing rename/export/archive/delete controls keep their
behavior. Show batch actions only when a selection exists.
Dragging to select text does not navigate. Clicking a plain cell during an
inline rename preserves the draft; Enter saves and Escape cancels.
Sorting ends the rename and keeps keyboard focus on the sort header.
Starting rename closes details. If the edited row leaves the visible page,
discard the hidden draft so it cannot disable navigation or return later.
Preserve row state across the temporary empty page while a shrinking catalog
clamps pagination, then reconcile against the final visible identities.
- Keep the existing shared table and portal primitives. Popovers must stay
within the Web Shell boundary and preserve React 18 ref forwarding.

## Scope

The implementation stays in the Web Shell package: the overview, its styles
and tests, the shared session details popover and tests, and English/Chinese
messages. No daemon routes, SDK fields, permission changes, new dependencies,
or cross-package refactors are required. Idle does not imply task completion.

## Verification

Validate title/row navigation versus selection, search and filter reset rules,
status priority, full details, keyboard entry and Escape dismissal, ID copy,
portal event propagation, and existing cross-workspace mutation restrictions.
Run focused unit tests, the build/typecheck/bundle workflow, and independent
browser or test-script verification. Record baseline and post-change evidence
under `.qwen/e2e-tests/`.

## Open questions

None. The accepted prototype establishes the first iteration; archived-session
browsing and changes to mutation semantics remain outside this PR.
77 changes: 56 additions & 21 deletions packages/cli/src/agent-view/supervisor-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
INTERNAL_AGENT_VIEW_SUPERVISOR_ARG,
ensureAgentViewSupervisor,
runAgentViewSupervisor,
type RunAgentViewSupervisorOptions,
} from './supervisor-runner.js';
import type {
AgentViewSupervisorHandler,
Expand All @@ -31,8 +32,10 @@ import {

const cleanupDirs: string[] = [];
const cleanupServers: AgentViewSupervisorServerHandle[] = [];
const cleanupSupervisors: Array<() => Promise<void>> = [];

afterEach(async () => {
await Promise.all(cleanupSupervisors.splice(0).map((cleanup) => cleanup()));
await Promise.allSettled(
cleanupServers.splice(0).map((server) => server.close()),
);
Expand Down Expand Up @@ -331,9 +334,9 @@ describe('Agent View supervisor runner', () => {

it('closes the supervisor server when shutdown is requested', async () => {
const { globalDir, socketPath } = await makeSupervisorPath();
const supervisorPromise = runAgentViewSupervisor({ globalDir });

await waitForSupervisor(socketPath, globalDir);
const { supervisorPromise, authToken } = await runTestSupervisor({
globalDir,
});
await expect(readAgentViewSupervisor({ globalDir })).resolves.toMatchObject(
{
pid: process.pid,
Expand All @@ -342,7 +345,6 @@ describe('Agent View supervisor runner', () => {
protocolVersion: 1,
},
);
const authToken = await readAuthToken(globalDir);
await expect(
callAgentViewSupervisor(socketPath, 'shutdown', undefined, {
authToken,
Expand All @@ -364,10 +366,9 @@ describe('Agent View supervisor runner', () => {

it('does not remove metadata written by a replacement supervisor', async () => {
const { globalDir, socketPath } = await makeSupervisorPath();
const supervisorPromise = runAgentViewSupervisor({ globalDir });

await waitForSupervisor(socketPath, globalDir);
const authToken = await readAuthToken(globalDir);
const { supervisorPromise, authToken } = await runTestSupervisor({
globalDir,
});
const replacement = {
schemaVersion: 1 as const,
pid: process.pid + 1,
Expand All @@ -390,21 +391,56 @@ describe('Agent View supervisor runner', () => {

it('auto-exits when maintenance sees only hibernated managed sessions', async () => {
const { globalDir, socketPath } = await makeSupervisorPath();
const supervisorPromise = runAgentViewSupervisor({
const { supervisorPromise, authToken } = await runTestSupervisor({
globalDir,
maintenanceIntervalMs: 10,
hibernationPolicy: { autoExitGraceMs: 0 },
});

await waitForSupervisor(socketPath, globalDir);
const authToken = await readAuthToken(globalDir);
await writeHibernatedSessionForTest(globalDir, 'session-1');
await supervisorPromise;

await expectSupervisorUnreachable(socketPath, authToken);
});
});

async function runTestSupervisor(
options: RunAgentViewSupervisorOptions & { globalDir: string },
): Promise<{
supervisorPromise: Promise<void>;
authToken: string | undefined;
}> {
const socketPath = getAgentViewSupervisorSocketPath(options);
const supervisorPromise = runAgentViewSupervisor(options);
let settled = false;
let authToken: string | undefined = undefined;
// Observe startup failures immediately; cleanup still awaits the original
// promise so errors are reported before its directory can be removed.
void supervisorPromise.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
cleanupSupervisors.push(async () => {
await vi.waitFor(
async () => {
if (settled) return;
await callAgentViewSupervisor(socketPath, 'shutdown', undefined, {
authToken: authToken ?? (await readAuthToken(options.globalDir)),
timeoutMs: 100,
});
},
{ timeout: 10_000, interval: 25 },
);
await supervisorPromise;
});
authToken = await waitForSupervisor(socketPath, options.globalDir);
return { supervisorPromise, authToken };
}

function createFakeSupervisor(
socketPath: string,
handler: AgentViewSupervisorHandler,
Expand Down Expand Up @@ -461,19 +497,18 @@ async function writeHibernatedSessionForTest(
async function waitForSupervisor(
socketPath: string,
globalDir: string,
): Promise<void> {
for (let attempt = 0; attempt < 20; attempt++) {
try {
): Promise<string | undefined> {
return vi.waitFor(
async () => {
const authToken = await readAuthToken(globalDir);
await callAgentViewSupervisor(socketPath, 'status', undefined, {
authToken: await readAuthToken(globalDir),
authToken,
timeoutMs: 100,
});
return;
} catch {
await delay(25);
}
}
throw new Error('Timed out waiting for test supervisor.');
return authToken;
},
{ timeout: 5_000, interval: 25 },
);
}

async function readAuthToken(globalDir: string): Promise<string | undefined> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@
background: color-mix(in srgb, var(--success-color) 14%, transparent);
}

.attention {
color: var(--warning-color);
}

/* The running indicator mirrors the sidebar's session-loading spinner: only
shown while a turn is active, so idle sessions stay visually quiet. */
.loading {
Expand Down
Loading
Loading