diff --git a/docs/design/web-shell-context-panels.md b/docs/design/web-shell-context-panels.md new file mode 100644 index 00000000000..ec4626020a6 --- /dev/null +++ b/docs/design/web-shell-context-panels.md @@ -0,0 +1,112 @@ +# Web Shell context panels + +## Goal + +Add a persistent header to active chat sessions and move supported workspace +and background-task context into a fixed-width environment panel. Keep the +existing artifact panel as an independent right-side surface. + +## Header + +The active chat header is opt-in so existing integrations without header props +keep their previous layout. Passing `header` enables the default header, whose +content is the current session title. `header.items` controls the title, +environment action, and artifact-panel action independently; an empty items +array hides the complete header. Passing `renderChatHeader` also enables the +header and replaces it completely; the renderer receives the session metadata, +enabled items, controlled panel state, and panel open-change callbacks. The +compact sidebar toggle remains owned by `sidebar` and renders outside the +custom header. While the artifact panel is closed, its toggle is in the chat +header. While it is open, that same toggle moves to the right edge of the +artifact-panel header, leaving the environment action at the right edge of the +chat header adjacent to the panel. + +The artifact-panel action remains available when no tab exists. Opening an +The empty panel shows Review and, when session-source metadata is supported, +side-task history plus New side task. Once a tab is open, the panel header add +menu contains Review and New side task without repeating the side-task history. +Review opens the most recent transcript turn containing reviewable file +changes, is disabled when no such turn exists, and is hidden from the add menu +while a review tab is already open. Closing a populated artifact panel keeps +its tabs so the header action can reopen the existing content. + +`rightPanel.items` independently controls whether Review and Side task appear +on the empty panel page. Both items are enabled by default. + +## Side tasks + +A side task is a distinct daemon thread session in the same workspace as its +parent. It renders the existing interactive chat pane, including the transcript, +composer, approval-mode selector, model selector, streaming state, and +permission handling. Creation uses the dedicated side-task endpoint to snapshot +the main session's complete persisted model context at that moment, then +continues independently. The snapshot is serialized against transcript writes, +so a side task can be created while the parent is responding without observing +a partial JSONL record. Inherited records are not replayed in the side-task +transcript; only messages created inside the side task are shown. + +Side-task sessions record `sourceType: side_task` and the parent session id as +`sourceId`. The Web Shell session catalog filters this source type, so side +tasks do not appear as top-level sessions. With saved side tasks, hovering Side +task on the empty right-panel page opens a menu of those sessions and a New +action. With no saved task, clicking the row creates one directly. Selecting a +saved task restores it as a tab. Closing a tab only detaches its client; the +daemon transcript remains available for later conversation. + +`/btw ` keeps the lightweight, one-shot BTW interaction. +`/btw side ` opens a new side-task draft and sends the question as +its first prompt when the daemon advertises `session_side_task`. Hosts can +trigger the same action through `shellRef.current.createSideTask()`. + +## Environment panel + +The environment panel uses only existing Web Shell capabilities: + +- workspace path; +- Git branch and working-tree summary; +- working-tree diff and commit history entry points; +- configured agents entry point; +- background agent, shell, and monitor task summaries. + +The environment action and environment section remain available throughout an +active chat session. A clean working tree is shown explicitly; agent and +background-task sections appear only when they have content. + +`environmentPanel.items` independently controls the environment, subagent, and +background-task sections. All three sections are enabled by default. + +The local `/fork` command refreshes the session task snapshot as soon as its +background agent launches. Fork agents have no parent transcript tool call, so +their right-panel detail resolves the virtual subagent session by agent task ID +instead of `toolUseId`. + +The local `/tasks` command opens the environment panel and refreshes its task +snapshot instead of opening the legacy task dialog. + +Side-task, subagent, and fork transcripts expose their own file changes and +artifacts through the main right panel. Their source session scopes tab +identities and workspace actions, so opening a nested output creates a separate +tab without replacing the main session's review or artifact tabs. + +It is a fixed-width, non-resizable layout column styled as a floating card with +a border and shadow. At narrower message widths it opens as a dismissible +floating popover instead of consuming chat width. + +The environment panel and artifact panel are independent. At desktop widths the +two may be visible together. When the viewport cannot fit both, the artifact +panel normally takes priority and the environment panel is hidden without +losing its open state. Opening a subagent or background task from the +environment panel keeps that panel visible beside the resulting detail. A +floating environment panel is positioned within the remaining message area and +never overlaps the artifact panel. + +## Responsive behavior + +The environment panel is hidden for split/full-page views. When the message +area cannot keep at least 800 pixels after docking the panel, the panel closes +and can be reopened as a floating popover. An open artifact panel takes +priority when both panels cannot fit, but the environment action remains +available for explicitly reopening the popover. The existing artifact drawer +behavior on narrow screens is unchanged. On desktop, the artifact panel is a +top-level layout column beside the chat shell, so it starts at the top of the +page and the chat header ends at the panel boundary. diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 95eaf0afec5..dd51c44c4cd 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -310,6 +310,7 @@ describe('qwen serve — capabilities envelope', () => { 'session_list', 'session_info', 'session_source_metadata', + 'session_side_task', 'session_prompt', 'session_cancel', 'session_events', diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index cdb020ad80b..b30623e9c8b 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -62,6 +62,7 @@ import { TurnBoundaryCompactionEngine } from './compactionEngine.js'; import { CHANNEL_STARTUP_PROFILE_META_KEY, CHANNEL_STARTUP_PROFILE_VERSION, + LOAD_REPLAY_HIDE_INHERITED_META_KEY, } from './bridgeTypes.js'; import { ApprovalMode, @@ -10827,6 +10828,103 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('creates a side task with hidden inherited replay', async () => { + const handle = makeChannel({ + extMethodImpl: async (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionSideTask) { + return { newSessionId: 'side-1', title: 'Side task' }; + } + if (method === SERVE_CONTROL_EXT_METHODS.sessionSource) { + return { persisted: true }; + } + return {}; + }, + resumeSessionImpl: () => ({}), + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + const parent = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + + const sideTask = await bridge.createSideTaskSession(parent.sessionId, { + name: 'Side task', + }); + + expect(sideTask).toMatchObject({ + sessionId: 'side-1', + sourceType: 'side_task', + sourceId: parent.sessionId, + sourcePersisted: true, + parentSessionId: parent.sessionId, + }); + expect(bridge.getSessionSummary(sideTask.sessionId)).toMatchObject({ + sourceType: 'side_task', + sourceId: parent.sessionId, + }); + expect(handle.agent.extMethodCalls).toContainEqual({ + method: SERVE_CONTROL_EXT_METHODS.sessionSource, + params: { + sessionId: sideTask.sessionId, + sourceType: 'side_task', + sourceId: parent.sessionId, + }, + }); + expect(handle.agent.loadSessionCalls[0]?._meta).toMatchObject({ + [LOAD_REPLAY_HIDE_INHERITED_META_KEY]: true, + }); + + await bridge.shutdown(); + }); + + it('creates a side task while the parent prompt is active', async () => { + const promptGate = deferred(); + const handle = makeChannel({ + promptImpl: async () => { + await promptGate.promise; + return { stopReason: 'end_turn' }; + }, + extMethodImpl: async (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionSideTask) { + return { newSessionId: 'side-active', title: 'Side task' }; + } + if (method === SERVE_CONTROL_EXT_METHODS.sessionSource) { + return { persisted: true }; + } + return {}; + }, + resumeSessionImpl: () => ({}), + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + const parent = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + const prompt = bridge.sendPrompt(parent.sessionId, { + sessionId: parent.sessionId, + prompt: [{ type: 'text', text: 'keep working' }], + }); + await vi.waitFor(() => expect(handle.agent.promptCalls).toHaveLength(1)); + + await expect( + bridge.createSideTaskSession(parent.sessionId, { name: 'Side task' }), + ).resolves.toMatchObject({ + sessionId: 'side-active', + parentSessionId: parent.sessionId, + }); + expect(bridge.getSessionSummary(parent.sessionId)).toMatchObject({ + hasActivePrompt: true, + }); + + promptGate.resolve(); + await prompt; + await bridge.shutdown(); + }); + it('carries persisted source metadata into ACP session restore', async () => { for (const action of ['load', 'resume'] as const) { const handle = makeChannel(); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 1ecf00acc4f..25d5a4d7629 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -103,6 +103,7 @@ import { CHANNEL_STARTUP_PROFILE_VERSION, DAEMON_CHANNEL_DELIVERY_META_KEY, LOAD_REPLAY_BULK_MODE, + LOAD_REPLAY_HIDE_INHERITED_META_KEY, LOAD_REPLAY_META_KEY, LOAD_REPLAY_MODE_META_KEY, LOAD_REPLAY_PAGE_SIZE_META_KEY, @@ -2031,6 +2032,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { interface InFlightRestore { action: 'load' | 'resume'; historyReplay: 'stream' | 'response'; + hideInheritedHistory: boolean; promise: Promise; /** * Synchronous reservation slot for callers that coalesce onto this @@ -4391,6 +4393,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } const historyReplay = action === 'load' ? (req.historyReplay ?? 'stream') : 'stream'; + const hideInheritedHistory = + action === 'load' && req.hideInheritedHistory === true; const existing = byId.get(req.sessionId); if (existing) { @@ -4452,7 +4456,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // missing snapshot. Same-action coalescing is unaffected. if ( action !== inFlight.action || - historyReplay !== inFlight.historyReplay + historyReplay !== inFlight.historyReplay || + hideInheritedHistory !== inFlight.hideInheritedHistory ) { throw new RestoreInProgressError( req.sessionId, @@ -4584,7 +4589,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // intentionally has no `mcpServers` field for the // same reason. mcpServers: [], - ...(historyReplay === 'response' || req.sourceType + ...(historyReplay === 'response' || + hideInheritedHistory || + req.sourceType ? { _meta: { ...sessionSourceRequestMeta( @@ -4603,6 +4610,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { : {}), } : {}), + ...(hideInheritedHistory + ? { + [LOAD_REPLAY_HIDE_INHERITED_META_KEY]: true, + } + : {}), }, } : {}), @@ -4846,6 +4858,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { inFlightRestores.set(req.sessionId, { action, historyReplay, + hideInheritedHistory, promise, coalesceState, }); @@ -6226,14 +6239,22 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); + const source = parseSessionSource(req.sourceType, req.sourceId); + if ('error' in source) { + throw new InvalidSessionMetadataError('sourceType', source.error); + } + const isSideTask = source.sourceType === 'side_task'; let originatorClientId: string | undefined; if (context?.clientId !== undefined) { originatorClientId = resolveTrustedClientId(entry, context.clientId); } - const branchResult = entry.promptQueue.then(async () => { - if (entry.promptActive) { + const concurrentSideTask = isSideTask && entry.promptActive; + const branchResult = ( + concurrentSideTask ? Promise.resolve() : entry.promptQueue + ).then(async () => { + if (entry.promptActive && !isSideTask) { throw new BranchWhilePromptActiveError(sessionId); } @@ -6258,13 +6279,18 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { try { const ci = await ensureChannel(); const result = (await withTimeout( - ci.connection.extMethod(SERVE_CONTROL_EXT_METHODS.sessionBranch, { - sessionId, - cwd: boundWorkspace, - name: req.name, - }), + ci.connection.extMethod( + isSideTask + ? SERVE_CONTROL_EXT_METHODS.sessionSideTask + : SERVE_CONTROL_EXT_METHODS.sessionBranch, + { + sessionId, + cwd: boundWorkspace, + name: req.name, + }, + ), initTimeoutMs, - 'branchSession', + isSideTask ? 'createSideTaskSession' : 'branchSession', )) as { newSessionId: string; title?: string; displayName?: string }; if (!result || typeof result.newSessionId !== 'string') { @@ -6280,12 +6306,20 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { let restored; try { + const hideInheritedHistory = req.replayInheritedHistory === false; restored = await restoreSession( 'load', { sessionId: result.newSessionId, workspaceCwd: boundWorkspace, clientId: context?.clientId, + ...(hideInheritedHistory + ? { + historyReplay: 'response', + hideInheritedHistory: true, + } + : {}), + ...source, }, { skipFreshSessionAdmission: true, @@ -6311,20 +6345,50 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const newEntry = byId.get(result.newSessionId); if (newEntry) newEntry.displayName = branchDisplayName; + let sourcePersisted: boolean | undefined; + if (newEntry?.sourceType) { + try { + const sourceResult = await withTimeout( + newEntry.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionSource, + { + sessionId: newEntry.sessionId, + sourceType: newEntry.sourceType, + ...(newEntry.sourceId !== undefined + ? { sourceId: newEntry.sourceId } + : {}), + }, + ), + initTimeoutMs, + 'sessionSource', + ); + sourcePersisted = + (sourceResult as { persisted?: boolean } | undefined) + ?.persisted === true; + } catch (error) { + sourcePersisted = false; + writeStderrLine( + `qwen serve: source metadata for branched session ${result.newSessionId} was not persisted: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } - const eventData = { - sourceSessionId: sessionId, - newSessionId: result.newSessionId, - displayName: branchDisplayName, - }; - const branchEnvelope = { - type: 'session_branched' as const, - data: eventData, - ...(originatorClientId ? { originatorClientId } : {}), - }; - // The branch announcement belongs to the new session only. Publishing - // it on the source session would persist in that session's replay ring. - newEntry?.events.publish(branchEnvelope); + if (!isSideTask) { + const eventData = { + sourceSessionId: sessionId, + newSessionId: result.newSessionId, + displayName: branchDisplayName, + }; + const branchEnvelope = { + type: 'session_branched' as const, + data: eventData, + ...(originatorClientId ? { originatorClientId } : {}), + }; + // The branch announcement belongs to the new session only. + newEntry?.events.publish(branchEnvelope); + } return { ...restored, @@ -6333,18 +6397,39 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { sessionId, displayName: entry.displayName ?? sessionId.slice(0, 8), }, + ...(sourcePersisted !== undefined ? { sourcePersisted } : {}), }; } finally { releaseAdmissionOnce(); } }); - entry.promptQueue = branchResult.then( - () => undefined, - () => undefined, - ); + if (!concurrentSideTask) { + entry.promptQueue = branchResult.then( + () => undefined, + () => undefined, + ); + } return branchResult; }, + async createSideTaskSession(sessionId, req, context) { + const result = await this.branchSession( + sessionId, + { + name: req.name, + sourceType: 'side_task', + sourceId: sessionId, + replayInheritedHistory: false, + }, + context, + ); + const { forkedFrom: _forkedFrom, ...sideTask } = result; + return { + ...sideTask, + parentSessionId: sessionId, + }; + }, + async changeSessionCwd( sessionId: string, req: ChangeSessionCwdRequest, diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index e39fd207a54..90e6516d584 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -147,6 +147,8 @@ export interface BridgeRestoreSessionRequest { historyReplay?: 'stream' | 'response'; /** Optional newest persisted-record page requested for response replay. */ historyPageSize?: number; + /** Keep inherited fork records as model context without replaying them. */ + hideInheritedHistory?: boolean; approvalMode?: ApprovalMode; /** * Persisted parent lineage recovered from the transcript by the caller (the @@ -165,6 +167,8 @@ export interface BridgeRestoreSessionRequest { export const LOAD_REPLAY_MODE_META_KEY = 'qwen.session.loadReplayMode'; export const LOAD_REPLAY_META_KEY = 'qwen.session.loadReplay'; export const LOAD_REPLAY_PAGE_SIZE_META_KEY = 'qwen.session.loadReplayPageSize'; +export const LOAD_REPLAY_HIDE_INHERITED_META_KEY = + 'qwen.session.loadReplayHideInherited'; export const LOAD_REPLAY_BULK_MODE = 'bulk'; export const LOAD_REPLAY_VERSION = 1 as const; @@ -284,6 +288,9 @@ export interface BridgeSessionTranscriptPage { export interface BridgeBranchSessionRequest { name?: string; + sourceType?: string; + sourceId?: string; + replayInheritedHistory?: boolean; } export interface BridgeBranchedSession extends BridgeRestoredSession { @@ -291,6 +298,15 @@ export interface BridgeBranchedSession extends BridgeRestoredSession { forkedFrom: { sessionId: string; displayName: string }; } +export interface BridgeSideTaskSessionRequest { + name?: string; +} + +export interface BridgeSideTaskSession extends BridgeRestoredSession { + displayName: string; + parentSessionId: string; +} + export interface BridgeForkAgentResult { sessionId: string; description: string; @@ -875,6 +891,13 @@ export interface AcpSessionBridge { context?: BridgeClientRequestContext, ): Promise; + /** Create a persisted side task with a snapshot of the parent's context. */ + createSideTaskSession( + sessionId: string, + req: BridgeSideTaskSessionRequest, + context?: BridgeClientRequestContext, + ): Promise; + /** * Change the working directory of a live session. The session must be * idle (no active prompt). Chains onto `entry.promptQueue` and updates diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 851cf1cd6ab..b7d4cb4c9bc 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -131,6 +131,7 @@ export const SERVE_CONTROL_EXT_METHODS = { sessionClose: 'qwen/control/session/close', sessionApprovalMode: 'qwen/control/session/approval_mode', sessionBranch: 'qwen/control/session/branch', + sessionSideTask: 'qwen/control/session/side_task', sessionForkAgent: 'qwen/control/session/fork_agent', sessionRecap: 'qwen/control/session/recap', sessionGenerationStart: 'qwen/control/session/generation/start', diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index c371e3a6d1e..40172b64fa1 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -799,6 +799,7 @@ import { fetchAllowedGitHub, createWorkspaceMcpBudget, deliverClientMcpMessage, + selectVisibleHistoryRecords, } from './acpAgent.js'; import { gzipSync } from 'node:zlib'; import type { Config } from '@qwen-code/qwen-code-core'; @@ -12423,6 +12424,55 @@ describe('QwenAgent extMethod renameSession routing', () => { await agentPromise; }); + it('creates a side task with source metadata and no branch suffix', async () => { + const recording = makeRecordingService(); + const sessionService = { + forkSession: vi.fn().mockResolvedValue(undefined), + renameSession: vi.fn().mockResolvedValue(true), + removeSession: vi.fn().mockResolvedValue(undefined), + }; + const innerConfig = makeLiveSessionInnerConfig(recording); + innerConfig.getSessionService.mockReturnValue( + sessionService as unknown as SessionService, + ); + const { agent, agentPromise } = await bootAgent(innerConfig); + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const result = await agent.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionSideTask, + { + cwd: '/tmp', + sessionId: liveSessionId, + name: 'Side task', + }, + ); + + expect(sessionService.forkSession).toHaveBeenCalledWith( + liveSessionId, + expect.any(String), + { + source: { + sourceType: 'side_task', + sourceId: liveSessionId, + }, + }, + ); + expect(recording.runWithWriteBarrier).toHaveBeenCalledOnce(); + const newSessionId = sessionService.forkSession.mock.calls[0]?.[1]; + expect(sessionService.renameSession).toHaveBeenCalledWith( + newSessionId, + 'Side task', + 'manual', + ); + expect(result).toMatchObject({ + title: 'Side task', + displayName: 'Side task', + }); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('keeps the live session open when strict session close flush fails', async () => { const recording = makeRecordingService(); recording.flush.mockRejectedValue(new Error('flush failed')); @@ -16528,3 +16578,55 @@ describe('deliverClientMcpMessage — reverse tool channel (#5626)', () => { }); }); }); + +describe('selectVisibleHistoryRecords', () => { + function makeRecord( + overrides: Partial<{ + type: string; + subtype: string; + systemPayload: unknown; + forkedFrom: { sessionId: string; messageUuid: string }; + }> = {}, + ) { + return { + uuid: `uuid-${Math.random().toString(36).slice(2)}`, + parentUuid: null, + sessionId: 'test-session', + timestamp: '2025-01-01T00:00:00Z', + type: 'user', + ...overrides, + } as never; + } + + const sourceBoundary = makeRecord({ + type: 'system', + subtype: 'session_source', + systemPayload: { sourceType: 'side_task', sourceId: 'parent-1' }, + }); + + it('filters records before a side-task source boundary regardless of hideInheritedHistory', () => { + const inherited = makeRecord({ + forkedFrom: { sessionId: 'parent-1', messageUuid: 'm1' }, + }); + const before = makeRecord(); + const after = makeRecord(); + const records = [inherited, before, sourceBoundary, after]; + + const withHide = selectVisibleHistoryRecords(records, true); + const withoutHide = selectVisibleHistoryRecords(records, false); + + expect(withHide).toEqual([sourceBoundary, after]); + expect(withoutHide).toEqual([sourceBoundary, after]); + }); + + it('filters forkedFrom records when hideInheritedHistory is true and no boundary exists', () => { + const inherited = makeRecord({ + forkedFrom: { sessionId: 'parent-1', messageUuid: 'm1' }, + }); + const own = makeRecord(); + const records = [inherited, own]; + + expect(selectVisibleHistoryRecords(records, true)).toEqual([own]); + expect(selectVisibleHistoryRecords(records, false)).toEqual(records); + }); +}); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 1448c27abb4..83cecdb52b4 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -300,6 +300,7 @@ import { CHANNEL_STARTUP_PROFILE_VERSION, CLIENT_MCP_OVER_WS_CONFIG_FLAG, LOAD_REPLAY_BULK_MODE, + LOAD_REPLAY_HIDE_INHERITED_META_KEY, LOAD_REPLAY_META_KEY, LOAD_REPLAY_MODE_META_KEY, LOAD_REPLAY_PAGE_SIZE_META_KEY, @@ -626,6 +627,34 @@ function isBulkLoadReplayRequest(params: LoadSessionRequest): boolean { return meta?.[LOAD_REPLAY_MODE_META_KEY] === LOAD_REPLAY_BULK_MODE; } +function shouldHideInheritedHistory(params: LoadSessionRequest): boolean { + const meta = isObjectRecord(params._meta) ? params._meta : undefined; + return meta?.[LOAD_REPLAY_HIDE_INHERITED_META_KEY] === true; +} + +export function selectVisibleHistoryRecords( + records: ChatRecord[], + hideInheritedHistory: boolean, +): ChatRecord[] { + const sourceBoundary = records.findIndex( + (record) => + record.type === 'system' && + record.subtype === 'session_source' && + isObjectRecord(record.systemPayload) && + record.systemPayload['sourceType'] === 'side_task', + ); + // A persisted side-task source boundary is authoritative for every replay; + // callers cannot opt inherited parent history back into that child session. + if (sourceBoundary >= 0) { + return records + .slice(sourceBoundary) + .filter((record) => record.forkedFrom === undefined); + } + return hideInheritedHistory + ? records.filter((record) => record.forkedFrom === undefined) + : records; +} + function isChannelSessionRequest(params: { _meta?: unknown }): boolean { const meta = isObjectRecord(params._meta) ? params._meta : undefined; const value = meta?.[SESSION_SOURCE_META_KEY]; @@ -4409,12 +4438,19 @@ class QwenAgent implements Agent { : {}), } as LoadSessionResponse; const records = sessionData.conversation.messages; - if (records.length === 0) return response; + const visibleRecords = selectVisibleHistoryRecords( + records, + shouldHideInheritedHistory(params), + ); + if (visibleRecords.length === 0) return response; const bulkReplay = isBulkLoadReplayRequest(params); const replayPage = bulkReplay - ? selectRecentHistoryRecords(records, getLoadReplayPageSize(params)) - : { records, hasMore: false }; + ? selectRecentHistoryRecords( + visibleRecords, + getLoadReplayPageSize(params), + ) + : { records: visibleRecords, hasMore: false }; const replay = await collectHistoryReplayUpdates({ sessionId: params.sessionId, config, @@ -4494,8 +4530,12 @@ class QwenAgent implements Agent { let replayUpdates: SessionUpdate[] = []; if (records) { createdSession.primeTurnFromHistory(records); - const replayPage = selectRecentHistoryRecords( + const visibleRecords = selectVisibleHistoryRecords( records, + shouldHideInheritedHistory(params), + ); + const replayPage = selectRecentHistoryRecords( + visibleRecords, replayPageSize, ); const replayUsage = createReplayCumulativeUsage(); @@ -4543,7 +4583,6 @@ class QwenAgent implements Agent { } }); } - const modesData = this.buildModesData(config); const availableModels = this.buildAvailableModels(config); const configOptions = this.buildConfigOptions(config); @@ -10009,7 +10048,9 @@ class QwenAgent implements Agent { apiKeyEnvKey: cfg?.apiKeyEnvKey ?? null, }; } - case SERVE_CONTROL_EXT_METHODS.sessionBranch: { + case SERVE_CONTROL_EXT_METHODS.sessionBranch: + case SERVE_CONTROL_EXT_METHODS.sessionSideTask: { + const isSideTask = method === SERVE_CONTROL_EXT_METHODS.sessionSideTask; const sessionId = params['sessionId']; if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) { throw RequestError.invalidParams( @@ -10035,7 +10076,20 @@ class QwenAgent implements Agent { const newSessionId = randomUUID(); const sessionService = sourceConfig.getSessionService(); - await sessionService.forkSession(sessionId, newSessionId); + const fork = () => + isSideTask + ? sessionService.forkSession(sessionId, newSessionId, { + source: { + sourceType: 'side_task', + sourceId: sessionId, + }, + }) + : sessionService.forkSession(sessionId, newSessionId); + if (isSideTask && recording) { + await recording.runWithWriteBarrier(fork); + } else { + await fork(); + } let title: string; try { @@ -10054,7 +10108,9 @@ class QwenAgent implements Agent { } } - title = await computeUniqueBranchTitle(baseName, sessionService); + title = isSideTask + ? baseName + : await computeUniqueBranchTitle(baseName, sessionService); const renamed = await sessionService.renameSession( newSessionId, title, diff --git a/packages/cli/src/acp-integration/session/history-replayer.test.ts b/packages/cli/src/acp-integration/session/history-replayer.test.ts index 627592d6d5c..1dd8ba19ce9 100644 --- a/packages/cli/src/acp-integration/session/history-replayer.test.ts +++ b/packages/cli/src/acp-integration/session/history-replayer.test.ts @@ -1574,4 +1574,4 @@ describe('HistoryReplayer', () => { ); }); }); -}); \ No newline at end of file +}); diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 177bd5cdcc9..805bd530d6e 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -49,6 +49,7 @@ export const SERVE_CAPABILITY_REGISTRY = { // must not be polled in a tight loop. session_info: { since: 'v1' }, session_source_metadata: { since: 'v1' }, + session_side_task: { since: 'v1' }, session_prompt: { since: 'v1' }, session_cancel: { since: 'v1' }, session_events: { since: 'v1' }, diff --git a/packages/cli/src/serve/multi-workspace-sessions.test.ts b/packages/cli/src/serve/multi-workspace-sessions.test.ts index 9d40c7caea6..b5388db4612 100644 --- a/packages/cli/src/serve/multi-workspace-sessions.test.ts +++ b/packages/cli/src/serve/multi-workspace-sessions.test.ts @@ -153,7 +153,7 @@ interface FakeBridge extends AcpSessionBridge { context?: BridgeClientRequestContext; }>; readonly primaryOnlyMutationCalls: Array<{ - route: 'branch' | 'fork' | 'cd'; + route: 'branch' | 'side-task' | 'fork' | 'cd'; sessionId: string; }>; } @@ -622,6 +622,10 @@ function makeBridge( primaryOnlyMutationCalls.push({ route: 'branch', sessionId }); throw new Error('Unexpected branchSession call'); }, + async createSideTaskSession(sessionId: string) { + primaryOnlyMutationCalls.push({ route: 'side-task', sessionId }); + throw new Error('Unexpected createSideTaskSession call'); + }, async launchSessionForkAgent(sessionId: string) { primaryOnlyMutationCalls.push({ route: 'fork', sessionId }); throw new Error('Unexpected launchSessionForkAgent call'); diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index a7fd9e41fa7..5f9c9f5499c 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -155,6 +155,7 @@ const TRANSCRIPT_CURSOR_TOO_LARGE_REPLAY_ERROR = const CHANNEL_DELIVERY_AUTHORIZATION_GRACE_MS = 60_000; const PRIMARY_ONLY_LIVE_SESSION_ROUTES = [ 'POST /session/:id/branch', + 'POST /session/:id/side-task', 'POST /session/:id/fork', 'POST /session/:id/cd', ] as const; @@ -2256,6 +2257,70 @@ export function registerSessionRoutes( ), ); + app.post( + '/session/:id/side-task', + mutate(), + withPrimaryOnlyMutableSession( + 'POST /session/:id/side-task', + async (req, res, sessionId, runtime) => { + const body = safeBody(req); + let name = + typeof body?.['name'] === 'string' ? body['name'] : undefined; + if (name) { + // eslint-disable-next-line no-control-regex + name = Array.from(name.replace(/[\x00-\x1F\x7F-\x9F]/g, '')) + .slice(0, 200) + .join(''); + } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + const result = await runtime.bridge.createSideTaskSession( + sessionId, + { name }, + { clientId }, + ); + try { + runtime.generationGuard?.assertOpen(); + } catch (error) { + if (!result.attached) { + const killed = await runtime.bridge + .killSession(result.sessionId, { requireZeroAttaches: true }) + .catch(() => false); + if (killed) { + await new SessionService(runtime.workspaceCwd) + .removeSession(result.sessionId) + .catch(() => {}); + } + } else { + await runtime.bridge + .detachClient(result.sessionId, result.clientId) + .catch(() => {}); + } + throw error; + } + if (!res.writable) { + if (!result.attached) { + runtime.bridge + .killSession(result.sessionId, { requireZeroAttaches: true }) + .then((killed) => { + if (!killed) return undefined; + return new SessionService(runtime.workspaceCwd!).removeSession( + result.sessionId, + ); + }) + .catch(() => {}); + } else { + runtime.bridge + .detachClient(result.sessionId, result.clientId) + .catch(() => {}); + } + return; + } + res.status(201).json(result); + }, + ), + ); + app.post( '/session/:id/fork', mutate(), diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 450706e5b8d..56c3f3f5c03 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -334,6 +334,7 @@ const EXPECTED_STAGE1_FEATURES = [ 'session_list', 'session_info', 'session_source_metadata', + 'session_side_task', 'session_prompt', 'session_cancel', 'session_events', diff --git a/packages/cli/src/serve/server/telemetry-catalog.test.ts b/packages/cli/src/serve/server/telemetry-catalog.test.ts index cdd457c6463..654b8cb8139 100644 --- a/packages/cli/src/serve/server/telemetry-catalog.test.ts +++ b/packages/cli/src/serve/server/telemetry-catalog.test.ts @@ -98,7 +98,7 @@ describe('legacy session telemetry route drift guard', () => { .map(({ method, path }) => `${method} ${path}`) .sort(); - expect(registered).toHaveLength(50); + expect(registered).toHaveLength(51); expect(registered).toEqual(catalog); }); }); diff --git a/packages/cli/src/serve/server/telemetry.test.ts b/packages/cli/src/serve/server/telemetry.test.ts index 8e04d6c7c7b..76a4284f61d 100644 --- a/packages/cli/src/serve/server/telemetry.test.ts +++ b/packages/cli/src/serve/server/telemetry.test.ts @@ -794,17 +794,17 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => { }); describe('legacy session telemetry route catalog', () => { - it('contains 50 unique routes with the audited 43/7 attribution split', () => { + it('contains 51 unique routes with the audited 44/7 attribution split', () => { const keys = legacySessionTelemetryRoutes.map( ({ method, path }) => `${method} ${path}`, ); - expect(keys).toHaveLength(50); - expect(new Set(keys).size).toBe(50); + expect(keys).toHaveLength(51); + expect(new Set(keys).size).toBe(51); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'handler_resolved', ), - ).toHaveLength(43); + ).toHaveLength(44); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'pre_resolved', diff --git a/packages/cli/src/serve/server/telemetry.ts b/packages/cli/src/serve/server/telemetry.ts index 7e5bdc639c8..071b4efef7f 100644 --- a/packages/cli/src/serve/server/telemetry.ts +++ b/packages/cli/src/serve/server/telemetry.ts @@ -59,6 +59,12 @@ export const legacySessionTelemetryRoutes = [ attribution: 'handler_resolved', route: 'POST /session/:id/fork', }, + { + method: 'POST', + path: '/session/:id/side-task', + attribution: 'handler_resolved', + route: 'POST /session/:id/side-task', + }, { method: 'POST', path: '/session/:id/cd', diff --git a/packages/cli/src/serve/virtual-subagent-sessions.test.ts b/packages/cli/src/serve/virtual-subagent-sessions.test.ts index 4222c056d2c..028f2797b47 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.test.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.test.ts @@ -88,6 +88,46 @@ describe('VirtualSubagentSessions', () => { ).toThrow('valid id parts'); }); + it('resolves an out-of-band fork by agent task id', async () => { + const runtime = { + workspaceId: 'workspace-1', + workspaceCwd: '/workspace', + env: { mode: 'parent-process', overlayKeys: [] }, + bridge: { + getSessionTasksStatus: async () => ({ + v: 1 as const, + sessionId: 'parent-session', + now: Date.now(), + tasks: [ + { + kind: 'agent' as const, + id: 'fork-agent-1', + label: 'Review current changes', + description: 'Review current changes', + status: 'running' as const, + startTime: Date.now(), + runtimeMs: 1, + outputFile: '/tmp/fork-agent-1.jsonl', + isBackgrounded: true, + }, + ], + }), + }, + } as unknown as WorkspaceRuntime; + + const resolved = await new VirtualSubagentSessions().resolve( + runtime, + 'parent-session', + 'fork-agent-1', + ); + + expect(resolved).toMatchObject({ + taskId: 'fork-agent-1', + title: 'Review current changes', + status: 'running', + }); + }); + it('resolves, fully loads, and independently streams an agent transcript', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-subagent-')); tempDirs.push(dir); diff --git a/packages/cli/src/serve/virtual-subagent-sessions.ts b/packages/cli/src/serve/virtual-subagent-sessions.ts index 4b7c4b7cf61..e8cfaddc548 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.ts @@ -903,6 +903,9 @@ export class VirtualSubagentSessions { runtime, parentSessionId, (candidate) => + // /fork has no parent transcript tool call, so its task ID is the + // stable reference used by Web Shell. + candidate.id === toolCallId || candidate.toolUseId === toolCallId || candidate.id.endsWith(`-${toolCallId}`), ); diff --git a/packages/core/src/services/session-transcript-reader.test.ts b/packages/core/src/services/session-transcript-reader.test.ts index 52e2fedaea4..ba71c01e800 100644 --- a/packages/core/src/services/session-transcript-reader.test.ts +++ b/packages/core/src/services/session-transcript-reader.test.ts @@ -494,6 +494,51 @@ describe('SessionTranscriptReader', () => { expect(page.nextCursorState).toBeUndefined(); }); + it('does not page into inherited side-task context', async () => { + const inheritedUser = { + ...record('parent-u1', 'source', 'parent prompt'), + forkedFrom: { + sessionId: 'parent-session', + messageUuid: 'parent-u1', + }, + }; + const inheritedAssistant = { + ...record('parent-a1', 'parent-u1', 'parent answer'), + forkedFrom: { + sessionId: 'parent-session', + messageUuid: 'parent-a1', + }, + }; + const sessionSource = { + ...record('source', null, 'session source'), + type: 'system' as const, + subtype: 'session_source' as const, + systemPayload: { + sourceType: 'side_task', + sourceId: 'parent-session', + }, + }; + await writeRecords([ + sessionSource, + inheritedUser, + inheritedAssistant, + record('side-u1', 'parent-a1', 'side prompt'), + record('side-a1', 'side-u1', 'side answer'), + ]); + + const page = await new SessionTranscriptReader(workspaceDir).readPage( + sessionId, + { direction: 'backward', limit: 100 }, + ); + + expect(page.records.map((item) => item.uuid)).toEqual([ + 'source', + 'side-u1', + 'side-a1', + ]); + expect(page.hasMore).toBe(false); + }); + it('keeps backward pages within a normal user turn boundary', async () => { const toolCall = record('a-tool', 'u1', 'call tool'); const toolResult = { @@ -1178,7 +1223,6 @@ describe('SessionTranscriptReader', () => { }); it('pages backward through records without a normal user turn start', async () => { - await writeRecords([ record('a1', null, 'orphan assistant reply'), record('u1', 'a1', 'second prompt'), diff --git a/packages/core/src/services/session-transcript-reader.ts b/packages/core/src/services/session-transcript-reader.ts index 0fcf41c9340..179d258d34c 100644 --- a/packages/core/src/services/session-transcript-reader.ts +++ b/packages/core/src/services/session-transcript-reader.ts @@ -122,6 +122,7 @@ interface UuidIndexEntry { parentUuid: string | null; type: ChatRecord['type']; subtype?: TranscriptRecordInput['subtype']; + inherited: boolean; segments: RecordSegment[]; } @@ -837,6 +838,7 @@ async function buildIndex(params: { let sequence = 0; let leafUuid: string | undefined; let startTime: string | undefined; + let sideTaskSourceUuid: string | undefined; await forEachLineInSnapshot( filePath, @@ -850,6 +852,14 @@ async function buildIndex(params: { if (!record || !isTranscriptConversationRecord(record)) { continue; } + if ( + record.type === 'system' && + record.subtype === 'session_source' && + isObjectRecord(record.systemPayload) && + record.systemPayload['sourceType'] === 'side_task' + ) { + sideTaskSourceUuid = record.uuid; + } if (record.timestamp) startTime ??= record.timestamp; leafUuid = record.uuid; const existing = byUuid.get(record.uuid); @@ -869,6 +879,7 @@ async function buildIndex(params: { ...(record.subtype !== undefined ? { subtype: record.subtype } : {}), + inherited: record.forkedFrom !== undefined, segments: [segment], }); } @@ -896,7 +907,15 @@ async function buildIndex(params: { } : undefined; }); - const activeUuids = [...chain.uuids]; + const sourceBoundary = sideTaskSourceUuid + ? chain.uuids.indexOf(sideTaskSourceUuid) + : -1; + const activeUuids = + sourceBoundary >= 0 + ? chain.uuids + .slice(sourceBoundary) + .filter((uuid) => byUuid.get(uuid)?.inherited !== true) + : [...chain.uuids]; const goalStatePositions: number[] = []; for (let position = 0; position < activeUuids.length; position++) { const uuid = activeUuids[position]!; diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index cf6f1cf8055..a5024d6f020 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -3093,6 +3093,69 @@ describe('SessionService', () => { expect(srcLines.every((r) => !r.forkedFrom)).toBe(true); }); + it('writes source metadata and drops the inherited title for sourced forks', async () => { + const oldId = '10101010-1010-1010-1010-101010101010'; + const newId = '20202020-2020-2020-2020-202020202020'; + const { file, lines } = seedSession(oldId); + fs.writeFileSync( + file, + [ + ...lines, + { + uuid: 'title-1', + parentUuid: 'u2', + sessionId: oldId, + type: 'system', + subtype: 'custom_title', + timestamp: '2026-04-22T00:00:02.000Z', + cwd, + version: 'test', + systemPayload: { + customTitle: 'Parent title', + titleSource: 'manual', + }, + }, + ] + .map((line) => JSON.stringify(line)) + .join('\n') + '\n', + ); + + const result = await service.forkSession(oldId, newId, { + source: { + sourceType: 'side_task', + sourceId: oldId, + }, + }); + const written = fs + .readFileSync(result.filePath, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + + expect(written[0]).toMatchObject({ + parentUuid: null, + sessionId: newId, + type: 'system', + subtype: 'session_source', + cwd, + version: 'test', + systemPayload: { + sourceType: 'side_task', + sourceId: oldId, + }, + }); + expect(written.some((record) => record.subtype === 'custom_title')).toBe( + false, + ); + expect(written[1]).toMatchObject({ + parentUuid: written[0].uuid, + forkedFrom: { + sessionId: oldId, + messageUuid: 'u1', + }, + }); + }); + it('copies artifact side records from the active branch', async () => { const oldId = '71717171-7171-7171-7171-717171717171'; const newId = '81818181-8181-8181-8181-818181818181'; diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index ca7a7ff38ea..e5088cb1cfc 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -1666,6 +1666,9 @@ export class SessionService { async forkSession( sourceSessionId: string, newSessionId: string, + options: { + source?: { sourceType: string; sourceId?: string }; + } = {}, ): Promise<{ filePath: string; copiedCount: number }> { if (!SESSION_FILE_PATTERN.test(`${sourceSessionId}.jsonl`)) { throw new Error(`Invalid source sessionId: ${sourceSessionId}`); @@ -1709,7 +1712,8 @@ export class SessionService { !( record.type === 'system' && (record.subtype === 'parent_session' || - record.subtype === 'session_source') + record.subtype === 'session_source' || + (options.source && record.subtype === 'custom_title')) ), ); if (sourceRecords.length === 0) { @@ -1719,32 +1723,53 @@ export class SessionService { // Rebuild the parentUuid chain in active-history order so the fork is a // clean linear descendant. `forkedFrom` captures the origin of each // message. - let prevUuid: string | null = null; + const sourceRecord: ChatRecord | undefined = options.source + ? { + uuid: randomUUID(), + parentUuid: null, + sessionId: newSessionId, + timestamp: new Date().toISOString(), + type: 'system', + subtype: 'session_source', + cwd: this.projectRoot, + version: records[0].version, + systemPayload: { + sourceType: options.source.sourceType, + ...(options.source.sourceId !== undefined + ? { sourceId: options.source.sourceId } + : {}), + }, + } + : undefined; + let prevUuid: string | null = sourceRecord?.uuid ?? null; const remappedArtifactIds = new Map(); - const forked: ChatRecord[] = sourceRecords.map((record) => { - const isArtifactRecord = isSessionArtifactRecord(record); - const systemPayload = remapSystemPayloadForFork( - record, - sourceSessionId, - newSessionId, - remappedArtifactIds, - ); - const next: ChatRecord = { - ...record, - sessionId: newSessionId, - cwd: this.projectRoot, - systemPayload, - parentUuid: isArtifactRecord ? record.parentUuid : prevUuid, - forkedFrom: { - sessionId: sourceSessionId, - messageUuid: record.uuid, - }, - }; - if (!isArtifactRecord) { - prevUuid = record.uuid; - } - return next; - }); + const forked: ChatRecord[] = [ + ...(sourceRecord ? [sourceRecord] : []), + ...sourceRecords.map((record) => { + const isArtifactRecord = isSessionArtifactRecord(record); + const systemPayload = remapSystemPayloadForFork( + record, + sourceSessionId, + newSessionId, + remappedArtifactIds, + ); + const next: ChatRecord = { + ...record, + sessionId: newSessionId, + cwd: this.projectRoot, + systemPayload, + parentUuid: isArtifactRecord ? record.parentUuid : prevUuid, + forkedFrom: { + sessionId: sourceSessionId, + messageUuid: record.uuid, + }, + }; + if (!isArtifactRecord) { + prevUuid = record.uuid; + } + return next; + }), + ]; // File-history snapshots are side-channel system records used by /rewind. // They may not sit on the active message leaf copied above, and copied diff --git a/packages/core/src/utils/transcript-records.ts b/packages/core/src/utils/transcript-records.ts index 95d7be556aa..cf4234f199f 100644 --- a/packages/core/src/utils/transcript-records.ts +++ b/packages/core/src/utils/transcript-records.ts @@ -35,6 +35,10 @@ export interface TranscriptRecordInput { readonly usageMetadata?: unknown; readonly toolCallResult?: unknown; readonly systemPayload?: unknown; + readonly forkedFrom?: { + readonly sessionId: string; + readonly messageUuid: string; + }; } export interface TranscriptReplayGapInput { diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 883edf3fd42..362b738aa28 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -77,7 +77,8 @@ const rootDir = join(__dirname, '..'); // DaemonSessionClient (#6930). // Bumped from 177KB to 178KB for workspace file byte-cursor paging after // merging the workspace pairing approval SDK surface. -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 178 * 1024; +// Bumped from 178KB to 184KB for side-task session APIs and source metadata. +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 184 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so // it's larger than the default barrel — but still budgeted so a future PR can't diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index a27774c03f6..d43b4bee1d9 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -33,6 +33,7 @@ import type { DaemonSessionContextUsageStatus, BranchSessionRequest, DaemonBranchedSession, + DaemonSideTaskSession, DaemonForkSessionResult, DaemonRestoredSession, DaemonSession, @@ -41,6 +42,7 @@ import type { DaemonSessionExportResult, DaemonSessionTranscriptPage, DaemonSessionTranscriptPageOptions, + SideTaskSessionRequest, DaemonSubagentSessionResolution, DaemonSessionGroup, DaemonSessionGroupCatalog, @@ -2463,7 +2465,9 @@ export class DaemonClient { { method: 'POST', headers: this.headers({ 'Content-Type': 'application/json' }, clientId), - body: JSON.stringify({ name: req.name }), + body: JSON.stringify({ + ...(req.name !== undefined ? { name: req.name } : {}), + }), }, async (res) => { if (!res.ok) { @@ -2474,6 +2478,29 @@ export class DaemonClient { ); } + async createSideTaskSession( + sessionId: string, + req: SideTaskSessionRequest = {}, + clientId?: string, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/session/${urlEncode(sessionId)}/side-task`, + { + method: 'POST', + headers: this.headers({ 'Content-Type': 'application/json' }, clientId), + body: JSON.stringify({ + ...(req.name !== undefined ? { name: req.name } : {}), + }), + }, + async (res) => { + if (!res.ok) { + throw await this.failOnError(res, 'POST /session/:id/side-task'); + } + return (await res.json()) as DaemonSideTaskSession; + }, + ); + } + async forkSession( sessionId: string, req: ForkSessionRequest, diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 4a0be90a017..249b1aa3f70 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -470,8 +470,10 @@ export type { DaemonProtocolVersions, BranchSessionRequest, DaemonBranchedSession, + DaemonSideTaskSession, DaemonForkSessionResult, ForkSessionRequest, + SideTaskSessionRequest, DaemonRestoredSession, DaemonSession, DaemonSessionArchiveState, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 963cde77635..db61813c335 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -838,6 +838,15 @@ export interface DaemonBranchedSession extends DaemonRestoredSession { forkedFrom: { sessionId: string; displayName: string }; } +export interface SideTaskSessionRequest { + name?: string; +} + +export interface DaemonSideTaskSession extends DaemonRestoredSession { + displayName: string; + parentSessionId: string; +} + export interface ForkSessionRequest { directive: string; } diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 8990ba0ccac..4fa87e121f4 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -2699,6 +2699,38 @@ describe('DaemonClient', () => { }); }); + describe('createSideTaskSession', () => { + it('uses the dedicated side-task endpoint', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(201, { + sessionId: 'side-1', + workspaceCwd: '/work/a', + attached: false, + state: {}, + displayName: 'Side task', + parentSessionId: 'main-1', + sourceType: 'side_task', + sourceId: 'main-1', + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + await client.createSideTaskSession( + 'main-1', + { + name: 'Side task', + }, + 'side-task-client', + ); + + expect(calls[0]?.url).toBe('http://daemon/session/main-1/side-task'); + expect(calls[0]?.headers['x-qwen-client-id']).toBe('side-task-client'); + expect(JSON.parse(calls[0]!.body!)).toEqual({ + name: 'Side task', + }); + }); + }); + describe('cancel', () => { it('POSTs /cancel and tolerates 204', async () => { const { fetch, calls } = recordingFetch( diff --git a/packages/web-shell/README.md b/packages/web-shell/README.md index f2b3c175b52..53f1468216a 100644 --- a/packages/web-shell/README.md +++ b/packages/web-shell/README.md @@ -368,5 +368,7 @@ Chart/Data 控件、无数据提示和错误提示默认跟随 WebShell 语言 | `/init` | ACP 透传 | 分析项目并创建定制的 `QWEN.md`。 | | `/stats` | ACP 透传 | 显示统计信息,包含 `model`、`tools` 子命令。 | | `/summary` | ACP 透传 | 生成当前会话摘要。 | -| `/tasks` | ACP 透传 | 列出后台任务。 | +| `/tasks` | 本地实现 | 打开环境信息面板并刷新后台任务。 | +| `/btw` | 本地实现 + ACP 透传 | daemon 支持侧边任务时新建侧边任务;否则发送一个不影响主对话的侧边问题。 | +| `/fork` | 本地实现 + ACP 透传 | 启动共享当前上下文的后台智能体。 | | `/insight` | ACP 透传 | 查看 insight 相关信息。 | diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index 5a0edd1b803..a6e9c0ae5ea 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -59,6 +59,49 @@ overflow: hidden; } +.contextShell { + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + flex-direction: column; + overflow: hidden; +} + +.chatHeaderRow { + display: flex; + min-width: 0; + flex: 0 0 auto; + align-items: center; + border-bottom: 1px solid var(--border); + background: var(--background); +} + +.customChatHeader { + min-width: 0; + flex: 1 1 auto; +} + +.contextBody { + position: relative; + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.contextBodyWithEnvironmentPanel .chatPane, +.contextBodyWithEnvironmentPanel .content { + overflow: visible; +} + +.contextBodyWithEnvironmentPanel [data-web-shell-message-list] { + box-sizing: border-box; + width: calc(100% + 332px); + padding-right: 356px; +} + .chatPane { flex: 1 1 auto; min-width: 0; @@ -760,10 +803,6 @@ margin-bottom: 8px; } -.chatHeader { - flex-shrink: 0; -} - .customFooter { flex-shrink: 0; } diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 3050186dc00..9ff32908af5 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -1,10 +1,11 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, createRef, type CSSProperties } from 'react'; +import { act, createRef, type CSSProperties, type ReactNode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import type { DaemonInputAnnotation, DaemonSessionMonitorTaskStatus, + DaemonSessionShellTaskStatus, DaemonSettingDescriptor, DaemonWorkspaceGitStatus, } from '@qwen-code/sdk/daemon'; @@ -80,6 +81,8 @@ type ChatEditorTestProps = { gitBranch?: string; gitStatus?: DaemonWorkspaceGitStatus; onOpenGitDiff?: () => void; + visibleToolbarActions?: string[]; + onChatWidthModeChange?: (mode: '1000' | 'wide') => void; }; type AddWorkspaceDialogTestProps = { @@ -164,7 +167,10 @@ const { workspaceProviders: qualifiedWorkspaceProviders, setWorkspaceSetting: qualifiedSetWorkspaceSetting, })), - sessionStatus: vi.fn(() => Promise.resolve({})), + sessionStatus: vi.fn(() => + Promise.resolve({ workspaceCwd: '/tmp/project' }), + ), + listWorkspaceSessions: vi.fn(() => Promise.resolve([])), }; const settingsSetValue = vi.fn().mockResolvedValue(undefined); return { @@ -254,6 +260,7 @@ const { messages: [] as unknown[], chatEditorRenderCount: 0, latestChatEditorProps: null as ChatEditorTestProps | null, + latestStatusBarTasks: null as DaemonSessionMonitorTaskStatus[] | null, latestMessageListProps: null as { failedPromptMessageId?: string; onRetryFailedPrompt?: () => void; @@ -316,6 +323,7 @@ const { vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ DAEMON_APPROVAL_MODES: ['default', 'plan', 'auto-edit', 'auto', 'yolo'], + DaemonSessionProvider: ({ children }: { children: ReactNode }) => children, useActions: () => mockSessionActions, useConnection: () => mockConnection, useDaemonFollowupSuggestion: () => ({ @@ -817,7 +825,15 @@ function mockComponent(path: string, exportName: string): void { }); } -mockComponent('./components/StatusBar', 'StatusBar'); +vi.doMock('./components/StatusBar', async () => { + const React = await import('react'); + return { + StatusBar: (props: { tasks?: DaemonSessionMonitorTaskStatus[] }) => { + testState.latestStatusBarTasks = props.tasks ?? []; + return React.createElement('div'); + }, + }; +}); vi.doMock('./components/StreamingStatus', async () => { const React = await import('react'); return { @@ -858,6 +874,11 @@ vi.doMock('./components/SplitView', async () => { workspaceActions: unknown, ) => void; onRightPanelOpen?: (request: unknown) => void; + onOpenMonitor?: ( + task: DaemonSessionMonitorTaskStatus, + sessionId: string, + sessionActions: typeof mockSessionActions, + ) => void; renderPaneHeaderActions?: (info: { sessionId: string; workspaceCwd?: string; @@ -966,6 +987,32 @@ vi.doMock('./components/SplitView', async () => { }, 'open artifact', ), + React.createElement( + 'button', + { + 'data-testid': 'split-open-monitor', + type: 'button', + onClick: () => + props.onOpenMonitor?.( + { + kind: 'monitor', + id: 'monitor-1', + label: 'monitor-label', + description: 'watch pane logs', + status: 'running', + startTime: 1, + runtimeMs: 10, + command: 'tail -f pane.log', + eventCount: 1, + droppedLines: 0, + toolUseId: 'monitor-call', + }, + 'pane-session', + mockSessionActions, + ), + }, + 'open monitor', + ), React.createElement( 'button', { @@ -1092,6 +1139,12 @@ vi.doMock('./components/messages/TasksStatusMessage', async () => { return React.createElement('div'); }, MonitorTaskDetail: () => React.createElement('div'), + ShellTaskDetail: (props: { task: DaemonSessionShellTaskStatus }) => + React.createElement( + 'div', + null, + `${props.task.command} ${props.task.cwd}`, + ), }; }); vi.doMock('./monitorDetailsContext', async () => { @@ -1113,8 +1166,13 @@ vi.doMock('./monitorDetailsContext', async () => { mockComponent('./components/messages/BtwMessage', 'BtwMessage'); mockComponent('./components/QueuedPromptDisplay', 'QueuedPromptDisplay'); -const { App, getBackgroundTaskActivityKey, mergeMonitorTaskSnapshot } = - await import('./App'); +const { + App, + getTaskActivityKey, + getEnvironmentAgentTasks, + mergeMonitorTaskSnapshot, + mergeSideTaskCatalog, +} = await import('./App'); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } @@ -1122,8 +1180,64 @@ const { App, getBackgroundTaskActivityKey, mergeMonitorTaskSnapshot } = const mounted: Array<{ root: Root; container: HTMLElement }> = []; -describe('background task activity key', () => { - it('includes background shells and monitors but excludes background agents', () => { +describe('mergeSideTaskCatalog', () => { + const listed = (sessionId: string) => ({ sessionId, title: sessionId }); + + it('replaces the catalog when the parent session changes', () => { + const next = mergeSideTaskCatalog( + { parentSessionId: 'parent-a', items: [listed('stale')], loaded: true }, + 'parent-b', + [listed('b1')], + new Set(['stale']), + ); + expect(next).toEqual({ + parentSessionId: 'parent-b', + items: [listed('b1')], + loaded: true, + }); + }); + + it('treats a successful listing as authoritative for confirmed items', () => { + const next = mergeSideTaskCatalog( + { + parentSessionId: 'parent-a', + items: [listed('kept'), listed('deleted-elsewhere')], + loaded: true, + }, + 'parent-a', + [listed('kept')], + new Set(), + ); + expect(next.items.map((item) => item.sessionId)).toEqual(['kept']); + }); + + it('keeps a locally created draft the listing has not echoed yet', () => { + const next = mergeSideTaskCatalog( + { + parentSessionId: 'parent-a', + items: [listed('kept'), listed('draft')], + loaded: true, + }, + 'parent-a', + [listed('kept')], + new Set(['draft']), + ); + expect(next.items.map((item) => item.sessionId)).toEqual(['kept', 'draft']); + }); + + it('does not duplicate a draft once the listing confirms it', () => { + const next = mergeSideTaskCatalog( + { parentSessionId: 'parent-a', items: [listed('draft')], loaded: true }, + 'parent-a', + [listed('draft')], + new Set(['draft']), + ); + expect(next.items.map((item) => item.sessionId)).toEqual(['draft']); + }); +}); + +describe('task activity key', () => { + it('includes background shells in any tool-call state', () => { const messages = [ { id: 'tools', @@ -1139,20 +1253,40 @@ describe('background task activity key', () => { callId: 'agent-call', toolName: 'agent', status: 'pending', - args: { run_in_background: true }, + args: {}, + subTools: [ + { + callId: 'nested-shell', + toolName: 'run_shell_command', + status: 'completed', + args: { is_background: true }, + }, + ], + }, + { + callId: 'foreground-agent', + toolName: 'agent', + status: 'in_progress', + args: { run_in_background: false }, + }, + { + callId: 'completed-shell', + toolName: 'shell', + status: 'completed', + args: { is_background: true }, }, { callId: 'monitor-call', toolName: 'monitor', status: 'completed', - args: {}, + args: { command: 'npm run dev --watch' }, }, ], }, ] satisfies Message[]; - expect(getBackgroundTaskActivityKey(messages)).toBe( - 'shell-call:in_progress|monitor-call:completed', + expect(getTaskActivityKey(messages)).toBe( + 'shell-call:in_progress|agent-call:pending|nested-shell:completed|completed-shell:completed|monitor-call:completed', ); }); @@ -1188,26 +1322,7 @@ describe('background task activity key', () => { expect(mockSessionActions.getTasks).not.toHaveBeenCalled(); }); - it('restarts shared task polling when a monitor opens from the task dialog', async () => { - const task: DaemonSessionMonitorTaskStatus = { - kind: 'monitor', - id: 'monitor-1', - label: 'monitor-label', - description: 'watch server log', - status: 'running', - startTime: 1_000, - runtimeMs: 5_000, - command: 'tail -f server.log', - eventCount: 3, - lastEventTime: 5_000, - droppedLines: 0, - }; - mockSessionActions.getTasks.mockResolvedValue({ - v: 1, - sessionId: 'session-1', - now: 6_000, - tasks: [task], - }); + it('opens environment information for /tasks without a dialog', async () => { const { container } = renderApp(); await flush(); expect(testState.latestBackgroundTasksRefreshTrigger).toBe(0); @@ -1215,14 +1330,14 @@ describe('background task activity key', () => { testState.prompt = '/tasks'; await clickSubmit(container); await flush(); - expect(testState.latestTasksStatusProps?.onOpenMonitor).toBeTypeOf( - 'function', - ); - - act(() => { - testState.latestTasksStatusProps?.onOpenMonitor?.(task); - }); + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).not.toBeNull(); + expect(testState.latestTasksStatusProps).toBeNull(); + expect(mockSessionActions.getTasks).not.toHaveBeenCalled(); expect(testState.latestBackgroundTasksRefreshTrigger).toBe(1); }); @@ -1269,6 +1384,23 @@ describe('background task activity key', () => { container.querySelector('button[title="watch server log"]'), ).not.toBeNull(); expect(testState.latestBackgroundTasksRefreshTrigger).toBe(1); + + act(() => { + container + .querySelector( + 'button[aria-label="Toggle environment information"]', + ) + ?.click(); + }); + + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).not.toBeNull(); + expect( + container.querySelector('button[title="watch server log"]'), + ).not.toBeNull(); }); it('merges a reopened monitor into its existing tab', async () => { @@ -1286,24 +1418,22 @@ describe('background task activity key', () => { lastEventTime: 5_000, droppedLines: 0, }; - mockSessionActions.getTasks.mockResolvedValue({ + mockConnection.capabilities.features = ['session_monitor_tool_correlation']; + mockSessionActions.getTasks.mockResolvedValueOnce({ v: 1, sessionId: 'session-1', now: 6_000, - tasks: [stopped], + tasks: [{ ...stopped, toolUseId: 'monitor-call' }], }); const { container } = renderApp(); await flush(); - testState.prompt = '/tasks'; - await clickSubmit(container); - await flush(); - expect(testState.latestTasksStatusProps?.onOpenMonitor).toBeTypeOf( - 'function', - ); - - act(() => { - testState.latestTasksStatusProps?.onOpenMonitor?.(stopped); + await act(async () => { + await testState.latestMonitorDetailsOnOpen?.({ + callId: 'monitor-call', + toolName: 'monitor', + status: 'completed', + }); }); await flush(); @@ -1325,8 +1455,18 @@ describe('background task activity key', () => { lastEventTime: 5_000, droppedLines: 0, }; - act(() => { - testState.latestTasksStatusProps?.onOpenMonitor?.(running); + mockSessionActions.getTasks.mockResolvedValueOnce({ + v: 1, + sessionId: 'session-1', + now: 7_000, + tasks: [{ ...running, toolUseId: 'monitor-call' }], + }); + await act(async () => { + await testState.latestMonitorDetailsOnOpen?.({ + callId: 'monitor-call', + toolName: 'monitor', + status: 'completed', + }); }); await flush(); @@ -1464,98 +1604,492 @@ describe('background task activity key', () => { }); }); -function renderApp(props: React.ComponentProps = {}): { - container: HTMLElement; - rerender: (nextProps?: React.ComponentProps) => void; - unmount: () => void; -} { - const container = document.createElement('div'); - document.body.appendChild(container); - const root = createRoot(container); - const doRender = (nextProps: React.ComponentProps = props) => { - act(() => { - root.render(); - }); - }; - doRender(props); - const entry = { root, container }; - mounted.push(entry); - const unmount = () => { - const index = mounted.indexOf(entry); - if (index >= 0) mounted.splice(index, 1); - act(() => root.unmount()); - container.remove(); - }; - return { container, rerender: doRender, unmount }; -} +describe('environment agent tasks', () => { + it('keeps a completed foreground agent from the session transcript', () => { + const messages = [ + { + id: 'tools', + role: 'tool_group', + tools: [ + { + callId: 'agent-call', + toolName: 'agent', + title: 'Agent: Explore code', + status: 'completed', + args: { + description: 'Explore code', + run_in_background: false, + }, + rawOutput: { + type: 'task_execution', + status: 'completed', + subagentColor: 'purple', + }, + }, + ], + }, + ] satisfies Message[]; -async function flush(): Promise { - await act(async () => { - await Promise.resolve(); + expect(getEnvironmentAgentTasks(messages, [])).toMatchObject([ + { + id: 'agent-call', + label: 'Explore code', + status: 'completed', + color: 'purple', + isBackgrounded: false, + toolUseId: 'agent-call', + }, + ]); }); -} -async function clickSubmit(container: HTMLElement): Promise { - await act(async () => { - container - .querySelector('[data-testid="submit"]') - ?.click(); - await Promise.resolve(); - }); -} + it('uses the prompt for a generic Agent title and ignores nested tools', () => { + const messages = [ + { + id: 'tools', + role: 'tool_group', + tools: [ + { + callId: 'agent-call', + toolName: 'agent', + title: 'Agent', + status: 'in_progress', + args: { + prompt: '查询杭州明天天气', + run_in_background: true, + }, + subTools: [ + { + callId: 'search-call', + toolName: 'web_search', + status: 'completed', + subContent: 'result', + }, + ], + }, + ], + }, + ] satisfies Message[]; -function deferred(): { - promise: Promise; - resolve: (value?: T | PromiseLike) => void; - reject: (reason?: unknown) => void; -} { - let resolve!: (value?: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = (value) => res(value as T | PromiseLike); - reject = rej; + expect(getEnvironmentAgentTasks(messages, [])).toMatchObject([ + { + id: 'agent-call', + label: '查询杭州明天天气', + }, + ]); }); - return { promise, resolve, reject }; -} -// A transcript block shaped like extractPendingPermission() expects. Defaults to -// a non-AskUserQuestion tool (→ pendingToolApproval); pass toolName -// 'ask_user_question' to exercise the pendingAskUserApproval branch instead. -// isAskUserPermission() classifies by rawInput.questions being a non-empty -// array, so the ask-user variant carries a toolCall.input.questions payload -// (getPermissionRawInput reads toolCall.input) — a bare toolName isn't enough. -function makePendingPermissionBlock( - overrides: { resolved?: boolean; toolName?: string } = {}, -): unknown { - const toolName = overrides.toolName ?? 'run_shell_command'; - const isAskUser = toolName === 'ask_user_question'; - return { - kind: 'permission', - resolved: overrides.resolved ?? false, - requestId: 'req-1', - sessionId: 'session-1', - title: 'Run ls', - toolCall: { - toolCallId: 'tc-1', - kind: isAskUser ? 'other' : 'execute', - _meta: { toolName }, - ...(isAskUser - ? { input: { questions: [{ question: 'Pick one', options: [] }] } } - : {}), - }, - options: [ - { optionId: 'proceed_once', label: 'Allow', raw: {} }, - { optionId: 'cancel', label: 'Reject', raw: {} }, - ], - }; -} + it('keeps the transcript color when a live agent task is available', () => { + const messages = [ + { + id: 'tools', + role: 'tool_group', + tools: [ + { + callId: 'agent-call', + toolName: 'agent', + title: 'Agent: Review code', + status: 'in_progress', + args: { subagent_type: 'reviewer' }, + rawOutput: { + type: 'task_execution', + subagentColor: 'purple', + }, + }, + ], + }, + ] satisfies Message[]; + const liveTask = { + kind: 'agent' as const, + id: 'agent-task', + label: 'reviewer: Review code', + description: 'Review code', + subagentType: 'reviewer', + status: 'running' as const, + startTime: 1, + runtimeMs: 1, + isBackgrounded: true, + toolUseId: 'agent-call', + }; -beforeEach(() => { - // Split persistence uses sessionStorage; clear it so one test's split doesn't - // auto-restore into the next test's App mount. - sessionStorage.clear(); - Object.defineProperty(window, 'matchMedia', { - configurable: true, + expect(getEnvironmentAgentTasks(messages, [liveTask])).toMatchObject([ + { + id: 'agent-task', + color: 'purple', + }, + ]); + }); + + it('deduplicates a live agent by the task id recorded in the message stream', () => { + const messages = [ + { + id: 'tools', + role: 'tool_group', + tools: [ + { + callId: 'call-agent-1', + toolName: 'agent', + title: 'Agent: Review code', + status: 'in_progress', + args: { subagent_type: 'shadcn-ux' }, + }, + ], + }, + { + id: 'agent-notification', + role: 'system', + content: 'background agent completed', + variant: 'info', + source: 'background_notification', + data: { + kind: 'agent', + taskId: 'agent-runtime-id', + toolUseId: 'call-agent-1', + status: 'completed', + }, + }, + ] satisfies Message[]; + const liveTask = { + kind: 'agent' as const, + id: 'agent-runtime-id', + label: 'shadcn-ux: Review code', + description: 'Review code', + subagentType: 'shadcn-ux', + status: 'completed' as const, + startTime: 1, + runtimeMs: 1, + isBackgrounded: true, + }; + + expect(getEnvironmentAgentTasks(messages, [liveTask])).toMatchObject([ + { + id: 'agent-runtime-id', + label: 'Review code', + status: 'completed', + }, + ]); + }); + + it('deduplicates a completed background agent whose live task lost its toolUseId', () => { + const messages = [ + { + id: 'tools', + role: 'tool_group', + tools: [ + { + callId: 'call-agent-1', + toolName: 'agent', + title: 'Agent: Review code', + status: 'completed', + args: { + description: 'Review code', + prompt: 'Review the diff for bugs', + subagent_type: 'general-purpose', + run_in_background: true, + }, + rawOutput: { + type: 'task_execution', + status: 'completed', + }, + }, + ], + }, + { + id: 'agent-notification', + role: 'system', + content: 'background agent completed', + variant: 'info', + source: 'background_notification', + data: { + kind: 'agent', + taskId: 'general-purpose-internal-1', + status: 'completed', + }, + }, + ] satisfies Message[]; + const liveTask = { + kind: 'agent' as const, + id: 'general-purpose-internal-1', + label: 'general-purpose: Review code', + description: 'Review code', + prompt: 'Review the diff for bugs', + subagentType: 'general-purpose', + status: 'completed' as const, + startTime: 1, + runtimeMs: 1, + isBackgrounded: true, + }; + + expect(getEnvironmentAgentTasks(messages, [liveTask])).toMatchObject([ + { + id: 'general-purpose-internal-1', + label: 'Review code', + status: 'completed', + }, + ]); + }); + + it('deduplicates a completed background agent with no toolUseId or prompt on the live task', () => { + const messages = [ + { + id: 'tools', + role: 'tool_group', + tools: [ + { + callId: 'call-agent-1', + toolName: 'agent', + title: 'Agent: Fix lint errors', + status: 'completed', + args: { + description: 'Fix lint errors', + prompt: 'Fix all lint errors in src/', + run_in_background: true, + }, + rawOutput: { + type: 'task_execution', + status: 'completed', + }, + }, + ], + }, + { + id: 'agent-notification', + role: 'system', + content: 'background agent completed', + variant: 'info', + source: 'background_notification', + data: { + kind: 'agent', + taskId: 'general-purpose-internal-2', + status: 'completed', + }, + }, + ] satisfies Message[]; + const liveTask = { + kind: 'agent' as const, + id: 'general-purpose-internal-2', + label: 'general-purpose: Fix lint errors', + description: 'Fix lint errors', + status: 'completed' as const, + startTime: 1, + runtimeMs: 1, + isBackgrounded: true, + }; + + expect(getEnvironmentAgentTasks(messages, [liveTask])).toMatchObject([ + { + id: 'general-purpose-internal-2', + label: 'Fix lint errors', + status: 'completed', + }, + ]); + }); + + it('does not collapse two agents that share a description when one is linked precisely', () => { + const messages = [ + { + id: 'tools', + role: 'tool_group', + tools: [ + { + callId: 'call-A', + toolName: 'agent', + title: 'Agent: Review code', + status: 'completed', + args: { description: 'Review code', run_in_background: true }, + rawOutput: { type: 'task_execution', status: 'completed' }, + }, + { + callId: 'call-B', + toolName: 'agent', + title: 'Agent: Review code', + status: 'completed', + args: { description: 'Review code', run_in_background: true }, + rawOutput: { type: 'task_execution', status: 'completed' }, + }, + ], + }, + ] satisfies Message[]; + // The precisely-linked task is listed first so a loose description fallback + // would steal it before reaching the orphaned one. + const linkedTask = { + kind: 'agent' as const, + id: 'task-B', + label: 'Review code', + description: 'Review code', + status: 'completed' as const, + startTime: 1, + runtimeMs: 1, + isBackgrounded: true, + toolUseId: 'call-B', + }; + const orphanTask = { + kind: 'agent' as const, + id: 'task-A', + label: 'Review code', + description: 'Review code', + status: 'completed' as const, + startTime: 1, + runtimeMs: 1, + isBackgrounded: true, + }; + + const result = getEnvironmentAgentTasks(messages, [linkedTask, orphanTask]); + expect(result).toHaveLength(2); + expect(result).toMatchObject([ + { id: 'task-A', description: 'Review code', status: 'completed' }, + { id: 'task-B', description: 'Review code', status: 'completed' }, + ]); + }); + + it('lists two precisely-linked agents that share a description once each', () => { + const messages = [ + { + id: 'tools', + role: 'tool_group', + tools: [ + { + callId: 'call-A', + toolName: 'agent', + title: 'Agent: Review code', + status: 'completed', + args: { description: 'Review code', run_in_background: true }, + rawOutput: { type: 'task_execution', status: 'completed' }, + }, + { + callId: 'call-B', + toolName: 'agent', + title: 'Agent: Review code', + status: 'completed', + args: { description: 'Review code', run_in_background: true }, + rawOutput: { type: 'task_execution', status: 'completed' }, + }, + ], + }, + ] satisfies Message[]; + const taskA = { + kind: 'agent' as const, + id: 'task-A', + label: 'Review code', + description: 'Review code', + status: 'completed' as const, + startTime: 1, + runtimeMs: 1, + isBackgrounded: true, + toolUseId: 'call-A', + }; + const taskB = { + kind: 'agent' as const, + id: 'task-B', + label: 'Review code', + description: 'Review code', + status: 'completed' as const, + startTime: 1, + runtimeMs: 1, + isBackgrounded: true, + toolUseId: 'call-B', + }; + + const result = getEnvironmentAgentTasks(messages, [taskA, taskB]); + expect(result).toHaveLength(2); + expect(result).toMatchObject([{ id: 'task-A' }, { id: 'task-B' }]); + }); +}); + +function renderApp(props: React.ComponentProps = {}): { + container: HTMLElement; + rerender: (nextProps?: React.ComponentProps) => void; + unmount: () => void; +} { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const doRender = (nextProps: React.ComponentProps = props) => { + act(() => { + root.render( + , + ); + }); + }; + doRender(props); + const entry = { root, container }; + mounted.push(entry); + const unmount = () => { + const index = mounted.indexOf(entry); + if (index >= 0) mounted.splice(index, 1); + act(() => root.unmount()); + container.remove(); + }; + return { container, rerender: doRender, unmount }; +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + }); +} + +async function clickSubmit(container: HTMLElement): Promise { + await act(async () => { + container + .querySelector('[data-testid="submit"]') + ?.click(); + await Promise.resolve(); + }); +} + +function deferred(): { + promise: Promise; + resolve: (value?: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value?: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = (value) => res(value as T | PromiseLike); + reject = rej; + }); + return { promise, resolve, reject }; +} + +// A transcript block shaped like extractPendingPermission() expects. Defaults to +// a non-AskUserQuestion tool (→ pendingToolApproval); pass toolName +// 'ask_user_question' to exercise the pendingAskUserApproval branch instead. +// isAskUserPermission() classifies by rawInput.questions being a non-empty +// array, so the ask-user variant carries a toolCall.input.questions payload +// (getPermissionRawInput reads toolCall.input) — a bare toolName isn't enough. +function makePendingPermissionBlock( + overrides: { resolved?: boolean; toolName?: string } = {}, +): unknown { + const toolName = overrides.toolName ?? 'run_shell_command'; + const isAskUser = toolName === 'ask_user_question'; + return { + kind: 'permission', + resolved: overrides.resolved ?? false, + requestId: 'req-1', + sessionId: 'session-1', + title: 'Run ls', + toolCall: { + toolCallId: 'tc-1', + kind: isAskUser ? 'other' : 'execute', + _meta: { toolName }, + ...(isAskUser + ? { input: { questions: [{ question: 'Pick one', options: [] }] } } + : {}), + }, + options: [ + { optionId: 'proceed_once', label: 'Allow', raw: {} }, + { optionId: 'cancel', label: 'Reject', raw: {} }, + ], + }; +} + +beforeEach(() => { + // Split persistence uses sessionStorage; clear it so one test's split doesn't + // auto-restore into the next test's App mount. + sessionStorage.clear(); + localStorage.removeItem('qwen-code-web-shell-chat-width'); + Object.defineProperty(window, 'matchMedia', { + configurable: true, // Query-aware: report a large screen (min-width matches) so the Session // Overview entry point is available, while keeping the mobile (max-width) // query false as the other tests expect. @@ -1604,6 +2138,12 @@ beforeEach(() => { }), })); mockWorkspace.client.workspaceById.mockClear(); + mockWorkspace.client.sessionStatus.mockReset(); + mockWorkspace.client.sessionStatus.mockResolvedValue({ + workspaceCwd: '/tmp/project', + }); + mockWorkspace.client.listWorkspaceSessions.mockReset(); + mockWorkspace.client.listWorkspaceSessions.mockResolvedValue([]); testState.prompt = 'hello'; testState.inputAnnotations = undefined; testState.promptImages = undefined; @@ -1612,6 +2152,7 @@ beforeEach(() => { testState.messages = []; testState.chatEditorRenderCount = 0; testState.latestChatEditorProps = null; + testState.latestStatusBarTasks = null; testState.latestMessageListProps = null; testState.latestAddWorkspaceDialogProps = null; testState.latestToolApprovalKeyboardActive = null; @@ -3505,77 +4046,929 @@ describe('App session callbacks', () => { await flush(); await act(async () => { - testState.latestChatEditorProps?.onSubmit('/model --voice'); + testState.latestChatEditorProps?.onSubmit('/model --voice'); + await Promise.resolve(); + }); + act(() => { + container + .querySelector('[data-testid="open-split-view"]') + ?.click(); + }); + await act(async () => { + providersResult.resolve(providerStatus); + await Promise.resolve(); + }); + await flush(); + + expect(container.querySelector('[data-testid="model-select"]')).toBeNull(); + }); + + it('does not open a pending command Voice picker after entering Settings', async () => { + mockConnection.workspaceCwd = '/work/secondary'; + mockWorkspace.capabilities = { + workspaceCwd: '/work/primary', + features: [ + 'workspace_qualified_voice', + 'workspace_qualified_rest_core', + 'workspace_settings', + ], + workspaces: [ + { + id: 'primary', + cwd: '/work/primary', + primary: true, + trusted: true, + }, + { + id: 'secondary', + cwd: '/work/secondary', + primary: false, + trusted: true, + }, + ], + } as typeof mockWorkspace.capabilities; + const providerStatus = { + v: 1 as const, + workspaceCwd: '/work/secondary', + initialized: true, + providers: [], + }; + const providersResult = deferred(); + qualifiedWorkspaceProviders.mockReturnValue(providersResult.promise); + const { container } = renderApp(); + await flush(); + + act(() => { + testState.latestChatEditorProps?.onSubmit('/model --voice'); + testState.latestChatEditorProps?.onSubmit('/settings'); + }); + expect(qualifiedWorkspaceProviders).toHaveBeenCalledOnce(); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).not.toBeNull(); + + await act(async () => { + providersResult.resolve(providerStatus); + await Promise.resolve(); + }); + await flush(); + + expect(container.querySelector('[data-testid="model-select"]')).toBeNull(); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).not.toBeNull(); + }); + + it('waits for session loading to finish before requesting status', async () => { + mockConnection.loadingTranscript = true; + const { rerender } = renderApp(); + await flush(); + + expect(mockWorkspace.client.sessionStatus).not.toHaveBeenCalled(); + + mockConnection.loadingTranscript = false; + rerender(); + + await vi.waitFor(() => { + expect(mockWorkspace.client.sessionStatus).toHaveBeenCalledWith( + 'session-1', + ); + }); + }); + + it('uses the session catalog title when the connection has no display name', async () => { + mockConnection.displayName = undefined; + mockWorkspace.client.listWorkspaceSessions.mockResolvedValue([ + { + sessionId: 'session-1', + workspaceCwd: '/tmp/project', + displayName: 'Real session title', + }, + ]); + + const { container } = renderApp(); + + await vi.waitFor(() => { + expect( + container.querySelector('[data-testid="chat-context-header"]') + ?.textContent, + ).toContain('Real session title'); + }); + }); + + it('keeps the persistent chat header opt-in for existing integrations', () => { + const { container } = renderApp({ header: undefined }); + + expect( + container.querySelector('[data-testid="chat-context-header"]'), + ).toBeNull(); + }); + + it('lets a custom renderer replace the complete persistent chat header', () => { + mockConnection.gitBranch = 'main'; + mockConnection.gitStatus = { + v: 2, + workspaceCwd: '/tmp/project', + branch: 'main', + unstaged: 1, + }; + const renderChatHeader = vi.fn(() => ( +
Custom session header
+ )); + const { container } = renderApp({ + header: undefined, + renderChatHeader, + }); + + expect( + container.querySelector('[data-testid="chat-context-header"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="custom-chat-header"]') + ?.textContent, + ).toContain('Custom session header'); + expect(renderChatHeader).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'session-1', + sessionName: 'Session One', + workspaceCwd: '/tmp/project', + items: ['title', 'environment', 'rightPanel'], + environmentPanelOpen: false, + rightPanelOpen: false, + onEnvironmentPanelOpenChange: expect.any(Function), + onRightPanelOpenChange: expect.any(Function), + }), + ); + expect(testState.latestChatEditorProps?.visibleToolbarActions).toContain( + 'gitBranch', + ); + }); + + it('keeps legacy task status for a custom header without explicit header configuration', () => { + const monitor: DaemonSessionMonitorTaskStatus = { + kind: 'monitor', + id: 'monitor-1', + label: 'Watch server', + description: 'Watch server', + status: 'running', + startTime: 1, + runtimeMs: 10, + }; + testState.backgroundTasks = [monitor]; + + renderApp({ + header: undefined, + renderChatHeader: () =>
Custom session header
, + }); + + expect(testState.latestStatusBarTasks).toEqual([monitor]); + }); + + it('controls the built-in chat header actions through header items', () => { + const { container } = renderApp({ + header: { items: ['environment'] }, + }); + + expect( + container.querySelector( + 'button[aria-label="Toggle environment information"]', + ), + ).not.toBeNull(); + expect( + container.querySelector('button[aria-label="Toggle right panel"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="chat-context-header"]') + ?.textContent, + ).not.toContain('Session One'); + }); + + it('hides the complete chat header when header items are empty', () => { + const { container } = renderApp({ header: { items: [] } }); + + expect( + container.querySelector('[data-testid="chat-context-header"]'), + ).toBeNull(); + }); + + it('opens environment information without restoring composer Git information', () => { + mockConnection.gitBranch = 'main'; + mockConnection.gitStatus = { + v: 2, + workspaceCwd: '/tmp/project', + branch: 'main', + unstaged: 1, + }; + const { container } = renderApp(); + const rightPanelButton = container.querySelector( + 'button[aria-label="Toggle right panel"]', + ); + + expect(rightPanelButton).not.toBeNull(); + act(() => { + container + .querySelector( + 'button[aria-label="Toggle environment information"]', + ) + ?.click(); + }); + + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).not.toBeNull(); + expect( + testState.latestChatEditorProps?.visibleToolbarActions, + ).not.toContain('gitBranch'); + }); + + it('keeps the right-panel action visible and opens a review-only empty state', () => { + const { container } = renderApp(); + const rightPanelButton = container.querySelector( + 'button[aria-label="Toggle right panel"]', + ); + + expect(rightPanelButton).not.toBeNull(); + act(() => rightPanelButton?.click()); + + const emptyActions = container.querySelector( + '[data-testid="right-panel-empty-actions"]', + ); + const actions = Array.from( + emptyActions?.querySelectorAll('button') ?? [], + ); + expect(actions).toHaveLength(1); + expect(actions[0]?.textContent).toContain('Review'); + expect(actions[0]?.disabled).toBe(true); + expect( + container.querySelector('button[aria-label="Add panel"]'), + ).toBeNull(); + + const header = container.querySelector( + '[data-testid="chat-context-header"]', + ); + expect( + header?.querySelector('button[aria-label="Toggle right panel"]'), + ).toBeNull(); + expect( + container + .querySelector('aside[aria-label="Right panel"]') + ?.querySelector('button[aria-label="Toggle right panel"]'), + ).not.toBeNull(); + const artifactDock = + container.querySelector('[role="separator"]')?.parentElement; + expect(artifactDock?.parentElement).toBe( + header?.parentElement?.parentElement?.parentElement, + ); + expect(header?.parentElement?.contains(artifactDock ?? null)).toBe(false); + }); + + it('opens the latest reviewable turn from the empty right panel', () => { + testState.messages = [ + { + id: 'user-1', + role: 'user', + content: 'write the first file', + }, + { + id: 'tools-1', + role: 'tool_group', + tools: [ + { + callId: 'write-1', + toolName: 'write_file', + status: 'completed', + args: { + file_path: 'src/first.ts', + content: 'export const first = true;\n', + }, + }, + ], + }, + { + id: 'user-2', + role: 'user', + content: 'write the latest file', + }, + { + id: 'tools-2', + role: 'tool_group', + tools: [ + { + callId: 'write-2', + toolName: 'write_file', + status: 'completed', + args: { + file_path: 'src/latest.ts', + content: 'export const latest = true;\n', + }, + }, + ], + }, + ]; + const { container } = renderApp(); + + act(() => { + container + .querySelector( + 'button[aria-label="Toggle right panel"]', + ) + ?.click(); + }); + const review = Array.from( + container.querySelectorAll( + '[data-testid="right-panel-empty-actions"] button', + ), + ).find((button) => button.textContent?.startsWith('Review')); + expect(review?.disabled).toBe(false); + + act(() => review?.click()); + + expect(container.querySelector('button[title="Review"]')).not.toBeNull(); + expect(container.textContent).toContain('latest.ts'); + expect(container.textContent).not.toContain('first.ts'); + }); + + it('floats environment information in ultrawide mode', () => { + mockConnection.gitBranch = 'main'; + mockConnection.gitStatus = { + v: 2, + workspaceCwd: '/tmp/project', + branch: 'main', + unstaged: 1, + }; + const { container } = renderApp(); + const environmentButton = container.querySelector( + 'button[aria-label="Toggle environment information"]', + ); + + act(() => { + testState.latestChatEditorProps?.onChatWidthModeChange?.('wide'); + environmentButton?.click(); + }); + + const environmentPanel = container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ); + expect(environmentPanel?.getAttribute('data-floating')).toBe('true'); + expect( + environmentPanel?.parentElement?.contains( + container.querySelector('[data-testid="chat-pane-container"]'), + ), + ).toBe(true); + }); + + it('closes environment information at the dock breakpoint and reopens it floating', async () => { + let availableMessageWidth = 1200; + const resizeCallbacks = new Set(); + const originalResizeObserver = globalThis.ResizeObserver; + globalThis.ResizeObserver = class { + constructor(private readonly callback: ResizeObserverCallback) { + resizeCallbacks.add(callback); + } + observe() {} + unobserve() {} + disconnect() { + resizeCallbacks.delete(this.callback); + } + } as typeof ResizeObserver; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + function () { + if (this.dataset['testid'] !== 'context-body') return new DOMRect(); + return new DOMRect(0, 0, availableMessageWidth, 600); + }, + ); + testState.messages = [ + { + id: 'tools', + role: 'tool_group', + tools: [ + { + callId: 'agent-call', + toolName: 'agent', + title: 'Inspect repository', + status: 'completed', + args: { subagent_type: 'Explore' }, + }, + ], + }, + ]; + const { container } = renderApp(); + const environmentButton = container.querySelector( + 'button[aria-label="Toggle environment information"]', + ); + + act(() => environmentButton?.click()); + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).not.toBeNull(); + + await act(async () => { + availableMessageWidth = 932; + resizeCallbacks.forEach((callback) => callback([], {} as ResizeObserver)); + await Promise.resolve(); + }); + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).toBeNull(); + + act(() => { + container + .querySelector( + 'button[aria-label="Toggle environment information"]', + ) + ?.click(); + }); + expect( + container + .querySelector('[data-testid="environment-panel"]:not([hidden])') + ?.getAttribute('data-floating'), + ).toBe('true'); + globalThis.ResizeObserver = originalResizeObserver; + }); + + it('opens environment information floating beside an open right panel', async () => { + const resizeCallbacks = new Set(); + const originalResizeObserver = globalThis.ResizeObserver; + globalThis.ResizeObserver = class { + constructor(private readonly callback: ResizeObserverCallback) { + resizeCallbacks.add(callback); + } + observe() {} + unobserve() {} + disconnect() { + resizeCallbacks.delete(this.callback); + } + } as typeof ResizeObserver; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + function () { + if (this.dataset['testid'] !== 'context-body') return new DOMRect(); + return new DOMRect(0, 0, 1_000, 600); + }, + ); + const { container } = renderApp(); + + await act(async () => { + resizeCallbacks.forEach((callback) => callback([], {} as ResizeObserver)); + await Promise.resolve(); + }); + act(() => { + container + .querySelector( + 'button[aria-label="Toggle right panel"]', + ) + ?.click(); + }); + act(() => { + container + .querySelector( + 'button[aria-label="Toggle environment information"]', + ) + ?.click(); + }); + + const environmentPanel = container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ); + expect(environmentPanel?.getAttribute('data-floating')).toBe('true'); + globalThis.ResizeObserver = originalResizeObserver; + }); + + it('keeps the environment action visible without dynamic activity', () => { + const { container } = renderApp(); + + expect( + container.querySelector( + 'button[aria-label="Toggle environment information"]', + ), + ).not.toBeNull(); + }); + + it('keeps the environment action visible for a clean working tree', () => { + mockConnection.gitBranch = 'main'; + mockConnection.gitStatus = { + v: 2, + workspaceCwd: '/tmp/project', + branch: 'main', + staged: 0, + unstaged: 0, + untracked: 0, + conflicted: 0, + }; + const { container } = renderApp(); + + expect( + container.querySelector( + 'button[aria-label="Toggle environment information"]', + ), + ).not.toBeNull(); + }); + + it('shows the environment action for a background task in the transcript', () => { + testState.messages = [ + { + id: 'tools', + role: 'tool_group', + tools: [ + { + callId: 'background-shell', + toolName: 'shell', + status: 'completed', + args: { + command: 'npm run dev', + is_background: true, + }, + }, + ], + }, + ]; + const { container } = renderApp(); + + expect( + container.querySelector( + 'button[aria-label="Toggle environment information"]', + ), + ).not.toBeNull(); + }); + + it('opens an environment monitor in the right panel', () => { + const monitor: DaemonSessionMonitorTaskStatus = { + kind: 'monitor', + id: 'monitor-1', + label: 'monitor-label', + description: 'watch server log', + status: 'running', + startTime: 1_000, + runtimeMs: 5_000, + command: 'tail -f server.log', + eventCount: 3, + lastEventTime: 5_000, + droppedLines: 0, + }; + testState.backgroundTasks = [monitor]; + const { container } = renderApp(); + + act(() => { + testState.latestChatEditorProps?.onChatWidthModeChange?.('wide'); + }); + act(() => { + container + .querySelector( + 'button[aria-label="Toggle environment information"]', + ) + ?.click(); + }); + const backgroundTasksButton = Array.from( + container.querySelectorAll('button[aria-expanded]'), + ).find((button) => button.textContent?.includes('Background tasks')); + act(() => backgroundTasksButton?.click()); + const monitorButton = Array.from( + container.querySelectorAll( + '[data-testid="environment-panel"] ul button', + ), + ).find((button) => button.textContent?.includes('watch server log')); + + act(() => monitorButton?.click()); + + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).not.toBeNull(); + expect( + container.querySelector('button[title="watch server log"]'), + ).not.toBeNull(); + expect(testState.latestBackgroundTasksRefreshTrigger).toBe(1); + }); + + it('opens an environment shell task in the right panel', () => { + const shell: DaemonSessionShellTaskStatus = { + kind: 'shell', + id: 'shell-1', + label: 'Development server', + description: 'Run the development server', + status: 'running', + startTime: 1_000, + runtimeMs: 5_000, + command: 'npm run dev', + cwd: '/tmp/project', + pid: 42, + }; + testState.backgroundTasks = [shell]; + const { container } = renderApp(); + + act(() => { + testState.latestChatEditorProps?.onChatWidthModeChange?.('wide'); + }); + act(() => { + container + .querySelector( + 'button[aria-label="Toggle environment information"]', + ) + ?.click(); + }); + const backgroundTasksButton = Array.from( + container.querySelectorAll('button[aria-expanded]'), + ).find((button) => button.textContent?.includes('Background tasks')); + act(() => backgroundTasksButton?.click()); + const shellButton = Array.from( + container.querySelectorAll( + '[data-testid="environment-panel"] ul button', + ), + ).find((button) => button.textContent?.includes('npm run dev')); + + act(() => shellButton?.click()); + + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).not.toBeNull(); + expect( + container.querySelector('button[title="npm run dev"]'), + ).not.toBeNull(); + expect(container.textContent).toContain('/tmp/project'); + expect(testState.latestBackgroundTasksRefreshTrigger).toBe(1); + }); + + it('closes environment information when the active session changes', () => { + mockConnection.gitBranch = 'main'; + mockConnection.gitStatus = { + v: 2, + workspaceCwd: '/tmp/project', + branch: 'main', + unstaged: 1, + }; + const { container, rerender } = renderApp(); + act(() => { + container + .querySelector( + 'button[aria-label="Toggle environment information"]', + ) + ?.click(); + }); + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).not.toBeNull(); + + mockConnection.sessionId = 'session-2'; + rerender(); + + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).toBeNull(); + }); + + it('keeps environment information open with its subagent panel', async () => { + let availableContextWidth = 1_200; + const resizeCallbacks = new Set(); + const originalResizeObserver = globalThis.ResizeObserver; + globalThis.ResizeObserver = class { + constructor(private readonly callback: ResizeObserverCallback) { + resizeCallbacks.add(callback); + } + observe() {} + unobserve() {} + disconnect() { + resizeCallbacks.delete(this.callback); + } + } as typeof ResizeObserver; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + function () { + if (this.dataset['testid'] !== 'context-body') return new DOMRect(); + return new DOMRect(0, 0, availableContextWidth, 600); + }, + ); + testState.messages = [ + { + id: 'tools', + role: 'tool_group', + tools: [ + { + callId: 'agent-call', + toolName: 'agent', + title: 'Inspect repository', + status: 'completed', + args: { subagent_type: 'Explore' }, + rawOutput: { + type: 'task_execution', + status: 'completed', + subagentName: 'Explore', + }, + }, + ], + }, + ]; + const { container } = renderApp(); + await act(async () => { + resizeCallbacks.forEach((callback) => callback([], {} as ResizeObserver)); await Promise.resolve(); }); + act(() => { container - .querySelector('[data-testid="open-split-view"]') + .querySelector( + 'button[aria-label="Toggle environment information"]', + ) ?.click(); }); + const subagentsButton = Array.from( + container.querySelectorAll('button[aria-expanded]'), + ).find((button) => button.textContent?.includes('Subagents')); + act(() => subagentsButton?.click()); + + const environmentButton = container.querySelector( + 'button[aria-label="Toggle environment information"]', + ); + act(() => environmentButton?.click()); + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).toBeNull(); + act(() => environmentButton?.click()); + expect( + Array.from( + container.querySelectorAll( + '[data-testid="environment-panel"]:not([hidden]) button[aria-expanded="true"]', + ), + ).some((button) => button.textContent?.includes('Subagents')), + ).toBe(true); + + const agentButton = Array.from( + container.querySelectorAll( + '[data-testid="environment-panel"]:not([hidden]) ul button', + ), + ).find((button) => button.textContent?.includes('Inspect repository')); + act(() => agentButton?.click()); await act(async () => { - providersResult.resolve(providerStatus); + availableContextWidth = 900; + resizeCallbacks.forEach((callback) => callback([], {} as ResizeObserver)); await Promise.resolve(); }); - await flush(); - expect(container.querySelector('[data-testid="model-select"]')).toBeNull(); + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).not.toBeNull(); + expect( + container + .querySelector('[data-testid="environment-panel"]:not([hidden])') + ?.getAttribute('data-floating'), + ).toBe('true'); + + act(() => { + container + .querySelector( + 'button[aria-label="Toggle right panel"]', + ) + ?.click(); + }); + + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).not.toBeNull(); + + act(() => { + testState.latestChatEditorProps?.onChatWidthModeChange?.('wide'); + }); + expect( + container + .querySelector('[data-testid="environment-panel"]:not([hidden])') + ?.getAttribute('data-floating'), + ).toBe('true'); + + act(() => { + container + .querySelector( + 'button[aria-label="Toggle right panel"]', + ) + ?.click(); + }); + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).toBeNull(); + const environmentToggle = container.querySelector( + 'button[aria-label="Toggle environment information"]', + ); + expect(environmentToggle).not.toBeNull(); + + act(() => environmentToggle?.click()); + expect( + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).not.toBeNull(); + globalThis.ResizeObserver = originalResizeObserver; }); - it('does not open a pending command Voice picker after entering Settings', async () => { - mockConnection.workspaceCwd = '/work/secondary'; - mockWorkspace.capabilities = { - workspaceCwd: '/work/primary', - features: [ - 'workspace_qualified_voice', - 'workspace_qualified_rest_core', - 'workspace_settings', - ], - workspaces: [ - { - id: 'primary', - cwd: '/work/primary', - primary: true, - trusted: true, - }, - { - id: 'secondary', - cwd: '/work/secondary', - primary: false, - trusted: true, - }, - ], - } as typeof mockWorkspace.capabilities; - const providerStatus = { - v: 1 as const, - workspaceCwd: '/work/secondary', - initialized: true, - providers: [], - }; - const providersResult = deferred(); - qualifiedWorkspaceProviders.mockReturnValue(providersResult.promise); + it('opens an out-of-band fork task in the right panel', () => { + testState.backgroundTasks = [ + { + kind: 'agent', + id: 'fork-agent-1', + label: 'Review current changes', + description: 'Review current changes', + status: 'running', + startTime: 1, + runtimeMs: 10, + isBackgrounded: true, + }, + ]; const { container } = renderApp(); - await flush(); act(() => { - testState.latestChatEditorProps?.onSubmit('/model --voice'); - testState.latestChatEditorProps?.onSubmit('/settings'); + container + .querySelector( + 'button[aria-label="Toggle environment information"]', + ) + ?.click(); }); - expect(qualifiedWorkspaceProviders).toHaveBeenCalledOnce(); + const subagentsButton = Array.from( + container.querySelectorAll('button[aria-expanded]'), + ).find((button) => button.textContent?.includes('Subagents')); + act(() => subagentsButton?.click()); + const forkButton = Array.from( + container.querySelectorAll( + '[data-testid="environment-panel"] ul button', + ), + ).find((button) => button.textContent?.includes('Review current changes')); + + expect(forkButton?.disabled).toBe(false); + act(() => forkButton?.click()); + expect( - container.querySelector('[data-testid="inline-panel"]'), + container.querySelector( + '[data-testid="environment-panel"]:not([hidden])', + ), + ).not.toBeNull(); + expect( + container.querySelector('button[title="Agent: Review current changes"]'), ).not.toBeNull(); + }); + + it('updates the header when session metadata supplies a generated title', () => { + mockConnection.displayName = undefined; + const { container, rerender } = renderApp(); + expect( + container.querySelector('[data-testid="chat-context-header"]') + ?.textContent, + ).toContain('New session'); + + mockConnection.displayName = 'Investigate task failures'; + rerender(); + + expect( + container.querySelector('[data-testid="chat-context-header"]') + ?.textContent, + ).toContain('Investigate task failures'); + }); + it('refreshes the generated title after the first turn completes', async () => { + mockConnection.displayName = undefined; + const { container, rerender } = renderApp(); + await vi.waitFor(() => { + expect(mockWorkspace.client.listWorkspaceSessions).toHaveBeenCalled(); + }); + mockWorkspace.client.listWorkspaceSessions.mockResolvedValue([ + { + sessionId: 'session-1', + workspaceCwd: '/tmp/project', + displayName: 'Generated session title', + }, + ]); + vi.useFakeTimers(); + + act(() => { + testState.streamingState = 'responding'; + rerender(); + }); + act(() => { + testState.streamingState = 'idle'; + rerender(); + }); await act(async () => { - providersResult.resolve(providerStatus); - await Promise.resolve(); + await vi.advanceTimersByTimeAsync(2000); }); - await flush(); - expect(container.querySelector('[data-testid="model-select"]')).toBeNull(); expect( - container.querySelector('[data-testid="inline-panel"]'), - ).not.toBeNull(); + container.querySelector('[data-testid="chat-context-header"]') + ?.textContent, + ).toContain('Generated session title'); }); it('submits through a disconnected session when prompt SSE restart is enabled', async () => { @@ -4730,6 +6123,10 @@ describe('App session callbacks', () => { await flush(); await flush(); + expect(testState.latestChatEditorProps?.visibleToolbarActions).toContain( + 'gitBranch', + ); + // Fast GET applied the branch-only last-known status. await vi.waitFor(() => { expect(testState.latestChatEditorProps?.gitStatus).toEqual({ @@ -6531,6 +7928,88 @@ describe('App session callbacks', () => { expect(editorClear).not.toHaveBeenCalled(); }); + it('refreshes background tasks after /fork launches', async () => { + mockSessionActions.forkSession.mockResolvedValue({ + sessionId: 'session-1', + description: 'Review current changes', + launched: true, + }); + const { container } = renderApp(); + await flush(); + + testState.prompt = '/fork Review current changes'; + await clickSubmit(container); + await flush(); + + expect(mockSessionActions.forkSession).toHaveBeenCalledWith( + 'Review current changes', + ); + expect(testState.latestBackgroundTasksRefreshTrigger).toBe(1); + }); + + it('keeps /btw as a lightweight side question when side tasks are available', async () => { + mockConnection.capabilities.features = ['session_side_task']; + const { container } = renderApp(); + await flush(); + + testState.prompt = '/btw explain the current implementation'; + await clickSubmit(container); + await flush(); + + expect(mockSessionActions.forkSession).not.toHaveBeenCalled(); + expect(mockSessionActions.btwSession).toHaveBeenCalledWith( + 'explain the current implementation', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(container.querySelector('button[title="Side task"]')).toBeNull(); + }); + + it('opens a new side task for /btw side when the capability is available', async () => { + mockConnection.capabilities.features = ['session_side_task']; + const { container } = renderApp(); + await flush(); + + testState.prompt = '/btw side explain the current implementation'; + await clickSubmit(container); + await flush(); + + expect(mockSessionActions.forkSession).not.toHaveBeenCalled(); + expect(mockSessionActions.btwSession).not.toHaveBeenCalled(); + expect(container.querySelector('button[title="Side task"]')).not.toBeNull(); + }); + + it('keeps /btw side as a lightweight question without the capability', async () => { + const { container } = renderApp(); + await flush(); + + testState.prompt = '/btw side explain the current implementation'; + await clickSubmit(container); + await flush(); + + expect(mockSessionActions.btwSession).toHaveBeenCalledWith( + 'side explain the current implementation', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(container.querySelector('button[title="Side task"]')).toBeNull(); + }); + + it('passes a directive to /fork as a regular background-agent directive', async () => { + mockSessionActions.forkSession.mockResolvedValue({ + sessionId: 'session-1', + description: 'delegate', + launched: true, + }); + const { container } = renderApp(); + await flush(); + + testState.prompt = '/fork delegate'; + await clickSubmit(container); + await flush(); + + expect(mockSessionActions.forkSession).toHaveBeenCalledWith('delegate'); + expect(container.querySelector('button[title="Side task"]')).toBeNull(); + }); + it('notifies the host before forwarding a slash command', async () => { const onSlashCommand = vi.fn(); const { container } = renderApp({ onSlashCommand }); @@ -7641,6 +9120,21 @@ describe('App session callbacks', () => { expect(shellRef.current).toBeNull(); }); + it('creates a side task from the external shell ref', async () => { + mockConnection.capabilities.features = ['session_side_task']; + const shellRef = createRef(); + const { container } = renderApp({ shellRef }); + await flush(); + + let created = false; + act(() => { + created = shellRef.current?.createSideTask() ?? false; + }); + + expect(created).toBe(true); + expect(container.querySelector('button[title="Side task"]')).not.toBeNull(); + }); + it('opens the Session Overview from the external shell ref like the sidebar button', async () => { let shellApi: WebShellApi | null = null; const { container } = renderApp({ @@ -8106,6 +9600,31 @@ describe('App session callbacks', () => { expect(document.body.textContent).toContain('Artifact not found.'); }); + it('opens a split pane monitor in the right panel', async () => { + const { container } = renderApp(); + await flush(); + + await act(async () => { + container + .querySelector('[data-testid="open-split-view"]') + ?.click(); + await Promise.resolve(); + }); + await act(async () => { + container + .querySelector('[data-testid="split-open-monitor"]') + ?.click(); + await Promise.resolve(); + }); + + expect( + document.body.querySelector('button[title="watch pane logs"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + }); + it('clears split pane artifact snapshots when switching sessions', async () => { const { container, rerender } = renderApp(); await flush(); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 04fea56b692..1aa4ec6dd2d 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -25,6 +25,7 @@ import { useWorkspace, useWorkspaceActions, useWorkspaceEventSignals, + type DaemonSessionActions, type DaemonWorkspaceActions, type DaemonSessionNotice, type DaemonStreamingState, @@ -32,8 +33,10 @@ import { import { DaemonHttpError, isDaemonTurnError } from '@qwen-code/sdk/daemon'; import type { DaemonInputAnnotation, + DaemonSessionAgentTaskStatus, DaemonTranscriptBlock, DaemonSessionMonitorTaskStatus, + DaemonSessionShellTaskStatus, DaemonSessionTaskStatus, DaemonSessionArtifact, DaemonWorkspaceCapability, @@ -42,8 +45,11 @@ import type { import { type SessionGitIntent } from './components/GitModePopover'; import { + SESSION_LIST_PAGE_SIZE, SESSION_MONITOR_TOOL_CORRELATION_FEATURE, + SESSION_SIDE_TASK_FEATURE, SESSION_TRANSCRIPT_PAGINATION_FEATURE, + WEB_SHELL_SIDE_TASK_SOURCE_TYPE, } from './constants/sessions'; import { extractPendingPermission } from './adapters/transcriptAdapter'; import { MessageList, type MessageListHandle } from './components/MessageList'; @@ -76,6 +82,11 @@ import { type WebShellToast, } from './components/ToastHost'; import { TodoPanel } from './components/panels/TodoPanel'; +import { + EnvironmentPanel, + type EnvironmentAgentTask, +} from './components/panels/EnvironmentPanel'; +import { ChatContextHeader } from './components/ChatContextHeader'; import { WelcomeHeader } from './components/WelcomeHeader'; import { ApprovalModeDialog } from './components/dialogs/ApprovalModeDialog'; import { ResumeDialog } from './components/dialogs/ResumeDialog'; @@ -98,6 +109,7 @@ import type { PaneHeaderActionsRenderer } from './components/ChatPane'; import { ArtifactPanel, type ArtifactPanelTab, + type SideTaskListItem, } from './components/artifacts/ArtifactPanel'; import { Drawer, DrawerContent, DrawerTitle } from './components/ui/drawer'; import type { @@ -170,6 +182,7 @@ import { import { mergeCommands } from './hooks/daemonSessionMappers'; import { useAnimationFrameTranscriptBlocks } from './hooks/useAnimationFrameTranscriptBlocks'; import { useBackgroundTasks } from './hooks/useBackgroundTasks'; +import { isSessionDisconnectedError } from './utils/sessionErrors'; import { useMessagesFromBlocks } from './hooks/useMessages'; import { useSessionArtifacts } from './hooks/useSessionArtifacts'; import { useShallowMemo, useStableArray } from './hooks/useShallowMemo'; @@ -238,6 +251,7 @@ import { type ComposerPlaceholderState, } from './utils/composerInputState'; import type { ACPToolCall, Message, PermissionRequest } from './adapters/types'; +import { isBackgroundSubAgentToolCall } from './adapters/toolClassification'; import { computeTodoDetails, computeTodoTimeline, @@ -274,6 +288,12 @@ import { type ComposerHeaderRenderer, type ComposerFooterRenderer, type ChatHeaderRenderer, + type WebShellChatHeaderItem, + type WebShellChatHeaderOptions, + type WebShellRightPanelItem, + type WebShellRightPanelOptions, + type WebShellEnvironmentPanelItem, + type WebShellEnvironmentPanelOptions, type FooterRenderer, type LoadingPhrasesResolver, type MarkdownTableMode, @@ -335,9 +355,22 @@ function TodoContextsProvider({ const MODES_CYCLE = DAEMON_APPROVAL_MODES; const MAX_TOASTS = 4; -const DEFAULT_REVIEW_PANEL_WIDTH = 760; +const DEFAULT_REVIEW_PANEL_WIDTH = 500; const MIN_ARTIFACT_PANEL_WIDTH = 320; const MIN_CHAT_PANE_WIDTH_WITH_ARTIFACT_PANEL = 500; +const MIN_DOCKED_MESSAGE_AREA_WIDTH = 800; +const DOCKED_ENVIRONMENT_PANEL_WIDTH = 332; +const DEFAULT_COMPOSER_TOOLBAR_ACTIONS = [ + 'approvalMode', + 'model', + 'widthMode', + 'voice', + 'workspace', +] as const satisfies readonly ComposerToolbarAction[]; +const DEFAULT_EMPTY_COMPOSER_TOOLBAR_ACTIONS = [ + ...DEFAULT_COMPOSER_TOOLBAR_ACTIONS, + 'gitBranch', +] as const satisfies readonly ComposerToolbarAction[]; const MAX_ARTIFACT_PANEL_SESSION_STATES = 20; interface ArtifactPanelSessionState { open: boolean; @@ -521,6 +554,8 @@ export interface WebShellApi { openSessionDrawer: () => void; /** Start a new session using the same lifecycle as the built-in New Chat action. */ createNewSession: () => Promise; + /** Open the right panel with a new side-task draft. */ + createSideTask: () => boolean; } export type WebShellComposerPlaceholderState = ComposerPlaceholderState; @@ -569,6 +604,12 @@ export interface WebShellProps { chatMaxWidth?: number; /** Optional workspace sidebar. Disabled by default. */ sidebar?: boolean | WebShellSidebarOptions; + /** Persistent chat header options. */ + header?: WebShellChatHeaderOptions; + /** Right extension panel options. */ + rightPanel?: WebShellRightPanelOptions; + /** Environment information panel options. */ + environmentPanel?: WebShellEnvironmentPanelOptions; /** Session ids to control the split view; an empty array closes it. */ splitSessionIds?: readonly string[]; /** Called when the split pane list changes from inside WebShell. */ @@ -660,8 +701,8 @@ export interface WebShellProps { /** Custom renderer shown directly below the chat composer input. */ renderComposerFooter?: ComposerFooterRenderer; /** - * Custom renderer shown at the top of the chat view, above the message list. - * Only rendered when a session is active (not in the welcome/empty state). + * Replaces the complete persistent chat header. Only rendered when a + * session is active (not in the welcome/empty state). */ renderChatHeader?: ChatHeaderRenderer; /** Custom component for the footer area below the Editor. Replaces the built-in StatusBar. */ @@ -744,6 +785,17 @@ const emptyComposerApi: WebShellComposerApi = { const EMPTY_BOTTOM_STATUS_ITEMS: readonly WebShellBottomStatusItem[] = []; const DEFAULT_CHAT_MAX_WIDTH = 1000; +const DEFAULT_CHAT_HEADER_ITEMS: readonly WebShellChatHeaderItem[] = [ + 'title', + 'environment', + 'rightPanel', +]; +const DEFAULT_RIGHT_PANEL_ITEMS: readonly WebShellRightPanelItem[] = [ + 'review', + 'sideTask', +]; +const DEFAULT_ENVIRONMENT_PANEL_ITEMS: readonly WebShellEnvironmentPanelItem[] = + ['environment', 'subagents', 'backgroundTasks']; const BOTTOM_PANEL_GAP_PX = 6; const BOTTOM_PANEL_FALLBACK_INSET_PX = 40; type ChatWidthMode = `${typeof DEFAULT_CHAT_MAX_WIDTH}` | 'wide'; @@ -988,9 +1040,10 @@ function parseRenameArgument( return { type: 'manual', displayName: trimmed }; } -function isBackgroundShellToolCall(tool: ACPToolCall): boolean { - if (tool.args?.is_background !== true) return false; +function isBackgroundTaskToolCall(tool: ACPToolCall): boolean { const name = tool.toolName.toLowerCase(); + if (name === 'monitor') return true; + if (tool.args?.is_background !== true) return false; return ( name === 'shell' || name === 'bash' || @@ -999,20 +1052,22 @@ function isBackgroundShellToolCall(tool: ACPToolCall): boolean { ); } -export function getBackgroundTaskActivityKey( - messages: readonly Message[], -): string { +export function getTaskActivityKey(messages: readonly Message[]): string { const parts: string[] = []; - for (const message of messages) { - if (message.role !== 'tool_group') continue; - for (const tool of message.tools) { + const visit = (tools: readonly ACPToolCall[]) => { + for (const tool of tools) { if ( - isBackgroundShellToolCall(tool) || - tool.toolName.toLowerCase() === 'monitor' + isBackgroundTaskToolCall(tool) || + isBackgroundSubAgentToolCall(tool) ) { parts.push(`${tool.callId}:${tool.status}`); } + if (tool.subTools) visit(tool.subTools); } + }; + for (const message of messages) { + if (message.role !== 'tool_group') continue; + visit(message.tools); } return parts.join('|'); } @@ -1026,6 +1081,309 @@ export function mergeMonitorTaskSnapshot( : next; } +function mergeShellTaskSnapshot( + current: DaemonSessionShellTaskStatus, + next: DaemonSessionShellTaskStatus, +): DaemonSessionShellTaskStatus { + return current.status !== 'running' && next.status === 'running' + ? current + : next; +} + +interface SideTaskCatalogState { + parentSessionId?: string; + items: SideTaskListItem[]; + loaded: boolean; +} + +// Merge a fresh side-task listing into the cached catalog. The listing is +// authoritative: a cached item survives only while it is still listed or is a +// locally created draft the daemon has not echoed back yet (optimisticIds). +// Without the optimistic guard, a task deleted or archived on another client +// would be re-added from the cache forever. +export function mergeSideTaskCatalog( + catalog: SideTaskCatalogState, + parentSessionId: string, + listedItems: SideTaskListItem[], + optimisticIds: ReadonlySet, +): SideTaskCatalogState { + if (catalog.parentSessionId !== parentSessionId) { + return { parentSessionId, items: listedItems, loaded: true }; + } + const listedIds = new Set(listedItems.map((item) => item.sessionId)); + return { + parentSessionId, + loaded: true, + items: [ + ...listedItems, + ...catalog.items.filter( + (item) => + !listedIds.has(item.sessionId) && optimisticIds.has(item.sessionId), + ), + ], + }; +} + +function agentStatusFromTool( + tool: ACPToolCall, +): DaemonSessionAgentTaskStatus['status'] { + if (tool.status === 'pending' || tool.status === 'in_progress') { + return 'running'; + } + if (tool.status === 'failed') return 'failed'; + const rawOutput = isRecord(tool.rawOutput) ? tool.rawOutput : undefined; + if (rawOutput?.['status'] === 'cancelled') return 'cancelled'; + return rawOutput?.['status'] === 'failed' ? 'failed' : 'completed'; +} + +function agentTaskAsToolCall(task: DaemonSessionAgentTaskStatus): ACPToolCall { + const status = + task.status === 'running' || task.status === 'paused' + ? 'in_progress' + : task.status === 'failed' + ? 'failed' + : 'completed'; + return { + callId: task.id, + toolName: 'agent', + title: `Agent: ${task.label}`, + status, + args: { + description: task.description, + ...(task.prompt ? { prompt: task.prompt } : {}), + ...(task.subagentType ? { subagent_type: task.subagentType } : {}), + run_in_background: task.isBackgrounded, + }, + rawOutput: { + type: 'task_execution', + subagentName: task.subagentType, + status: task.status, + }, + startTime: task.startTime, + ...(task.endTime !== undefined ? { endTime: task.endTime } : {}), + }; +} + +function isEnvironmentAgentToolCall(tool: ACPToolCall): boolean { + const name = tool.toolName.toLowerCase(); + if (name === 'agent' || name === 'task') return true; + if (typeof tool.args?.subagent_type === 'string') return true; + return ( + isRecord(tool.rawOutput) && tool.rawOutput['type'] === 'task_execution' + ); +} + +function derivedTaskIdForTool(tool: ACPToolCall): string | undefined { + const rawOutput = isRecord(tool.rawOutput) ? tool.rawOutput : undefined; + const subagentName = + typeof rawOutput?.['subagentName'] === 'string' + ? rawOutput['subagentName'] + : undefined; + const subagentType = + typeof tool.args?.subagent_type === 'string' + ? tool.args.subagent_type + : undefined; + return subagentName + ? `${subagentName}-${tool.callId}` + : subagentType + ? `${subagentType}-${tool.callId}` + : undefined; +} + +export function getEnvironmentAgentTasks( + messages: readonly Message[], + sessionTasks: readonly DaemonSessionTaskStatus[], +): EnvironmentAgentTask[] { + const liveAgents = sessionTasks.filter( + (task): task is DaemonSessionAgentTaskStatus => task.kind === 'agent', + ); + const taskIdsByToolUseId = new Map(); + for (const message of messages) { + if (message.role !== 'system' || !isRecord(message.data)) continue; + const taskId = message.data['taskId']; + const toolUseId = message.data['toolUseId']; + if (typeof taskId === 'string' && typeof toolUseId === 'string') { + taskIdsByToolUseId.set(toolUseId, taskId); + } + } + + // A live task already linked precisely (by toolUseId, message taskId, or + // derived id) to some transcript tool call must never be claimed by the loose + // content fallback: two agents sharing a description would otherwise collapse + // into one (the fallback steals the linked task, its owner re-matches it, and + // the orphan is dropped). + const envToolCallIds = new Set(); + const preciselyClaimedTaskIds = new Set(taskIdsByToolUseId.values()); + const collectPreciseLinks = (tools: readonly ACPToolCall[]) => { + for (const tool of tools) { + if ( + isEnvironmentAgentToolCall(tool) && + !envToolCallIds.has(tool.callId) + ) { + envToolCallIds.add(tool.callId); + const derivedTaskId = derivedTaskIdForTool(tool); + if (derivedTaskId) preciselyClaimedTaskIds.add(derivedTaskId); + } + if (tool.subTools) collectPreciseLinks(tool.subTools); + } + }; + for (const message of messages) { + if (message.role === 'tool_group') collectPreciseLinks(message.tools); + } + const isPreciselyClaimed = (task: DaemonSessionAgentTaskStatus): boolean => + (task.toolUseId != null && envToolCallIds.has(task.toolUseId)) || + preciselyClaimedTaskIds.has(task.id); + + const agents: EnvironmentAgentTask[] = []; + const seenTaskIds = new Set(); + const seenToolCallIds = new Set(); + const visit = (tools: readonly ACPToolCall[]) => { + for (const tool of tools) { + if ( + isEnvironmentAgentToolCall(tool) && + !seenToolCallIds.has(tool.callId) + ) { + seenToolCallIds.add(tool.callId); + const rawOutput = isRecord(tool.rawOutput) ? tool.rawOutput : undefined; + const color = + typeof rawOutput?.['subagentColor'] === 'string' + ? rawOutput['subagentColor'] + : undefined; + const description = + typeof tool.args?.description === 'string' + ? tool.args.description + : undefined; + const prompt = + typeof tool.args?.prompt === 'string' ? tool.args.prompt : undefined; + const subagentType = + typeof tool.args?.subagent_type === 'string' + ? tool.args.subagent_type + : undefined; + const subagentName = + typeof rawOutput?.['subagentName'] === 'string' + ? rawOutput['subagentName'] + : undefined; + const taskId = taskIdsByToolUseId.get(tool.callId); + const derivedTaskId = derivedTaskIdForTool(tool); + // Completed background agents can lose their toolUseId / derived-id + // linkage (e.g. across a daemon reload); fall back to content matching, + // the same signal the daemon's legacy resolver uses. + const matchesLiveTaskContent = ( + task: DaemonSessionAgentTaskStatus, + ): boolean => { + if (prompt && task.prompt === prompt) return true; + if ( + description && + task.description === description && + subagentType && + task.subagentType === subagentType + ) { + return true; + } + return !!description && task.description === description; + }; + const liveTask = liveAgents.find( + (task) => + task.toolUseId === tool.callId || + task.id === taskId || + task.id === derivedTaskId || + (!seenTaskIds.has(task.id) && + !isPreciselyClaimed(task) && + matchesLiveTaskContent(task)), + ); + const title = tool.title?.replace(/^Agent:\s*/i, '').trim(); + const meaningfulTitle = + title && title.toLowerCase() !== 'agent' ? title : undefined; + const label = + meaningfulTitle ?? + description ?? + prompt ?? + subagentName ?? + subagentType ?? + ''; + const taskDescription = + description ?? prompt ?? subagentName ?? subagentType ?? ''; + const startTime = tool.startTime ?? 0; + + agents.push( + liveTask + ? { + ...liveTask, + label, + description: taskDescription || liveTask.description, + ...(subagentType ? { subagentType } : {}), + ...(color ? { color } : {}), + } + : { + kind: 'agent', + id: taskId ?? derivedTaskId ?? tool.callId, + label, + description: taskDescription, + status: agentStatusFromTool(tool), + startTime, + ...(tool.endTime !== undefined + ? { endTime: tool.endTime } + : {}), + runtimeMs: Math.max( + 0, + (tool.endTime ?? tool.startTime ?? startTime) - startTime, + ), + ...(subagentType ? { subagentType } : {}), + ...(color ? { color } : {}), + isBackgrounded: isBackgroundSubAgentToolCall(tool), + toolUseId: tool.callId, + }, + ); + if (liveTask) seenTaskIds.add(liveTask.id); + } + if (tool.subTools) visit(tool.subTools); + } + }; + + for (const message of messages) { + if (message.role === 'tool_group') visit(message.tools); + } + for (const task of liveAgents) { + if ( + seenTaskIds.has(task.id) || + (task.toolUseId && seenToolCallIds.has(task.toolUseId)) + ) { + continue; + } + const alreadyListed = agents.some( + (a) => + (a.toolUseId != null && a.toolUseId === task.toolUseId) || + (a.description !== '' && a.description === task.description), + ); + if (alreadyListed) continue; + agents.push(task); + } + return agents; +} + +function findToolCall( + messages: readonly Message[], + callId: string, +): ACPToolCall | undefined { + const findNested = ( + tools: readonly ACPToolCall[], + ): ACPToolCall | undefined => { + for (const tool of tools) { + if (tool.callId === callId) return tool; + const nested = tool.subTools ? findNested(tool.subTools) : undefined; + if (nested) return nested; + } + return undefined; + }; + + for (const message of messages) { + if (message.role !== 'tool_group') continue; + const tool = findNested(message.tools); + if (tool) return tool; + } + return undefined; +} + function mapToWebShellTaskInfo( task: DaemonSessionTaskStatus, ): WebShellTaskInfo { @@ -1180,6 +1538,9 @@ export function App({ bottomStatusItems, chatMaxWidth, sidebar, + header, + rightPanel, + environmentPanel, splitSessionIds: externalSplitSessionIds, onSplitSessionIdsChange, renderPaneHeaderActions, @@ -1225,6 +1586,28 @@ export function App({ () => resolveSidebarOptions(sidebar), [sidebar], ); + const chatHeaderItems = header?.items ?? DEFAULT_CHAT_HEADER_ITEMS; + const chatHeaderEnabled = + chatHeaderItems.length > 0 && Boolean(header || renderChatHeader); + const titleHeaderItemVisible = chatHeaderItems.includes('title'); + const environmentHeaderItemVisible = chatHeaderItems.includes('environment'); + const rightPanelHeaderItemVisible = chatHeaderItems.includes('rightPanel'); + const rightPanelItems = rightPanel?.items ?? DEFAULT_RIGHT_PANEL_ITEMS; + const environmentPanelItems = + environmentPanel?.items ?? DEFAULT_ENVIRONMENT_PANEL_ITEMS; + // The environment panel is only reachable through the chat header toggle, + // so its sections replace the composer git entry / footer task pills only + // when that header is actually enabled. Embeddings that omit the header keep + // the legacy entries. + const environmentPanelReachable = + chatHeaderEnabled && + environmentHeaderItemVisible && + (!renderChatHeader || Boolean(header)); + const environmentGitReplacementEnabled = + environmentPanelReachable && environmentPanelItems.includes('environment'); + const environmentTasksReplacementEnabled = + environmentPanelReachable && + environmentPanelItems.includes('backgroundTasks'); const [sidebarCollapsed, setSidebarCollapsed] = useState(() => readSidebarCollapsed(sidebarOptions.defaultCollapsed), ); @@ -1566,6 +1949,9 @@ export function App({ const [sessionBranch, setSessionBranch] = useState< { name: string; baseBranch: string } | undefined >(undefined); + const [sessionStatusDisplayName, setSessionStatusDisplayName] = useState< + string | undefined + >(undefined); // Tracks the session id from the latest effect run. In-flight fetches // compare their captured sid against this ref on resolve: a match means // the response is still relevant and may set OR clear the worktree state; @@ -1579,10 +1965,24 @@ export function App({ // discard the one response we actually need. useEffect(() => { const sid = connection.sessionId; + const previousSid = worktreeSessionIdRef.current; worktreeSessionIdRef.current = sid; if (!sid) { setSessionWorktree(undefined); setSessionBranch(undefined); + setSessionStatusDisplayName(undefined); + return; + } + if (previousSid !== sid) { + setSessionWorktree(undefined); + setSessionBranch(undefined); + setSessionStatusDisplayName(undefined); + } + if ( + connection.status !== 'connected' || + connection.loadingTranscript || + connection.catchingUp + ) { return; } workspace.client @@ -1591,15 +1991,35 @@ export function App({ if (worktreeSessionIdRef.current === sid) { setSessionWorktree(summary.worktree); setSessionBranch(summary.branch); + setSessionStatusDisplayName(summary.displayName); } + return workspace.client + .listWorkspaceSessions(summary.workspaceCwd, { pageSize: 200 }) + .then((sessions) => { + if (worktreeSessionIdRef.current !== sid) return; + const listedSession = sessions.find( + (session) => session.sessionId === sid, + ); + setSessionStatusDisplayName( + listedSession?.displayName ?? summary.displayName, + ); + }) + .catch(() => undefined); }) .catch(() => { if (worktreeSessionIdRef.current === sid) { setSessionWorktree(undefined); setSessionBranch(undefined); + setSessionStatusDisplayName(undefined); } }); - }, [connection.sessionId, workspace.client]); + }, [ + connection.catchingUp, + connection.loadingTranscript, + connection.sessionId, + connection.status, + workspace.client, + ]); // Active workspace: the connected session's workspace, else the workspace // picked for the next session (locked / selected / primary). Computed once // and shared by the git-status effect and the Changes-dialog entry point so @@ -1749,6 +2169,8 @@ export function App({ const nextBtwMessageIdRef = useRef(1); const btwAbortControllerRef = useRef(null); const chatPaneRef = useRef(null); + const contextBodyRef = useRef(null); + const [contextBodyWidth, setContextBodyWidth] = useState(null); const currentSessionIdRef = useRef(connection.sessionId); const lastNotifiedSessionIdRef = useRef(undefined); const lastNotifiedWorkspaceIdRef = useRef(undefined); @@ -1808,6 +2230,8 @@ export function App({ const [artifactPanelTabs, setArtifactPanelTabs] = useState< ArtifactPanelTab[] >([]); + const artifactPanelTabsRef = useRef(artifactPanelTabs); + artifactPanelTabsRef.current = artifactPanelTabs; useEffect(() => { if (artifactPanelExtraArtifacts.length === 0 || artifacts.length === 0) { return; @@ -1925,6 +2349,13 @@ export function App({ ), [displayMessages, artifactsByTurn, connection.workspaceCwd], ); + const latestReviewChanges = useMemo(() => { + let latest: readonly TurnOutputFileChange[] = []; + for (const changes of fileChangesByTurn.values()) { + if (changes.length > 0) latest = changes; + } + return latest; + }, [fileChangesByTurn]); const scheduledTasksByTurn = useMemo( () => getScheduledTasksByTurn(displayMessages), [displayMessages], @@ -1934,6 +2365,12 @@ export function App({ [messageTurnOutputs], ); const [artifactPanelOpen, setArtifactPanelOpen] = useState(false); + const [environmentPanelOpen, setEnvironmentPanelOpen] = useState(false); + const preserveEnvironmentPanelOnArtifactOpenRef = useRef(false); + useLayoutEffect(() => { + preserveEnvironmentPanelOnArtifactOpenRef.current = false; + setEnvironmentPanelOpen(false); + }, [connection.sessionId]); const artifactPanelOpenRef = useRef(artifactPanelOpen); artifactPanelOpenRef.current = artifactPanelOpen; const [activeArtifactPanelTabId, setActiveArtifactPanelTabId] = useState< @@ -2016,6 +2453,256 @@ export function App({ setPaneArtifactSnapshots(new Map()); setArtifactPanelWidth(savedState.width); }, [connection.sessionId]); + const sideTasksAvailable = + Boolean(connection.sessionId && connection.workspaceCwd) && + connection.capabilities?.features.includes(SESSION_SIDE_TASK_FEATURE) === + true; + const [sideTaskCatalog, setSideTaskCatalog] = useState({ + items: [], + loaded: false, + }); + const optimisticSideTaskIdsRef = useRef(new Set()); + const visibleSideTasks = + sideTaskCatalog.parentSessionId === connection.sessionId + ? sideTaskCatalog.items + : []; + const sideTasksLoading = + visibleSideTasks.length === 0 && + (sideTaskCatalog.parentSessionId !== connection.sessionId || + !sideTaskCatalog.loaded); + const nextSideTaskTabIdRef = useRef(0); + const createSideTask = useCallback( + (initialPrompt?: string) => { + const parentSessionId = connection.sessionId; + if (!parentSessionId || !sideTasksAvailable) return false; + const tab: ArtifactPanelTab = { + id: `side-task:draft:${Date.now()}:${++nextSideTaskTabIdRef.current}`, + kind: 'side_task', + title: t('sideTask.title'), + parentSessionId, + workspaceCwd: connection.workspaceCwd, + nameFromFirstPrompt: true, + ...(initialPrompt?.trim() + ? { initialPrompt: initialPrompt.trim() } + : {}), + }; + setArtifactPanelTabs((tabs) => [...tabs, tab]); + setActiveArtifactPanelTabId(tab.id); + setArtifactPanelOpen(true); + return true; + }, + [connection.sessionId, connection.workspaceCwd, sideTasksAvailable, t], + ); + const createEmptySideTask = useCallback(() => { + if (createSideTask()) return; + pushToast('error', t('sideTask.createFailed')); + }, [createSideTask, pushToast, t]); + const createSideTaskSession = useCallback( + async (_tabId: string, parentSessionId: string, title: string) => { + const parentClientId = + connection.sessionId === parentSessionId + ? connection.clientId + : undefined; + const session = await workspace.client.createSideTaskSession( + parentSessionId, + { + name: title, + }, + parentClientId, + ); + await workspace.client + .detachSession(session.sessionId, session.clientId) + .catch(() => undefined); + return { + sessionId: session.sessionId, + displayName: session.displayName, + }; + }, + [connection.clientId, connection.sessionId, workspace.client], + ); + const handleSideTaskCreated = useCallback( + (tabId: string, sessionId: string) => { + let createdTab = artifactPanelTabsRef.current.find( + (candidate) => candidate.id === tabId, + ); + setArtifactPanelTabs((tabs) => + tabs.map((tab) => + tab.id === tabId && tab.kind === 'side_task' + ? { ...tab, sessionId } + : tab, + ), + ); + if (!createdTab) { + // Creation can resolve after we navigate away from the parent session; + // the draft tab then lives in a saved per-session bucket rather than the + // live tabs, so write the sessionId there too or reopening the parent + // creates a duplicate side task. + for (const state of artifactPanelStateBySessionRef.current.values()) { + const candidate = state.tabs.find( + (bucketTab) => bucketTab.id === tabId, + ); + if (!candidate) continue; + createdTab = candidate; + state.tabs = state.tabs.map((bucketTab) => + bucketTab.id === tabId && bucketTab.kind === 'side_task' + ? { ...bucketTab, sessionId } + : bucketTab, + ); + break; + } + } + const sideTaskTab = + createdTab?.kind === 'side_task' ? createdTab : undefined; + if (!sideTaskTab) return; + optimisticSideTaskIdsRef.current.add(sessionId); + setSideTaskCatalog((catalog) => { + if (catalog.parentSessionId !== sideTaskTab.parentSessionId) { + return catalog; + } + if (catalog.items.some((item) => item.sessionId === sessionId)) { + return catalog; + } + return { + ...catalog, + items: [ + ...catalog.items, + { + sessionId, + title: sideTaskTab.title, + workspaceCwd: sideTaskTab.workspaceCwd, + updatedAt: new Date().toISOString(), + }, + ], + }; + }); + }, + [], + ); + const handleSideTaskTitleChange = useCallback( + (tabId: string, title: string, fromFirstPrompt = false) => { + const sideTaskTab = artifactPanelTabsRef.current.find( + (tab) => tab.id === tabId && tab.kind === 'side_task', + ); + const sessionId = + sideTaskTab?.kind === 'side_task' ? sideTaskTab.sessionId : undefined; + setArtifactPanelTabs((tabs) => + tabs.map((tab) => { + if (tab.id !== tabId || tab.kind !== 'side_task') return tab; + if (!fromFirstPrompt && tab.title === title) return tab; + return { + ...tab, + title, + ...(fromFirstPrompt + ? { + nameFromFirstPrompt: false, + initialPrompt: undefined, + } + : {}), + }; + }), + ); + if (sessionId) { + setSideTaskCatalog((catalog) => ({ + ...catalog, + items: catalog.items.map((item) => + item.sessionId === sessionId ? { ...item, title } : item, + ), + })); + } + }, + [], + ); + const openSideTask = useCallback( + (sideTask: SideTaskListItem) => { + const parentSessionId = connection.sessionId; + if (!parentSessionId) return; + const tab: ArtifactPanelTab = { + id: `side-task:${sideTask.sessionId}`, + kind: 'side_task', + title: sideTask.title, + sessionId: sideTask.sessionId, + parentSessionId, + workspaceCwd: sideTask.workspaceCwd ?? connection.workspaceCwd, + }; + setArtifactPanelTabs((tabs) => + tabs.some( + (item) => + item.kind === 'side_task' && item.sessionId === sideTask.sessionId, + ) + ? tabs + : [...tabs, tab], + ); + const existingTab = artifactPanelTabsRef.current.find( + (item) => + item.kind === 'side_task' && item.sessionId === sideTask.sessionId, + ); + setActiveArtifactPanelTabId(existingTab?.id ?? tab.id); + setArtifactPanelOpen(true); + }, + [connection.sessionId, connection.workspaceCwd], + ); + useEffect(() => { + const parentSessionId = connection.sessionId; + const workspaceCwd = connection.workspaceCwd; + if (!sideTasksAvailable || !parentSessionId || !workspaceCwd) { + setSideTaskCatalog({ items: [], loaded: false }); + return; + } + if (!artifactPanelOpen) return; + setSideTaskCatalog((catalog) => + catalog.parentSessionId === parentSessionId + ? { ...catalog, loaded: false } + : { parentSessionId, items: [], loaded: false }, + ); + let cancelled = false; + void workspace.client + .listWorkspaceSessions(workspaceCwd, { + pageSize: SESSION_LIST_PAGE_SIZE, + archiveState: 'active', + sourceType: WEB_SHELL_SIDE_TASK_SOURCE_TYPE, + sourceId: parentSessionId, + }) + .then((sessions) => { + if (cancelled) return; + const listedItems = sessions.map((session) => ({ + sessionId: session.sessionId, + title: + session.displayName?.trim() || + `${t('sideTask.title')} ${session.sessionId.slice(0, 8)}`, + workspaceCwd: session.workspaceCwd || workspaceCwd, + updatedAt: session.updatedAt || session.createdAt, + })); + for (const item of listedItems) { + optimisticSideTaskIdsRef.current.delete(item.sessionId); + } + setSideTaskCatalog((catalog) => + mergeSideTaskCatalog( + catalog, + parentSessionId, + listedItems, + optimisticSideTaskIdsRef.current, + ), + ); + }) + .catch(() => { + if (cancelled) return; + setSideTaskCatalog((catalog) => + catalog.parentSessionId === parentSessionId + ? { ...catalog, loaded: true } + : catalog, + ); + }); + return () => { + cancelled = true; + }; + }, [ + connection.sessionId, + connection.workspaceCwd, + artifactPanelOpen, + sideTasksAvailable, + t, + workspace.client, + ]); const getMaxArtifactPanelWidth = useCallback(() => { const chatPaneWidth = chatPaneRef.current?.getBoundingClientRect().width; if (!chatPaneWidth) { @@ -2077,11 +2764,14 @@ export function App({ selectedPath?: string, workspaceActions?: DaemonWorkspaceActions, reviewWorkspaceCwd?: string, + tabId = 'review', ) => { const reviewTab: ArtifactPanelTab = { - id: 'review', + id: tabId, kind: 'review', title: t('turnOutputs.review'), + changes, + ...(selectedPath ? { selectedPath } : {}), ...(workspaceActions ? { workspaceActions } : {}), ...(reviewWorkspaceCwd ? { workspaceCwd: reviewWorkspaceCwd } : {}), }; @@ -2100,13 +2790,20 @@ export function App({ }, [getDefaultReviewPanelWidth, t], ); + const openLatestReviewPanel = useCallback(() => { + if (latestReviewChanges.length === 0) return; + openReviewPanel(latestReviewChanges); + }, [latestReviewChanges, openReviewPanel]); const openScheduledTaskPanel = useCallback( ( task: TurnOutputScheduledTask, tabWorkspaceActions?: ReturnType, + sourceSessionId?: string, ) => { const tab: ArtifactPanelTab = { - id: `scheduled-task:${task.toolCallId}`, + id: sourceSessionId + ? `scheduled-task:${sourceSessionId}:${task.toolCallId}` + : `scheduled-task:${task.toolCallId}`, kind: 'scheduled_task', title: t('scheduledTasks.title'), task, @@ -2128,12 +2825,22 @@ export function App({ [getDefaultReviewPanelWidth, t], ); const openMonitorPanel = useCallback( - (task: DaemonSessionMonitorTaskStatus) => { + ( + task: DaemonSessionMonitorTaskStatus, + sourceSessionId?: string, + sourceSessionActions?: DaemonSessionActions, + ) => { const tab: ArtifactPanelTab = { - id: `monitor:${task.id}`, + id: sourceSessionId + ? `monitor:${sourceSessionId}:${task.id}` + : `monitor:${task.id}`, kind: 'monitor', title: task.description, task, + ...(sourceSessionId ? { sessionId: sourceSessionId } : {}), + ...(sourceSessionActions + ? { sessionActions: sourceSessionActions } + : {}), }; setArtifactPanelTabs((tabs) => tabs.some((item) => item.id === tab.id) @@ -2156,6 +2863,45 @@ export function App({ }, [getDefaultReviewPanelWidth], ); + const openShellPanel = useCallback( + ( + task: DaemonSessionShellTaskStatus, + sourceSessionId?: string, + sourceSessionActions?: DaemonSessionActions, + ) => { + const tab: ArtifactPanelTab = { + id: sourceSessionId + ? `shell:${sourceSessionId}:${task.id}` + : `shell:${task.id}`, + kind: 'shell', + title: task.command, + task, + ...(sourceSessionId ? { sessionId: sourceSessionId } : {}), + ...(sourceSessionActions + ? { sessionActions: sourceSessionActions } + : {}), + }; + setArtifactPanelTabs((tabs) => + tabs.some((item) => item.id === tab.id) + ? tabs.map((item) => { + if (item.id !== tab.id || item.kind !== 'shell') return item; + const mergedTask = mergeShellTaskSnapshot(item.task, task); + return { + ...tab, + title: mergedTask.command, + task: mergedTask, + }; + }) + : [...tabs, tab], + ); + setActiveArtifactPanelTabId(tab.id); + setArtifactPanelWidth((width) => + artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(), + ); + setArtifactPanelOpen(true); + }, + [getDefaultReviewPanelWidth], + ); const openSubagentPanelForSession = useCallback( (tool: ACPToolCall, sessionId: string, workspaceCwd?: string) => { const rawOutput = @@ -2206,6 +2952,28 @@ export function App({ openSubagentPanelForSession, ], ); + const openEnvironmentAgent = useCallback( + (task: DaemonSessionAgentTaskStatus) => { + if (!connection.sessionId) return; + if (!artifactPanelOpenRef.current) { + preserveEnvironmentPanelOnArtifactOpenRef.current = true; + } + const tool = task.toolUseId + ? findToolCall(messages, task.toolUseId) + : undefined; + openSubagentPanelForSession( + tool ?? agentTaskAsToolCall(task), + connection.sessionId, + connection.workspaceCwd, + ); + }, + [ + connection.sessionId, + connection.workspaceCwd, + messages, + openSubagentPanelForSession, + ], + ); const handleTurnOutputOpen = useCallback( (request: TurnOutputOpenRequest) => { if (onRightPanelOpen) { @@ -2218,11 +2986,18 @@ export function App({ request.selectedPath, request.workspaceActions, request.workspaceCwd, + request.sourceSessionId + ? `review:${request.sourceSessionId}:${request.turnId}` + : undefined, ); return; } if (request.kind === 'scheduled_task') { - openScheduledTaskPanel(request.task, request.workspaceActions); + openScheduledTaskPanel( + request.task, + request.workspaceActions, + request.sourceSessionId, + ); return; } if (request.kind === 'subagent') { @@ -2233,7 +3008,7 @@ export function App({ ); return; } - if (!request.workspaceActions) { + if (!request.workspaceActions || request.sourceSessionId) { setArtifactPanelExtraArtifacts((current) => { const index = current.findIndex( (artifact) => artifact.id === request.artifact.id, @@ -2245,7 +3020,9 @@ export function App({ }); } const tab: ArtifactPanelTab = { - id: request.id, + id: request.sourceSessionId + ? `${request.sourceSessionId}:${request.id}` + : request.id, kind: 'artifact', title: request.title, artifactId: request.artifactId, @@ -2303,12 +3080,9 @@ export function App({ ); const closeArtifactPanel = useCallback(() => { setArtifactPanelOpen(false); - setArtifactPanelTabs([]); - setActiveArtifactPanelTabId(null); - setReviewChanges([]); - setSelectedReviewPath(null); - setArtifactPanelExtraArtifacts([]); - setPaneArtifactSnapshots(new Map()); + setSideTaskCatalog((catalog) => + catalog.items.length === 0 ? { ...catalog, loaded: false } : catalog, + ); }, []); useLayoutEffect(() => { if (!artifactPanelOpen) return; @@ -2555,18 +3329,26 @@ export function App({ }) as CSSProperties, [bottomPanelHeight, bottomPanelInset], ); - const backgroundTaskActivityKey = useMemo( - () => getBackgroundTaskActivityKey(messages), + const taskActivityKey = useMemo( + () => getTaskActivityKey(messages), [messages], ); const [backgroundTasksRefreshTrigger, setBackgroundTasksRefreshTrigger] = useState(0); - const backgroundTasks = useBackgroundTasks( + const sessionTasks = useBackgroundTasks( connection.sessionId, - backgroundTaskActivityKey, + taskActivityKey, connection.status === 'connected', backgroundTasksRefreshTrigger, ); + const environmentAgentTasks = useMemo( + () => getEnvironmentAgentTasks(messages, sessionTasks), + [messages, sessionTasks], + ); + const backgroundTasks = useMemo( + () => sessionTasks.filter((task) => task.kind !== 'agent'), + [sessionTasks], + ); const monitorDetailsSessionIdRef = useRef(connection.sessionId); monitorDetailsSessionIdRef.current = connection.sessionId; const openMonitorPanelFromTool = useCallback( @@ -2605,7 +3387,12 @@ export function App({ setArtifactPanelTabs((tabs) => { let changed = false; const next = tabs.map((tab) => { - if (tab.kind !== 'monitor') return tab; + if ( + tab.kind !== 'monitor' || + (tab.sessionId && tab.sessionId !== connection.sessionId) + ) { + return tab; + } const task = monitors.get(tab.task.id); if (!task || task === tab.task) return tab; const mergedTask = mergeMonitorTaskSnapshot(tab.task, task); @@ -2619,7 +3406,39 @@ export function App({ }); return changed ? next : tabs; }); - }, [backgroundTasks]); + }, [backgroundTasks, connection.sessionId]); + useEffect(() => { + const shellTasks = new Map( + backgroundTasks + .filter( + (task): task is DaemonSessionShellTaskStatus => task.kind === 'shell', + ) + .map((task) => [task.id, task]), + ); + if (shellTasks.size === 0) return; + setArtifactPanelTabs((tabs) => { + let changed = false; + const next = tabs.map((tab) => { + if ( + tab.kind !== 'shell' || + (tab.sessionId && tab.sessionId !== connection.sessionId) + ) { + return tab; + } + const task = shellTasks.get(tab.task.id); + if (!task || task === tab.task) return tab; + const mergedTask = mergeShellTaskSnapshot(tab.task, task); + if (mergedTask === tab.task) return tab; + changed = true; + return { + ...tab, + title: mergedTask.command, + task: mergedTask, + }; + }); + return changed ? next : tabs; + }); + }, [backgroundTasks, connection.sessionId]); const footerTasks = useMemo( () => (renderFooter ? backgroundTasks.map(mapToWebShellTaskInfo) : []), [backgroundTasks, renderFooter], @@ -3277,6 +4096,14 @@ export function App({ }, [openMonitorPanel], ); + const handleOpenShellDetails = useCallback( + (task: DaemonSessionShellTaskStatus) => { + setTasksDialogMessage(null); + setBackgroundTasksRefreshTrigger((value) => value + 1); + openShellPanel(task); + }, + [openShellPanel], + ); const [selectedTheme, setSelectedTheme] = useState( providedTheme ?? WebShellThemeId.Dark, ); @@ -3289,12 +4116,38 @@ export function App({ }, []); const connectionRef = useRef(connection); connectionRef.current = connection; + const refreshActiveSessionDisplayName = useCallback(async () => { + const activeConnection = connectionRef.current; + if (!activeConnection.sessionId || !activeConnection.workspaceCwd) return; + try { + const sessions = await workspace.client.listWorkspaceSessions( + activeConnection.workspaceCwd, + { pageSize: 200 }, + ); + if ( + connectionRef.current.sessionId !== activeConnection.sessionId || + connectionRef.current.displayName + ) { + return; + } + const displayName = sessions.find( + (session) => session.sessionId === activeConnection.sessionId, + )?.displayName; + if (displayName?.trim()) setSessionStatusDisplayName(displayName); + } catch { + // The live session_metadata_updated event remains the primary path. + } + }, [workspace.client]); + const refreshActiveSessionDisplayNameRef = useRef( + refreshActiveSessionDisplayName, + ); + refreshActiveSessionDisplayNameRef.current = refreshActiveSessionDisplayName; const requireActiveSessionForLocalCommand = useCallback((): boolean => { if (connectionRef.current.sessionId) return true; pushToast('info', t('localCommand.noSession')); return false; }, [pushToast, t]); - const sessionDisplayName = connection.displayName; + const sessionDisplayName = connection.displayName ?? sessionStatusDisplayName; const [currentMode, setCurrentMode] = useState('default'); const currentModeRef = useRef(currentMode); currentModeRef.current = currentMode; @@ -3459,14 +4312,18 @@ export function App({ } delayedReloadTimerRef.current = setTimeout(() => { setSessionListReloadToken((n) => n + 1); + void refreshActiveSessionDisplayNameRef.current(); }, 2000); }, []); const dispatchSessionChange = useCallback( (event: SessionChangeEvent) => { onSessionChange?.(event); setSessionListReloadToken((n) => n + 1); + if (event.type === 'turn_complete') { + scheduleDelayedSessionListReload(); + } }, - [onSessionChange], + [onSessionChange, scheduleDelayedSessionListReload], ); // Ref-stable handle so that useCallback hooks (sendPrompt, enqueuePrompt, // turn_complete effect) don't need dispatchSessionChange in their dep arrays. @@ -5475,10 +6332,12 @@ export function App({ }, openSessionDrawer, createNewSession: () => createNewSession(), + createSideTask, }), [ closeMobileDrawer, createNewSession, + createSideTask, openPanel, openSessionDrawer, requestOpenSplitView, @@ -5728,9 +6587,32 @@ export function App({ setTasksDialogMessage({ snapshot }); }) .catch((error: unknown) => { + if (isSessionDisconnectedError(error)) return; reportError(error, 'Failed to load tasks'); }); }, [reportError, requireActiveSessionForLocalCommand, sessionActions]); + const openEnvironmentTasksPanel = useCallback(() => { + if (!requireActiveSessionForLocalCommand()) return; + setEnvironmentPanelOpen(true); + setBackgroundTasksRefreshTrigger((value) => value + 1); + }, [requireActiveSessionForLocalCommand]); + const openEnvironmentTask = useCallback( + (task: DaemonSessionTaskStatus) => { + if (task.kind === 'monitor' || task.kind === 'shell') { + if (!artifactPanelOpenRef.current) { + preserveEnvironmentPanelOnArtifactOpenRef.current = true; + } + if (task.kind === 'monitor') { + handleOpenMonitorDetails(task); + } else { + handleOpenShellDetails(task); + } + return; + } + openTasksPanel(); + }, + [handleOpenMonitorDetails, handleOpenShellDetails, openTasksPanel], + ); const dispatchGoalSet = useCallback( (condition: string, setAt: number) => { @@ -6017,7 +6899,7 @@ export function App({ return true; } if (cmd === 'tasks') { - openTasksPanel(); + openEnvironmentTasksPanel(); return true; } if (cmd === 'goal') { @@ -6165,6 +7047,7 @@ export function App({ pushToast('warning', t('fork.notStarted')); return; } + setBackgroundTasksRefreshTrigger((value) => value + 1); pushToast( 'success', t('fork.started', { name: result.description }), @@ -6608,7 +7491,20 @@ export function App({ return true; } if (cmd === 'btw') { - runVisibleBtw(text.slice(match[0].length)); + const rawQuestion = text.slice(match[0].length).trim(); + const sideTaskMatch = /^side(?:\s+|$)/i.exec(rawQuestion); + if (sideTasksAvailable && sideTaskMatch) { + const question = rawQuestion + .slice(sideTaskMatch[0].length) + .trim(); + if (!question) { + pushToast('error', t('btw.side.empty')); + return true; + } + createSideTask(question); + return true; + } + runVisibleBtw(rawQuestion); return true; } if (cmd === 'stats') { @@ -6854,7 +7750,9 @@ export function App({ handleSetMode, handleLanguageChange, blockLocalCommandDuringTurn, - openTasksPanel, + createSideTask, + sideTasksAvailable, + openEnvironmentTasksPanel, hiddenCommands, pushToast, reportError, @@ -7436,7 +8334,7 @@ export function App({ mergeCommands( retainedCommands, refreshedSkillCommands, - getLocalCommands(t), + getLocalCommands(t, { sideTaskAvailable: sideTasksAvailable }), ), t, ) @@ -7458,6 +8356,7 @@ export function App({ hiddenCommands, loadedSkills, loadedSkillsReady, + sideTasksAvailable, t, ]); @@ -7516,6 +8415,99 @@ export function App({ const effectiveChatWidthMode: ChatWidthMode = isChatEmptyState ? getDefaultChatWidthMode() : chatWidthMode; + const activeGitBranch = sessionWorktree + ? (selectedWorkspaceGitStatus?.branch ?? sessionWorktree.branch) + : sessionBranch + ? (selectedWorkspaceGitStatus?.branch ?? sessionBranch.name) + : connection.sessionId + ? connection.gitBranch + : (selectedWorkspaceGitStatus?.branch ?? undefined); + const environmentPanelCanDock = + contextBodyWidth === null || + contextBodyWidth >= + MIN_DOCKED_MESSAGE_AREA_WIDTH + DOCKED_ENVIRONMENT_PANEL_WIDTH; + const environmentPanelFits = + chatWidthMode !== 'wide' && environmentPanelCanDock; + const environmentPanelVisible = + environmentPanelOpen && + !isChatEmptyState && + !activePanel && + mainView === 'chat'; + const handleEnvironmentPanelOpenChange = useCallback((open: boolean) => { + if (!open) { + preserveEnvironmentPanelOnArtifactOpenRef.current = false; + setEnvironmentPanelOpen(false); + return; + } + setEnvironmentPanelOpen(true); + }, []); + const dismissEnvironmentPanel = useCallback(() => { + preserveEnvironmentPanelOnArtifactOpenRef.current = false; + setEnvironmentPanelOpen(false); + }, []); + const handleRightPanelOpenChange = useCallback( + (open: boolean) => { + if (open) { + setArtifactPanelOpen(true); + } else { + closeArtifactPanel(); + } + }, + [closeArtifactPanel], + ); + useLayoutEffect(() => { + const body = contextBodyRef.current; + if (!body) return; + const updateWidth = () => { + const availableWidth = body.getBoundingClientRect().width; + if (availableWidth <= 0) return; + setContextBodyWidth((current) => + current === availableWidth ? current : availableWidth, + ); + }; + const handleWindowResize = () => { + preserveEnvironmentPanelOnArtifactOpenRef.current = false; + updateWidth(); + }; + updateWidth(); + window.addEventListener('resize', handleWindowResize); + const observer = new ResizeObserver(updateWidth); + observer.observe(body); + return () => { + window.removeEventListener('resize', handleWindowResize); + observer.disconnect(); + }; + }, []); + const previousEnvironmentCanDockRef = useRef(environmentPanelCanDock); + useLayoutEffect(() => { + const crossedDockBreakpoint = + previousEnvironmentCanDockRef.current && !environmentPanelCanDock; + previousEnvironmentCanDockRef.current = environmentPanelCanDock; + if ( + crossedDockBreakpoint && + !preserveEnvironmentPanelOnArtifactOpenRef.current + ) { + setEnvironmentPanelOpen(false); + } + }, [environmentPanelCanDock]); + const previousArtifactPanelOpenForEnvironmentRef = useRef(artifactPanelOpen); + useLayoutEffect(() => { + const artifactPanelJustOpened = + !previousArtifactPanelOpenForEnvironmentRef.current && artifactPanelOpen; + previousArtifactPanelOpenForEnvironmentRef.current = artifactPanelOpen; + if (!artifactPanelOpen) { + preserveEnvironmentPanelOnArtifactOpenRef.current = false; + return; + } + if (!artifactPanelJustOpened) return; + const preserveEnvironmentPanel = + preserveEnvironmentPanelOnArtifactOpenRef.current; + if (!preserveEnvironmentPanel && !environmentPanelFits) { + setEnvironmentPanelOpen(false); + } + }, [artifactPanelOpen, environmentPanelFits]); + const environmentPanelMounted = + !isChatEmptyState && !activePanel && mainView === 'chat'; const chatWidthToggleMin = getChatMaxWidth(chatMaxWidth); const appClassName = [ @@ -8102,8 +9094,93 @@ export function App({ /> )} +
+ {chatHeaderEnabled && + !isChatEmptyState && + !activePanel && + mainView === 'chat' && ( +
+ {sidebarOptions.enabled && + sidebarOptions.showCompactToggle && ( + + )} + {renderChatHeader ? ( +
+ {renderChatHeader({ + sessionId: connection.sessionId, + sessionName: sessionDisplayName, + workspaceCwd: connection.workspaceCwd, + items: chatHeaderItems, + environmentPanelOpen: environmentPanelVisible, + rightPanelOpen: artifactPanelOpen, + onEnvironmentPanelOpenChange: + handleEnvironmentPanelOpenChange, + onRightPanelOpenChange: handleRightPanelOpenChange, + })} +
+ ) : ( + + handleEnvironmentPanelOpenChange( + !environmentPanelVisible, + ) + } + onToggleRightPanel={() => + handleRightPanelOpenChange(!artifactPanelOpen) + } + /> + )} +
+ )} +
{sidebarOptions.enabled && sidebarOptions.showCompactToggle && + (!chatHeaderEnabled || isChatEmptyState) && !activePanel && mainView === 'chat' && (
+ {environmentPanelMounted && ( +