Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0300fcb
feat(web-shell): persist environment panel state
Aug 31, 2026
9df6b47
Merge remote-tracking branch 'origin/main' into worktree-env-panel-at…
Aug 31, 2026
9d2406c
Merge remote-tracking branch 'origin/main' into worktree-env-panel-at…
Aug 31, 2026
d2ef6a2
Merge remote-tracking branch 'origin/main' into worktree-env-panel-at…
Sep 1, 2026
f97a898
fix(web-shell): address PR review regressions
Sep 1, 2026
a833dab
fix(web-shell): address remaining panel review issues
Sep 1, 2026
dbbfec1
test(web-shell): update system image preview expectation
Sep 1, 2026
918b731
Merge remote-tracking branch 'origin/main' into worktree-env-panel-at…
Sep 1, 2026
c386d84
fix(web-shell): harden restored panel state
Sep 1, 2026
f5ce6ee
Merge remote-tracking branch 'origin/main' into worktree-env-panel-at…
Sep 1, 2026
7a968d1
fix(web-shell): preserve deferred panel tabs
Sep 1, 2026
8a7410a
fix(web-shell): preserve panel state across async restores
Sep 2, 2026
bf4ee29
Merge remote-tracking branch 'origin/main' into worktree-env-panel-at…
Sep 2, 2026
424e657
fix(web-shell): close panel restoration races
Sep 2, 2026
e562610
fix(web-shell): order restored panel requests
Sep 2, 2026
f7bb83a
test(web-shell): pin completed agent polling
Sep 2, 2026
545b089
fix(web-shell): preserve live panel state during restore
Sep 2, 2026
e173d14
chore: merge origin/main
Sep 3, 2026
e3564cb
fix(daemon): refresh live panel snapshots reliably
Sep 3, 2026
f9dd67c
Merge remote-tracking branch 'origin/main' into worktree-env-panel-at…
Sep 3, 2026
e813b92
fix(web-shell): isolate session panel state
Sep 3, 2026
1a870f9
Merge remote-tracking branch 'origin/main' into worktree-env-panel-at…
Sep 3, 2026
ab9f804
fix(web-shell): release detached terminals
Sep 3, 2026
27894da
fix(web-shell): restore cached panel artifacts
Sep 4, 2026
a4abd30
chore: merge origin/main
Sep 4, 2026
a43060b
chore: merge origin/main
Sep 4, 2026
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
21 changes: 21 additions & 0 deletions docs/design/web-shell-agent-workflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Web Shell agent workflow

## Goal

Show the complete persisted subagent hierarchy in a restorable right-panel tab while retaining the existing low-cost live agent inventory.

## Data flow

- `GET /session/:id/agent-trace` is a live-session-owner read. It scans at most 2,000 metadata sidecars asynchronously, validates their session identity, and returns parent/root relationships plus lineage health.
- `GET /session/:id/agents` remains the bounded live inventory and polling source.
- The Workflow tab uses the trace as topology and overlays matching live agents by ID for current status and timing.

The trace is requested only after transcript loading completes and only while a Workflow tab is open. Agent inventory activity changes trigger a trace refresh; there is no second fixed polling loop.

## Persistence

Local storage contains only the Workflow tab ID, title, kind, and session ID. Trace content is fetched after load. The first fetch shows a skeleton; later refreshes retain the prior graph.

## Compatibility

The route is advertised by the dedicated `session_agent_trace` capability. Older daemons continue to expose the existing agent inventory without the Workflow entry.
17 changes: 17 additions & 0 deletions docs/design/web-shell-right-panel-restoration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Web Shell right-panel restoration

## Goal

Restore each session's right-panel open state, tab order, and active tab after a page reload without storing content payloads in browser storage.

## Design

`localStorage` stores one entry per logical session. Each tab is reduced to stable lookup fields such as attachment ID, source session ID, workspace path, artifact ID, task ID, tool-call ID, side-task session ID, or terminal ID and working directory. Runtime objects, transcript content, terminal output, diffs, blobs, data URLs, and live action objects are never persisted.

