mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-04 05:40:58 +00:00
* feat(web-shell): add split pane header action slot with overflow Let hosts render per-session actions in each split pane header, collapsing them into a … menu when the pane is too narrow. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(web-shell): add pane header actions PR screenshots Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web-shell): tighten pane header overflow measurement Drop the per-render children effect dependency that rebuilt ResizeObserver during streaming, and reserve workspace-tag width when computing available header space. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web-shell): address pane header overflow review blockers Mount host actions in only one tree, and wrap overflow entries as DropdownMenuItems so Radix selection and keyboard navigation work. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web-shell): keep pane header actions alive across overflow Flatten Fragment host actions before building the overflow menu, and keep the same host instances mounted when collapsing so stateful actions are not reset. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web-shell): address pane header overflow review suggestions (#7808) * fix(web-shell): proxy overflow clicks via action slots Wrap host pane actions in stable slots so the overflow menu can activate interactive descendants without requiring opaque custom components to forward internal data attributes. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web-shell): address overflow menu review suggestions (#7808) * fix(web-shell): harden pane header overflow actions (#7808) Restore the 8px gap between the built-in maximize/close controls, ignore aria-hidden glyphs when labelling overflow items, omit non-interactive children from the overflow menu, and document the popover constraint on renderHeaderActions. Refreshes the design doc to match the mount-once implementation. --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com> Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com> Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com>
807 lines
28 KiB
TypeScript
807 lines
28 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
|
|
import { Maximize2Icon, Minimize2Icon } from 'lucide-react';
|
|
import {
|
|
useActions,
|
|
useConnection,
|
|
useDaemonFollowupSuggestion,
|
|
useStreamingState,
|
|
useTranscriptHistory,
|
|
useTranscriptStore,
|
|
useWorkspace,
|
|
useWorkspaceActions,
|
|
type DaemonWorkspaceActions,
|
|
} from '@qwen-code/webui/daemon-react-sdk';
|
|
import type {
|
|
DaemonSessionArtifact,
|
|
DaemonWorkspaceCapability,
|
|
} from '@qwen-code/sdk/daemon';
|
|
import type { ACPToolCall } from '../adapters/types';
|
|
import { SubagentDetailsProvider } from '../subagentDetailsContext';
|
|
import { useI18n } from '../i18n';
|
|
import { useWebShellCustomization } from '../customization';
|
|
import { SESSION_TRANSCRIPT_PAGINATION_FEATURE } from '../constants/sessions';
|
|
import { useAnimationFrameTranscriptBlocks } from '../hooks/useAnimationFrameTranscriptBlocks';
|
|
import { useMessagesFromBlocks } from '../hooks/useMessages';
|
|
import { useSessionArtifacts } from '../hooks/useSessionArtifacts';
|
|
import { extractPendingPermission } from '../adapters/transcriptAdapter';
|
|
import type { PromptImage } from '../adapters/promptTypes';
|
|
import type {
|
|
ComposerSubmitCommit,
|
|
ComposerSubmitMetadata,
|
|
EditorHandle,
|
|
} from '../hooks/useComposerCore';
|
|
import { useQueuedPrompts } from '../hooks/useQueuedPrompts';
|
|
import { isAskUserPermission } from '../utils/askUserPermission';
|
|
import { isDaemonApprovalMode } from '../utils/sessionPreparation';
|
|
import { isVisibleComposerModel } from '../utils/composerModels';
|
|
import { shouldBlockComposerSubmit } from '../utils/composerInputState';
|
|
import { invokeSlashCommandHandler } from '../utils/slash-command-action';
|
|
import type { WebShellSlashCommandHandler } from '../App';
|
|
import { getModelDisplayName } from '../utils/modelDisplay';
|
|
import {
|
|
hasMultipleWorkspaces,
|
|
workspaceLabelForCwd,
|
|
} from '../utils/workspace';
|
|
import { workspaceAccentColor } from '../utils/workspaceColor';
|
|
import {
|
|
resolveVoiceWorkspaceTarget,
|
|
type VoiceStatusRevision,
|
|
} from '../voice/voice-workspace-target';
|
|
import {
|
|
getLocalCommands,
|
|
localizeBuiltinDescriptions,
|
|
skillDescriptionKey,
|
|
} from '../constants/localCommands';
|
|
import { mergeCommands } from '../hooks/daemonSessionMappers';
|
|
import { MessageList } from './MessageList';
|
|
import { StreamingStatus } from './StreamingStatus';
|
|
import { ChatEditor, type ComposerToolbarAction } from './ChatEditor';
|
|
import { QueuedPromptDisplay } from './QueuedPromptDisplay';
|
|
import { ToolApproval } from './messages/ToolApproval';
|
|
import { AskUserQuestion } from './messages/AskUserQuestion';
|
|
import type {
|
|
TurnOutputKind,
|
|
TurnOutputOpenRequest,
|
|
} from './artifacts/TurnOutputs';
|
|
import { TURN_OUTPUT_KINDS } from './artifacts/TurnOutputs';
|
|
import {
|
|
getArtifactsByTurn,
|
|
getFileChangesByTurn,
|
|
getScheduledTasksByTurn,
|
|
} from './artifacts/turnOutputSelectors';
|
|
import { PaneHeaderActions } from './PaneHeaderActions';
|
|
import styles from './ChatPane.module.css';
|
|
import accentStyles from './WorkspaceAccent.module.css';
|
|
|
|
// Split-view panes get the same interactive composer controls as the main chat,
|
|
// each scoped to the pane's own session: the approval-mode and model pickers,
|
|
// plus voice dictation. The width toggle is omitted (panes size themselves); the
|
|
// slash menu is populated from the session's own command list (see below).
|
|
const PANE_TOOLBAR_ACTIONS: readonly ComposerToolbarAction[] = [
|
|
'approvalMode',
|
|
'model',
|
|
'voice',
|
|
];
|
|
const EMPTY_VOICE_WORKSPACE_REVISIONS: Readonly<Record<string, number>> = {};
|
|
|
|
export interface PaneHeaderActionsInfo {
|
|
sessionId: string;
|
|
workspaceCwd?: string;
|
|
}
|
|
|
|
export type PaneHeaderActionsRenderer = (
|
|
info: PaneHeaderActionsInfo,
|
|
) => ReactNode;
|
|
|
|
export interface ChatPaneProps {
|
|
/** Header label; falls back to the session's own display name / id. */
|
|
title?: string;
|
|
/**
|
|
* The workspace this pane's session lives in. Passed explicitly by the split
|
|
* view (which knows it per session) and shown as a composer-toolbar chip on a
|
|
* multi-workspace daemon; falls back to the connection's own workspace.
|
|
*/
|
|
workspaceCwd?: string;
|
|
/**
|
|
* Extra actions rendered in the pane header, before the built-in
|
|
* maximize/close buttons. Receives this pane's session id (and workspace
|
|
* when known) so the host can scope each control to the right session. When
|
|
* the actions no longer fit beside the title they collapse into a `…`
|
|
* overflow menu.
|
|
*
|
|
* Each child should be a single interactive element (button or link). When
|
|
* collapsed, the overflow menu lists the actions and proxies a click to that
|
|
* element, labelling each item from its accessible name (decorative
|
|
* `aria-hidden` glyphs are ignored). The action instance stays mounted in a
|
|
* hidden, off-pane slot across collapse so its state survives; because that
|
|
* slot is `visibility: hidden`, an action that opens a popover anchored to
|
|
* itself must render the popover through a portal — one rendered as a
|
|
* descendant of the action (or anchored to its bounding box) would be
|
|
* invisible or mispositioned while collapsed.
|
|
*/
|
|
renderHeaderActions?: PaneHeaderActionsRenderer;
|
|
onClose?: () => void;
|
|
/**
|
|
* Toggle this pane between maximized (solo, filling the whole split) and the
|
|
* tiled layout. Omitted when only one pane is open — there's nothing to
|
|
* maximize against.
|
|
*/
|
|
onToggleMaximize?: () => void;
|
|
/** Whether this pane is currently the maximized (solo) one. */
|
|
isMaximized?: boolean;
|
|
onError?: (error: unknown, fallback: string) => void;
|
|
/** Host slash-command callback shared with the main chat composer. */
|
|
onSlashCommand?: WebShellSlashCommandHandler;
|
|
onRightPanelOpen?: (request: TurnOutputOpenRequest) => void;
|
|
onPaneArtifactsChange?: (
|
|
sessionId: string,
|
|
artifacts: readonly DaemonSessionArtifact[],
|
|
workspaceActions: DaemonWorkspaceActions,
|
|
) => void;
|
|
messageTurnOutputs?: readonly TurnOutputKind[];
|
|
/** Allow prompt admission to recover a disconnected SSE stream. */
|
|
restartSseOnPrompt?: boolean;
|
|
hidden?: boolean;
|
|
voiceUserRevision?: number;
|
|
voiceWorkspaceRevisions?: Readonly<Record<string, number>>;
|
|
voiceWorkspaces?: readonly DaemonWorkspaceCapability[];
|
|
}
|
|
|
|
/**
|
|
* A self-contained interactive chat, scoped to whichever `DaemonSessionProvider`
|
|
* it is nested under. Rendering N of these (each under its own provider) inside
|
|
* one window is the split view: every pane has its own transcript, streaming
|
|
* state, approvals, and composer, and the browser scopes keyboard focus to the
|
|
* pane the user clicks into — so there is no cross-pane approval arbitration.
|
|
*/
|
|
export function ChatPane({
|
|
title,
|
|
workspaceCwd,
|
|
renderHeaderActions,
|
|
onClose,
|
|
onToggleMaximize,
|
|
isMaximized = false,
|
|
onError,
|
|
onSlashCommand,
|
|
onRightPanelOpen,
|
|
onPaneArtifactsChange,
|
|
messageTurnOutputs,
|
|
restartSseOnPrompt = false,
|
|
hidden = false,
|
|
voiceUserRevision = 0,
|
|
voiceWorkspaceRevisions = EMPTY_VOICE_WORKSPACE_REVISIONS,
|
|
voiceWorkspaces,
|
|
}: ChatPaneProps) {
|
|
const { t } = useI18n();
|
|
const { renderComposerFooter: CustomComposerFooter } =
|
|
useWebShellCustomization();
|
|
const connection = useConnection();
|
|
const actions = useActions();
|
|
const workspaceActions = useWorkspaceActions();
|
|
const workspace = useWorkspace();
|
|
const blocks = useAnimationFrameTranscriptBlocks();
|
|
const messages = useMessagesFromBlocks(t, blocks);
|
|
const transcriptHistory = useTranscriptHistory();
|
|
const store = useTranscriptStore();
|
|
const streamingState = useStreamingState();
|
|
const { artifacts } = useSessionArtifacts();
|
|
const openSubagentDetails = useCallback(
|
|
(tool: ACPToolCall) => {
|
|
if (!connection.sessionId || !onRightPanelOpen) return;
|
|
const rawOutput =
|
|
tool.rawOutput && typeof tool.rawOutput === 'object'
|
|
? (tool.rawOutput as Record<string, unknown>)
|
|
: undefined;
|
|
const subagentType =
|
|
(typeof tool.args?.subagent_type === 'string'
|
|
? tool.args.subagent_type
|
|
: undefined) ??
|
|
(typeof rawOutput?.['subagentName'] === 'string'
|
|
? rawOutput['subagentName']
|
|
: undefined);
|
|
onRightPanelOpen({
|
|
id: `subagent:${connection.sessionId}:${tool.callId}`,
|
|
kind: 'subagent',
|
|
title: tool.title || subagentType || t('agent.label'),
|
|
turnId: tool.callId,
|
|
tool,
|
|
sessionId: connection.sessionId,
|
|
workspaceCwd: connection.workspaceCwd ?? workspaceCwd,
|
|
});
|
|
},
|
|
[
|
|
connection.sessionId,
|
|
connection.workspaceCwd,
|
|
onRightPanelOpen,
|
|
t,
|
|
workspaceCwd,
|
|
],
|
|
);
|
|
useEffect(() => {
|
|
const sessionId = connection.sessionId;
|
|
if (!sessionId) return;
|
|
onPaneArtifactsChange?.(sessionId, artifacts, workspaceActions);
|
|
return () => {
|
|
onPaneArtifactsChange?.(sessionId, [], workspaceActions);
|
|
};
|
|
}, [
|
|
artifacts,
|
|
connection.sessionId,
|
|
onPaneArtifactsChange,
|
|
workspaceActions,
|
|
]);
|
|
const streamingStateRef = useRef(streamingState);
|
|
streamingStateRef.current = streamingState;
|
|
const reloadTranscript = useCallback(
|
|
async (signal: AbortSignal) => {
|
|
if (!connection.sessionId) return;
|
|
await actions.reloadSession(signal);
|
|
},
|
|
[actions, connection.sessionId],
|
|
);
|
|
const transcriptReloadSupported =
|
|
connection.capabilities?.features.includes(
|
|
SESSION_TRANSCRIPT_PAGINATION_FEATURE,
|
|
) === true;
|
|
const editorRef = useRef<EditorHandle | null>(null);
|
|
const {
|
|
followupState,
|
|
onAcceptFollowup,
|
|
onDismissFollowup,
|
|
clear: clearFollowup,
|
|
} = useDaemonFollowupSuggestion({
|
|
onAccept: (suggestion) => {
|
|
editorRef.current?.insertText(suggestion);
|
|
},
|
|
});
|
|
|
|
const reportError = useCallback(
|
|
(error: unknown, fallback: string) => {
|
|
if (onError) onError(error, fallback);
|
|
else console.error(fallback, error);
|
|
},
|
|
[onError],
|
|
);
|
|
const onSlashCommandRef = useRef(onSlashCommand);
|
|
onSlashCommandRef.current = onSlashCommand;
|
|
const notifySuccess = useCallback(
|
|
(message: string) => store.dispatch([{ type: 'status', text: message }]),
|
|
[store],
|
|
);
|
|
|
|
const pendingApproval = useMemo(
|
|
() => extractPendingPermission(blocks),
|
|
[blocks],
|
|
);
|
|
const isAskUser = isAskUserPermission(pendingApproval);
|
|
const pendingToolApproval =
|
|
pendingApproval && !isAskUser ? pendingApproval : null;
|
|
const pendingAskUserApproval =
|
|
pendingApproval && isAskUser ? pendingApproval : null;
|
|
// Tracked in a ref so an async approval-mode switch (handleSelectMode) reads
|
|
// the approval current when setApprovalMode *resolves*, not a stale one
|
|
// captured at click time — mirrors App's pendingApprovalRef.
|
|
const pendingToolApprovalRef = useRef(pendingToolApproval);
|
|
pendingToolApprovalRef.current = pendingToolApproval;
|
|
const approvalActive =
|
|
pendingToolApproval !== null || pendingAskUserApproval !== null;
|
|
const paneVoiceCwd =
|
|
connection.sessionId &&
|
|
connection.workspaceCwd &&
|
|
(!workspaceCwd || workspaceCwd === connection.workspaceCwd)
|
|
? connection.workspaceCwd
|
|
: undefined;
|
|
const voiceTarget = useMemo(
|
|
() =>
|
|
resolveVoiceWorkspaceTarget({
|
|
capabilities: workspace.capabilities,
|
|
intendedCwd: paneVoiceCwd,
|
|
sessionId: connection.sessionId,
|
|
workspaces: voiceWorkspaces,
|
|
}),
|
|
[
|
|
connection.sessionId,
|
|
paneVoiceCwd,
|
|
voiceWorkspaces,
|
|
workspace.capabilities,
|
|
],
|
|
);
|
|
const voiceStatusRevision: VoiceStatusRevision = useMemo(
|
|
() => ({
|
|
user: voiceUserRevision,
|
|
workspace: voiceTarget
|
|
? (voiceWorkspaceRevisions[voiceTarget.workspaceKey] ?? 0)
|
|
: 0,
|
|
}),
|
|
[voiceTarget, voiceUserRevision, voiceWorkspaceRevisions],
|
|
);
|
|
const isResponding = streamingState !== 'idle';
|
|
const artifactsByTurn = useMemo(
|
|
() =>
|
|
getArtifactsByTurn(messages, artifacts, connection.workspaceCwd || ''),
|
|
[messages, artifacts, connection.workspaceCwd],
|
|
);
|
|
const fileChangesByTurn = useMemo(
|
|
() =>
|
|
getFileChangesByTurn(
|
|
messages,
|
|
artifactsByTurn,
|
|
connection.workspaceCwd || '',
|
|
),
|
|
[messages, artifactsByTurn, connection.workspaceCwd],
|
|
);
|
|
const scheduledTasksByTurn = useMemo(
|
|
() => getScheduledTasksByTurn(messages),
|
|
[messages],
|
|
);
|
|
const visibleTurnOutputKinds = useMemo(
|
|
() => new Set<TurnOutputKind>(messageTurnOutputs ?? TURN_OUTPUT_KINDS),
|
|
[messageTurnOutputs],
|
|
);
|
|
const {
|
|
queuedPrompts,
|
|
queuedTexts,
|
|
enqueuePrompt,
|
|
removeQueuedPrompt,
|
|
insertQueuedPrompt,
|
|
editQueuedPrompt,
|
|
editLastQueuedPrompt,
|
|
clearQueuedPrompts,
|
|
} = useQueuedPrompts({
|
|
connected: connection.status === 'connected',
|
|
sessionId: connection.sessionId,
|
|
clientId: connection.clientId,
|
|
streamingState,
|
|
sessionActions: actions,
|
|
store,
|
|
editorRef,
|
|
reportError,
|
|
notifySuccess,
|
|
t,
|
|
});
|
|
|
|
// Anchor the streaming timer to the turn's own start (the last user message's
|
|
// timestamp) rather than letting StreamingStatus fall back to "now" — so a
|
|
// pane opened mid-turn shows the real elapsed time, not a reset-to-zero clock.
|
|
const activeTurnStartedAt = useMemo(() => {
|
|
if (!isResponding) return undefined;
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
const message = messages[i];
|
|
if (message?.role === 'user') return message.timestamp;
|
|
}
|
|
return undefined;
|
|
}, [messages, isResponding]);
|
|
|
|
const handleSubmit = useCallback(
|
|
(
|
|
text: string,
|
|
images?: PromptImage[],
|
|
commitAccepted?: ComposerSubmitCommit,
|
|
metadata?: ComposerSubmitMetadata,
|
|
): boolean => {
|
|
const trimmed = text.trim();
|
|
if (!trimmed) return false;
|
|
if (
|
|
invokeSlashCommandHandler(text, onSlashCommandRef.current, reportError)
|
|
) {
|
|
return true;
|
|
}
|
|
if (
|
|
shouldBlockComposerSubmit({
|
|
connectionStatus: connection.status,
|
|
hasSession: Boolean(connection.sessionId),
|
|
restartSseOnPrompt,
|
|
})
|
|
) {
|
|
return false;
|
|
}
|
|
const inputAnnotations = metadata?.inputAnnotations;
|
|
if (streamingStateRef.current === 'idle') {
|
|
actions
|
|
.sendPrompt(trimmed, {
|
|
...(images && images.length ? { images } : {}),
|
|
...(inputAnnotations ? { inputAnnotations } : {}),
|
|
onAdmitted: () => {
|
|
clearFollowup();
|
|
commitAccepted?.();
|
|
},
|
|
})
|
|
.catch((error: unknown) =>
|
|
reportError(error, 'Failed to send prompt'),
|
|
);
|
|
return false;
|
|
}
|
|
return inputAnnotations
|
|
? enqueuePrompt(trimmed, images, undefined, inputAnnotations)
|
|
: enqueuePrompt(trimmed, images);
|
|
},
|
|
[
|
|
actions,
|
|
clearFollowup,
|
|
connection.sessionId,
|
|
connection.status,
|
|
enqueuePrompt,
|
|
reportError,
|
|
restartSseOnPrompt,
|
|
],
|
|
);
|
|
|
|
const handleConfirm = useCallback(
|
|
(id: string, selectedOption: string, answers?: Record<string, string>) => {
|
|
actions
|
|
.submitPermission(id, selectedOption, answers)
|
|
.catch((error: unknown) =>
|
|
reportError(error, 'Failed to submit permission choice'),
|
|
);
|
|
},
|
|
[actions, reportError],
|
|
);
|
|
|
|
const handleCancel = useCallback(() => {
|
|
actions
|
|
.cancel()
|
|
.catch((error: unknown) =>
|
|
reportError(error, 'Failed to cancel request'),
|
|
);
|
|
}, [actions, reportError]);
|
|
|
|
const handleRightPanelOpen = useCallback(
|
|
(request: TurnOutputOpenRequest) => {
|
|
if (!onRightPanelOpen) return;
|
|
if (request.kind === 'subagent') {
|
|
onRightPanelOpen(request);
|
|
return;
|
|
}
|
|
onRightPanelOpen({ ...request, workspaceActions });
|
|
},
|
|
[onRightPanelOpen, workspaceActions],
|
|
);
|
|
|
|
// Composer wiring, all scoped to THIS pane's own DaemonSession context. The
|
|
// slash menu lists the session's daemon commands — they run server-side when
|
|
// submitted (via sendPrompt), so e.g. `/clear` clears this pane's session, not
|
|
// the outer one. The approval-mode and model pickers likewise drive this
|
|
// session's own actions; the SDK reflects the change back on `connection`.
|
|
const commands = useMemo(() => {
|
|
return localizeBuiltinDescriptions(
|
|
mergeCommands(connection.commands ?? [], getLocalCommands(t)),
|
|
t,
|
|
).map((command) => {
|
|
const skillKey = skillDescriptionKey(command.name);
|
|
if (!skillKey) return command;
|
|
return {
|
|
...command,
|
|
displayCategory: 'skill' as const,
|
|
description: t(skillKey),
|
|
};
|
|
});
|
|
}, [connection.commands, t]);
|
|
const availableModels = useMemo(
|
|
() =>
|
|
(connection.models ?? []).filter(isVisibleComposerModel).map((model) => ({
|
|
id: model.id,
|
|
label: getModelDisplayName(model.label || model.id),
|
|
})),
|
|
[connection.models],
|
|
);
|
|
const handleSelectMode = useCallback(
|
|
(modeId: string) => {
|
|
// Modes always arrive from the toolbar's own picker, but narrow anyway so
|
|
// the daemon action gets a well-typed value (mirrors App's handleSetMode).
|
|
if (!isDaemonApprovalMode(modeId)) {
|
|
reportError(
|
|
new Error(`Unsupported approval mode: ${modeId}`),
|
|
'Failed to set approval mode',
|
|
);
|
|
return;
|
|
}
|
|
actions
|
|
.setApprovalMode(modeId)
|
|
.then(() => {
|
|
// Mirror App's handleSetMode: switching THIS pane to yolo (or
|
|
// auto-edit for an edit tool) auto-approves a tool call already
|
|
// awaiting approval in this pane, so the shortcut behaves the same as
|
|
// in the single-session chat.
|
|
const approval = pendingToolApprovalRef.current;
|
|
if (!approval) return;
|
|
const autoApprove =
|
|
modeId === 'yolo' ||
|
|
(modeId === 'auto-edit' && approval.toolKind === 'edit');
|
|
if (!autoApprove) return;
|
|
const allowOnce = approval.options.find(
|
|
(option) => option.kind === 'allow_once',
|
|
);
|
|
if (!allowOnce) return;
|
|
actions
|
|
.submitPermission(approval.id, allowOnce.id)
|
|
.catch((error: unknown) =>
|
|
reportError(error, 'Failed to auto-approve tool call'),
|
|
);
|
|
})
|
|
.catch((error: unknown) =>
|
|
reportError(error, 'Failed to set approval mode'),
|
|
);
|
|
},
|
|
[actions, reportError],
|
|
);
|
|
const handleSelectModel = useCallback(
|
|
(modelId: string) => {
|
|
actions
|
|
.setModel(modelId)
|
|
.catch((error: unknown) =>
|
|
reportError(error, 'Failed to switch model'),
|
|
);
|
|
},
|
|
[actions, reportError],
|
|
);
|
|
|
|
const headerLabel =
|
|
title || connection.displayName || connection.sessionId?.slice(0, 8) || '';
|
|
|
|
// On a multi-workspace daemon, surface this pane's workspace as a composer-
|
|
// toolbar chip (next to where the git-branch chip sits), so it's clear which
|
|
// workspace a message goes to. Multi-workspace-ness comes from the shared
|
|
// workspace provider (the pane's own session connection may not carry it).
|
|
const paneWorkspaceCwd = workspaceCwd ?? connection.workspaceCwd;
|
|
const showWorkspaceChip =
|
|
hasMultipleWorkspaces(workspace.capabilities) && !!paneWorkspaceCwd;
|
|
// Memoized so the array identity is stable across renders — `ChatEditor` is
|
|
// `React.memo`, and a fresh `[...]` each render would defeat it.
|
|
const paneToolbarActions = useMemo(
|
|
() =>
|
|
showWorkspaceChip
|
|
? [...PANE_TOOLBAR_ACTIONS, 'workspace' as const]
|
|
: PANE_TOOLBAR_ACTIONS,
|
|
[showWorkspaceChip],
|
|
);
|
|
const headerActions =
|
|
connection.sessionId && renderHeaderActions
|
|
? renderHeaderActions({
|
|
sessionId: connection.sessionId,
|
|
workspaceCwd: paneWorkspaceCwd || undefined,
|
|
})
|
|
: null;
|
|
|
|
// Also surface the workspace in the pane HEADER (always visible at the top),
|
|
// not just the composer chip at the bottom — on a narrow split the composer
|
|
// chip collapses to a bare folder icon, so the header is where you tell panes
|
|
// apart. A stable per-workspace accent color (same palette as the sidebar
|
|
// session-group dots) lets same-workspace panes read as a group at a glance,
|
|
// and keeps them distinguishable even when the header name ellipsizes.
|
|
const workspaceLabel =
|
|
showWorkspaceChip && paneWorkspaceCwd
|
|
? workspaceLabelForCwd(
|
|
paneWorkspaceCwd,
|
|
workspace.capabilities?.workspaces,
|
|
)
|
|
: undefined;
|
|
const workspaceAccent = showWorkspaceChip
|
|
? workspaceAccentColor(paneWorkspaceCwd, workspace.capabilities)
|
|
: undefined;
|
|
const workspaceAccentClass = workspaceAccent
|
|
? accentStyles[workspaceAccent]
|
|
: undefined;
|
|
|
|
return (
|
|
<section
|
|
className={styles.pane}
|
|
data-testid="chat-pane"
|
|
aria-label={headerLabel}
|
|
>
|
|
<header
|
|
className={`${styles.header} ${workspaceAccentClass ?? ''}`.trim()}
|
|
>
|
|
{workspaceLabel && (
|
|
<span
|
|
// role="img" so the whole dot+name badge is announced as its
|
|
// aria-label ("Workspace: <name>"); aria-label on a bare <span>
|
|
// (generic role) isn't reliably surfaced by screen readers.
|
|
role="img"
|
|
className={styles.workspaceTag}
|
|
title={paneWorkspaceCwd}
|
|
aria-label={t('workspace.paneLabel', { name: workspaceLabel })}
|
|
data-web-shell-pane-workspace
|
|
>
|
|
<span className={styles.workspaceTagDot} aria-hidden="true" />
|
|
<span className={styles.workspaceTagText}>{workspaceLabel}</span>
|
|
</span>
|
|
)}
|
|
<span className={styles.title} title={headerLabel}>
|
|
{headerLabel}
|
|
</span>
|
|
<PaneHeaderActions
|
|
trailing={
|
|
onToggleMaximize || onClose ? (
|
|
<>
|
|
{onToggleMaximize && (
|
|
<button
|
|
type="button"
|
|
className={styles.maximizeButton}
|
|
onClick={onToggleMaximize}
|
|
aria-pressed={isMaximized}
|
|
aria-label={t(
|
|
isMaximized
|
|
? 'splitView.restorePane'
|
|
: 'splitView.maximizePane',
|
|
)}
|
|
title={t(
|
|
isMaximized
|
|
? 'splitView.restorePane'
|
|
: 'splitView.maximizePane',
|
|
)}
|
|
>
|
|
{/* Same icon vocabulary as the dialog fullscreen toggle. */}
|
|
{isMaximized ? (
|
|
<Minimize2Icon size={16} aria-hidden />
|
|
) : (
|
|
<Maximize2Icon size={16} aria-hidden />
|
|
)}
|
|
</button>
|
|
)}
|
|
{onClose && (
|
|
<button
|
|
type="button"
|
|
className={styles.closeButton}
|
|
onClick={onClose}
|
|
aria-label={t('splitView.closePane')}
|
|
title={t('splitView.closePane')}
|
|
data-testid="pane-close"
|
|
>
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
width="16"
|
|
height="16"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
d="M6 6l12 12M18 6L6 18"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</>
|
|
) : null
|
|
}
|
|
>
|
|
{headerActions}
|
|
</PaneHeaderActions>
|
|
</header>
|
|
|
|
{connection.error && (
|
|
<div className={styles.connectionError} role="alert">
|
|
<span className={styles.connectionErrorText}>
|
|
{t('splitView.paneConnectionError')}: {connection.error}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className={styles.body}>
|
|
<SubagentDetailsProvider onOpen={openSubagentDetails}>
|
|
<MessageList
|
|
messages={messages}
|
|
pendingApproval={pendingToolApproval}
|
|
loadingTranscript={connection.loadingTranscript}
|
|
catchingUp={connection.catchingUp}
|
|
hasOlderHistory={transcriptHistory.hasMore}
|
|
loadingOlderHistory={transcriptHistory.loading}
|
|
historyCapacityReached={transcriptHistory.capacityReached}
|
|
historyPaginationError={transcriptHistory.paginationError}
|
|
onLoadOlderHistory={transcriptHistory.loadMore}
|
|
transcriptBlockCount={blocks.length}
|
|
transcriptActivity={store}
|
|
onReloadTranscript={
|
|
transcriptReloadSupported ? reloadTranscript : undefined
|
|
}
|
|
isResponding={isResponding}
|
|
workspaceCwd={connection.workspaceCwd || ''}
|
|
hideSessionTimeline
|
|
turnFileChanges={
|
|
visibleTurnOutputKinds.has('file') ? fileChangesByTurn : undefined
|
|
}
|
|
turnArtifacts={
|
|
visibleTurnOutputKinds.has('artifact')
|
|
? artifactsByTurn
|
|
: undefined
|
|
}
|
|
turnScheduledTasks={
|
|
visibleTurnOutputKinds.has('scheduled_task')
|
|
? scheduledTasksByTurn
|
|
: undefined
|
|
}
|
|
onTurnOutputOpen={handleRightPanelOpen}
|
|
generateContent={
|
|
connection.capabilities?.features.includes('session_generation')
|
|
? actions.generateSessionContent
|
|
: undefined
|
|
}
|
|
/>
|
|
</SubagentDetailsProvider>
|
|
</div>
|
|
|
|
<div className={styles.footer}>
|
|
{pendingToolApproval && (
|
|
<div className={styles.approval} data-testid="pane-approval">
|
|
<ToolApproval
|
|
request={pendingToolApproval}
|
|
onConfirm={handleConfirm}
|
|
variant="floating"
|
|
// Several panes can show approvals at once; don't auto-focus one
|
|
// pane's approval (it would steal focus from the pane the user is
|
|
// in). Keyboard handling is focus-scoped, so each pane's approval
|
|
// is still fully keyboard-operable once clicked/tabbed into.
|
|
keyboardActive={false}
|
|
/>
|
|
</div>
|
|
)}
|
|
{pendingAskUserApproval && (
|
|
<div className={styles.approval} data-testid="pane-approval">
|
|
<AskUserQuestion
|
|
request={pendingAskUserApproval}
|
|
onConfirm={handleConfirm}
|
|
variant="floating"
|
|
keyboardActive={false}
|
|
/>
|
|
</div>
|
|
)}
|
|
{/* Panes keep the composer status compact: spinner + elapsed time +
|
|
token count + cancel hint, but no rotating "witty" loading phrase. */}
|
|
<StreamingStatus startedAt={activeTurnStartedAt} showPhrase={false} />
|
|
<QueuedPromptDisplay
|
|
prompts={queuedPrompts}
|
|
t={t}
|
|
onDelete={removeQueuedPrompt}
|
|
onInsert={insertQueuedPrompt}
|
|
onEdit={editQueuedPrompt}
|
|
/>
|
|
<ChatEditor
|
|
ref={editorRef}
|
|
onSubmit={handleSubmit}
|
|
onCancel={handleCancel}
|
|
isRunning={isResponding}
|
|
commands={commands}
|
|
queuedMessages={queuedTexts}
|
|
onPopQueuedMessages={editLastQueuedPrompt}
|
|
onClearQueuedMessages={clearQueuedPrompts}
|
|
visibleToolbarActions={paneToolbarActions}
|
|
workspaceName={showWorkspaceChip ? workspaceLabel : undefined}
|
|
workspaceTitle={paneWorkspaceCwd}
|
|
workspaceColor={workspaceAccent}
|
|
currentMode={connection.currentMode ?? 'default'}
|
|
currentModel={connection.currentModel ?? ''}
|
|
availableModels={availableModels}
|
|
onSelectMode={handleSelectMode}
|
|
onSelectModel={handleSelectModel}
|
|
dialogOpen={approvalActive}
|
|
disabled={approvalActive}
|
|
voiceTarget={hidden ? undefined : voiceTarget}
|
|
voiceStatusRevision={voiceStatusRevision}
|
|
followupState={followupState}
|
|
onAcceptFollowup={onAcceptFollowup}
|
|
onDismissFollowup={onDismissFollowup}
|
|
sessionId={connection.sessionId}
|
|
atWorkspaceCwd={paneWorkspaceCwd}
|
|
placeholderText={t('splitView.composerPlaceholder')}
|
|
/>
|
|
{CustomComposerFooter && (
|
|
<CustomComposerFooter
|
|
disabled={approvalActive}
|
|
isRunning={isResponding}
|
|
currentMode={connection.currentMode ?? 'default'}
|
|
currentModel={connection.currentModel ?? ''}
|
|
sessionName={connection.displayName}
|
|
/>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|