diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 1967863fbae..a6c2d4a2b4f 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -4611,7 +4611,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { withTimeout( entry.connection.extMethod( SERVE_CONTROL_EXT_METHODS.sessionRewind, - { sessionId, promptId: req.promptId, rewindFiles: true }, + { + sessionId, + promptId: req.promptId, + rewindFiles: req.rewindFiles !== false, + }, ), initTimeoutMs, SERVE_CONTROL_EXT_METHODS.sessionRewind, diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 635656a1629..bfd5c4f1c03 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -40,6 +40,7 @@ export interface RewindSnapshotInfo { export interface RewindRequest { promptId: string; + rewindFiles?: boolean; } export interface RewindResponse { diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index cf522dbfd5b..42c4a6ed3dd 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -1061,6 +1061,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { emitGoalStatus: ReturnType; restoreHistory: ReturnType; rewindToTurn: ReturnType; + getRewindableUserTurnCount: ReturnType; } | undefined; let processExitSpy: MockInstance; @@ -1371,6 +1372,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { rewindToTurn: vi .fn() .mockReturnValue({ targetTurnIndex: 1, apiTruncateIndex: 2 }), + getRewindableUserTurnCount: vi.fn().mockReturnValue(1), }; lastSessionMock = sessionMock; return sessionMock as unknown as InstanceType; @@ -4779,7 +4781,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => { cwd: '/tmp', }); - expect(lastSessionMock?.rewindToTurn).toHaveBeenCalledWith(1); + expect(lastSessionMock?.rewindToTurn).toHaveBeenCalledWith(1, { + rewindFiles: true, + }); expect(response).toEqual({ success: true, historyBeforeRewind: [{ role: 'user', parts: [{ text: 'before' }] }], @@ -4793,6 +4797,39 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('rewindSession extension method can skip file rewind', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + await setupSessionMocks(sessionId); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await agent.extMethod('rewindSession', { + sessionId, + targetTurnIndex: 1, + rewindFiles: false, + cwd: '/tmp', + }); + + expect(lastSessionMock?.rewindToTurn).toHaveBeenCalledWith(1, { + rewindFiles: false, + }); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('rewindSession rejects invalid session ids', async () => { await setupSessionMocks('11111111-1111-1111-1111-111111111111'); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index d201fd75e6d..492177f92a4 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -66,7 +66,6 @@ import type { AgentParams, ApprovalMode, Config, - ConversationRecord, DeviceAuthorizationData, HookConfig, McpBudgetEvent, @@ -75,6 +74,7 @@ import type { ProviderConfig, ProviderModelConfig, ProviderSetupInputs, + ResumedSessionData, } from '@qwen-code/qwen-code-core'; import { AgentSideConnection, @@ -2823,10 +2823,7 @@ class QwenAgent implements Agent { this.setupFileSystem(config); const sessionData = config.getResumedSessionData(); - const session = await this.createAndStoreSession( - config, - sessionData?.conversation, - ); + const session = await this.createAndStoreSession(config, sessionData); await this.#restoreWorktreeOnResume(config, session); @@ -2865,7 +2862,11 @@ class QwenAgent implements Agent { await this.ensureAuthenticated(config); this.setupFileSystem(config); - const session = await this.createAndStoreSession(config); + const session = await this.createAndStoreSession( + config, + config.getResumedSessionData(), + { replayHistory: false }, + ); await this.#restoreWorktreeOnResume(config, session); @@ -5138,6 +5139,7 @@ class QwenAgent implements Agent { } const fhs = session.getConfig().getFileHistoryService(); const snapshots = fhs.getSnapshots(); + const rewindableTurnCount = session.getRewindableUserTurnCount(); const prefix = (sessionId as string) + '########'; const results = await Promise.all( snapshots @@ -5147,6 +5149,7 @@ class QwenAgent implements Agent { s.promptId.startsWith(prefix) && /^\d+$/.test(s.promptId.slice(prefix.length)), ) + .filter(({ idx }) => idx < rewindableTurnCount) .map(async ({ s, idx }) => { const stats = await fhs.getDiffStats(s.promptId); return { @@ -6324,10 +6327,13 @@ class QwenAgent implements Agent { ); } + const rewindFiles = params['rewindFiles'] !== false; const historyBeforeRewind = session.captureHistorySnapshot(); let rewindResult; try { - rewindResult = session.rewindToTurn(turnIndex as number); + rewindResult = session.rewindToTurn(turnIndex as number, { + rewindFiles, + }); } catch (err) { if (err instanceof RequestError) { const msg = err.message; @@ -6347,7 +6353,6 @@ class QwenAgent implements Agent { let filesChanged: string[] = []; let filesFailed: string[] = []; - const rewindFiles = params['rewindFiles'] !== false; if (rewindFiles && promptId) { const fhs = session.getConfig().getFileHistoryService(); try { @@ -7158,7 +7163,8 @@ class QwenAgent implements Agent { private async createAndStoreSession( config: Config, - conversation?: ConversationRecord, + sessionData?: ResumedSessionData, + options: { replayHistory?: boolean } = {}, ): Promise { const sessionId = config.getSessionId(); const geminiClient = config.getGeminiClient(); @@ -7182,8 +7188,14 @@ class QwenAgent implements Agent { await session.sendAvailableCommandsUpdate(); }, 0); - if (conversation && conversation.messages) { - await session.replayHistory(conversation.messages); + if (sessionData?.fileHistorySnapshots?.length) { + config + .getFileHistoryService() + .restoreFromSnapshots(sessionData.fileHistorySnapshots); + } + + if (options.replayHistory !== false && sessionData?.conversation.messages) { + await session.replayHistory(sessionData.conversation.messages); } // Install rewriter AFTER history replay to avoid rewriting historical messages diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 8e6b8ccb62b..a7fe80938a7 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -32,6 +32,7 @@ import type { import type { LoadedSettings } from '../../config/settings.js'; import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js'; import { CommandKind } from '../../ui/commands/types.js'; +import { MessageType } from '../../ui/types.js'; const debugLoggerWarnSpy = vi.hoisted(() => vi.fn()); @@ -506,6 +507,37 @@ describe('Session', () => { ); }); + it('can rewind the conversation without restoring file history', () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'first' }] }, + { role: 'model', parts: [{ text: 'first reply' }] }, + { role: 'user', parts: [{ text: 'second' }] }, + { role: 'model', parts: [{ text: 'second reply' }] }, + ]; + vi.mocked(mockChat.getHistory).mockReturnValue(history); + vi.mocked(mockChat.getHistoryShallow).mockReturnValue(history); + vi.mocked(mockFileHistoryService.getSnapshots).mockReturnValue([ + { + promptId: 'p1', + timestamp: new Date('2026-06-13T00:00:00.000Z'), + trackedFileBackups: {}, + }, + ]); + + const result = session.rewindToTurn(1, { rewindFiles: false }); + + expect(result).toEqual({ targetTurnIndex: 1, apiTruncateIndex: 2 }); + expect(mockChat.truncateHistory).toHaveBeenCalledWith(2); + expect( + mockFileHistoryService.restoreFromSnapshots, + ).not.toHaveBeenCalled(); + expect(mockChatRecordingService.rewindRecording).toHaveBeenCalledWith( + 1, + { truncatedCount: 2 }, + undefined, + ); + }); + it('preserves startup context when rewinding to the first user turn', () => { const history: Content[] = [ { @@ -528,6 +560,33 @@ describe('Session', () => { expect(mockChat.truncateHistory).toHaveBeenCalledWith(1); }); + it('counts only real user prompts as rewindable turns', () => { + const history: Content[] = [ + { + role: 'user', + parts: [ + { + text: `${SYSTEM_REMINDER_OPEN}\nstartup context\n${SYSTEM_REMINDER_CLOSE}`, + }, + ], + }, + { role: 'user', parts: [{ text: 'first' }] }, + { role: 'model', parts: [{ text: 'first reply' }] }, + { + role: 'user', + parts: [ + { + text: `${SYSTEM_REMINDER_OPEN}\nNew tools available: foo\n${SYSTEM_REMINDER_CLOSE}`, + }, + ], + }, + { role: 'user', parts: [{ text: 'second' }] }, + ]; + vi.mocked(mockChat.getHistoryShallow).mockReturnValue(history); + + expect(session.getRewindableUserTurnCount()).toBe(2); + }); + it('does not count a mid-history MCP added-tool reminder as a user turn', () => { // drainPendingAddedMcpToolsReminder injects a pure // user entry mid-history. Counting it as a real turn would land the @@ -4319,6 +4378,58 @@ describe('Session', () => { expect(mockGeminiClient.tryCompressChat).not.toHaveBeenCalled(); expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); }); + + it('keeps goal terminal observer after ACP /goal set', async () => { + vi.mocked( + nonInteractiveCliCommands.handleSlashCommand, + ).mockResolvedValueOnce({ + type: 'submit_prompt', + content: [{ text: 'Continue until the goal is met.' }], + outputHistoryItems: [ + { + type: MessageType.GOAL_STATUS, + kind: 'set', + condition: 'check weather', + setAt: 1234, + }, + ], + }); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: '/goal check weather' }], + }); + + core.notifyGoalTerminal('test-session-id', { + kind: 'achieved', + condition: 'check weather', + iterations: 1, + durationMs: 5000, + lastReason: 'Weather checked.', + }); + + await vi.waitFor(() => { + expect(mockClient.sessionUpdate).toHaveBeenCalledWith({ + sessionId: 'test-session-id', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: '' }, + _meta: { + goalTerminal: { + kind: 'achieved', + condition: 'check weather', + iterations: 1, + durationMs: 5000, + lastReason: 'Weather checked.', + }, + }, + }, + }); + }); + }); }); it('passes resolved paths to read_many_files tool', async () => { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index b3eb811a2c7..5c0f0292dde 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -136,7 +136,11 @@ import { } from '../../nonInteractiveCliCommands.js'; import { isSlashCommand } from '../../ui/utils/commandUtils.js'; import { CommandKind } from '../../ui/commands/types.js'; -import { MessageType, type HistoryItemGoalStatus } from '../../ui/types.js'; +import { + isTerminalGoalStatusKind, + MessageType, + type HistoryItemGoalStatus, +} from '../../ui/types.js'; import { parseAcpModelOption } from '../../utils/acpModelUtils.js'; import { classifyApiError } from '../../ui/hooks/useGeminiStream.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; @@ -853,7 +857,10 @@ export class Session implements SessionContext { await this.historyReplayer.replay(records); } - rewindToTurn(targetTurnIndex: number): { + rewindToTurn( + targetTurnIndex: number, + opts?: { rewindFiles?: boolean }, + ): { targetTurnIndex: number; apiTruncateIndex: number; } { @@ -894,12 +901,15 @@ export class Session implements SessionContext { chat.truncateHistory(apiTruncateIndex); chat.stripThoughtsFromHistory(); + const rewindFiles = opts?.rewindFiles !== false; const fileHistoryService = this.config.getFileHistoryService(); - const survivingSnapshots = fileHistoryService - .getSnapshots() - .slice(0, targetTurnIndex + 1); + const survivingSnapshots = rewindFiles + ? fileHistoryService.getSnapshots().slice(0, targetTurnIndex + 1) + : undefined; - fileHistoryService.restoreFromSnapshots(survivingSnapshots); + if (survivingSnapshots) { + fileHistoryService.restoreFromSnapshots(survivingSnapshots); + } this.config .getChatRecordingService() @@ -916,6 +926,20 @@ export class Session implements SessionContext { return this.config.getGeminiClient()!.getChat().getHistoryShallow(); } + getRewindableUserTurnCount(): number { + const apiHistory = this.captureHistorySnapshot(); + const startIndex = getStartupContextLength(apiHistory); + let count = 0; + + for (let i = startIndex; i < apiHistory.length; i++) { + if (this.#isUserTextContent(apiHistory[i]!)) { + count += 1; + } + } + + return count; + } + restoreHistory(history: Content[]): void { if ( this.pendingPrompt || @@ -4418,6 +4442,7 @@ export class Session implements SessionContext { if (!('outputHistoryItems' in result)) { return; } + let hasActiveGoalStatus = false; for (const item of result.outputHistoryItems ?? []) { if (item.type === MessageType.GOAL_STATUS) { this.emitGoalStatus({ @@ -4434,8 +4459,14 @@ export class Session implements SessionContext { ? { lastReason: item.lastReason } : {}), }); + if (!isTerminalGoalStatusKind(item.kind)) { + hasActiveGoalStatus = true; + } } } + if (hasActiveGoalStatus) { + this.#installGoalTerminalObserver(); + } } /** diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 3d921e7a5e4..0ba2d952506 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -3994,7 +3994,7 @@ export function createServeApp( try { const response = await bridge.rewindSession( sessionId, - { promptId }, + { promptId, rewindFiles: body['rewindFiles'] !== false }, clientId !== undefined ? { clientId } : undefined, ); res.status(200).json(response); diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 9562c9c5dad..6599c7605be 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -1941,6 +1941,50 @@ describe('SessionService', () => { expect(srcLines.every((r) => !r.forkedFrom)).toBe(true); }); + it('preserves file history snapshots on the forked session', async () => { + const oldId = '31313131-3131-3131-3131-313131313131'; + const newId = '41414141-4141-4141-4141-414141414141'; + const { file, lines } = seedSession(oldId); + const snapshotRecord = { + uuid: 'snapshot-1', + parentUuid: 'u2', + sessionId: oldId, + type: 'system', + subtype: 'file_history_snapshot', + timestamp: '2026-04-22T00:00:02.000Z', + cwd, + version: 'test', + systemPayload: { + snapshots: [ + { + promptId: `${oldId}########0`, + timestamp: '2026-04-22T00:00:00.000Z', + trackedFileBackups: { + 'a.txt': { + backupFileName: 'backup-a', + version: 1, + backupTime: '2026-04-22T00:00:00.000Z', + }, + }, + }, + ], + }, + }; + fs.writeFileSync( + file, + [...lines, snapshotRecord].map((l) => JSON.stringify(l)).join('\n') + + '\n', + ); + + await service.forkSession(oldId, newId); + const loaded = await service.loadSession(newId); + + expect(loaded?.fileHistorySnapshots).toHaveLength(1); + expect(loaded?.fileHistorySnapshots?.[0]?.promptId).toBe( + `${newId}########0`, + ); + }); + it('forks only the active branch after rewind', async () => { const oldId = '12121212-1212-1212-1212-121212121212'; const newId = '34343434-3434-3434-3434-343434343434'; diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index ca73b57240a..ab4767ccbb3 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -24,6 +24,7 @@ import { deserializeSnapshots, FILE_HISTORY_DIR, MAX_SNAPSHOTS, + serializeSnapshot, } from './fileHistoryService.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; import { createDebugLogger } from '../utils/debugLogger.js'; @@ -1061,10 +1062,19 @@ export class SessionService { // message. let prevUuid: string | null = null; const forked: ChatRecord[] = sourceRecords.map((record) => { + const systemPayload = + record.type === 'system' && record.subtype === 'file_history_snapshot' + ? remapFileHistorySnapshotPayload( + record.systemPayload, + sourceSessionId, + newSessionId, + ) + : record.systemPayload; const next: ChatRecord = { ...record, sessionId: newSessionId, cwd: this.projectRoot, + systemPayload, parentUuid: prevUuid, forkedFrom: { sessionId: sourceSessionId, @@ -1075,6 +1085,36 @@ export class SessionService { 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 + // records may still point at the source session's prompt ids. Remap and + // top up snapshots explicitly so /branch sessions expose /rewind/snapshots + // immediately after they become live. + const sourceSession = await this.loadSession(sourceSessionId); + const existingSnapshotPromptIds = + collectFileHistorySnapshotPromptIds(forked); + const remappedSnapshots = sourceSession?.fileHistorySnapshots + ?.map((snapshot) => + remapSnapshotPromptId(snapshot, sourceSessionId, newSessionId), + ) + .filter((snapshot) => !existingSnapshotPromptIds.has(snapshot.promptId)); + if (remappedSnapshots?.length) { + const snapshotRecord: ChatRecord = { + uuid: randomUUID(), + parentUuid: prevUuid, + sessionId: newSessionId, + type: 'system', + subtype: 'file_history_snapshot', + timestamp: new Date().toISOString(), + cwd: this.projectRoot, + version: records[0].version, + systemPayload: { + snapshots: remappedSnapshots.map(serializeSnapshot), + }, + }; + forked.push(snapshotRecord); + } + fs.mkdirSync(chatsDir, { recursive: true }); const body = forked.map((r) => JSON.stringify(r)).join('\n') + '\n'; @@ -1464,6 +1504,67 @@ export function buildApiHistoryFromConversation( return result; } +function remapSnapshotPromptId( + snapshot: FileHistorySnapshot, + sourceSessionId: string, + newSessionId: string, +): FileHistorySnapshot { + const sourcePrefix = `${sourceSessionId}########`; + if (!snapshot.promptId.startsWith(sourcePrefix)) { + return snapshot; + } + return { + ...snapshot, + promptId: `${newSessionId}########${snapshot.promptId.slice(sourcePrefix.length)}`, + }; +} + +function remapFileHistorySnapshotPayload( + payload: ChatRecord['systemPayload'], + sourceSessionId: string, + newSessionId: string, +): ChatRecord['systemPayload'] { + const filePayload = payload as FileHistorySnapshotRecordPayload | undefined; + if (!Array.isArray(filePayload?.snapshots)) return payload; + + try { + const snapshots = deserializeSnapshots(filePayload.snapshots).map( + (snapshot) => + serializeSnapshot( + remapSnapshotPromptId(snapshot, sourceSessionId, newSessionId), + ), + ); + return { snapshots }; + } catch { + return payload; + } +} + +function collectFileHistorySnapshotPromptIds( + records: ChatRecord[], +): Set { + const ids = new Set(); + for (const record of records) { + if ( + record.type !== 'system' || + record.subtype !== 'file_history_snapshot' || + !record.systemPayload + ) { + continue; + } + const payload = record.systemPayload as FileHistorySnapshotRecordPayload; + if (!Array.isArray(payload.snapshots)) continue; + try { + for (const snapshot of deserializeSnapshots(payload.snapshots)) { + ids.add(snapshot.promptId); + } + } catch { + // Malformed snapshot records are ignored the same way loadSession does. + } + } + return ids; +} + /** * Replays stored UI telemetry events to rebuild metrics when resuming a session. * Also restores the last prompt token count from the best available source. diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index b31ca59e321..26343ffcea4 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -28,7 +28,8 @@ const rootDir = join(__dirname, '..'); // Bumped from 119KB to 122KB for the workspace extension management surface // (install/update/enable/disable/uninstall/refresh/check update endpoints). // Bumped from 122KB to 124KB for daemon fork-session APIs/events. -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 124 * 1024; +// Bumped from 124KB to 125KB for rewind/branch transcript/session APIs. +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 125 * 1024; rmSync(join(rootDir, 'dist'), { recursive: true, force: true }); mkdirSync(join(rootDir, 'dist'), { recursive: true }); diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 47fc8922a91..9e43f2b4905 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -1541,7 +1541,7 @@ export class DaemonClient { async rewindSession( sessionId: string, promptId: string, - opts?: { clientId?: string }, + opts?: { clientId?: string; rewindFiles?: boolean }, ): Promise { return await this.fetchWithTimeout( `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/rewind`, @@ -1551,7 +1551,12 @@ export class DaemonClient { { 'Content-Type': 'application/json' }, opts?.clientId, ), - body: JSON.stringify({ promptId }), + body: JSON.stringify({ + promptId, + ...(opts?.rewindFiles !== undefined + ? { rewindFiles: opts.rewindFiles } + : {}), + }), }, async (res) => { if (!res.ok) { diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index ce07492fa34..3c7cb4d8291 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -337,9 +337,15 @@ export class DaemonSessionClient { return await this.client.getRewindSnapshots(this.sessionId); } - async rewind(promptId: string): Promise { + async rewind( + promptId: string, + opts?: { rewindFiles?: boolean }, + ): Promise { return await this.client.rewindSession(this.sessionId, promptId, { clientId: this.clientId, + ...(opts?.rewindFiles !== undefined + ? { rewindFiles: opts.rewindFiles } + : {}), }); } diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index e1bbbfeafb5..1aef1e76368 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -86,6 +86,7 @@ export { daemonToolPreviewToMarkdown, daemonUiEventToTerminalText, extractContentPart, + extractServerTimestamp, formatBlockTimestamp, getOutputText as getDaemonUiOutputText, getSessionUpdatePayload, @@ -126,6 +127,7 @@ export type { DaemonPermissionTranscriptBlock, DaemonStatusTranscriptBlock, DaemonTextTranscriptBlock, + DaemonTextDeltaMeta, DaemonToolPreview, DaemonToolTranscriptBlock, DaemonTranscriptBlock, diff --git a/packages/sdk-typescript/src/daemon/ui/index.ts b/packages/sdk-typescript/src/daemon/ui/index.ts index f64cfe88703..44e8232f606 100644 --- a/packages/sdk-typescript/src/daemon/ui/index.ts +++ b/packages/sdk-typescript/src/daemon/ui/index.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { normalizeDaemonEvent, getSessionUpdatePayload } from './normalizer.js'; +export { + extractServerTimestamp, + normalizeDaemonEvent, + getSessionUpdatePayload, +} from './normalizer.js'; export { createDaemonToolPreview } from './toolPreview.js'; export { appendLocalUserTranscriptMessage, @@ -63,6 +67,7 @@ export type { DaemonPermissionTranscriptBlock, DaemonStatusTranscriptBlock, DaemonTextTranscriptBlock, + DaemonTextDeltaMeta, DaemonToolPreview, DaemonToolTranscriptBlock, DaemonTranscriptBlock, diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index 0b7523574a2..f4e94e95c46 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -195,6 +195,9 @@ export function normalizeDaemonEvent( case 'followup_suggestion': return normalizeFollowupSuggestion(event, base); + case 'mid_turn_message_injected': + return normalizeMidTurnMessageInjected(event, base); + case 'user_shell_command': { const command = getString(event.data, 'command'); const cwd = getString(event.data, 'cwd'); @@ -403,6 +406,33 @@ function normalizeFollowupSuggestion( ]; } +function normalizeMidTurnMessageInjected( + event: DaemonEvent, + base: NormalizedEventBase, +): DaemonUiEvent[] { + if (!isRecord(event.data)) { + return fallbackDebug(event, base, 'malformed mid_turn_message_injected'); + } + const messages = Array.isArray(event.data['messages']) + ? event.data['messages'].filter( + (message): message is string => + typeof message === 'string' && message.length > 0, + ) + : []; + if (messages.length === 0) { + return fallbackDebug(event, base, 'malformed mid_turn_message_injected'); + } + return [ + { + ...base, + type: 'status', + text: `Inserted message: ${messages.join('\n')}`, + source: 'mid_turn_message_injected', + data: event.data, + }, + ]; +} + function createBase( event: DaemonEvent, opts: NormalizeDaemonEventOptions, @@ -433,7 +463,9 @@ function createBase( * Forward-compat: SDK reads whichever location the daemon eventually emits * without requiring a coordinated SDK release. */ -function extractServerTimestamp(event: DaemonEvent): number | undefined { +export function extractServerTimestamp( + event: DaemonEvent, +): number | undefined { const direct = (event as { serverTimestamp?: unknown }).serverTimestamp; if (typeof direct === 'number' && Number.isFinite(direct)) return direct; const envelopeMeta = (event as { _meta?: unknown })._meta; @@ -524,6 +556,7 @@ function normalizeSessionUpdate( case 'agent_message_chunk': { const text = getTextContent(update['content']); const parentToolCallId = extractParentToolCallId(update); + const meta = extractUpdateMeta(update); const events: DaemonUiEvent[] = []; if (text) { events.push({ @@ -531,6 +564,7 @@ function normalizeSessionUpdate( type: 'assistant.text.delta' as const, text, ...(parentToolCallId ? { parentToolCallId } : {}), + ...(meta ? { meta } : {}), }); } // A turn's per-round token usage rides on an otherwise-empty @@ -618,6 +652,13 @@ function extractParentToolCallId( return meta ? getString(meta, 'parentToolCallId') : undefined; } +function extractUpdateMeta( + update: Record, +): Record | undefined { + const meta = isRecord(update['_meta']) ? update['_meta'] : undefined; + return meta ? { ...meta } : undefined; +} + /** * Read the token usage the daemon stamps on `agent_message_chunk._meta.usage`. * Returns undefined when no usage is present (older agents, non-usage chunks) so diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index ee2bfc538c3..51b9e680ab4 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -221,7 +221,7 @@ function applyDaemonTranscriptEvent( ); break; case 'assistant.done': - finishAssistant(next); + finishAssistant(next, event); // PR-E cancellation propagation: when the assistant turn ENDS // abnormally, any in-flight tool block whose status the daemon // never updated to a terminal state would otherwise spin forever. @@ -418,16 +418,24 @@ export function selectPendingPermissionBlocks( function finalizeStreamingTextBlock( state: DaemonTranscriptState, blockId: string | undefined, + event?: DaemonUiEvent, ): void { const block = getWritableBlockById(state, blockId); if (block?.kind === 'assistant' || block?.kind === 'thought') { block.streaming = false; block.updatedAt = state.now; + if (event?.eventId !== undefined) block.eventId = event.eventId; + if (event?.serverTimestamp !== undefined) { + block.serverTimestamp = event.serverTimestamp; + } } } -function clearActiveAssistant(state: DaemonTranscriptState): void { - finalizeStreamingTextBlock(state, state.activeAssistantBlockId); +function clearActiveAssistant( + state: DaemonTranscriptState, + event?: DaemonUiEvent, +): void { + finalizeStreamingTextBlock(state, state.activeAssistantBlockId, event); state.activeAssistantBlockId = undefined; } @@ -462,18 +470,23 @@ function applyAssistantUsage( block.updatedAt = state.now; } -function clearActiveThought(state: DaemonTranscriptState): void { - finalizeStreamingTextBlock(state, state.activeThoughtBlockId); +function clearActiveThought( + state: DaemonTranscriptState, + event?: DaemonUiEvent, +): void { + finalizeStreamingTextBlock(state, state.activeThoughtBlockId, event); state.activeThoughtBlockId = undefined; } function clearActiveAssistantForParent( state: DaemonTranscriptState, parentToolCallId: string, + event?: DaemonUiEvent, ): void { finalizeStreamingTextBlock( state, state.activeAssistantBlockByParent[parentToolCallId], + event, ); delete state.activeAssistantBlockByParent[parentToolCallId]; } @@ -481,10 +494,12 @@ function clearActiveAssistantForParent( function clearActiveThoughtForParent( state: DaemonTranscriptState, parentToolCallId: string, + event?: DaemonUiEvent, ): void { finalizeStreamingTextBlock( state, state.activeThoughtBlockByParent[parentToolCallId], + event, ); delete state.activeThoughtBlockByParent[parentToolCallId]; } @@ -520,13 +535,20 @@ function appendTextDelta( parentMap && parentId != null ? parentMap[parentId] : state[activeKey]; const existing = getWritableBlockById(state, effectiveId); - if (existing && existing.kind === kind) { + if ( + existing && + existing.kind === kind && + canMergeTextDelta(existing, event) + ) { existing.text = appendBoundedText(existing.text, text); existing.updatedAt = state.now; if (event.eventId !== undefined) existing.eventId = event.eventId; if (event.serverTimestamp !== undefined) { existing.serverTimestamp = event.serverTimestamp; } + if ('meta' in event && event.meta) { + existing.meta = { ...existing.meta, ...event.meta }; + } if (kind === 'assistant' || kind === 'thought') existing.streaming = true; return; } @@ -537,6 +559,7 @@ function appendTextDelta( text, event.eventId, event.serverTimestamp, + 'meta' in event ? event.meta : undefined, ); if (kind === 'assistant' || kind === 'thought') block.streaming = true; if (kind === 'thought') block.collapsed = true; @@ -565,16 +588,34 @@ function appendTextDelta( } } -function finishAssistant(state: DaemonTranscriptState): void { - clearActiveAssistant(state); +function canMergeTextDelta( + existing: DaemonTranscriptBlock, + event: DaemonUiEvent, +): boolean { + if ( + existing.kind !== 'user' && + existing.kind !== 'assistant' && + existing.kind !== 'thought' + ) { + return false; + } + if (existing.meta?.qwenDiscreteMessage === true) return false; + return !('meta' in event) || event.meta?.qwenDiscreteMessage !== true; +} + +function finishAssistant( + state: DaemonTranscriptState, + event?: DaemonUiEvent, +): void { + clearActiveAssistant(state, event); for (const parentId of Object.keys(state.activeAssistantBlockByParent)) { - clearActiveAssistantForParent(state, parentId); + clearActiveAssistantForParent(state, parentId, event); } for (const parentId of Object.keys(state.activeThoughtBlockByParent)) { - clearActiveThoughtForParent(state, parentId); + clearActiveThoughtForParent(state, parentId, event); } - clearActiveThought(state); + clearActiveThought(state, event); } function upsertToolBlock( @@ -1021,6 +1062,7 @@ function createTextBlock( text: string, eventId?: number, serverTimestamp?: number, + meta?: Record, ): DaemonTextTranscriptBlock { return { id: allocateBlockId(state, kind), @@ -1031,6 +1073,7 @@ function createTextBlock( updatedAt: state.now, ...(eventId !== undefined ? { eventId } : {}), ...(serverTimestamp !== undefined ? { serverTimestamp } : {}), + ...(meta ? { meta: { ...meta } } : {}), }; } @@ -1201,18 +1244,20 @@ function rewindTranscriptToUserTurn( state: DaemonTranscriptState, targetTurnIndex: number, ): void { - let userTurn = 0; + let userTurnIndex = 0; let lastUserIndex = -1; + for (let index = 0; index < state.blocks.length; index += 1) { if (state.blocks[index]?.kind !== 'user') continue; lastUserIndex = index; - if (userTurn === targetTurnIndex) { + if (userTurnIndex === targetTurnIndex) { truncateTranscriptBeforeBlock(state, index); return; } - userTurn += 1; + userTurnIndex += 1; } - if (lastUserIndex >= 0 && targetTurnIndex >= userTurn) { + + if (lastUserIndex >= 0 && targetTurnIndex >= userTurnIndex) { truncateTranscriptBeforeBlock(state, lastUserIndex); } } @@ -1224,16 +1269,13 @@ function truncateTranscriptBeforeBlock( takeBlocksOwnership(state); state.blocks = state.blocks.slice(0, blockIndex); ownedBlocks.set(state, state.blocks); + rebuildTranscriptIndexes(state); +} + +function rebuildTranscriptIndexes(state: DaemonTranscriptState): void { state.blockIndexById = rebuildDaemonTranscriptBlockIndex(state.blocks); state.toolBlockByCallId = {}; state.permissionBlockByRequestId = {}; - for (const block of state.blocks) { - if (block.kind === 'tool') - state.toolBlockByCallId[block.toolCallId] = block.id; - if (block.kind === 'permission') { - state.permissionBlockByRequestId[block.requestId] = block.id; - } - } state.trimmedToolNotificationByCallId = {}; state.activeUserBlockId = undefined; state.activeAssistantBlockId = undefined; @@ -1243,7 +1285,22 @@ function truncateTranscriptBeforeBlock( state.currentToolCallId = undefined; state.pendingUserShellCommand = undefined; state.lastFollowupSuggestion = undefined; - state.toolProgress = {}; + + const liveToolCallIds = new Set(); + for (const block of state.blocks) { + if (block.kind === 'tool') { + state.toolBlockByCallId[block.toolCallId] = block.id; + liveToolCallIds.add(block.toolCallId); + } else if (block.kind === 'permission') { + state.permissionBlockByRequestId[block.requestId] = block.id; + } + } + + for (const toolCallId of Object.keys(state.toolProgress)) { + if (!liveToolCallIds.has(toolCallId)) { + delete state.toolProgress[toolCallId]; + } + } } function appendBlock( diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 90178e55e0f..50abaaf3cdc 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -89,6 +89,11 @@ export interface DaemonUiTextEvent extends DaemonUiEventBase { type: 'user.text.delta' | 'assistant.text.delta' | 'thought.text.delta'; text: string; parentToolCallId?: string; + meta?: DaemonTextDeltaMeta; +} + +export interface DaemonTextDeltaMeta extends Record { + qwenDiscreteMessage?: boolean; } export interface DaemonUiUserImageEvent extends DaemonUiEventBase { @@ -744,6 +749,8 @@ export interface DaemonTextTranscriptBlock extends DaemonTranscriptBlockBase { collapsed?: boolean; /** Used by the reducer for per-subAgent block routing; renderers may use it for nesting. */ parentToolCallId?: string; + /** Raw ACP update metadata used by renderers for display-only routing. */ + meta?: DaemonTextDeltaMeta; /** * Token usage folded onto this assistant block by the reducer from the * round's `assistant.usage` event(s). Summed across a turn's assistant blocks diff --git a/packages/sdk-typescript/test/daemon-ui-transcript.test.ts b/packages/sdk-typescript/test/daemon-ui-transcript.test.ts new file mode 100644 index 00000000000..744856ef6e3 --- /dev/null +++ b/packages/sdk-typescript/test/daemon-ui-transcript.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { + createDaemonTranscriptState, + reduceDaemonTranscriptEvents, +} from '../src/daemon/ui/transcript.js'; +import type { DaemonUiEvent } from '../src/daemon/ui/types.js'; + +describe('daemon transcript rewind', () => { + it('drops the target user turn and later transcript blocks', () => { + const events: DaemonUiEvent[] = [ + { type: 'user.text.delta', text: 'first' }, + { type: 'assistant.text.delta', text: 'first answer' }, + { type: 'assistant.done' }, + { type: 'user.text.delta', text: 'second' }, + { type: 'assistant.text.delta', text: 'second answer' }, + { type: 'assistant.done' }, + { + type: 'session.rewound', + promptId: 'session########1', + targetTurnIndex: 1, + }, + ]; + + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + events, + { now: 1 }, + ); + + expect(state.blocks.map((block) => block.kind)).toEqual([ + 'user', + 'assistant', + ]); + expect( + state.blocks.map((block) => ('text' in block ? block.text : '')), + ).toEqual(['first', 'first answer']); + expect(state.activeUserBlockId).toBeUndefined(); + expect(state.activeAssistantBlockId).toBeUndefined(); + }); +}); diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index bd8d8c15502..27ec31c88eb 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -64,6 +64,105 @@ describe('daemon UI normalizer and transcript reducer', () => { ]); }); + it('preserves assistant message metadata on transcript blocks', () => { + const events = normalizeDaemonEvent({ + id: 10, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Background agent "x" completed.' }, + _meta: { + source: 'background_notification', + qwenDiscreteMessage: true, + backgroundTask: { taskId: 'task-1', status: 'completed' }, + }, + }, + }, + }); + + expect(events[0]).toMatchObject({ + type: 'assistant.text.delta', + meta: { + source: 'background_notification', + qwenDiscreteMessage: true, + backgroundTask: { taskId: 'task-1', status: 'completed' }, + }, + }); + + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + events, + { now: 2 }, + ); + expect(state.blocks[0]).toMatchObject({ + kind: 'assistant', + meta: { + source: 'background_notification', + qwenDiscreteMessage: true, + backgroundTask: { taskId: 'task-1', status: 'completed' }, + }, + }); + }); + + it('keeps discrete assistant messages separate from normal text blocks', () => { + const normalBefore = normalizeDaemonEvent({ + id: 11, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: '前面的正常回复。' }, + }, + }, + }); + const notification = normalizeDaemonEvent({ + id: 12, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Background agent "x" completed.' }, + _meta: { + source: 'background_notification', + qwenDiscreteMessage: true, + backgroundTask: { taskId: 'task-1', status: 'completed' }, + }, + }, + }, + }); + const normalAfter = normalizeDaemonEvent({ + id: 13, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: '后面的正常回复。' }, + }, + }, + }); + + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + [...normalBefore, ...notification, ...normalAfter], + { now: 2 }, + ); + + expect(state.blocks).toMatchObject([ + { kind: 'assistant', text: '前面的正常回复。' }, + { + kind: 'assistant', + text: 'Background agent "x" completed.', + meta: { qwenDiscreteMessage: true }, + }, + { kind: 'assistant', text: '后面的正常回复。' }, + ]); + }); + it('passes the agent-stamped plan stats snapshot through to rawOutput', () => { const events = normalizeDaemonEvent({ id: 5, @@ -2504,6 +2603,42 @@ describe('daemon UI time schema (PR-B)', () => { }); }); + it('stamps assistant.done serverTimestamp onto the active assistant block', () => { + let state = createDaemonTranscriptState({ now: 1 }); + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + _meta: { timestamp: 1_000 }, + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'done' }, + }, + }, + }), + ); + + state = reduceDaemonTranscriptEvents(state, [ + { + type: 'assistant.done', + reason: 'end_turn', + eventId: 2, + serverTimestamp: 5_000, + }, + ]); + + expect(state.blocks[0]).toMatchObject({ + kind: 'assistant', + text: 'done', + streaming: false, + eventId: 2, + serverTimestamp: 5_000, + }); + }); + it('extracts serverTimestamp from top-level envelope field when present', () => { const events = normalizeDaemonEvent({ id: 3, @@ -5330,6 +5465,23 @@ describe('R5 review batch — coverage additions', () => { expect(events[0]?.type).toBe('debug'); }); + it('normalizes mid_turn_message_injected to structured status', () => { + const events = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'mid_turn_message_injected', + data: { sessionId: 's1', messages: ['你好'] }, + }); + expect(events).toEqual([ + expect.objectContaining({ + type: 'status', + text: 'Inserted message: 你好', + source: 'mid_turn_message_injected', + data: { sessionId: 's1', messages: ['你好'] }, + }), + ]); + }); + it('store.clearAwaitingResync clears latch', async () => { const { createDaemonTranscriptStore } = await import( '../../src/daemon/ui/index.js' diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index a8d40c6b471..6a774ab5d3d 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -1,4 +1,8 @@ .app { + --chat-content-width: 1000px; + --chat-shell-width: 1040px; + --chat-regular-content-width: 1000px; + --chat-regular-shell-width: 1040px; --font-mono: 'SF Mono', 'Fira Code', 'JetBrains Mono', 'Cascadia Code', monospace; --font-sans: @@ -8,6 +12,7 @@ display: flex; flex-direction: column; height: 100%; + min-height: 0; background: var(--app-bg); color: var(--text-primary); font-family: var(--font-mono); @@ -16,6 +21,17 @@ overflow: hidden; } +.appChat { + height: 100%; + min-height: 0; + overflow: hidden; +} + +.appChatEmpty { + justify-content: center; + overflow-y: auto; +} + .app, .app * { box-sizing: border-box; @@ -38,6 +54,7 @@ --error-color: #fc8181; --user-color: #48bb78; --assist-color: #b794f4; + --user-msg-bg: rgba(255, 255, 255, 0.15); --welcome-logo-gradient: linear-gradient( 90deg, #4796e4 0%, @@ -46,9 +63,15 @@ ); --editor-bg: #0a0a0a; --msg-body-color: #e3bcfe; - --thinking-pre-color: #9d9ec6; + --thinking-pre-color: #757575; + --processed-label-color: #a4a4a4; + --chat-editor-container-bg: #2f2f2f; + --chat-editor-selection-bg: #3d5a7a; + --chat-editor-selection-color: #ffffff; + --command-panel-bg: #2f2f2f; --subtle-bg: rgba(255, 255, 255, 0.04); --subtle-bg-strong: rgba(255, 255, 255, 0.06); + --inline-code-bg: #2a2a2a; --info-bg: rgba(74, 158, 255, 0.08); --info-border: rgba(74, 158, 255, 0.2); --success-bg: rgba(72, 187, 120, 0.12); @@ -62,7 +85,7 @@ --app-bg: #fff; --bg-primary: #ffffff; --bg-secondary: #f7f7f2; - --bg-tertiary: #efeee8; + --bg-tertiary: #f2f2f3; --bg-surface: #f5f4ed; --border-color: #d8d6cc; --text-primary: #22231f; @@ -75,6 +98,7 @@ --error-color: #c0362c; --user-color: #0a7a3d; --assist-color: #7b4ab8; + --user-msg-bg: var(--bg-tertiary); --welcome-logo-gradient: linear-gradient( 90deg, #0b66c3 0%, @@ -83,9 +107,15 @@ ); --editor-bg: #f7f7f2; --msg-body-color: #0b6f3a; - --thinking-pre-color: #74788f; + --thinking-pre-color: #a3a4a5; + --processed-label-color: #777879; + --chat-editor-container-bg: var(--bg-primary); + --chat-editor-selection-bg: rgba(11, 102, 195, 0.18); + --chat-editor-selection-color: var(--text-primary); + --command-panel-bg: var(--bg-primary); --subtle-bg: rgba(32, 31, 25, 0.05); --subtle-bg-strong: rgba(32, 31, 25, 0.08); + --inline-code-bg: #f1f2f2; --info-bg: rgba(11, 102, 195, 0.08); --info-border: rgba(11, 102, 195, 0.22); --success-bg: rgba(31, 122, 58, 0.11); @@ -95,17 +125,8 @@ --error-border: rgba(192, 54, 44, 0.24); } -.dialogOverlay { - position: absolute; - inset: 0; - z-index: 10; - display: flex; - flex-direction: column; - background: var(--app-bg); -} - .content { - flex: 0 1 auto; + flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; @@ -116,9 +137,19 @@ flex: 1 1 auto; } +.appChatEmpty .content { + flex: 0 0 auto; + overflow: visible; +} + .footer { - margin: 0 10px; + position: relative; + width: min(100%, var(--chat-shell-width)); + margin: 0 auto; + padding-right: 20px; + padding-left: 20px; flex-shrink: 0; + transition: width 320ms ease; } .footerHidden { @@ -126,24 +157,88 @@ } .bottomPanels { + position: absolute; + top: -50px; + right: 0; + left: 0; + z-index: 3; display: flex; - flex-direction: column; - gap: 4px; - padding-bottom: 4px; + justify-content: center; + pointer-events: none; +} + +.bottomPanels > * { + pointer-events: auto; } -.tasksBottomPanel { +.approvalOverlay { + position: absolute; + right: 20px; + bottom: 12px; + left: 20px; + z-index: 6; + pointer-events: auto; +} + +.scrollToBottomLayer { + position: absolute; + top: -44px; + right: 0; + left: 0; + z-index: 3; display: flex; - flex-direction: column; - gap: 4px; - margin-top: 6px; - padding-bottom: 6px; + justify-content: center; + pointer-events: none; +} + +.scrollToBottomLayerWithTodos { + top: -94px; +} + +.scrollToBottomButton { + display: inline-flex; + box-sizing: border-box; + width: 34px; + height: 34px; + align-items: center; + justify-content: center; + padding: 0; + border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent); + border-radius: 50%; + background: color-mix(in srgb, var(--bg-primary) 96%, transparent); + box-shadow: 0 8px 24px color-mix(in srgb, #000 10%, transparent); + color: var(--text-primary); + cursor: pointer; + backdrop-filter: blur(8px); + pointer-events: auto; + transition: + border-color 120ms ease, + box-shadow 120ms ease, + transform 120ms ease; +} + +.scrollToBottomButton:hover, +.scrollToBottomButton:focus-visible { + border-color: color-mix( + in srgb, + var(--text-primary) 24%, + var(--border-color) + ); + box-shadow: 0 10px 28px color-mix(in srgb, #000 14%, transparent); + outline: none; +} + +.scrollToBottomIcon { + width: 18px; + height: 18px; + stroke-width: 1.8; } .btwPanel { flex-shrink: 0; - margin: 0 24px; - padding: 4px 0; + margin: 0 auto; + width: min(100%, var(--chat-regular-shell-width, 1040px)); + padding: 4px 20px; } .composer { @@ -151,24 +246,67 @@ padding: 0; } +.emptyWelcomeFooter { + padding: 12px 0 0; +} + .queuedPrompts { display: flex; flex-direction: column; gap: 2px; - padding: 0 12px 6px 36px; + padding: 0 12px 6px 10px; color: var(--text-dimmed); font-size: 12px; line-height: 1.4; } .queuedPrompt { + display: flex; + align-items: center; + gap: 8px; min-width: 0; max-width: 100%; overflow: hidden; +} + +.queuedPromptText { + min-width: 0; + flex: 1; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.queuedPromptActions { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.queuedPromptAction { + display: inline-flex; + align-items: center; + gap: 3px; + appearance: none; + border: 0; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font: inherit; + padding: 0; +} + +.queuedPromptAction svg { + width: 12px; + height: 12px; + flex-shrink: 0; +} + +.queuedPromptAction:hover { + color: var(--text-primary); +} + .queuedHint { font-style: italic; } diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 28466f4772f..eef9b936beb 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -2,9 +2,11 @@ import { createContext, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, + type CSSProperties, type ReactNode, } from 'react'; import { @@ -30,10 +32,13 @@ import type { import { extractPendingPermission } from './adapters/transcriptAdapter'; import { removeInjectedFromQueue } from './midTurnDedup'; import { MessageList, type MessageListHandle } from './components/MessageList'; -import { Editor, type EditorHandle } from './components/Editor'; +import { + ChatEditor, + type ComposerToolbarAction, +} from './components/ChatEditor'; +import type { EditorHandle } from './hooks/useComposerCore'; import type { PromptImage } from './adapters/promptTypes'; import { StatusBar, type StatusBarHandle } from './components/StatusBar'; -import { ShortcutsPanel } from './components/ShortcutsPanel'; import { StreamingStatus } from './components/StreamingStatus'; import { ToastHost, @@ -42,40 +47,31 @@ import { } from './components/ToastHost'; import { TodoPanel } from './components/panels/TodoPanel'; import { WelcomeHeader } from './components/WelcomeHeader'; -import { - APPROVAL_MODE_ACTIVE_EVENT, - ApprovalModeMessage, -} from './components/messages/ApprovalModeMessage'; +import { ApprovalModeDialog } from './components/dialogs/ApprovalModeDialog'; import { ResumeDialog } from './components/dialogs/ResumeDialog'; +import { DialogShell } from './components/dialogs/DialogShell'; +import { + ModelDialog, + type ModelDialogMode, +} from './components/dialogs/ModelDialog'; import { - AGENTS_ACTIVE_EVENT, AgentsMessage, type AgentsInitialMode, } from './components/messages/AgentsMessage'; -import { - MEMORY_ACTIVE_EVENT, - MemoryMessage, -} from './components/messages/MemoryMessage'; -import { - MODEL_ACTIVE_EVENT, - ModelMessage, - type ModelInlineMode, -} from './components/messages/ModelMessage'; -import { - AUTH_ACTIVE_EVENT, - AuthMessage, -} from './components/messages/AuthMessage'; +import { MemoryMessage } from './components/messages/MemoryMessage'; +import { AuthMessage } from './components/messages/AuthMessage'; import { ToolsDialog } from './components/dialogs/ToolsDialog'; import { ExtensionsDialog } from './components/dialogs/ExtensionsDialog'; -import { - SETTINGS_ACTIVE_EVENT, - SettingsMessage, -} from './components/messages/SettingsMessage'; +import { SettingsMessage } from './components/messages/SettingsMessage'; import { resolveShellOutputMaxLines } from './components/messages/ToolGroup'; +import { isAskUserQuestionToolName } from './components/messages/toolFormatting'; +import { ToolApproval } from './components/messages/ToolApproval'; +import { AskUserQuestion } from './components/messages/AskUserQuestion'; import { HelpDialog } from './components/dialogs/HelpDialog'; import { ThemeDialog } from './components/dialogs/ThemeDialog'; import { DeleteSessionDialog } from './components/dialogs/DeleteSessionDialog'; import { ReleaseSessionDialog } from './components/dialogs/ReleaseSessionDialog'; +import { RewindDialog } from './components/dialogs/RewindDialog'; import { getLocalCommands } from './constants/localCommands'; import { mergeCommands } from './hooks/daemonSessionMappers'; import { useAnimationFrameValue } from './hooks/useAnimationFrameValue'; @@ -101,7 +97,6 @@ import { TasksStatusMessage, type SerializedTasksMessage, } from './components/messages/TasksStatusMessage'; -import { handleTasksSlashCommand } from './utils/tasksCommand'; import { isBackgroundSubAgentToolCall } from './adapters/toolClassification'; import { DAEMON_APPROVAL_MODES, @@ -119,8 +114,9 @@ import { import { MCP_STATUS_ACTIVE_EVENT, parseMcpStatusMessage, - serializeMcpStatusMessage, + type SerializedMcpStatusMessage, } from './components/messages/McpStatusMessage'; +import { McpDialog } from './components/dialogs/McpDialog'; import { GOAL_STATUS_ACTIVE_EVENT, parseGoalStatusMessage, @@ -153,6 +149,9 @@ import { type WebShellMarkdownCustomization, type ToolHeaderExtraRenderer, type WelcomeHeaderRenderer, + type WelcomeFooterRenderer, + type ComposerToolbarStartRenderer, + type ComposerToolbarEndRenderer, type FooterRenderer, type WebShellTaskInfo, } from './customization'; @@ -204,11 +203,15 @@ function TodoContextsProvider({ } const MODES_CYCLE = DAEMON_APPROVAL_MODES; -const MAX_DISPLAYED_QUEUED_PROMPTS = 3; const MAX_QUEUED_PROMPT_PREVIEW_CHARS = 240; const MAX_TOASTS = 4; const COMPACT_MODE_SETTING_KEY = 'ui.compactMode'; const HIDE_TIPS_SETTING_KEY = 'ui.hideTips'; +const HIDDEN_COMPOSER_MODEL_IDS = new Set(['coder-model(qwen-oauth)']); + +function isVisibleComposerModel(model: { id: string }): boolean { + return !HIDDEN_COMPOSER_MODEL_IDS.has(model.id); +} function normalizeHiddenCommand(command: string): string { return command.trim().replace(/^\/+/, '').toLowerCase(); @@ -256,17 +259,22 @@ type GoalStatusTranscriptBlock = DaemonTranscriptBlock & { data?: unknown; }; +function parseGoalStatusFromBlock(block: DaemonTranscriptBlock) { + const statusBlock = block as GoalStatusTranscriptBlock; + if (statusBlock.source !== 'goal') return null; + return ( + parseGoalStatusMessage(statusBlock.data) ?? + parseGoalStatusMessage(statusBlock.text) + ); +} + function getLatestActiveGoalFromBlocks( blocks: readonly DaemonTranscriptBlock[], ): ActiveGoalStatus | null { for (let i = blocks.length - 1; i >= 0; i--) { const block = blocks[i]; if (block.kind !== 'status') continue; - const statusBlock = block as GoalStatusTranscriptBlock; - const status = - statusBlock.source === 'goal' - ? parseGoalStatusMessage(statusBlock.data) - : parseGoalStatusMessage(statusBlock.text); + const status = parseGoalStatusFromBlock(block); if (!status) continue; if (status.kind === 'set' || status.kind === 'checking') { return { @@ -301,11 +309,11 @@ export interface BugReportInfo { export interface WebShellProps { /** Called whenever the attached daemon session id changes. */ onSessionIdChange?: (sessionId: string) => void; - /** Visual theme for the embedded shell. Defaults to the dark terminal skin. */ + /** Visual theme for the embedded shell. */ theme?: WebShellTheme; /** Called when `/theme` changes the web-shell theme. */ onThemeChange?: (theme: WebShellTheme) => void; - /** UI language for the Web terminal. Defaults to `?language=` or browser language. */ + /** UI language for the web-shell. Defaults to `?language=` or browser language. */ language?: 'en' | 'zh-CN' | 'zh' | 'zh-cn'; /** Called when `/language ui` changes the web-shell UI language. */ onLanguageChange?: (language: WebShellLanguage) => void; @@ -313,6 +321,10 @@ export interface WebShellProps { className?: string; /** Inline styles applied to the root element. */ style?: React.CSSProperties; + /** Maximum chat content width in regular mode. Defaults to 1000px. */ + chatMaxWidth?: number; + /** Built-in composer toolbar actions to show. Defaults to all actions. */ + composerToolbarActions?: readonly ComposerToolbarAction[]; /** Called when connection status changes (idle/connecting/connected/disconnected/error). */ onConnectionChange?: (status: string) => void; /** Called when prompt status changes (idle/waiting/responding). */ @@ -335,6 +347,12 @@ export interface WebShellProps { renderToolHeaderExtra?: ToolHeaderExtraRenderer; /** Custom renderer for the welcome header. Receives version, cwd, model, and mode. */ renderWelcomeHeader?: WelcomeHeaderRenderer; + /** Custom renderer shown below the chat composer in the empty welcome state. */ + renderWelcomeFooter?: WelcomeFooterRenderer; + /** Custom renderer inserted before the built-in chat composer toolbar controls. */ + renderComposerToolbarStart?: ComposerToolbarStartRenderer; + /** Custom renderer inserted after the built-in composer toolbar controls. */ + renderComposerToolbarEnd?: ComposerToolbarEndRenderer; /** Custom component for the footer area below the Editor. Replaces the built-in StatusBar. */ renderFooter?: FooterRenderer; /** Collapse thinking blocks to 5 lines with a click-to-expand toggle. */ @@ -349,12 +367,18 @@ export interface WebShellProps { onToast?: (tone: ToastTone, message: string) => void; /** Imperative handle for externally controlling the composer input. */ composerRef?: React.Ref; + /** Called once the real composer API is mounted and safe to call. */ + onComposerReady?: (api: WebShellComposerApi) => void; /** Declarative composer input value. Increment composerInputVersion to replay the same value. */ composerInput?: WebShellComposerInput; /** Replay key for composerInput. */ composerInputVersion?: number; } +type SessionActionsWithCreate = { + createSession: () => Promise<{ sessionId: string }>; +}; + const emptyComposerApi: WebShellComposerApi = { insertText: () => {}, setText: () => {}, @@ -364,6 +388,51 @@ const emptyComposerApi: WebShellComposerApi = { submit: () => {}, }; +type ChatWidthMode = '1000' | 'wide'; + +const CHAT_WIDTH_STORAGE_KEY = 'qwen-code-web-shell-chat-width'; +const DEFAULT_CHAT_MAX_WIDTH = 1000; +const CHAT_SHELL_HORIZONTAL_PADDING = 40; + +function readChatWidthMode(): ChatWidthMode { + if (typeof window === 'undefined') return '1000'; + try { + return window.localStorage.getItem(CHAT_WIDTH_STORAGE_KEY) === 'wide' + ? 'wide' + : '1000'; + } catch { + return '1000'; + } +} + +function writeChatWidthMode(mode: ChatWidthMode): void { + try { + window.localStorage.setItem(CHAT_WIDTH_STORAGE_KEY, mode); + } catch { + // localStorage can be unavailable in private or embedded contexts. + } +} + +function getChatMaxWidth(value: number | undefined): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? value + : DEFAULT_CHAT_MAX_WIDTH; +} + +function getChatWidthStyle( + mode: ChatWidthMode, + chatMaxWidth: number | undefined, +): CSSProperties { + const contentWidth = `${getChatMaxWidth(chatMaxWidth)}px`; + const shellWidth = `calc(${contentWidth} + ${CHAT_SHELL_HORIZONTAL_PADDING}px)`; + return { + '--chat-regular-content-width': contentWidth, + '--chat-regular-shell-width': shellWidth, + '--chat-content-width': mode === 'wide' ? '100%' : contentWidth, + '--chat-shell-width': mode === 'wide' ? '100%' : shellWidth, + } as CSSProperties; +} + function assignComposerRef( ref: React.Ref | undefined, value: WebShellComposerApi, @@ -535,6 +604,17 @@ function isEditToolPermission(request: PermissionRequest): boolean { return request.toolKind === 'edit'; } +function isAskUserPermission(request: PermissionRequest | null): boolean { + if ( + !request?.rawInput?.questions || + !Array.isArray(request.rawInput.questions) + ) { + return false; + } + if (!request.toolName) return true; + return isAskUserQuestionToolName(request.toolName); +} + function parseRenameArgument( raw: string, ): @@ -654,15 +734,22 @@ function translateCopyMessage( function QueuedPromptDisplay({ prompts, t, + onDelete, + onInsert, + onEditLast, }: { prompts: readonly QueuedPrompt[]; t: ReturnType; + onDelete: (id: number) => void; + onInsert: (id: number) => void; + onEditLast: () => void; }) { if (prompts.length === 0) return null; + const lastPromptId = prompts[prompts.length - 1]?.id; return (
- {prompts.slice(0, MAX_DISPLAYED_QUEUED_PROMPTS).map((prompt) => { + {prompts.map((prompt) => { const normalizedPreview = prompt.text.replace(/\s+/g, ' ').trim(); const preview = normalizedPreview.length > MAX_QUEUED_PROMPT_PREVIEW_CHARS @@ -671,20 +758,69 @@ function QueuedPromptDisplay({ const imageCount = prompt.images?.length ?? 0; return (
- {preview} - {imageCount > 0 - ? ` ${t('queue.imageCount', { count: imageCount })}` - : ''} + + {preview} + {imageCount > 0 + ? ` ${t('queue.imageCount', { count: imageCount })}` + : ''} + + + + {imageCount === 0 && ( + + )} + {prompt.id === lastPromptId && ( + + )} +
); })} - {prompts.length > MAX_DISPLAYED_QUEUED_PROMPTS && ( -
- {t('queue.more', { - count: prompts.length - MAX_DISPLAYED_QUEUED_PROMPTS, - })} -
- )}
{t('queue.footer')}
); @@ -706,7 +842,12 @@ export function App({ slashCommandCategoryOrder, renderToolHeaderExtra, renderWelcomeHeader, + renderWelcomeFooter, + renderComposerToolbarStart, + renderComposerToolbarEnd, renderFooter, + chatMaxWidth, + composerToolbarActions, compactThinking = false, collapseCompletedTurns = true, virtualScrollThreshold, @@ -714,9 +855,12 @@ export function App({ onTranscriptChange, onToast, composerRef, + onComposerReady, composerInput, composerInputVersion, }: WebShellProps = {}) { + const [chatWidthMode, setChatWidthMode] = + useState(readChatWidthMode); const [selectedLanguage, setSelectedLanguage] = useState( () => providedLanguage === undefined @@ -728,6 +872,9 @@ export function App({ () => ({ renderToolHeaderExtra, renderWelcomeHeader, + renderWelcomeFooter, + renderComposerToolbarStart, + renderComposerToolbarEnd, renderFooter, compactThinking, collapseCompletedTurns, @@ -736,6 +883,9 @@ export function App({ [ renderToolHeaderExtra, renderWelcomeHeader, + renderWelcomeFooter, + renderComposerToolbarStart, + renderComposerToolbarEnd, renderFooter, compactThinking, collapseCompletedTurns, @@ -784,10 +934,9 @@ export function App({ const nextRecapMessageIdRef = useRef(1); const nextBtwMessageIdRef = useRef(1); const btwAbortControllerRef = useRef(null); - // Scopes the best-effort mid-turn enqueue POST(s) to the turn they were typed - // in. Aborted when the turn settles so a slow/late push can't arrive during a - // SUBSEQUENT turn (e.g. the browser's own next-turn resend of the same text) - // and get injected there — cross-turn double delivery. + // Scopes explicit "insert queued message" POST(s) to the current turn. + // Aborted when the turn settles so a slow/late insert can't arrive during a + // subsequent turn and get injected in the wrong place. const midTurnEnqueueAbortRef = useRef(null); const activeSessionIdRef = useRef(connection.sessionId); const displayMessages = useMemo(() => { @@ -833,9 +982,13 @@ export function App({ [messageBlocks], ); const pendingApproval = useShallowMemo(rawPendingApproval); + const pendingAskUserApproval = isAskUserPermission(pendingApproval) + ? pendingApproval + : null; + const pendingToolApproval = + pendingApproval && !pendingAskUserApproval ? pendingApproval : null; const pendingApprovalRef = useRef(pendingApproval); pendingApprovalRef.current = pendingApproval; - const shouldHideComposer = pendingApproval !== null; const floatingTodosState = useMemo( () => getFloatingTodos(messages), [messages], @@ -916,25 +1069,39 @@ export function App({ [backgroundTasks, renderFooter], ); const statusBarRef = useRef(null); + const messageListRef = useRef(null); const editorRef = useRef(null); + const notifiedComposerReadyRef = useRef(null); + const footerRef = useRef(null); + const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const previousFooterRectRef = useRef(null); + const previousEmptyStateRef = useRef(false); + const resumeChatBottomFollow = useCallback( + (behavior: ScrollBehavior = 'smooth') => { + setShowScrollToBottom(false); + requestAnimationFrame(() => { + messageListRef.current?.scrollToBottom(behavior); + requestAnimationFrame(() => { + messageListRef.current?.scrollToBottom(behavior); + }); + }); + }, + [], + ); const setEditorHandle = useCallback( (handle: EditorHandle | null) => { editorRef.current = handle; assignComposerRef(composerRef, handle ?? emptyComposerApi); + if (handle && notifiedComposerReadyRef.current !== handle) { + notifiedComposerReadyRef.current = handle; + onComposerReady?.(handle); + } }, - [composerRef], + [composerRef, onComposerReady], ); useEffect(() => { assignComposerRef(composerRef, editorRef.current ?? emptyComposerApi); }, [composerRef]); - const messageListRef = useRef(null); - const handleLocateFloatingTodos = useCallback(() => { - if (!floatingTodosState.sourceMessageId) return; - messageListRef.current?.scrollToMessage( - floatingTodosState.sourceMessageId, - floatingTodosState.sourceCallId ?? undefined, - ); - }, [floatingTodosState.sourceMessageId, floatingTodosState.sourceCallId]); const [activeGoal, setActiveGoal] = useState(null); const activeGoalRef = useRef(null); activeGoalRef.current = activeGoal; @@ -978,6 +1145,26 @@ export function App({ ); const streamingState = useStreamingState(); const streamingStateRef = useRef(streamingState); + const localStreamingStartedAtRef = useRef(Date.now()); + const previousStreamingStateRef = + useRef(streamingState); + if ( + previousStreamingStateRef.current === 'idle' && + streamingState !== 'idle' + ) { + localStreamingStartedAtRef.current = Date.now(); + } + previousStreamingStateRef.current = streamingState; + const activeTurnStartedAt = useMemo(() => { + if (streamingState === 'idle') return undefined; + for (let i = displayMessages.length - 1; i >= 0; i--) { + const message = displayMessages[i]; + if (message?.role === 'user') { + return message.timestamp ?? localStreamingStartedAtRef.current; + } + } + return localStreamingStartedAtRef.current; + }, [displayMessages, streamingState]); const lastSubmittedPromptRef = useRef(''); const lastSubmittedImagesRef = useRef(undefined); const retryableTurnErrorIdRef = useRef(null); @@ -1001,19 +1188,22 @@ export function App({ .catch(() => {}); }, [connected, workspaceActions]); - const [modelInlineMode, setModelInlineMode] = - useState(null); - const [approvalModeInlineOpen, setApprovalModeInlineOpen] = useState(false); + const [modelDialogMode, setModelDialogMode] = + useState(null); + const [showApprovalModeDialog, setShowApprovalModeDialog] = useState(false); const [showResumeDialog, setShowResumeDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showReleaseDialog, setShowReleaseDialog] = useState(false); + const [showRewindDialog, setShowRewindDialog] = useState(false); const [showHelpDialog, setShowHelpDialog] = useState(false); const [showThemeDialog, setShowThemeDialog] = useState(false); const [showToolsDialog, setShowToolsDialog] = useState(false); const [showExtensionsDialog, setShowExtensionsDialog] = useState(false); - const [settingsInlineOpen, setSettingsInlineOpen] = useState(false); - const [memoryInlineOpen, setMemoryInlineOpen] = useState(false); - const [authInlineOpen, setAuthInlineOpen] = useState(false); + const [mcpDialogMessage, setMcpDialogMessage] = + useState(null); + const [showSettingsDialog, setShowSettingsDialog] = useState(false); + const [showMemoryDialog, setShowMemoryDialog] = useState(false); + const [showAuthDialog, setShowAuthDialog] = useState(false); const [memoryRefreshSignal, setMemoryRefreshSignal] = useState(0); const [memoryAddSignal, setMemoryAddSignal] = useState(0); @@ -1085,24 +1275,15 @@ export function App({ const [memoryAddScope, setMemoryAddScope] = useState<'workspace' | 'global'>( 'workspace', ); - const [agentsInlineMode, setAgentsInlineMode] = + const [agentsDialogMode, setAgentsDialogMode] = useState(null); - const [memoryPortalHost, setMemoryPortalHost] = - useState(null); - const [showShortcuts, setShowShortcuts] = useState(false); const [escapeHintVisible, setEscapeHintVisible] = useState(false); const escPressCountRef = useRef(0); const escapeTimerRef = useRef | null>(null); - const approvalModePanelActive = usePanelActive(APPROVAL_MODE_ACTIVE_EVENT); - const [tasksPanelMessage, setTasksPanelMessage] = + const [tasksDialogMessage, setTasksDialogMessage] = useState(null); const mcpPanelActive = usePanelActive(MCP_STATUS_ACTIVE_EVENT); const tasksPanelActive = usePanelActive(TASKS_STATUS_ACTIVE_EVENT); - const agentsPanelActive = usePanelActive(AGENTS_ACTIVE_EVENT); - const memoryPanelActive = usePanelActive(MEMORY_ACTIVE_EVENT); - const modelPanelActive = usePanelActive(MODEL_ACTIVE_EVENT); - const settingsPanelActive = usePanelActive(SETTINGS_ACTIVE_EVENT); - const authPanelActive = usePanelActive(AUTH_ACTIVE_EVENT); const [selectedTheme, setSelectedTheme] = useState( providedTheme ?? WebShellThemeId.Dark, ); @@ -1114,6 +1295,18 @@ export function App({ const sessionDisplayName = connection.displayName; const [currentMode, setCurrentMode] = useState('default'); const [queuedPrompts, setQueuedPrompts] = useState([]); + const queuedTexts = useMemo( + () => queuedPrompts.map((prompt) => prompt.text), + [queuedPrompts], + ); + const availableModels = useMemo( + () => + (connection.models ?? []).filter(isVisibleComposerModel).map((m) => ({ + id: m.id, + label: m.label, + })), + [connection.models], + ); const queuedPromptsRef = useRef([]); const nextQueuedPromptIdRef = useRef(1); const drainingQueueRef = useRef(false); @@ -1121,28 +1314,21 @@ export function App({ showResumeDialog || showDeleteDialog || showReleaseDialog || + showRewindDialog || showHelpDialog || showThemeDialog || showToolsDialog || - showExtensionsDialog; - const inlinePanelOpen = - approvalModeInlineOpen || - authInlineOpen || - agentsInlineMode !== null || - memoryInlineOpen || - modelInlineMode !== null || - settingsInlineOpen; - const bottomHidden = - dialogOpen || - inlinePanelOpen || - approvalModePanelActive || - mcpPanelActive || - tasksPanelActive || - agentsPanelActive || - memoryPanelActive || - modelPanelActive || - settingsPanelActive || - authPanelActive; + showExtensionsDialog || + modelDialogMode !== null || + showApprovalModeDialog || + tasksDialogMessage !== null || + mcpDialogMessage !== null || + agentsDialogMode !== null || + showSettingsDialog || + showMemoryDialog || + showAuthDialog; + const bottomHidden = mcpPanelActive || tasksPanelActive; + const interactionBlocked = dialogOpen || bottomHidden; const reportError = useCallback( (error: unknown, fallback: string) => { @@ -1185,7 +1371,7 @@ export function App({ btwAbortControllerRef.current = null; setRecapMessage(null); setBtwMessage(null); - setTasksPanelMessage(null); + setTasksDialogMessage(null); lastRecapBlockCountRef.current = 0; }, [connection.sessionId]); @@ -1288,7 +1474,7 @@ export function App({ useEffect(() => { const onBtwShortcut = (e: KeyboardEvent) => { - if (bottomHidden || pendingApproval) return; + if (interactionBlocked || pendingApproval) return; const message = btwMessage; if (!message || message.role !== 'btw') return; @@ -1327,7 +1513,7 @@ export function App({ window.addEventListener('keydown', onBtwShortcut, true); return () => window.removeEventListener('keydown', onBtwShortcut, true); - }, [bottomHidden, btwMessage, dismissBtwMessage, pendingApproval]); + }, [interactionBlocked, btwMessage, dismissBtwMessage, pendingApproval]); useEffect(() => { queuedPromptsRef.current = queuedPrompts; @@ -1337,7 +1523,6 @@ export function App({ (text: string, images?: PromptImage[], onComplete?: () => void) => { const trimmed = text.trim(); if (!trimmed) return true; - const hasImages = !!images && images.length > 0; const nextPrompt: QueuedPrompt = { id: nextQueuedPromptIdRef.current++, text: trimmed, @@ -1346,36 +1531,14 @@ export function App({ }; queuedPromptsRef.current = [...queuedPromptsRef.current, nextPrompt]; setQueuedPrompts(queuedPromptsRef.current); - // Best-effort: also offer text-only messages to the running turn so the - // daemon can drain them mid-turn (text-only because the drain channel - // carries plain strings — messages with images stay queued for the next - // turn). On success the daemon emits `mid_turn_message_injected`, which - // the effect below removes from the queue so it isn't resent. If idle / - // unsupported / failed, the message harmlessly stays queued. - if (!hasImages) { - // One controller per turn: all mid-turn pushes typed during the same - // turn share it, and the settle effect aborts it so a late arrival - // can't land in the next turn. An aborted push resolves - // `{ accepted: false }`, so the message simply stays queued and is - // resent next turn — exactly-once preserved. - let abort = midTurnEnqueueAbortRef.current; - if (!abort) { - abort = new AbortController(); - midTurnEnqueueAbortRef.current = abort; - } - void sessionActions.enqueueMidTurnMessage(trimmed, { - signal: abort.signal, - }); - } return true; }, - [sessionActions], + [], ); - // When the turn settles, abort any still-in-flight mid-turn push so it can't - // arrive during the next turn and be injected twice (see midTurnEnqueueAbortRef). - // The aborted push resolves `{ accepted: false }`; the message is already in - // queuedPrompts and follows the normal next-turn path. + // When the turn settles, abort any still-in-flight explicit insert so it can't + // arrive during the next turn (see midTurnEnqueueAbortRef). If aborted, the + // message remains in queuedPrompts. useEffect(() => { if (streamingState !== 'idle') return; const ctrl = midTurnEnqueueAbortRef.current; @@ -1400,11 +1563,57 @@ export function App({ const popQueuedPromptsForEdit = useCallback((): string | null => { const current = queuedPromptsRef.current; if (current.length === 0) return null; - queuedPromptsRef.current = []; - setQueuedPrompts([]); - return current.map((prompt) => prompt.text).join('\n\n'); + const next = current.slice(0, -1); + const prompt = current[current.length - 1]; + queuedPromptsRef.current = next; + setQueuedPrompts(next); + return prompt?.text ?? null; + }, []); + + const removeQueuedPrompt = useCallback((id: number) => { + const next = queuedPromptsRef.current.filter((prompt) => prompt.id !== id); + if (next.length === queuedPromptsRef.current.length) return; + queuedPromptsRef.current = next; + setQueuedPrompts(next); }, []); + const insertQueuedPrompt = useCallback( + async (id: number) => { + const prompt = queuedPromptsRef.current.find((item) => item.id === id); + if (!prompt || (prompt.images?.length ?? 0) > 0) return; + let abort = midTurnEnqueueAbortRef.current; + if (!abort) { + abort = new AbortController(); + midTurnEnqueueAbortRef.current = abort; + } + let result: Awaited< + ReturnType + >; + try { + result = await sessionActions.enqueueMidTurnMessage(prompt.text, { + signal: abort.signal, + }); + } catch (error) { + reportError(error, t('queue.insertFailed')); + return; + } + if (!result.accepted) return; + const next = queuedPromptsRef.current.filter((item) => item.id !== id); + queuedPromptsRef.current = next; + setQueuedPrompts(next); + }, + [reportError, sessionActions, t], + ); + + const editLastQueuedPrompt = useCallback(() => { + const queuedText = popQueuedPromptsForEdit(); + if (!queuedText) return; + const current = editorRef.current?.getText() ?? ''; + const next = current.trim() ? `${queuedText}\n${current}` : queuedText; + editorRef.current?.setText(next); + editorRef.current?.focus(); + }, [popQueuedPromptsForEdit]); + const clearQueuedPrompts = useCallback((): boolean => { if (queuedPromptsRef.current.length === 0) return false; queuedPromptsRef.current = []; @@ -1488,14 +1697,7 @@ export function App({ ); const handleToggleShortcuts = useCallback(() => { - setShowShortcuts((prev) => !prev); - }, []); - - // Idempotent close for the shortcuts panel's outside-press / Escape dismissal. - // Must not toggle: on touch, touchstart and the synthesized mousedown both - // fire, and a toggle would reopen the panel right after closing it. - const handleCloseShortcuts = useCallback(() => { - setShowShortcuts(false); + setShowHelpDialog(true); }, []); const workspaceSettingsState = useSettings({ @@ -1506,9 +1708,6 @@ export function App({ setValue: setWorkspaceSetting, reload: reloadWorkspaceSettings, } = workspaceSettingsState; - const compactModeSetting = workspaceSettings.find( - (setting) => setting.key === COMPACT_MODE_SETTING_KEY, - ); const themeSetting = workspaceSettings.find( (setting) => setting.key === THEME_SETTING_KEY, ); @@ -1523,13 +1722,6 @@ export function App({ const compactModeRef = useRef(compactMode); compactModeRef.current = compactMode; - useEffect(() => { - const value = compactModeSetting?.values.effective; - if (typeof value === 'boolean') { - setCompactMode(value); - } - }, [compactModeSetting?.values.effective]); - useEffect(() => { if (providedTheme) { setSelectedTheme(providedTheme); @@ -1623,14 +1815,6 @@ export function App({ .then((result) => { const effectiveMode = result.mode || modeId; setCurrentMode(effectiveMode); - if (effectiveMode === 'auto') { - // TODO: CLI also shows stripped dangerous allow rules via - // PermissionManager.getStrippedDangerousRules(). The daemon - // API (DaemonApprovalModeResult) doesn't expose this info yet. - // Once the daemon returns strippedRules in the response, display - // them here like CLI's emitAutoModeEntryNotices does. - store.dispatch([{ type: 'status', text: t('mode.auto.notice') }]); - } const approval = pendingApprovalRef.current; if (!approval) return; const shouldAutoApprove = @@ -1707,16 +1891,12 @@ export function App({ }, [connection.error, onError]); useEffect(() => { - if (connection.currentModel) { - setCurrentModel(connection.currentModel); - } - }, [connection.currentModel]); + setCurrentModel(connection.currentModel ?? ''); + }, [connection.currentModel, connection.sessionId]); useEffect(() => { - if (connection.currentMode) { - setCurrentMode(connection.currentMode); - } - }, [connection.currentMode]); + setCurrentMode(connection.currentMode ?? 'default'); + }, [connection.currentMode, connection.sessionId]); useEffect(() => { if (connection.sessionId) { @@ -1833,12 +2013,13 @@ export function App({ text: serializeContextUsageMessage(result), }, ]); + resumeChatBottomFollow('smooth'); }) .catch((error: unknown) => { reportError(error, 'Failed to load context usage'); }); }, - [store, sessionActions, reportError], + [store, sessionActions, reportError, resumeChatBottomFollow], ); // Stable reference: this travels through the memoized MessageList → @@ -1847,11 +2028,50 @@ export function App({ showContextUsage('/context detail', true); }, [showContextUsage]); + const branchCurrentSession = useCallback( + (name?: string) => { + sessionActions + .branchSession(name || undefined) + .then((result) => { + store.dispatch([ + { + type: 'status', + text: t('branch.success', { + name: result.displayName, + }), + }, + ]); + }) + .catch((error: unknown) => { + reportError(error, t('branch.failed')); + }); + }, + [reportError, sessionActions, store, t], + ); + const handleBranchCurrentSession = useCallback(() => { + branchCurrentSession(); + }, [branchCurrentSession]); + + const createNewSession = useCallback(() => { + (sessionActions as typeof sessionActions & SessionActionsWithCreate) + .createSession() + .then((session) => { + if (onSessionIdChange) { + onSessionIdChange(session.sessionId); + return; + } + return sessionActions.loadSession(session.sessionId); + }) + .catch((error: unknown) => { + reportError(error, 'Failed to create a new session'); + }); + }, [onSessionIdChange, reportError, sessionActions]); + const openTasksPanel = useCallback(() => { sessionActions .getTasks() .then((snapshot) => { - setTasksPanelMessage({ snapshot }); + setTasksDialogMessage({ snapshot }); }) .catch((error: unknown) => { reportError(error, 'Failed to load tasks'); @@ -1904,6 +2124,28 @@ export function App({ [reportError, sessionActions, store], ); + const loadRewindSnapshots = useCallback( + () => sessionActions.getRewindSnapshots(), + [sessionActions], + ); + + const rewindConversationOnly = useCallback( + (promptId: string) => + sessionActions + .rewindSession(promptId, { rewindFiles: false }) + .then(() => undefined), + [sessionActions], + ); + + const handleRewindError = useCallback( + (error: unknown) => { + if (isAlreadyDispatched(error)) return; + const reason = error instanceof Error ? error.message : String(error); + pushToast('error', t('rewind.failed', { reason })); + }, + [pushToast, t], + ); + const handleGoalSlashCommand = useCallback( ( text: string, @@ -1970,18 +2212,19 @@ export function App({ const match = text.match(/^\/([\w-]+)/); if (match) { const cmd = match[1]; + if (hiddenCommands.has(normalizeHiddenCommand(cmd))) { + if (promptBlocked) return enqueuePrompt(text, images); + sendPrompt(text, images).catch((error: unknown) => + reportError(error, 'Failed to send hidden slash command'), + ); + return true; + } if (cmd === 'help') { setShowHelpDialog(true); return true; } if (cmd === 'tasks') { - store.appendLocalUserMessage(text); - handleTasksSlashCommand({ - cmd, - getTasks: sessionActions.getTasks, - dispatch: (events) => store.dispatch(events), - reportError, - }); + openTasksPanel(); return true; } if (cmd === 'goal') { @@ -2086,24 +2329,14 @@ export function App({ setShowReleaseDialog(true); return true; } + if (cmd === 'rewind') { + setShowRewindDialog(true); + return true; + } if (cmd === 'branch') { if (promptBlocked) return enqueuePrompt(text, images); const branchName = text.slice(match[0].length).trim(); - sessionActions - .branchSession(branchName || undefined) - .then((result) => { - store.dispatch([ - { - type: 'status', - text: t('branch.success', { - name: result.displayName, - }), - }, - ]); - }) - .catch((error: unknown) => { - reportError(error, t('branch.failed')); - }); + branchCurrentSession(branchName || undefined); return true; } if (cmd === 'fork') { @@ -2134,15 +2367,13 @@ export function App({ return true; } if (cmd === 'auth') { - store.appendLocalUserMessage(text); - setAuthInlineOpen(true); + setShowAuthDialog(true); return true; } if (cmd === 'model') { const modelArg = text.slice(match[0].length).trim(); if (modelArg === '--fast') { - store.appendLocalUserMessage(text); - setModelInlineMode('fast'); + setModelDialogMode('fast'); return true; } if (modelArg.startsWith('--fast ')) { @@ -2162,8 +2393,7 @@ export function App({ reportError(error, t('model.switch')); }); } else { - store.appendLocalUserMessage(text); - setModelInlineMode('main'); + setModelDialogMode('main'); } return true; } @@ -2190,14 +2420,12 @@ export function App({ if (modeArg) { handleSetMode(modeArg); } else { - store.appendLocalUserMessage(text); - setApprovalModeInlineOpen(true); + setShowApprovalModeDialog(true); } return true; } if (cmd === 'mcp') { const mcpArg = text.slice(match[0].length).trim().toLowerCase(); - store.appendLocalUserMessage(text); workspaceActions .loadMcpStatus() .then(async (status) => { @@ -2215,18 +2443,13 @@ export function App({ } }), ); - store.dispatch([ - { - type: 'status', - text: serializeMcpStatusMessage({ - status, - toolsByServer, - showDescriptions: mcpArg === 'desc', - showSchema: mcpArg === 'schema', - showTips: !mcpArg, - }), - }, - ]); + setMcpDialogMessage({ + status, + toolsByServer, + showDescriptions: mcpArg === 'desc', + showSchema: mcpArg === 'schema', + showTips: !mcpArg, + }); }) .catch((error: unknown) => { reportError(error, 'Failed to load MCP status'); @@ -2265,6 +2488,7 @@ export function App({ }, ]); } + resumeChatBottomFollow('smooth'); }) .catch((error: unknown) => { reportError(error, 'Failed to load skills'); @@ -2295,6 +2519,7 @@ export function App({ }, ]); } + resumeChatBottomFollow('smooth'); }) .catch((error: unknown) => { reportError(error, 'Failed to load tools'); @@ -2303,13 +2528,7 @@ export function App({ return true; } if (cmd === 'settings') { - if (hideSettings) { - store.appendLocalUserMessage(text); - store.dispatch([{ type: 'status', text: t('command.hidden') }]); - return true; - } - store.appendLocalUserMessage(text); - setSettingsInlineOpen(true); + setShowSettingsDialog(true); return true; } if (cmd === 'context') { @@ -2328,7 +2547,6 @@ export function App({ } if (cmd === 'memory') { const memoryArg = text.slice(match[0].length).trim().toLowerCase(); - store.appendLocalUserMessage(text); if (memoryArg === 'refresh') { setMemoryRefreshSignal((signal) => signal + 1); } else if (memoryArg === 'add' || memoryArg.startsWith('add ')) { @@ -2340,12 +2558,11 @@ export function App({ ); setMemoryAddSignal((signal) => signal + 1); } - setMemoryInlineOpen(true); + setShowMemoryDialog(true); return true; } if (cmd === 'agents') { const subCommand = text.slice(match[0].length).trim().toLowerCase(); - store.appendLocalUserMessage(text); let agentsMode: AgentsInitialMode = 'menu'; if (subCommand === 'create') { agentsMode = 'create'; @@ -2362,14 +2579,13 @@ export function App({ } else if (subCommand === 'manage') { agentsMode = 'manage'; } - setAgentsInlineMode(agentsMode); + setAgentsDialogMode(agentsMode); return true; } if (cmd === 'extensions') { const args = text.slice(match[0].length).trim(); const subCommand = args.split(/\s+/)[0]?.toLowerCase(); if (!subCommand || subCommand === 'manage') { - store.appendLocalUserMessage(text); setShowExtensionsDialog(true); return true; } @@ -2490,15 +2706,11 @@ export function App({ return true; } if (cmd === 'clear') { - sessionActions.newSession().catch((error: unknown) => { - reportError(error, 'Failed to create a new session'); - }); + createNewSession(); return true; } if (cmd === 'new' || cmd === 'reset') { - sessionActions.newSession().catch((error: unknown) => { - reportError(error, 'Failed to create a new session'); - }); + createNewSession(); return true; } if (cmd === 'rename') { @@ -2564,6 +2776,7 @@ export function App({ text: serializeStatsMessage(result, statsView), }, ]); + resumeChatBottomFollow('smooth'); }) .catch(() => {}); return true; @@ -2629,6 +2842,7 @@ export function App({ store.dispatch([ { type: 'status', text: serializeStatusMessage(info) }, ]); + resumeChatBottomFollow('smooth'); }); return true; } @@ -2712,16 +2926,20 @@ export function App({ sessionActions, store, enqueuePrompt, + branchCurrentSession, + createNewSession, handleBusyGoalClear, handleGoalSlashCommand, handleThemeChange, handleSetMode, handleLanguageChange, - hideSettings, + openTasksPanel, + hiddenCommands, pushToast, reportError, runVisibleRecap, runVisibleBtw, + resumeChatBottomFollow, selectedLanguage, showContextUsage, t, @@ -2729,11 +2947,22 @@ export function App({ ], ); + const handleEditorSubmit = useCallback( + (text: string, images?: PromptImage[]) => { + const accepted = handleSubmit(text, images); + if (accepted !== false) { + resumeChatBottomFollow('smooth'); + } + return accepted; + }, + [handleSubmit, resumeChatBottomFollow], + ); + useEffect(() => { if (drainingQueueRef.current) return; if (!connected) return; if (streamingState !== 'idle') return; - if (bottomHidden) return; + if (interactionBlocked) return; if (pendingApproval) return; if (queuedPrompts.length === 0) return; @@ -2765,7 +2994,7 @@ export function App({ }; }, [ connected, - bottomHidden, + interactionBlocked, handleSubmit, pendingApproval, popNextQueuedPrompt, @@ -2791,9 +3020,9 @@ export function App({ }, [sessionActions, reportError]); const handleFocusTaskPill = useCallback((): boolean => { - if (bottomHidden) return false; + if (interactionBlocked) return false; return statusBarRef.current?.focusTaskPill() ?? false; - }, [bottomHidden]); + }, [interactionBlocked]); const handleReturnToEditor = useCallback((text?: string) => { if (text) { @@ -2802,6 +3031,9 @@ export function App({ } editorRef.current?.focus(); }, []); + const handleFollowStateChange = useCallback((isFollowing: boolean) => { + setShowScrollToBottom(!isFollowing); + }, []); const handleRetry = useCallback(() => { if ( @@ -2828,7 +3060,7 @@ export function App({ useEffect(() => { const onGlobalShortcut = (e: KeyboardEvent) => { - if (bottomHidden) return; + if (interactionBlocked) return; if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { if (e.key === 'l') { e.preventDefault(); @@ -2850,7 +3082,7 @@ export function App({ window.addEventListener('keydown', onGlobalShortcut, true); return () => window.removeEventListener('keydown', onGlobalShortcut, true); }, [ - bottomHidden, + interactionBlocked, handleClearScreen, handleToggleCompact, handleRetry, @@ -2875,23 +3107,14 @@ export function App({ if (escPressCountRef.current > 0) { resetEscapeState(); } - if (e.key === 'Tab' && e.shiftKey && !bottomHidden) { + if (e.key === 'Tab' && e.shiftKey && !interactionBlocked) { e.preventDefault(); handleCycleMode(); } return; } - if (pendingApproval || bottomHidden) return; - - if (tasksPanelMessage) { - e.preventDefault(); - e.stopPropagation(); - setTasksPanelMessage(null); - handleReturnToEditor(); - resetEscapeState(); - return; - } + if (pendingApproval || interactionBlocked) return; if (clearQueuedPrompts()) { e.preventDefault(); @@ -2939,13 +3162,11 @@ export function App({ handleCancel, handleCycleMode, pendingApproval, - bottomHidden, - tasksPanelMessage, - handleReturnToEditor, + interactionBlocked, clearQueuedPrompts, ]); - const isDisabled = !connected; + const isDisabled = !connected || connection.catchingUp; const handleModelSelect = useCallback( (modelId: string) => { @@ -3020,9 +3241,21 @@ export function App({ ), [renderWelcomeHeader, welcomeHeaderProps], ); + const welcomeFooter = useMemo( + () => renderWelcomeFooter?.(welcomeHeaderProps), + [renderWelcomeFooter, welcomeHeaderProps], + ); + const isChatEmptyState = + displayMessages.length === 0 && + !showFloatingTodos && + !pendingApproval && + !btwMessage; + const chatWidthToggleMin = getChatMaxWidth(chatMaxWidth); const appClassName = [ styles.app, + styles.appChat, + isChatEmptyState ? styles.appChatEmpty : undefined, selectedTheme === WebShellThemeId.Light ? styles.themeLight : styles.themeDark, @@ -3030,91 +3263,318 @@ export function App({ ] .filter(Boolean) .join(' '); + const appStyle = useMemo( + () => ({ + ...externalStyle, + ...getChatWidthStyle(chatWidthMode, chatMaxWidth), + }), + [chatMaxWidth, chatWidthMode, externalStyle], + ); + const handleChatWidthModeChange = useCallback((mode: ChatWidthMode) => { + setChatWidthMode(mode); + writeChatWidthMode(mode); + }, []); + + useLayoutEffect(() => { + const footer = footerRef.current; + if (!footer) return; + + const previousRect = previousFooterRectRef.current; + const wasEmpty = previousEmptyStateRef.current; + const nextRect = footer.getBoundingClientRect(); + + if (wasEmpty && !isChatEmptyState && previousRect) { + const offsetY = previousRect.top - nextRect.top; + if (Math.abs(offsetY) > 1) { + footer.style.transition = 'width 320ms ease'; + footer.style.transform = `translateY(${offsetY}px)`; + requestAnimationFrame(() => { + footer.style.transition = 'width 320ms ease, transform 280ms ease'; + footer.style.transform = ''; + }); + window.setTimeout(() => { + footer.style.transition = ''; + }, 320); + } + } + + previousFooterRectRef.current = nextRect; + previousEmptyStateRef.current = isChatEmptyState; + }, [isChatEmptyState]); return ( -
+
{!onToast && } - {dialogOpen && ( -
- {showResumeDialog && ( - { - sessionActions - .loadSession(sessionId) - .catch((error: unknown) => { - reportError(error, 'Failed to load session'); - }); - }} - onClose={() => setShowResumeDialog(false)} - /> - )} - {showDeleteDialog && ( - { - store.dispatch([ - { - type: 'status', - text: - sessionIds.length === 1 - ? `${t('delete.deleted')} (${sessionIds[0]!.slice(0, 8)})` - : t('delete.deletedCount', { - count: sessionIds.length, - }), - }, - ]); - }} - onError={(error) => { - if (isAlreadyDispatched(error)) return; - const reason = - error instanceof Error ? error.message : String(error); - pushToast('error', t('delete.failed', { reason })); - }} - onClose={() => setShowDeleteDialog(false)} - /> - )} - {showReleaseDialog && ( - { - store.dispatch([ - { - type: 'status', - text: `${t('release.released')} (${sessionId.slice(0, 8)})`, - }, - ]); - }} - onError={(error) => { - if (isAlreadyDispatched(error)) return; - const reason = - error instanceof Error ? error.message : String(error); - pushToast('error', t('release.failed', { reason })); - }} - onClose={() => setShowReleaseDialog(false)} - /> - )} - {showHelpDialog && ( - setShowHelpDialog(false)} - /> - )} - {showThemeDialog && ( - setShowThemeDialog(false)} - /> - )} - {showToolsDialog && ( - setShowToolsDialog(false)} /> - )} - {showExtensionsDialog && ( - setShowExtensionsDialog(false)} - /> - )} -
+ {showResumeDialog && ( + setShowResumeDialog(false)} + > + { + sessionActions + .loadSession(sessionId) + .catch((error: unknown) => { + reportError(error, 'Failed to load session'); + }); + }} + onClose={() => setShowResumeDialog(false)} + /> + + )} + {modelDialogMode && ( + setModelDialogMode(null)} + > + { + if (modelDialogMode === 'fast') { + handleFastModelSelect(modelId); + } else { + handleModelSelect(modelId); + } + setModelDialogMode(null); + }} + /> + + )} + {showApprovalModeDialog && ( + setShowApprovalModeDialog(false)} + > + { + handleSetMode(modeId); + setShowApprovalModeDialog(false); + }} + /> + + )} + {showToolsDialog && ( + setShowToolsDialog(false)} + > + + + )} + {showExtensionsDialog && ( + setShowExtensionsDialog(false)} + > + + + )} + {mcpDialogMessage && ( + setMcpDialogMessage(null)} + > + setMcpDialogMessage(null)} + /> + + )} + {tasksDialogMessage && ( + setTasksDialogMessage(null)} + > + setTasksDialogMessage(null)} + /> + + )} + {agentsDialogMode && ( + setAgentsDialogMode(null)} + > + store.dispatch([{ type: 'status', text }])} + onClose={() => setAgentsDialogMode(null)} + /> + + )} + {showSettingsDialog && ( + setShowSettingsDialog(false)} + > + { + setShowSettingsDialog(false); + if (key === 'fastModel') setModelDialogMode('fast'); + else if (key === 'tools.approvalMode') + setShowApprovalModeDialog(true); + }} + /> + + )} + {showMemoryDialog && ( + setShowMemoryDialog(false)} + > + { + store.dispatch([{ type, text }]); + }} + /> + + )} + {showHelpDialog && ( + setShowHelpDialog(false)} + > + + + )} + {showThemeDialog && ( + setShowThemeDialog(false)} + > + setShowThemeDialog(false)} + /> + + )} + {showAuthDialog && ( + setShowAuthDialog(false)} + > + { + store.dispatch([ + type === 'error' + ? { type: 'error', text } + : { type: 'status', text }, + ]); + }} + onClose={() => setShowAuthDialog(false)} + /> + + )} + {showDeleteDialog && ( + setShowDeleteDialog(false)} + > + { + store.dispatch([ + { + type: 'status', + text: + sessionIds.length === 1 + ? `${t('delete.deleted')} (${sessionIds[0]!.slice(0, 8)})` + : t('delete.deletedCount', { + count: sessionIds.length, + }), + }, + ]); + }} + onError={(error) => { + if (isAlreadyDispatched(error)) return; + const reason = + error instanceof Error ? error.message : String(error); + pushToast('error', t('delete.failed', { reason })); + }} + onClose={() => setShowDeleteDialog(false)} + /> + + )} + {showReleaseDialog && ( + setShowReleaseDialog(false)} + > + { + store.dispatch([ + { + type: 'status', + text: `${t('release.released')} (${sessionId.slice(0, 8)})`, + }, + ]); + }} + onError={(error) => { + if (isAlreadyDispatched(error)) return; + const reason = + error instanceof Error ? error.message : String(error); + pushToast('error', t('release.failed', { reason })); + }} + onClose={() => setShowReleaseDialog(false)} + /> + + )} + {showRewindDialog && ( + setShowRewindDialog(false)} + > + setShowRewindDialog(false)} + /> + )} @@ -3124,125 +3584,37 @@ export function App({ details={todoDetails} >
0 || + pendingApproval + ? styles.contentHasMessages + : undefined, + ] + .filter(Boolean) + .join(' ')} > - {authInlineOpen && ( - { - store.dispatch([ - type === 'error' - ? { type: 'error', text } - : { type: 'status', text }, - ]); - }} - onClose={() => setAuthInlineOpen(false)} - /> - )} - {approvalModeInlineOpen && ( - setApprovalModeInlineOpen(false)} - /> - )} - {modelInlineMode && ( - setModelInlineMode(null)} - /> - )} - {agentsInlineMode && ( - - store.dispatch([{ type: 'status', text }]) - } - onClose={() => setAgentsInlineMode(null)} - /> - )} - {memoryInlineOpen && ( - { - store.dispatch([{ type, text }]); - }} - onClose={() => setMemoryInlineOpen(false)} - /> - )} - {settingsInlineOpen && ( - setSettingsInlineOpen(false)} - onLanguageChange={handleSettingsLanguageChange} - onThemeChange={handleThemeChange} - onSubDialog={(key) => { - setSettingsInlineOpen(false); - if (key === 'fastModel') - setModelInlineMode('fast'); - else if (key === 'tools.approvalMode') - setApprovalModeInlineOpen(true); - }} - /> - )} - - ) : undefined - } - tailKey={ - agentsInlineMode || - memoryInlineOpen || - modelInlineMode || - authInlineOpen || - approvalModeInlineOpen || - settingsInlineOpen - ? `inline-${authInlineOpen ? 'auth' : 'none'}-${modelInlineMode ?? 'none'}-${agentsInlineMode ?? 'none'}-${memoryInlineOpen ? 'memory' : 'none'}-${approvalModeInlineOpen ? 'approval' : 'none'}-${settingsInlineOpen ? 'settings' : 'none'}` - : undefined - } - // The approval-mode/model pickers and the settings panel are - // reachable by mouse from the status bar, so they reveal - // themselves when opened while the user is scrolled up; the - // agents/memory panels keep the user's scroll position. - autoScrollTailIntoView={ - approvalModeInlineOpen || - modelInlineMode !== null || - settingsInlineOpen - } + onBranchSession={handleBranchCurrentSession} + welcomeHeader={isChatEmptyState ? welcomeHeader : undefined} + tailContent={undefined} + tailKey={undefined} + onFollowStateChange={handleFollowStateChange} virtualScrollThreshold={virtualScrollThreshold} /> - {btwMessage?.role === 'btw' && (
)} - -
-
- -
- {showFloatingTodos && !tasksPanelMessage && ( -
- + {showScrollToBottom && ( +
-
- )} - {!shouldHideComposer && ( + > + +
+ )} + {showFloatingTodos && ( +
+ +
+ )} + {pendingToolApproval && ( +
+ +
+ )} + {pendingAskUserApproval && ( +
+ +
+ )}
- - + + prompt.text)} + queuedMessages={queuedTexts} onFocusFooter={handleFocusTaskPill} onPopQueuedMessages={popQueuedPromptsForEdit} onClearQueuedMessages={clearQueuedPrompts} currentMode={currentMode} + currentModel={currentModel} + chatWidthMode={chatWidthMode} + showChatWidthToggle={!isChatEmptyState} + chatWidthToggleMin={chatWidthToggleMin} + visibleToolbarActions={composerToolbarActions} + availableModels={availableModels} + onSelectMode={handleSetMode} + onSelectModel={handleModelSelect} + onChatWidthModeChange={handleChatWidthModeChange} sessionName={sessionDisplayName} - dialogOpen={bottomHidden || tasksPanelMessage !== null} + dialogOpen={interactionBlocked} followupState={followupState} onAcceptFollowup={onAcceptFollowup} onDismissFollowup={onDismissFollowup} composerInput={composerInput} composerInputVersion={composerInputVersion} placeholderText={ - !connected + !connected || connection.catchingUp ? t('common.loading') : streamingState !== 'idle' ? t('editor.processing') @@ -3312,24 +3739,7 @@ export function App({ } />
- )} - {tasksPanelMessage && ( -
- { - setTasksPanelMessage(null); - handleReturnToEditor(); - }} - /> -
- )} - {!shouldHideComposer && - !tasksPanelMessage && - (showShortcuts ? ( - - ) : CustomFooter ? ( + {CustomFooter ? ( ({ - id: m.id, - label: m.label, - contextWindow: m.contextWindow, - }))} + availableModels={(connection.models ?? []) + .filter(isVisibleComposerModel) + .map((m) => ({ + id: m.id, + label: m.label, + contextWindow: m.contextWindow, + }))} skills={loadedSkills} - onSelectMode={(mode) => handleSetMode(mode)} - onSelectModel={(model) => { - sessionActions.setModel(model).then(() => { - setCurrentModel(model); - }); - }} + onSelectMode={handleSetMode} + onSelectModel={handleModelSelect} /> ) : ( setApprovalModeInlineOpen((v) => !v)} + onSelectMode={() => setShowApprovalModeDialog((v) => !v)} onSelectModel={() => - setModelInlineMode((v) => (v ? null : 'main')) + setModelDialogMode((v) => (v ? null : 'main')) } onShowContext={() => showContextUsage('/context', false)} - onOpenSettings={() => setSettingsInlineOpen((v) => !v)} + onOpenSettings={() => setShowSettingsDialog(true)} ref={statusBarRef} onOpenTasks={() => openTasksPanel()} onReturnToInput={handleReturnToEditor} @@ -3373,9 +3781,14 @@ export function App({ activeGoal={activeGoal} hideSettings={hideSettings} onToggleShortcuts={handleToggleShortcuts} + compact={true} /> - ))} -
+ )} + {isChatEmptyState && welcomeFooter && ( +
{welcomeFooter}
+ )} +
+
diff --git a/packages/web-shell/client/adapters/messageTypes.ts b/packages/web-shell/client/adapters/messageTypes.ts index c53481ce736..6a494e78954 100644 --- a/packages/web-shell/client/adapters/messageTypes.ts +++ b/packages/web-shell/client/adapters/messageTypes.ts @@ -85,7 +85,6 @@ export interface DaemonAssistantMessage extends DaemonMessageMeta { id: string; role: 'assistant'; content: string; - thinking?: string; isStreaming?: boolean; /** * Token usage folded onto this assistant block by the daemon SDK reducer @@ -96,6 +95,13 @@ export interface DaemonAssistantMessage extends DaemonMessageMeta { usage?: { inputTokens: number; outputTokens: number; cachedTokens?: number }; } +export interface DaemonThinkingMessage extends DaemonMessageMeta { + id: string; + role: 'thinking'; + content: string; + isStreaming?: boolean; +} + export interface DaemonToolGroupMessage extends DaemonMessageMeta { id: string; role: 'tool_group'; @@ -157,6 +163,7 @@ export interface DaemonInsightErrorMessage extends DaemonMessageMeta { export type DaemonMessage = | DaemonUserMessage | DaemonAssistantMessage + | DaemonThinkingMessage | DaemonToolGroupMessage | DaemonPlanMessage | DaemonSystemMessage diff --git a/packages/web-shell/client/adapters/transcriptAdapter.test.ts b/packages/web-shell/client/adapters/transcriptAdapter.test.ts index 8ad54806660..094e0ed74ed 100644 --- a/packages/web-shell/client/adapters/transcriptAdapter.test.ts +++ b/packages/web-shell/client/adapters/transcriptAdapter.test.ts @@ -11,6 +11,8 @@ function state(blocks: DaemonTranscriptBlock[]): DaemonTranscriptState { blockIndexById: Object.fromEntries( blocks.map((block, index) => [block.id, index]), ), + activeAssistantBlockByParent: {}, + activeThoughtBlockByParent: {}, toolBlockByCallId: {}, trimmedToolNotificationByCallId: {}, permissionBlockByRequestId: {}, diff --git a/packages/web-shell/client/adapters/transcriptToMessages.test.ts b/packages/web-shell/client/adapters/transcriptToMessages.test.ts index e9cfd7dcbfb..5ff3c120072 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.test.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.test.ts @@ -125,6 +125,36 @@ function toolBlock( } describe('transcriptBlocksToDaemonMessages', () => { + it('hides background task notifications by metadata', () => { + const messages = transcriptBlocksToDaemonMessages([ + textBlock( + 'bg-1', + 'assistant', + 'Background agent "general-purpose: 查询百度云活动信息" completed.', + 1, + false, + { + meta: { + source: 'background_notification', + qwenDiscreteMessage: true, + backgroundTask: { taskId: 'task-1', status: 'completed' }, + }, + }, + ), + textBlock('assistant-1', 'assistant', '正常回复', 2), + ]); + + expect(messages).toEqual([ + { + id: 'assistant-1', + role: 'assistant', + content: '正常回复', + isStreaming: false, + timestamp: 2, + }, + ]); + }); + it('renders daemon plan status blocks as plan messages', () => { const plan = { sessionUpdate: 'plan', @@ -204,6 +234,31 @@ describe('transcriptBlocksToDaemonMessages', () => { ]); }); + it('localizes structured mid-turn inserted status blocks', () => { + const messages = transcriptBlocksToDaemonMessages( + [ + statusBlock('mid-1', 'Inserted message: hello', 1, { + source: 'mid_turn_message_injected', + data: { sessionId: 's1', messages: ['你好'] }, + }), + ], + { + labels: { + midTurnInserted: (message) => `已插入消息:${message}`, + }, + }, + ); + + expect(messages).toEqual([ + expect.objectContaining({ + role: 'system', + content: '已插入消息:你好', + source: 'mid_turn_message_injected', + data: { sessionId: 's1', messages: ['你好'] }, + }), + ]); + }); + it('ignores daemon plan entries without content', () => { const plan = { sessionUpdate: 'plan', @@ -393,7 +448,7 @@ describe('transcriptBlocksToDaemonMessages', () => { expect(messages).toMatchObject([ { role: 'tool_group', tools: [{ callId: 'tc1' }] }, - { role: 'assistant', thinking: 'next I should grep' }, + { role: 'thinking', content: 'next I should grep' }, { role: 'tool_group', tools: [{ callId: 'tc2' }] }, ]); }); @@ -849,7 +904,7 @@ describe('transcriptBlocksToDaemonMessages', () => { textBlock('assistant-main', 'assistant', 'main continues', 40), ]); - expect(messages).toHaveLength(2); + expect(messages).toHaveLength(3); expect(messages[0]).toMatchObject({ id: 'tg-agent-start', role: 'tool_group', @@ -871,9 +926,13 @@ describe('transcriptBlocksToDaemonMessages', () => { ).toBeUndefined(); expect(messages[1]).toMatchObject({ id: 'thought-main', + role: 'thinking', + content: 'wait for background agent', + }); + expect(messages[2]).toMatchObject({ + id: 'assistant-main', role: 'assistant', content: 'main continues', - thinking: 'wait for background agent', }); }); @@ -898,7 +957,7 @@ describe('transcriptBlocksToDaemonMessages', () => { textBlock('assistant-main', 'assistant', 'waiting for agents', 40), ]); - expect(messages).toHaveLength(2); + expect(messages).toHaveLength(3); expect(messages[0]).toMatchObject({ id: 'tg-agent-background', role: 'tool_group', @@ -915,9 +974,13 @@ describe('transcriptBlocksToDaemonMessages', () => { }); expect(messages[1]).toMatchObject({ id: 'thought-main', + role: 'thinking', + content: 'server diff looks clean', + }); + expect(messages[2]).toMatchObject({ + id: 'assistant-main', role: 'assistant', content: 'waiting for agents', - thinking: 'server diff looks clean', }); }); @@ -990,6 +1053,32 @@ describe('transcriptBlocksToDaemonMessages', () => { }); }); + it('filters language_changed daemon debug noise', () => { + const messages = transcriptBlocksToDaemonMessages([ + { + id: 'debug-1', + kind: 'debug', + text: + 'language_changed (unrecognized daemon event): ' + + '{"sessionId":"dd699cc0-6ef7-4882-92d9-1076ac5b87e9",' + + '"language":"en","outputLanguage":"English","refreshed":true}', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + } as DaemonTranscriptBlock, + ]); + + expect(messages).toEqual([]); + }); + + it('filters SDK model switch status noise', () => { + const messages = transcriptBlocksToDaemonMessages([ + statusBlock('st1', 'Model switched: qwen3-coder-plus(openai)', 1), + ]); + + expect(messages).toEqual([]); + }); + it('preserves structured status source and data', () => { const data = { kind: 'set', @@ -1169,25 +1258,14 @@ describe('transcriptBlocksToDaemonMessages', () => { textBlock('a2', 'assistant', ' response part 2', 6), ]); - const assistantMsgs = messages.filter((m) => m.role === 'assistant'); - expect(assistantMsgs).toHaveLength(3); - expect(assistantMsgs[0]).toMatchObject({ - role: 'assistant', - content: '', - thinking: 'thinking part 1', - }); - expect(assistantMsgs[1]).toMatchObject({ - role: 'assistant', - content: 'response part 1', - thinking: ' thinking part 2', - }); - expect(assistantMsgs[2]).toMatchObject({ - role: 'assistant', - content: ' response part 2', - }); - - const toolGroups = messages.filter((m) => m.role === 'tool_group'); - expect(toolGroups).toHaveLength(2); + expect(messages).toMatchObject([ + { role: 'thinking', content: 'thinking part 1' }, + { role: 'tool_group', tools: [{ callId: 'tc1' }] }, + { role: 'thinking', content: ' thinking part 2' }, + { role: 'assistant', content: 'response part 1' }, + { role: 'tool_group', tools: [{ callId: 'tc2' }] }, + { role: 'assistant', content: ' response part 2' }, + ]); }); it('keeps thought blocks after tool groups in transcript order', () => { @@ -1202,13 +1280,13 @@ describe('transcriptBlocksToDaemonMessages', () => { ]); expect(messages).toMatchObject([ - { role: 'assistant', thinking: 'first thought' }, + { role: 'thinking', content: 'first thought' }, { role: 'tool_group', tools: [{ callId: 'tc1' }] }, - { role: 'assistant', thinking: 'second thought' }, + { role: 'thinking', content: 'second thought' }, { role: 'tool_group', tools: [{ callId: 'tc2' }] }, - { role: 'assistant', thinking: 'third thought' }, + { role: 'thinking', content: 'third thought' }, { role: 'tool_group', tools: [{ callId: 'tc3' }] }, - { role: 'assistant', thinking: 'final thought' }, + { role: 'thinking', content: 'final thought' }, ]); }); @@ -1787,7 +1865,7 @@ describe('transcriptBlocksToDaemonMessages', () => { textBlock('assistant-1', 'assistant', 'main turn continues', 4), ]); - expect(messages).toHaveLength(2); + expect(messages).toHaveLength(3); expect(messages[0]).toMatchObject({ role: 'tool_group', tools: [ @@ -1801,9 +1879,12 @@ describe('transcriptBlocksToDaemonMessages', () => { messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; expect(agent?.subContent).toBeUndefined(); expect(messages[1]).toMatchObject({ + role: 'thinking', + content: 'background agent is running', + }); + expect(messages[2]).toMatchObject({ role: 'assistant', content: 'main turn continues', - thinking: 'background agent is running', }); }); @@ -1947,12 +2028,11 @@ describe('transcriptBlocksToDaemonMessages', () => { textBlock('a1', 'assistant', 'response text', 5), ]); - expect(messages).toHaveLength(3); - // First: thinking-only assistant + expect(messages).toHaveLength(4); + // First: thinking message expect(messages[0]).toMatchObject({ - role: 'assistant', - thinking: 'first thinking', - content: '', + role: 'thinking', + content: 'first thinking', }); // Second: completed agent card (not pending) const agentGroup = messages[1]; @@ -1964,10 +2044,13 @@ describe('transcriptBlocksToDaemonMessages', () => { endTime: 3, }); } - // Third: second thinking + response (not absorbed into agent subContent) + // Third: second thinking, fourth: response (not absorbed into agent subContent) expect(messages[2]).toMatchObject({ + role: 'thinking', + content: 'second thinking', + }); + expect(messages[3]).toMatchObject({ role: 'assistant', - thinking: 'second thinking', content: 'response text', }); }); @@ -2023,16 +2106,12 @@ describe('transcriptBlocksToDaemonMessages', () => { textBlock('a2', 'assistant', 'different approach', 5), ]); - const assistantMsgs = messages.filter((m) => m.role === 'assistant'); - expect(assistantMsgs).toHaveLength(2); - expect(assistantMsgs[0]).toMatchObject({ - thinking: 'first thinking', - content: 'let me try this', - }); - expect(assistantMsgs[1]).toMatchObject({ - thinking: 'second thinking', - content: 'different approach', - }); + expect(messages).toMatchObject([ + { role: 'thinking', content: 'first thinking' }, + { role: 'assistant', content: 'let me try this' }, + { role: 'thinking', content: 'second thinking' }, + { role: 'assistant', content: 'different approach' }, + ]); }); it('converts error blocks to system error messages', () => { @@ -2108,7 +2187,7 @@ describe('transcriptBlocksToDaemonMessages', () => { ]); }); - it('creates assistant message with empty content for thought-only blocks', () => { + it('creates thinking message for thought-only blocks', () => { const messages = transcriptBlocksToDaemonMessages([ textBlock('t1', 'thought', 'let me think about this', 1), ]); @@ -2116,9 +2195,8 @@ describe('transcriptBlocksToDaemonMessages', () => { expect(messages).toEqual([ { id: 't1', - role: 'assistant', - content: '', - thinking: 'let me think about this', + role: 'thinking', + content: 'let me think about this', isStreaming: false, timestamp: 1, }, @@ -2209,7 +2287,7 @@ describe('transcriptBlocksToDaemonMessages', () => { ]); }); - it('merges thought and assistant without tools into single message', () => { + it('keeps thought and assistant as separate messages without tools', () => { const messages = transcriptBlocksToDaemonMessages([ textBlock('t1', 'thought', 'analyzing...', 1), textBlock('a1', 'assistant', 'here is my answer', 2), @@ -2218,11 +2296,17 @@ describe('transcriptBlocksToDaemonMessages', () => { expect(messages).toEqual([ { id: 't1', + role: 'thinking', + content: 'analyzing...', + isStreaming: false, + timestamp: 1, + }, + { + id: 'a1', role: 'assistant', content: 'here is my answer', - thinking: 'analyzing...', isStreaming: false, - timestamp: 1, + timestamp: 2, }, ]); }); @@ -2270,6 +2354,7 @@ describe('transcriptBlocksToDaemonMessages', () => { role: 'system', content: 'Request cancelled.', variant: 'info', + source: 'prompt_cancelled', timestamp: 20, }, ]); @@ -2287,6 +2372,7 @@ describe('transcriptBlocksToDaemonMessages', () => { role: 'system', content: '请求已取消。', variant: 'info', + source: 'prompt_cancelled', timestamp: 20, }, ]); @@ -2913,9 +2999,9 @@ describe('transcriptBlocksToDaemonMessages', () => { ]); expect(messages).toHaveLength(3); - const assistant = messages.find((message) => message.role === 'assistant'); - expect(assistant).toMatchObject({ - thinking: 'I need to review the PR diff.', + const thinking = messages.find((message) => message.role === 'thinking'); + expect(thinking).toMatchObject({ + content: 'I need to review the PR diff.', }); const agentA = messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined; diff --git a/packages/web-shell/client/adapters/transcriptToMessages.ts b/packages/web-shell/client/adapters/transcriptToMessages.ts index d04994e451c..2c6f52fd5fe 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.ts @@ -39,12 +39,17 @@ type ExtendedDaemonStatusTranscriptBlock = DaemonStatusTranscriptBlock & { }; type ExtendedDaemonTextTranscriptBlock = DaemonTextTranscriptBlock & { - meta?: Record; + meta?: { + source?: unknown; + qwenDiscreteMessage?: boolean; + backgroundTask?: unknown; + }; }; interface TranscriptMessageLabels { promptCancelled?: string; branchSuccess?: (name: string) => string; + midTurnInserted?: (message: string) => string; } interface TranscriptMessageOptions { @@ -72,6 +77,17 @@ function getSessionBranchDisplayName(data: unknown): string | null { : null; } +function getMidTurnInjectedText(data: unknown): string | null { + if (!data || typeof data !== 'object') return null; + const messages = (data as { messages?: unknown }).messages; + if (!Array.isArray(messages)) return null; + const text = messages + .filter((message): message is string => typeof message === 'string') + .join('\n') + .trim(); + return text || null; +} + function isBackgroundNotificationAssistantBlock( block: DaemonTextTranscriptBlock, ): boolean { @@ -160,6 +176,7 @@ export function transcriptBlocksToDaemonMessages( const toolsByCallId = new Map(); const permissionToolInfoByCallId = new Map(); let currentAssistantIdx: number | null = null; + let currentThinkingIdx: number | null = null; // Tool cards are standalone transcript turns. Once a tool is emitted, // the next top-level assistant/thought block must start a fresh assistant // message instead of being appended to text that appeared before the tool. @@ -175,6 +192,7 @@ export function transcriptBlocksToDaemonMessages( switch (block.kind) { case 'user': { currentAssistantIdx = null; + currentThinkingIdx = null; needsNewContentMessage = false; const textBlock = block as DaemonTextTranscriptBlock; const msg: DaemonUserMessage = { @@ -243,6 +261,7 @@ export function transcriptBlocksToDaemonMessages( timestamp: blockTime, }); currentAssistantIdx = messages.length - 1; + currentThinkingIdx = null; } } if (lastProgress && !hasTerminal) { @@ -277,6 +296,7 @@ export function transcriptBlocksToDaemonMessages( ...(usage ? { usage } : {}), }; needsNewContentMessage = false; + currentThinkingIdx = null; } else if (!isTextBlockEmpty(textBlock)) { messages.push({ id: block.id, @@ -287,6 +307,7 @@ export function transcriptBlocksToDaemonMessages( ...(textBlock.usage ? { usage: textBlock.usage } : {}), }); currentAssistantIdx = messages.length - 1; + currentThinkingIdx = null; needsNewContentMessage = false; } else if (textBlock.usage && target && target.role === 'assistant') { const usage = mergeAssistantUsage(target.usage, textBlock.usage); @@ -308,33 +329,28 @@ export function transcriptBlocksToDaemonMessages( break; } const target = - currentAssistantIdx !== null - ? messages[currentAssistantIdx] + currentThinkingIdx !== null + ? messages[currentThinkingIdx] : undefined; - if ( - target && - target.role === 'assistant' && - !needsNewContentMessage && - !target.content - ) { - messages[currentAssistantIdx!] = { + if (target && target.role === 'thinking' && !needsNewContentMessage) { + messages[currentThinkingIdx!] = { ...target, - thinking: (target.thinking || '') + textBlock.text, + content: target.content + textBlock.text, isStreaming: textBlock.streaming, }; needsNewContentMessage = false; } else { messages.push({ id: block.id, - role: 'assistant', - content: '', - thinking: textBlock.text, + role: 'thinking', + content: textBlock.text, isStreaming: textBlock.streaming, timestamp: blockTime, }); - currentAssistantIdx = messages.length - 1; + currentThinkingIdx = messages.length - 1; needsNewContentMessage = false; } + currentAssistantIdx = null; break; } @@ -366,6 +382,8 @@ export function transcriptBlocksToDaemonMessages( appendToolCallMessage(messages, block.id, toolCall, blockTime); toolsByCallId.set(toolCall.callId, toolCall); + currentAssistantIdx = null; + currentThinkingIdx = null; needsNewContentMessage = true; break; } @@ -512,10 +530,16 @@ export function transcriptBlocksToDaemonMessages( statusBlock.source === 'session_branched' ? getSessionBranchDisplayName(statusBlock.data) : null; + const midTurnInsertedText = + statusBlock.source === 'mid_turn_message_injected' + ? getMidTurnInjectedText(statusBlock.data) + : null; const text = branchDisplayName && options.labels?.branchSuccess ? options.labels.branchSuccess(branchDisplayName) - : statusBlock.text; + : midTurnInsertedText && options.labels?.midTurnInserted + ? options.labels.midTurnInserted(midTurnInsertedText) + : statusBlock.text; if (isIgnoredWebShellStatus(text)) break; const todos = parsePlanTodos(text); if (todos) { @@ -567,6 +591,7 @@ export function transcriptBlocksToDaemonMessages( role: 'system', content: promptCancelledText, variant: 'info', + source: 'prompt_cancelled', timestamp: blockTime, }); needsNewContentMessage = true; diff --git a/packages/web-shell/client/adapters/types.ts b/packages/web-shell/client/adapters/types.ts index b8d9c9d7589..d867f9186ba 100644 --- a/packages/web-shell/client/adapters/types.ts +++ b/packages/web-shell/client/adapters/types.ts @@ -12,6 +12,7 @@ import type { DaemonInsightReadyMessage, DaemonPlanMessage, DaemonSystemMessage, + DaemonThinkingMessage, DaemonToolGroupMessage, DaemonUserMessage, DaemonUserShellMessage, @@ -29,6 +30,7 @@ export type StreamingState = DaemonStreamingState; export type UserMessage = DaemonUserMessage; export type AssistantMessage = DaemonAssistantMessage; +export type ThinkingMessage = DaemonThinkingMessage; export type InsightErrorMessage = DaemonInsightErrorMessage; export type InsightProgressMessage = DaemonInsightProgressMessage; export type InsightReadyMessage = DaemonInsightReadyMessage; @@ -69,6 +71,8 @@ export interface TurnCollapseHead { cachedTokens?: number; /** Number of tool calls shown in this turn. */ toolCallCount?: number; + /** Number of assistant thinking blocks shown in this turn. */ + thinkingCount?: number; /** * Prompt wall-clock (ms) for a still-running turn. Present only while the turn * is active; the row ticks `now - liveStartedAt` once a second so the elapsed diff --git a/packages/web-shell/client/build-artifact.test.ts b/packages/web-shell/client/build-artifact.test.ts index 18f190597d8..7e2e5264d58 100644 --- a/packages/web-shell/client/build-artifact.test.ts +++ b/packages/web-shell/client/build-artifact.test.ts @@ -32,6 +32,9 @@ describe('build artifact — package boundary', () => { const bundle = readBundle(); expect(bundle).toContain('from "react"'); expect(bundle).toContain('from "react/jsx-runtime"'); + expect(bundle).not.toContain('react/jsx-dev-runtime'); + expect(bundle).not.toContain('jsxDEV'); + expect(bundle).not.toContain('fileName:'); }); it('externalizes @qwen-code/sdk subpaths', () => { diff --git a/packages/web-shell/client/completions/slashCompletion.test.ts b/packages/web-shell/client/completions/slashCompletion.test.ts index da715d99931..165b998968f 100644 --- a/packages/web-shell/client/completions/slashCompletion.test.ts +++ b/packages/web-shell/client/completions/slashCompletion.test.ts @@ -4,6 +4,7 @@ import { EditorState } from '@codemirror/state'; import type { CommandInfo } from '../adapters/types'; import { getTranslator } from '../i18n'; import { + getSlashCommandCompletionResult, getSlashCommandArgumentHint, slashCompletionSource, } from './slashCompletion'; @@ -66,6 +67,153 @@ describe('getSlashCommandArgumentHint', () => { }); }); +describe('getSlashCommandCompletionResult', () => { + it('returns top-level command items for the custom slash panel', () => { + const commands: CommandInfo[] = [ + { + name: 'clear', + description: 'Clear the screen', + source: 'builtin-command', + }, + { + name: 'demo:ping', + description: 'Project command', + source: 'skill-dir-command', + }, + ]; + + const result = getSlashCommandCompletionResult( + '/', + 1, + commands, + [], + 'en', + getTranslator('en'), + ); + + expect(result?.kind).toBe('command'); + expect(result?.from).toBe(0); + expect(result?.to).toBe(1); + expect(result?.items.map((item) => item.label)).toEqual([ + '/demo:ping', + '/clear', + ]); + expect(result?.items.map((item) => item.section)).toEqual([ + 'Custom commands', + 'System commands', + ]); + }); + + it('returns explicit subcommands after accepting a parent command', () => { + const commands: CommandInfo[] = [ + { + name: 'agents', + description: 'Manage agents', + argumentHint: 'manage|create', + }, + ]; + + const result = getSlashCommandCompletionResult( + '/agents ', + 8, + commands, + [], + 'en', + getTranslator('en'), + ); + + expect(result?.kind).toBe('subcommand'); + expect(result?.from).toBe(0); + expect(result?.to).toBe(8); + expect(result?.items.map((item) => item.apply)).toEqual([ + '/agents manage ', + '/agents create ', + ]); + }); + + it('returns command-provided subcommands when no built-in tree exists', () => { + const commands: CommandInfo[] = [ + { + name: 'custom', + description: 'Custom command', + subcommands: ['one', 'two'], + }, + ]; + + const result = getSlashCommandCompletionResult( + '/custom t', + 9, + commands, + [], + 'en', + getTranslator('en'), + ); + + expect(result?.items.map((item) => item.label)).toEqual(['two']); + expect(result?.items[0]?.apply).toBe('/custom two '); + }); + + it('returns dynamic skills for /skills', () => { + const commands: CommandInfo[] = [ + { name: 'skills', description: 'Run a skill' }, + ]; + + const result = getSlashCommandCompletionResult( + '/skills re', + 10, + commands, + [{ name: 'review', description: 'Review current code' }], + 'en', + getTranslator('en'), + ); + + expect(result?.items).toEqual([ + { + id: '/skills review', + label: 'review', + detail: 'Review current code', + apply: '/skills review ', + type: 'skill', + }, + ]); + }); + + it('honors panel category order and filtered commands', () => { + const commands: CommandInfo[] = [ + { + name: 'clear', + description: 'Clear the screen', + source: 'builtin-command', + }, + { + name: 'hidden', + description: 'Hidden command', + source: 'skill-dir-command', + }, + { + name: 'demo:ping', + description: 'Project command', + source: 'skill-dir-command', + }, + ].filter((command) => command.name !== 'hidden'); + + const result = getSlashCommandCompletionResult( + '/', + 1, + commands, + [], + 'en', + getTranslator('en'), + ['system', 'custom', 'skill'], + ); + + expect(result?.items.map((item) => item.label)).toEqual([ + '/clear', + '/demo:ping', + ]); + }); +}); + describe('slashCompletionSource', () => { it('completes a top-level slash command from any cursor position in the command', () => { const commands: CommandInfo[] = [ diff --git a/packages/web-shell/client/completions/slashCompletion.ts b/packages/web-shell/client/completions/slashCompletion.ts index e40f7c0ff8b..fbf39770166 100644 --- a/packages/web-shell/client/completions/slashCompletion.ts +++ b/packages/web-shell/client/completions/slashCompletion.ts @@ -20,6 +20,26 @@ export interface SkillInfo { description: string; } +export type SlashCommandCompletionKind = 'command' | 'subcommand'; + +export interface SlashCommandCompletionItem { + id: string; + label: string; + apply: string; + detail?: string; + category?: CommandDisplayCategory; + section?: string; + type?: 'command-info' | 'skill'; +} + +export interface SlashCommandCompletionResult { + kind: SlashCommandCompletionKind; + from: number; + to: number; + query: string; + items: SlashCommandCompletionItem[]; +} + type Translate = (key: string) => string; const COMMAND_NAME_PATTERN = String.raw`([^\s/]+)`; @@ -146,6 +166,7 @@ function resolveSubcommands( parts: string[], dynamicSkills: SkillInfo[] | undefined, language: WebShellLanguage, + commandSubcommands?: string[], ): SubcommandNode[] | null { if (cmdName === 'skills' && parts.length === 0) { if (!dynamicSkills || dynamicSkills.length === 0) return null; @@ -166,6 +187,13 @@ function resolveSubcommands( nodes = implicitTree[cmdName]; } + if (!nodes && parts.length === 0 && commandSubcommands?.length) { + nodes = commandSubcommands.map((name) => ({ + name, + description: '', + })); + } + if (!nodes) return null; for (const part of parts) { @@ -290,6 +318,146 @@ export function getSlashCommandArgumentHint( return `[${nodes.map((node) => node.name).join('|')}]`; } +function getLineBounds(text: string, cursor: number) { + const safeCursor = Math.max(0, Math.min(cursor, text.length)); + const lineStart = text.lastIndexOf('\n', Math.max(0, safeCursor - 1)) + 1; + const nextNewline = text.indexOf('\n', safeCursor); + const lineEnd = nextNewline === -1 ? text.length : nextNewline; + return { + lineStart, + lineEnd, + lineText: text.slice(lineStart, lineEnd), + textBefore: text.slice(lineStart, safeCursor), + cursor: safeCursor, + }; +} + +export function getSlashCommandCompletionResult( + text: string, + cursor: number, + commands: CommandInfo[], + skills: SkillInfo[] = [], + language: WebShellLanguage = 'en', + translate: Translate = (key) => key, + categoryOrder: CommandDisplayCategoryOrder = DEFAULT_COMMAND_CATEGORY_ORDER, +): SlashCommandCompletionResult | null { + const { + lineStart, + lineEnd, + lineText, + textBefore, + cursor: safeCursor, + } = getLineBounds(text, cursor); + + const subMatch = textBefore.match( + new RegExp(`^/${COMMAND_NAME_PATTERN}\\s+(.*)$`), + ); + if (subMatch) { + const [, cmdName, rest] = subMatch; + const cmd = commands.find((c) => c.name === cmdName); + const tree = language === 'zh-CN' ? SUBCOMMAND_TREE_ZH : SUBCOMMAND_TREE_EN; + const implicitTree = + language === 'zh-CN' + ? IMPLICIT_SUBCOMMAND_TREE_ZH + : IMPLICIT_SUBCOMMAND_TREE_EN; + const hasTree = !!tree[cmdName] || cmdName === 'skills'; + const hasImplicitTree = !!implicitTree[cmdName]; + if (!cmd?.subcommands?.length && !hasTree && !hasImplicitTree) { + return null; + } + + const tokens = rest.split(/\s+/); + const currentTyping = tokens.pop() || ''; + const completedParts = tokens; + + if ( + !cmd?.subcommands?.length && + !hasTree && + hasImplicitTree && + !currentTyping + ) { + return null; + } + + const nodes = resolveSubcommands( + cmdName, + completedParts, + skills, + language, + cmd?.subcommands, + ); + if (!nodes) return null; + + const lp = currentTyping.toLowerCase(); + const prefix = `/${cmdName} ${ + completedParts.length > 0 ? completedParts.join(' ') + ' ' : '' + }`; + const filteredNodes = nodes + .filter((n) => !currentTyping || n.name.toLowerCase().includes(lp)) + .sort((a, b) => + currentTyping ? comparePrefixFirst(a.name, b.name, lp) : 0, + ); + const isSkillList = cmdName === 'skills' && completedParts.length === 0; + const items = filteredNodes.map((node): SlashCommandCompletionItem => { + const command = `${prefix}${node.name}`; + return { + id: command, + label: node.name, + detail: node.description || undefined, + apply: `${command} `, + ...(isSkillList ? { type: 'skill' as const } : {}), + }; + }); + + if (items.length === 0) return null; + return { + kind: 'subcommand', + from: lineStart, + to: safeCursor, + query: currentTyping, + items, + }; + } + + const match = lineText.match(/^\/([^\s/]*)$/); + if (!match) return null; + + const prefix = match[1]; + const lp = prefix.toLowerCase(); + const filteredCommands = commands + .filter((command) => { + if (!prefix) return true; + return command.name.toLowerCase().includes(lp); + }) + .sort((a, b) => compareSlashCommands(a, b, lp, categoryOrder)); + + const items = filteredCommands.map((command): SlashCommandCompletionItem => { + const apply = `/${command.name} `; + const category = getCommandDisplayCategory(command); + const showCommandInfo = category === 'custom' || category === 'skill'; + return { + id: command.name, + label: `/${command.name}`, + detail: command.description || undefined, + apply, + category, + section: translate(COMMAND_SECTION_KEYS[category]), + ...(showCommandInfo && command.description + ? { type: 'command-info' as const } + : {}), + }; + }); + + if (items.length === 0) return null; + return { + kind: 'command', + from: lineStart, + to: lineEnd, + query: prefix, + items, + }; +} + export function slashCompletionSource( getCommands: () => CommandInfo[], getSkills: () => SkillInfo[] = () => [], @@ -342,6 +510,7 @@ export function slashCompletionSource( completedParts, getSkills(), language, + cmd?.subcommands, ); if (!nodes) return null; @@ -368,9 +537,6 @@ export function slashCompletionSource( } : {}), detail: n.description || undefined, - ...(isSkillList && n.description - ? { info: `/${n.name}\n\n${n.description}` } - : {}), apply: `${command} `, }; }); @@ -407,9 +573,7 @@ export function slashCompletionSource( return { label: command, detail: c.description || undefined, - ...(showCommandInfo && c.description - ? { info: `${command}\n\n${c.description}` } - : {}), + ...(showCommandInfo && c.description ? { type: 'command-info' } : {}), apply: `${command} `, section: getCommandSection(c, translate, categoryOrder), }; diff --git a/packages/web-shell/client/components/ChatEditor.module.css b/packages/web-shell/client/components/ChatEditor.module.css new file mode 100644 index 00000000000..403cbe1c327 --- /dev/null +++ b/packages/web-shell/client/components/ChatEditor.module.css @@ -0,0 +1,1076 @@ +.editorShell { + position: relative; + container-type: inline-size; + margin: 0 0 14px; +} + +.container { + position: relative; + container-type: inline-size; + border-radius: 16px; + margin: 0; + cursor: text; + --chat-editor-input-max-height: 300px; + --dac-glow-on: 0; + --dac-glow-pulse: 0; + --dac-g1: #6a78ff; + --dac-g2: #8a7bff; +} + +.content { + position: relative; + z-index: 2; + border: 1px solid var(--border-color); + border-radius: inherit; + background: var(--chat-editor-container-bg); + padding: 12px 16px 8px; +} + +.dacAura, +.dacHalo { + pointer-events: none; +} + +.dacAura { + position: absolute; + inset: -50px; + z-index: 0; + opacity: calc(var(--dac-glow-on) * (0.66 + var(--dac-glow-pulse) * 0.34)); + -webkit-mask: radial-gradient( + ellipse 56% 64% at center, + transparent 28%, + #000 48%, + transparent 94% + ); + mask: radial-gradient( + ellipse 56% 64% at center, + transparent 28%, + #000 48%, + transparent 94% + ); + will-change: opacity; +} + +.dacAura::before, +.dacAura::after { + position: absolute; + inset: 0; + content: ''; + will-change: opacity; +} + +.dacAura::before { + background: color-mix(in srgb, var(--dac-g1) 58%, transparent); + -webkit-mask: + repeating-linear-gradient(90deg, #000 0 1px, transparent 1px 5px), + repeating-linear-gradient(0deg, #000 0 1px, transparent 1px 5px); + -webkit-mask-composite: source-in; + mask: + repeating-linear-gradient(90deg, #000 0 1px, transparent 1px 5px), + repeating-linear-gradient(0deg, #000 0 1px, transparent 1px 5px); + mask-composite: intersect; + opacity: 1; + transition: opacity 0.2s ease; +} + +.dacAura::after { + background: color-mix(in srgb, var(--dac-g1) 66%, transparent); + -webkit-mask: + repeating-linear-gradient(90deg, #000 0 1px, transparent 1px 5px), + repeating-linear-gradient(0deg, #000 0 1px, transparent 1px 5px), + repeating-linear-gradient( + 90deg, + #000 0, + rgba(0, 0, 0, 0.08) 28px, + #000 56px + ); + -webkit-mask-composite: source-in, source-in; + mask: + repeating-linear-gradient(90deg, #000 0 1px, transparent 1px 5px), + repeating-linear-gradient(0deg, #000 0 1px, transparent 1px 5px), + repeating-linear-gradient( + 90deg, + #000 0, + rgba(0, 0, 0, 0.08) 28px, + #000 56px + ); + mask-composite: intersect, intersect; + opacity: 0; + animation: dac-aura-cols 2.4s linear infinite paused; + transition: opacity 0.2s ease; +} + +.container[data-dac-typing] .dacAura::before { + opacity: 0; +} + +.container[data-dac-typing] .dacAura::after { + opacity: 1; + animation-play-state: running; +} + +.dacHalo { + position: absolute; + inset: 0; + z-index: 1; + border-radius: inherit; + opacity: var(--dac-glow-on); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--dac-g2) 36%, transparent), + 0 1px 5px color-mix(in srgb, var(--dac-g1) 18%, transparent), + 0 6px 30px color-mix(in srgb, var(--dac-g1) 22%, transparent); + will-change: opacity; +} + +.dacHalo::after { + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: 0 6px 40px color-mix(in srgb, var(--dac-g1) 14%, transparent); + content: ''; + opacity: calc(var(--dac-glow-on) * var(--dac-glow-pulse)); + will-change: opacity; +} + +@keyframes dac-aura-cols { + from { + -webkit-mask-position: + 0 0, + 0 0, + 0 0; + mask-position: + 0 0, + 0 0, + 0 0; + } + + to { + -webkit-mask-position: + 0 0, + 0 0, + 56px 0; + mask-position: + 0 0, + 0 0, + 56px 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .dacAura::after, + .container[data-dac-typing] .dacAura::after { + opacity: 0; + animation: none; + } + + .container[data-dac-typing] .dacAura::before { + opacity: 1; + } +} + +.slashPortalLayer { + display: contents; +} + +.slashPanel { + position: fixed; + z-index: var(--web-shell-popover-z-index, 1000); + --slash-panel-max-height: min(360px, 50vh); + --slash-anchor-width: 620px; + --slash-command-col: 20ch; + --slash-desc-col: 32ch; + --slash-column-gap: 2ch; + --slash-row-width: calc( + var(--slash-command-col) + var(--slash-desc-col) + var(--slash-column-gap) + + 16px + ); + width: fit-content; + min-width: min(240px, calc(var(--slash-anchor-width) - 32px)); + max-width: min( + 620px, + calc(var(--slash-anchor-width) - 32px), + calc(100vw - 24px) + ); + max-height: var(--slash-panel-max-height); + padding: 6px; + overflow: visible; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); + color: var(--text-primary); + cursor: default; + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.35; +} + +.slashPanelBody { + max-height: calc(var(--slash-panel-max-height) - 12px); + overflow: hidden; + border-radius: 6px; +} + +.slashList { + display: flex; + width: min(var(--slash-row-width), calc(100vw - 46px)); + max-width: 100%; + max-height: inherit; + flex-direction: column; + gap: 2px; + overflow-x: hidden; + overflow-y: auto; + border-radius: 6px; + scrollbar-color: var(--border-color) transparent; + scrollbar-width: thin; +} + +.slashList::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.slashList::-webkit-scrollbar-track { + background: transparent; +} + +.slashList::-webkit-scrollbar-thumb { + border-radius: 3px; + background: var(--border-color); +} + +.slashEntry { + display: flex; + min-width: 0; + flex-direction: column; +} + +.slashSection { + height: 1px; + margin: 5px 8px; + background: var(--border-color); +} + +.slashItem { + position: relative; + display: grid; + width: 100%; + min-width: 0; + grid-template-columns: + minmax(0, var(--slash-command-col)) + minmax(0, var(--slash-desc-col)); + column-gap: var(--slash-column-gap); + align-items: baseline; + padding: 5px 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-primary); + font: inherit; + text-align: left; + cursor: pointer; +} + +.slashItem:hover, +.slashItemActive { + background: var(--bg-tertiary); +} + +.slashItem:hover .slashCommand, +.slashItemActive .slashCommand { + color: var(--accent-color); +} + +.slashDetail { + position: fixed; + z-index: calc(var(--web-shell-popover-z-index, 1000) + 1); + width: min(320px, calc(100vw - 48px)); + padding: 8px 10px; + overflow: auto; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-primary); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); + color: var(--text-primary); + font-family: var(--font-sans, system-ui, sans-serif); + font-size: 12px; + line-height: 1.45; + overflow-wrap: anywhere; + pointer-events: auto; +} + +.slashDetailCommand { + margin-bottom: 5px; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.35; + white-space: nowrap; +} + +.slashDetailText { + color: var(--text-secondary); + white-space: pre-wrap; +} + +.slashCommand { + min-width: 0; + overflow: hidden; + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.slashDescription { + min-width: 0; + overflow: hidden; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +@container (max-width: 699px) { + .slashPanel { + left: 12px; + width: calc(100% - 24px); + min-width: 0; + } + + .slashList { + width: 100%; + } + + .slashItem { + width: 100%; + grid-template-columns: minmax(0, 1fr); + row-gap: 2px; + } +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0 0 8px; +} + +.tag { + display: inline-flex; + align-items: center; + max-width: 100%; + min-height: 22px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-tertiary); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.2; +} + +.tagLabel, +.tagValue { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tagLabel { + padding: 3px 0 3px 7px; + color: var(--accent-color); +} + +.tagValue { + max-width: 32ch; + padding: 3px 0 3px 0.5ch; + color: var(--text-secondary); +} + +.tagRemove { + flex: 0 0 auto; + width: 22px; + height: 22px; + padding: 0; + border: 0; + background: transparent; + color: var(--text-dimmed); + font: inherit; + line-height: 22px; + cursor: pointer; + border-radius: 0 4px 4px 0; +} + +.tagRemove:hover, +.tagRemove:focus-visible { + color: var(--error-color); + outline: none; +} + +.editorArea { + display: flex; + align-items: flex-start; + gap: 6px; + min-height: 60px; + padding: 4px 0; +} + +.editorArea > :last-child { + min-width: 0; + flex: 1; +} + +.shellPrefix { + flex: 0 0 auto; + padding-top: 1px; + color: var(--accent-color); + font-family: var(--font-mono); + font-size: 14px; + font-weight: 600; + line-height: 1.6; + user-select: none; + pointer-events: none; +} + +.editorArea .cm-content { + font-family: var(--font-sans, system-ui, sans-serif); + font-size: 14px; + line-height: 1.6; +} + +.editorArea .cm-scroller { + scrollbar-color: var(--border-color) transparent; + scrollbar-width: thin; +} + +.editorArea .cm-scroller::-webkit-scrollbar { + width: 6px; +} + +.editorArea .cm-scroller::-webkit-scrollbar-track { + background: transparent; +} + +.editorArea .cm-scroller::-webkit-scrollbar-thumb { + border-radius: 3px; + background: var(--border-color); +} + +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + min-width: 0; + padding: 4px 0 0; + gap: 8px; +} + +.toolbarLeading, +.toolbarStart, +.toolbarEnd, +.toolbarLeft, +.toolbarRight { + display: flex; + align-items: center; + gap: 4px; +} + +.toolbarLeading { + margin-left: -5px; +} + +.toolbarStart { + min-width: 0; +} + +.toolbarEnd { + min-width: 0; +} + +.toolbarLeft { + min-width: 0; + flex-wrap: wrap; +} + +.toolbarRight { + flex-shrink: 0; + margin-right: -5px; +} + +@container (max-width: 699px) { + .toolbarLeft .toolBtn { + width: auto; + padding: 0 6px; + justify-content: center; + } + + .toolbarLeft .toolBtnText { + display: none; + } +} + +.dropdownWrapper { + position: relative; +} + +.dropdown { + position: absolute; + bottom: calc(100% + 4px); + left: 0; + min-width: 160px; + padding: 4px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 100; + display: flex; + flex-direction: column; + gap: 2px; +} + +.dropdownRich { + min-width: min(360px, calc(100vw - 32px)); + padding: 6px; + gap: 2px; +} + +.dropdownCheck { + min-width: min(300px, calc(100vw - 32px)); + padding: 6px; + gap: 2px; +} + +.dropdownItem { + display: block; + width: 100%; + padding: 6px 12px; + border: none; + background: transparent; + color: var(--text-primary); + font-family: var(--font-sans, system-ui, sans-serif); + font-size: 13px; + text-align: left; + border-radius: 4px; + cursor: pointer; + white-space: nowrap; +} + +.dropdownRich .dropdownItem { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) 18px; + gap: 10px; + align-items: center; + padding: 6px 9px; + white-space: normal; +} + +.dropdownCheck .dropdownItem { + display: grid; + grid-template-columns: minmax(0, 1fr) 18px; + gap: 10px; + align-items: center; + padding: 6px 9px; +} + +.dropdownItem:hover { + background: var(--bg-tertiary); +} + +.dropdownItemActive { + color: var(--accent-color); + background: var(--bg-tertiary); +} + +.dropdownItemIcon, +.dropdownItemCheck { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); +} + +.dropdownItemIcon svg { + width: 19px; + height: 19px; +} + +.dropdownItemCheck svg { + width: 16px; + height: 16px; +} + +.dropdownItemContent { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.dropdownItemLabel { + overflow: hidden; + color: var(--text-primary); + font-size: 13px; + font-weight: 400; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dropdownItemDesc { + overflow: hidden; + color: var(--text-dimmed); + font-size: 12px; + font-weight: 400; + line-height: 1.2; + text-overflow: ellipsis; +} + +.dropdownItemActive .dropdownItemIcon, +.dropdownItemActive .dropdownItemCheck { + color: var(--text-primary); +} + +.toolBtn { + position: relative; + display: inline-flex; + align-items: center; + gap: 4px; + height: 28px; + padding: 0 8px; + border: none; + background: transparent; + color: var(--text-secondary); + font-family: var(--font-sans, system-ui, sans-serif); + font-size: 13px; + line-height: 1; + border-radius: 6px; + cursor: pointer; + white-space: nowrap; +} + +.toolBtn:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.toolBtn[data-tooltip]:hover::after, +.toolBtn[data-tooltip]:focus-visible::after { + position: absolute; + right: 50%; + bottom: calc(100% + 8px); + z-index: 120; + padding: 4px 7px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-primary); + color: var(--text-primary); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.14); + content: attr(data-tooltip); + font-family: var(--font-sans, system-ui, sans-serif); + font-size: 12px; + line-height: 1.2; + pointer-events: none; + transform: translateX(50%); + white-space: nowrap; +} + +.toolBtnIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + font-size: 16px; + line-height: 1; +} + +.toolBtnIcon svg { + display: block; +} + +.widthModeBtn { + flex-shrink: 0; +} + +.quickActionsBtn { + display: none; +} + +.toolBtnModeIcon, +.toolBtnModelIcon { + display: inline-flex; + width: 18px; + height: 18px; + align-items: center; + justify-content: center; +} + +.toolBtnModeIcon svg, +.toolBtnModelIcon svg { + display: block; + width: 16px; + height: 16px; +} + +.toolBtnText { + display: inline-flex; + align-items: center; + color: var(--text-primary); + line-height: 1; +} + +.toolBtnArrow { + display: inline-flex; + align-items: center; + justify-content: center; + width: 10px; + height: 10px; + margin-left: 2px; +} + +.toolBtnArrow svg { + display: block; +} + +.chevronDown { + width: 7px; + height: 7px; + border-right: 1px solid currentColor; + border-bottom: 1px solid currentColor; + transform: rotate(45deg); + margin-top: -3px; +} + +.quickActionsPanel { + display: none; + position: relative; + z-index: 2; + margin-top: 8px; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--bg-primary); + box-shadow: 0 8px 24px color-mix(in srgb, #000 12%, transparent); + cursor: default; +} + +.quickActionsHeader { + padding: 6px 2px; + color: var(--text-dimmed); + font-size: 12px; + line-height: 1.2; +} + +.quickActionsLayout { + display: grid; + grid-template-columns: minmax(0, 1.8fr) minmax(104px, 0.7fr); + gap: 7px; + align-items: stretch; +} + +.quickActionsGrid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-auto-rows: minmax(22px, auto); + gap: 3px; + padding-right: 6px; + border-right: 1px solid var(--border-color); +} + +.quickAction { + display: flex; + min-width: 0; + min-height: 22px; + align-items: center; + justify-content: center; + padding: 3px 5px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-sans, system-ui, sans-serif); + text-align: center; + cursor: pointer; +} + +.quickAction:hover, +.quickAction:focus-visible { + background: var(--bg-tertiary); + outline: none; +} + +.quickActionLabel { + display: -webkit-box; + overflow: hidden; + max-width: 100%; + font-family: var(--font-sans, system-ui, sans-serif); + font-size: 10px; + line-height: 1.15; + text-overflow: ellipsis; + white-space: normal; + word-break: keep-all; + overflow-wrap: anywhere; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.quickKeysGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: repeat(3, minmax(0, 1fr)); + align-self: stretch; + gap: 4px; + height: 100%; + min-height: 100%; +} + +.quickKey { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + padding: 3px 4px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 11px; + line-height: 1; + text-align: center; + white-space: nowrap; + cursor: pointer; +} + +.quickKeyLabel { + overflow: hidden; + max-width: 100%; + text-overflow: ellipsis; + white-space: nowrap; +} + +.quickKey:hover, +.quickKey:focus-visible { + background: var(--bg-tertiary); + outline: none; +} + +@container (max-width: 699px) { + .quickActionsBtn { + display: inline-flex; + } + + .quickActionsPanel { + display: block; + } +} + +.sendBtn { + flex-shrink: 0; + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + background: var(--bg-tertiary); + color: var(--text-dimmed); + cursor: default; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + margin-left: 4px; + transition: all 0.2s ease; +} + +.sendBtn:disabled { + opacity: 0.5; + cursor: default; +} + +.sendBtn:not(:disabled) { + background: var(--accent-color); + color: #fff; + cursor: pointer; +} + +.sendBtn:not(:disabled):hover { + filter: brightness(1.15); + transform: scale(1.05); +} + +.sendBtn:not(:disabled):active { + transform: scale(0.95); +} + +.sendBtnRunning, +.sendBtnRunning:not(:disabled) { + background: #1b1d21; + color: #fff; + cursor: pointer; +} + +.sendBtnRunning:not(:disabled):hover { + filter: brightness(1.08); +} + +.sendIcon { + display: inline-block; + width: 16px; + height: 16px; +} + +.stopIcon { + width: 10px; + height: 10px; + border-radius: 2px; + background: currentColor; +} + +.images { + display: flex; + gap: 6px; + padding: 4px 0 0; + flex-wrap: wrap; +} + +.imageThumb { + position: relative; + width: 48px; + height: 48px; + border-radius: 4px; + overflow: hidden; + border: 1px solid var(--border-color); +} + +.imageThumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.imageRemove { + position: absolute; + top: 0; + right: 0; + width: 16px; + height: 16px; + font-size: 12px; + line-height: 16px; + text-align: center; + background: rgba(0, 0, 0, 0.7); + color: var(--error-color); + border: none; + cursor: pointer; + border-radius: 0 0 0 4px; +} + +.imageRemove:hover { + background: var(--error-color); + color: var(--text-primary); +} + +.searchPanel { + display: flex; + flex-direction: column; + position: absolute; + right: 0; + bottom: calc(100% + 8px); + left: 0; + z-index: 20; + max-height: min(320px, 50vh); + padding: 8px; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--bg-primary); + box-shadow: 0 8px 24px color-mix(in srgb, #000 12%, transparent); + cursor: default; +} + +.searchBar { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 2px 6px; + font-size: 12px; +} + +.searchLabel { + color: var(--text-dimmed); + flex-shrink: 0; +} + +.searchInput { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 12px; +} + +.searchInput::placeholder { + color: var(--text-dimmed); +} + +.searchResults { + display: flex; + flex-direction: column; + gap: 2px; + min-height: 0; + padding: 2px 0 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +.searchResult { + display: grid; + grid-template-columns: 12px minmax(0, 1fr); + align-items: center; + gap: 8px; + width: 100%; + flex: none; + height: 30px; + padding: 5px 8px; + overflow: hidden; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.4; + text-align: left; + white-space: nowrap; + cursor: pointer; +} + +.searchResult:hover, +.searchResultActive { + background: var(--bg-tertiary); + color: var(--accent-color); +} + +.searchResultMarker { + color: var(--accent-color); + line-height: 1.45; +} + +.searchResultText { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.searchEmpty { + padding: 4px 2px 0; + color: var(--text-dimmed); + font-size: 12px; +} diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx new file mode 100644 index 00000000000..3a95b63a891 --- /dev/null +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -0,0 +1,1555 @@ +import { + forwardRef, + memo, + useImperativeHandle, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; +import type { CSSProperties, ReactNode, RefObject } from 'react'; +import { DAEMON_APPROVAL_MODES } from '@qwen-code/webui/daemon-react-sdk'; +import type { CommandInfo } from '../adapters/types'; +import type { UseDaemonFollowupSuggestionReturn } from '@qwen-code/webui/daemon-react-sdk'; +import type { CommandDisplayCategoryOrder } from '../utils/commandDisplay'; +import type { SkillInfo } from '../completions/slashCompletion'; +import { useI18n } from '../i18n'; +import { + useWebShellCustomization, + type WebShellComposerInput, + type WebShellComposerTag, +} from '../customization'; +import { + useComposerCore, + type EditorHandle, + type SlashMenuState, + getComposerTagDisplay, + getComposerTagLabel, + getComposerTagValue, +} from '../hooks/useComposerCore'; +import { ModeIcon } from './ModeIcon'; +import styles from './ChatEditor.module.css'; + +export type ComposerToolbarAction = + | 'approvalMode' + | 'model' + | 'commands' + | 'files' + | 'widthMode'; + +interface ChatEditorProps { + onSubmit: ( + text: string, + images?: import('../adapters/promptTypes').PromptImage[], + ) => boolean | void; + onCycleMode?: () => void; + onToggleShortcuts?: () => void; + onCancel?: () => void; + isRunning?: boolean; + disabled?: boolean; + placeholderText?: string; + commands: CommandInfo[]; + skills?: SkillInfo[]; + slashCommandCategoryOrder?: CommandDisplayCategoryOrder; + queuedMessages?: string[]; + onPopQueuedMessages?: () => string | null; + onClearQueuedMessages?: () => boolean; + currentMode?: string; + currentModel?: string; + chatWidthMode?: '1000' | 'wide'; + showChatWidthToggle?: boolean; + chatWidthToggleMin?: number; + visibleToolbarActions?: readonly ComposerToolbarAction[]; + availableModels?: Array<{ id: string; label?: string }>; + onSelectMode?: (mode: string) => void; + onSelectModel?: (model: string) => void; + onChatWidthModeChange?: (mode: '1000' | 'wide') => void; + onFocusFooter?: () => boolean; + dialogOpen?: boolean; + followupState?: UseDaemonFollowupSuggestionReturn['followupState']; + onAcceptFollowup?: UseDaemonFollowupSuggestionReturn['onAcceptFollowup']; + onDismissFollowup?: UseDaemonFollowupSuggestionReturn['onDismissFollowup']; + sessionName?: string; + composerInput?: WebShellComposerInput; + composerInputVersion?: number; +} + +const CHAT_EDITOR_THEME = { + '&': { + fontSize: '14px', + background: 'transparent', + border: 'none', + }, + '&.cm-focused': { + outline: 'none', + }, + '.cm-scroller': { + maxHeight: 'var(--chat-editor-input-max-height, 300px)', + overflowX: 'hidden', + overflowY: 'auto', + }, + '.cm-content': { + padding: '0', + fontFamily: 'var(--font-sans, system-ui, sans-serif)', + color: 'var(--text-primary, #e0e0e0)', + caretColor: 'var(--accent-color, #4a9eff)', + fontSize: '14px', + lineHeight: '1.6', + }, + '.cm-line': { + padding: '0', + }, + '.cm-placeholder': { + color: 'var(--text-dimmed, #666)', + }, + '.cm-followup-ghost': { + color: 'var(--text-dimmed, #666)', + opacity: '0.72', + pointerEvents: 'none', + userSelect: 'none', + }, + '.cm-selectionBackground, &.cm-focused .cm-selectionBackground': { + backgroundColor: 'var(--chat-editor-selection-bg) !important', + }, + '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': { + backgroundColor: 'var(--chat-editor-selection-bg) !important', + }, + '&.cm-focused .cm-content ::selection': { + backgroundColor: 'var(--chat-editor-selection-bg)', + color: 'var(--chat-editor-selection-color)', + }, + '.cm-content ::selection': { + backgroundColor: 'var(--chat-editor-selection-bg)', + color: 'var(--chat-editor-selection-color)', + }, + '.cm-cursor': { + borderLeftColor: 'var(--accent-color, #4a9eff)', + borderLeftWidth: '2px', + }, +}; + +const SLASH_PANEL_THEME_VARS = [ + '--accent-color', + '--bg-primary', + '--bg-tertiary', + '--border-color', + '--font-mono', + '--font-sans', + '--text-primary', + '--text-secondary', +] as const; + +function SendIcon() { + return ( + + + + ); +} + +function StopIcon() { + return