The panel shell is restored before paint, without an opening animation, and shows a skeleton while the session transcript or artifacts are loading. Tab metadata and content are restored only after both loads complete. Reloads and session switches use the same key-only restore path. Only the active attachment or cross-session task tab fetches its content immediately; inactive tabs fetch when selected. Workspace files, artifacts, and side tasks continue to use their existing lazy data sources.

The environment panel lists attachment metadata and session artifacts in separate, expanded sections. Both sections show skeletons while their APIs load. Attachment content is fetched only when its name is selected; no thumbnails or content payloads are loaded for the list.

Subagents come from a session-scoped agent snapshot rather than transcript pagination. The daemon merges persisted agent sidecars with the live task registry by agent ID, with live state winning. Web Shell fetches the snapshot after session load when the environment panel opens and polls every three seconds only while an agent is running; older daemons fall back to the existing task/transcript view.

Unsent composer previews have no durable session identity and are intentionally not restored.
2 changes: 1 addition & 1 deletion docs/developers/daemon/00-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ Use these anchors when moving from the docs into the latest `main` code:
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| HTTP routes | The route catalog lives in `qwen-serve-protocol.md`; this daemon set only references it and explains implementation ownership. | [`../qwen-serve-protocol.md`](../qwen-serve-protocol.md), [`20`](./20-quickstart-operations.md) |
| Event schema | `EVENT_SCHEMA_VERSION = 1`; 53 known event types; id-less subscriber synthetic frames; `_meta.serverTimestamp` stamped by `EventBus.publish()` (with `formatSseFrame()` fallback for synthetic frames). | [`09`](./09-event-schema.md), [`10`](./10-event-bus.md) |
| Capabilities | `SERVE_PROTOCOL_VERSION = 'v1'`; 151 registered tags; 44 conditional tags. | [`11`](./11-capabilities-versioning.md) |
| Capabilities | `SERVE_PROTOCOL_VERSION = 'v1'`; 154 registered tags; 44 conditional tags. | [`11`](./11-capabilities-versioning.md) |
| Session shell | `POST /session/:id/shell` exists behind `--enable-session-shell`, bearer or trusted-loopback authority, and session-bound `X-Qwen-Client-Id`; capability tag is conditional. | [`11`](./11-capabilities-versioning.md), [`17`](./17-configuration.md), [`20`](./20-quickstart-operations.md) |
| Rate limiting | Optional per-tier HTTP rate limit is exposed by CLI flags/env and conditional capability tag. | [`11`](./11-capabilities-versioning.md), [`17`](./17-configuration.md) |

Expand Down
3 changes: 3 additions & 0 deletions integration-tests/cli/qwen-serve-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ describe('qwen serve — capabilities envelope', () => {
'session_prompt',
'session_turn_status',
'session_attachments',
'session_attachment_list',
'session_mid_turn_message_mutation',
'session_mid_turn_message_query',
'session_cancel',
Expand Down Expand Up @@ -388,6 +389,8 @@ describe('qwen serve — capabilities envelope', () => {
'session_context_usage',
'session_supported_commands',
'session_tasks',
'session_agents',
'session_agent_trace',
'session_monitor_tool_correlation',
'session_stats',
'session_lsp',
Expand Down
99 changes: 98 additions & 1 deletion packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3381,14 +3381,26 @@ describe('createAcpSessionBridge', () => {
state: {},
};
}
if (method === 'qwen/status/session/tasks') {
if (
method === 'qwen/status/session/tasks' ||
method === 'qwen/status/session/agents'
) {
return {
v: 1,
sessionId: params['sessionId'],
now: 1_700_000_000_000,
tasks: [],
};
}
if (method === 'qwen/status/session/agent_trace') {
return {
v: 1,
sessionId: params['sessionId'],
nodes: [],
rootAgentIds: [params['rootAgentId']],
warnings: [],
};
}
if (method === 'qwen/status/session/lsp') {
return {
v: 1,
Expand Down Expand Up @@ -3465,6 +3477,19 @@ describe('createAcpSessionBridge', () => {
sessionId: session.sessionId,
tasks: [],
});
await expect(
bridge.getSessionAgentsStatus(session.sessionId),
).resolves.toMatchObject({
sessionId: session.sessionId,
tasks: [],
});
await expect(
bridge.getSessionAgentTrace(session.sessionId, 'root-1'),
).resolves.toMatchObject({
sessionId: session.sessionId,
rootAgentIds: ['root-1'],
nodes: [],
});
await expect(
bridge.getSessionLspStatus(session.sessionId),
).resolves.toMatchObject({
Expand All @@ -3490,6 +3515,8 @@ describe('createAcpSessionBridge', () => {
'qwen/status/session/context',
'qwen/status/session/supported_commands',
'qwen/status/session/tasks',
'qwen/status/session/agents',
'qwen/status/session/agent_trace',
'qwen/status/session/lsp',
'qwen/status/session/resources',
]);
Expand Down Expand Up @@ -3869,6 +3896,38 @@ describe('createAcpSessionBridge', () => {
await bridge.shutdown();
});

it('flushes transcripts through the backward page barrier', async () => {
const handle = makeChannel({
extMethodImpl: (method, params) => {
if (method === SERVE_STATUS_EXT_METHODS.sessionTranscript) {
return {
v: 1,
sessionId: params['sessionId'],
events: [],
hasMore: false,
};
}
throw new Error(`unexpected extMethod ${method}`);
},
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });

await bridge.flushSessionTranscript?.('session-1');

expect(handle.agent.extMethodCalls).toEqual([
{
method: SERVE_STATUS_EXT_METHODS.sessionTranscript,
params: {
cwd: WS_A,
sessionId: 'session-1',
direction: 'backward',
limit: 1,
},
},
]);
await bridge.shutdown();
});

it('times out transcript page status requests', async () => {
vi.useFakeTimers();
try {
Expand Down Expand Up @@ -4634,6 +4693,12 @@ describe('createAcpSessionBridge', () => {
await expect(
bridge.getSessionTasksStatus('missing'),
).rejects.toBeInstanceOf(SessionNotFoundError);
await expect(
bridge.getSessionAgentsStatus('missing'),
).rejects.toBeInstanceOf(SessionNotFoundError);
await expect(bridge.getSessionAgentTrace('missing')).rejects.toBeInstanceOf(
SessionNotFoundError,
);
await expect(bridge.getSessionLspStatus('missing')).rejects.toBeInstanceOf(
SessionNotFoundError,
);
Expand Down Expand Up @@ -15405,6 +15470,38 @@ describe('createAcpSessionBridge', () => {
await bridge.shutdown();
});

it('lists every attachment stored for a session', async () => {
const bridge = makeBridge({
channelFactory: async () =>
makeChannel({ promptImpl: () => ({ stopReason: 'end_turn' }) })
.channel,
});
try {
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const textReference = await bridge.storeSessionAttachment(
session.sessionId,
new TextEncoder().encode('hello'),
'text/plain',
{ clientId: session.clientId },
'notes.txt',
);
const imageReference = await bridge.storeSessionAttachment(
session.sessionId,
Uint8Array.from([1, 2, 3]),
'image/png',
{ clientId: session.clientId },
);

expect(
await bridge.listSessionAttachments(session.sessionId, {
clientId: session.clientId,
}),
).toEqual(expect.arrayContaining([textReference, imageReference]));
} finally {
await bridge.shutdown();
}
});

it('keeps inline media bytes in echoes for legacy clients', async () => {
const bridge = makeBridge({
channelFactory: async () =>
Expand Down
34 changes: 34 additions & 0 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ import {
SERVE_CONTROL_EXT_METHODS,
SERVE_STATUS_EXT_METHODS,
STATUS_SCHEMA_VERSION,
type ServeSessionAgentsStatus,
type ServeSessionAgentTrace,
type ServeSessionStatsStatus,
type ServeSessionContextStatus,
type ServeSessionLspStatus,
Expand Down Expand Up @@ -12038,6 +12040,21 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
);
},

async getSessionAgentsStatus(sessionId) {
return requestSessionStatus<ServeSessionAgentsStatus>(
sessionId,
SERVE_STATUS_EXT_METHODS.sessionAgents,
);
},

async getSessionAgentTrace(sessionId, rootAgentId) {
return requestSessionStatus<ServeSessionAgentTrace>(
sessionId,
SERVE_STATUS_EXT_METHODS.sessionAgentTrace,
rootAgentId === undefined ? undefined : { rootAgentId },
);
},

async getSessionLspStatus(sessionId) {
return requestSessionStatus<ServeSessionLspStatus>(
sessionId,
Expand All @@ -12064,6 +12081,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
return requestSessionTranscriptPage(req);
},

async flushSessionTranscript(sessionId) {
// The child flushes before every backward page; a one-record page is the
// existing read-only barrier without adding another ACP extension.
await requestSessionTranscriptPage({
sessionId,
direction: 'backward',
limit: 1,
});
},

async cancelSessionTask(sessionId, taskId, taskKind, context) {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
Expand Down Expand Up @@ -12876,6 +12903,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
return await entry.attachments.read(attachmentId);
},

async listSessionAttachments(sessionId, context) {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
resolveTrustedClientId(entry, context?.clientId);
return await entry.attachments.list();
},

async removeSessionAttachment(sessionId, attachmentId, context) {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
Expand Down
20 changes: 20 additions & 0 deletions packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import type {
} from './sessionArtifacts.js';
import type { SessionAttachmentReference } from './sessionAttachments.js';
import type {
ServeSessionAgentsStatus,
ServeSessionAgentTrace,
ServeSessionContextStatus,
ServeSessionHooksStatus,
ServeSessionLspStatus,
Expand Down Expand Up @@ -1825,6 +1827,15 @@ export interface AcpSessionBridge extends WorkspaceEventBridge {
opts?: { includeWorkflows?: boolean },
): Promise<ServeSessionTasksStatus>;

/** Read persisted and live subagents for a live session. */
getSessionAgentsStatus(sessionId: string): Promise<ServeSessionAgentsStatus>;

/** Read the persisted subagent lineage for a live session. */
getSessionAgentTrace(
sessionId: string,
rootAgentId?: string,
): Promise<ServeSessionAgentTrace>;

/** Read sanitized LSP server status for a live session. */
getSessionLspStatus(sessionId: string): Promise<ServeSessionLspStatus>;

Expand Down Expand Up @@ -1852,6 +1863,9 @@ export interface AcpSessionBridge extends WorkspaceEventBridge {
req: BridgeSessionTranscriptPageRequest,
): Promise<BridgeSessionTranscriptPage>;

/** Flush pending transcript writes for a live session. */
flushSessionTranscript?(sessionId: string): Promise<void>;

/** Cancel a background task in a live session. */
cancelSessionTask(
sessionId: string,
Expand Down Expand Up @@ -2138,6 +2152,12 @@ export interface AcpSessionBridge extends WorkspaceEventBridge {
context?: BridgeClientRequestContext,
): Promise<{ data: Buffer; mimeType: string } | undefined>;

/** List every attachment currently stored for the session, upload order. */
listSessionAttachments(
sessionId: string,
context?: BridgeClientRequestContext,
): Promise<SessionAttachmentReference[]>;

removeSessionAttachment(
sessionId: string,
attachmentId: string,
Expand Down
Loading
Loading