Files
qwen-code/packages/web-shell/client/components/ChatPane.tsx
T

2146 lines
76 KiB
TypeScript

/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useModelRefresh } from '../hooks/useModelRefresh';
import { useSubscriptionLogout } from '../hooks/useSubscriptionLogout';
import { CapacityRecoveryDialog } from './workspaces/CapacityRecoveryDialog';
import { useCapacityRecovery } from '../hooks/useCapacityRecovery';
import {
beginLocalCommand,
finishLocalCommand,
} from '../utils/message-deletion';
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { ExpandIcon, ShrinkIcon } from 'lucide-react';
import {
useActions,
useConnection,
useDaemonFollowupSuggestion,
useDaemonSessionOwnerGuard,
useStreamingState,
useTranscriptHistory,
useTranscriptStore,
useWorkspace,
type DaemonSessionActions,
} from '@qwen-code/web-shell/daemon-react-sdk';
import {
type DaemonSessionArtifact,
type DaemonSessionMonitorTaskStatus,
type DaemonSessionSummary,
type DaemonWorkspaceCapability,
type ReasoningSelection,
} from '@qwen-code/sdk/daemon';
import type { ACPToolCall } from '../adapters/types';
import { SubagentDetailsProvider } from '../subagentDetailsContext';
import { MonitorDetailsProvider } from '../monitorDetailsContext';
import { WorkflowDetailsProvider } from '../workflowDetailsContext';
import { useI18n } from '../i18n';
import { getWorkflowModeMessages } from './workflow-mode-messages';
import { McpAppVisibilityContext } from '../mcpAppHostContext';
import { getSubagentDetailsUnavailableReason } from './messages/toolFormatting';
import { useWebShellCustomization } from '../customization';
import {
SESSION_MONITOR_TOOL_CORRELATION_FEATURE,
SESSION_TRANSCRIPT_PAGINATION_FEATURE,
} from '../constants/sessions';
import { useAnimationFrameTranscriptSnapshot } from '../hooks/useAnimationFrameTranscriptBlocks';
import { useMessagesFromBlocks } from '../hooks/useMessages';
import { useSessionArtifacts } from '../hooks/useSessionArtifacts';
import { useBackgroundTasks } from '../hooks/useBackgroundTasks';
import { extractPendingPermission } from '../adapters/transcriptAdapter';
import type { PromptFile, PromptImage } from '../adapters/promptTypes';
import type { AttachmentPreviewRequest } from '../adapters/messageTypes';
import type {
ComposerSubmitCommit,
ComposerSubmitMetadata,
EditorHandle,
} from '../hooks/useComposerCore';
import { useQueuedPrompts } from '../hooks/useQueuedPrompts';
import {
isModelSetupCommand,
resolveModelManagement,
type WebShellModelManagementOptions,
} from '../modelManagement';
import type {
WebShellUnavailableModel,
WebShellUnavailableModelTooltipMount,
} from '../unavailableModels';
import { isAskUserPermission } from '../utils/askUserPermission';
import { isDaemonApprovalMode } from '../utils/sessionPreparation';
import { isVisibleComposerModel } from '../utils/composerModels';
import { shouldBlockComposerSubmit } from '../utils/composerInputState';
import { base64ToBlob } from '../utils/base64';
import { isDefinitelyRejectedPromptAdmission } from '../utils/promptAdmission';
import {
getActiveTodosForPlanRevision,
isExitPlanApprovalRequest,
} from '../utils/todos';
import { findMonitorTaskForTool } from '../utils/monitorTasks';
import {
getTaskActivityKey,
hasActiveTaskActivity,
} from '../utils/taskActivity';
import { invokeSlashCommandHandler } from '../utils/slash-command-action';
import { parseWebShellGoalCommand } from '../utils/goalCondition';
import { buildGoalControlRequest } from '../utils/goalControlRequest';
import {
useContextUsageControls,
type RegisterContextUsageControls,
} from '../hooks/useContextUsageControls';
import { isGoalGateBlocked } from '../utils/goalGate';
import type { WebShellSlashCommandHandler } from '../App';
import { getModelDisplayName } from '../utils/modelDisplay';
import { formatDateTime } from '../utils/formatDateTime';
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 {
useSessionCatalogController,
useDaemonActivePromptBridge,
} from '../session-catalog/session-catalog-hooks';
import type { MessageListHandle } from './MessageList';
import { TranscriptViewport } from './TranscriptViewport';
import { StreamingStatus } from './StreamingStatus';
import { ChatEditor, type ComposerToolbarAction } from './ChatEditor';
import { SessionRecoveryBanner } from './SessionRecoveryBanner';
import { QueuedPromptDisplay } from './QueuedPromptDisplay';
import { GoalStatusStrip } from './GoalStatusStrip';
import composerStatusStyles from './ComposerStatusStack.module.css';
import { GoalEditDialog } from './dialogs/GoalEditDialog';
import { nextApprovalMode, parsePlanCommand } from '../utils/planMode';
import { useApprovalModes } from '../hooks/useApprovalModes';
import { ToolApproval } from './messages/ToolApproval';
import { AskUserQuestion } from './messages/AskUserQuestion';
import { createContextUsageMessageData } from './messages/ContextUsageMessage';
import type {
TurnOutputKind,
TurnOutputOpenRequest,
} from './artifacts/TurnOutputs';
import { TURN_OUTPUT_KINDS } from './artifacts/TurnOutputs';
import { useArtifactWorkspaceTarget } from './artifacts/useArtifactWorkspaceTarget';
import {
getArtifactsByTurn,
getFileChangesByTurn,
getScheduledTasksByTurn,
} from './artifacts/turnOutputSelectors';
import { PaneHeaderActions } from './PaneHeaderActions';
import { SessionDetailsTooltip } from './sidebar/SessionDetailsTooltip';
import styles from './ChatPane.module.css';
import accentStyles from './WorkspaceAccent.module.css';
// Split-view panes get the same session-scoped composer controls as the main
// chat. The width toggle is omitted because panes size themselves.
const PANE_TOOLBAR_ACTIONS: readonly ComposerToolbarAction[] = [
'addMenu',
'approvalMode',
'plan',
'workflowMode',
'contextUsage',
'model',
'voice',
];
const EMPTY_VOICE_WORKSPACE_REVISIONS: Readonly<Record<string, number>> = {};
function OptionalMonitorDetailsProvider({
enabled,
onOpen,
children,
}: {
enabled: boolean;
onOpen: (tool: ACPToolCall) => Promise<boolean>;
children: ReactNode;
}) {
return enabled ? (
<MonitorDetailsProvider onOpen={onOpen}>{children}</MonitorDetailsProvider>
) : (
children
);
}
export interface PaneHeaderActionsInfo {
sessionId: string;
workspaceCwd?: string;
/** The pane's own session actions; lets an action drive session data. */
sessionActions?: DaemonSessionActions;
}
export type PaneHeaderActionsRenderer = (
info: PaneHeaderActionsInfo,
) => ReactNode;
interface UnknownPromptAdmission {
owner: { sessionId: string | undefined };
commitAccepted?: ComposerSubmitCommit;
payloadAvailable: boolean;
}
export interface ChatPaneProps {
/** Header label; falls back to the session's own display name / id. */
title?: string;
/** Session-list metadata for the shared title details. */
sessionSummary?: DaemonSessionSummary;
/** Last interacted pane, independent of whether its session is running. */
isActive?: boolean;
/** Must be referentially stable; reports pending state and unmount cleanup. */
onApprovalChange?: (sessionId: string, pending: boolean) => void;
/**
* The workspace this pane's session lives in. Passed explicitly by the split
* view (which knows it per session); 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;
onImageIngestionNotice?: (tone: 'warning' | 'error', message: string) => void;
/** Host slash-command callback shared with the main chat composer. */
onSlashCommand?: WebShellSlashCommandHandler;
modelManagement?: WebShellModelManagementOptions;
unavailableModels?: readonly WebShellUnavailableModel[];
mountUnavailableModelTooltip?: WebShellUnavailableModelTooltipMount;
onOpenGoals?: () => void;
onRightPanelOpen?: (request: TurnOutputOpenRequest) => void;
onOpenMonitor?: (
task: DaemonSessionMonitorTaskStatus,
sessionId: string,
sessionActions: DaemonSessionActions,
) => void;
registerContextUsageControls?: RegisterContextUsageControls;
onBeforeContextCompress?: (sessionId: string) => void;
onOpenContextUsage?: (
sessionId: string,
sessionActions: DaemonSessionActions,
) => void;
onPaneArtifactsChange?: (
sessionId: string,
artifacts: readonly DaemonSessionArtifact[],
) => void;
messageTurnOutputs?: readonly TurnOutputKind[];
/** Render inside a parent surface that already provides its own frame. */
embedded?: boolean;
onFirstPromptAdmitted?: (text: string) => void;
/** Whether this pane owns Session Catalog turn-completion reconciliation. */
reportCatalogTurnCompletion?: boolean;
hidden?: boolean;
voiceUserRevision?: number;
voiceWorkspaceRevisions?: Readonly<Record<string, number>>;
voiceWorkspaces?: readonly DaemonWorkspaceCapability[];
/** Enable the app-scoped experimental Session Workflow presentation. */
sessionWorkflowEnabled?: boolean;
showResponseTokensPerSecond?: boolean;
planControlVisible?: boolean;
}
/**
* 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,
sessionSummary,
isActive = false,
onApprovalChange,
workspaceCwd,
renderHeaderActions,
onClose,
onToggleMaximize,
isMaximized = false,
onError,
onImageIngestionNotice,
onSlashCommand,
modelManagement,
unavailableModels,
mountUnavailableModelTooltip,
onOpenGoals,
onRightPanelOpen,
onOpenMonitor,
onPaneArtifactsChange,
registerContextUsageControls,
onBeforeContextCompress,
onOpenContextUsage,
messageTurnOutputs,
embedded = false,
onFirstPromptAdmitted,
reportCatalogTurnCompletion = true,
hidden = false,
voiceUserRevision = 0,
voiceWorkspaceRevisions = EMPTY_VOICE_WORKSPACE_REVISIONS,
voiceWorkspaces,
sessionWorkflowEnabled = false,
showResponseTokensPerSecond = false,
planControlVisible = false,
}: ChatPaneProps) {
const { t, language } = useI18n();
const parentSurfaceVisible = useContext(McpAppVisibilityContext);
const {
renderComposerFooter: CustomComposerFooter,
askUserFreeTextLabel,
onRefreshModels,
allowedApprovalModes,
} = useWebShellCustomization();
const connection = useConnection();
const modelRefresh = useModelRefresh(onRefreshModels);
const actions = useActions();
const sessionOwnerGuard = useDaemonSessionOwnerGuard();
const workspace = useWorkspace();
const paneSettingsCwd =
connection.sessionContext?.kind === 'standalone'
? undefined
: (workspaceCwd ?? connection.workspaceCwd);
const availableApprovalModes = useApprovalModes(
paneSettingsCwd,
allowedApprovalModes,
);
const capacityRecovery = useCapacityRecovery(
workspace.client,
workspace.capabilities?.features,
connection,
actions,
);
const attachmentWorkspaceTarget = useArtifactWorkspaceTarget(
connection.workspaceCwd,
);
const sessionCatalogController = useSessionCatalogController(
workspace.client,
);
// Each pane owns its DaemonSessionProvider, so each publishes the daemon's
// live prompt state into its own provider (#9487).
const daemonHasActivePrompt = useDaemonActivePromptBridge(
workspace.client,
workspaceCwd ?? connection.workspaceCwd,
connection.sessionId,
);
const sessionHasActivePrompt =
daemonHasActivePrompt || !!connection.backgroundTurn;
const sessionHasActivePromptRef = useRef(sessionHasActivePrompt);
sessionHasActivePromptRef.current = sessionHasActivePrompt;
const { blocks, blockChangeSummary } = useAnimationFrameTranscriptSnapshot();
const messages = useMessagesFromBlocks(t, blocks, blockChangeSummary);
const taskActivityKey = useMemo(
() => getTaskActivityKey(messages),
[messages],
);
const workflowsEnabled =
connection.supportedCommands?.workflowsEnabled === true;
// The activity fact travels beside the key, derived structurally — the
// key itself is not parseable back (callId is unconstrained text) — and
// gated on the endpoint the hook will actually poll.
const taskActivityActive = useMemo(
() => hasActiveTaskActivity(messages, { workflowsEnabled }),
[messages, workflowsEnabled],
);
const sessionTasks = useBackgroundTasks(
connection.sessionId,
taskActivityKey,
taskActivityActive,
connection.status === 'connected',
0,
workflowsEnabled,
);
const transcriptHistory = useTranscriptHistory();
const store = useTranscriptStore();
const streamingState = useStreamingState();
const [goalControlBusy, setGoalControlBusy] = useState(false);
const goalControlOpSeqRef = useRef(0);
const goalControlOwnerRef = useRef<
{ opId: number; sessionId: string | undefined } | undefined
>(undefined);
const [goalEditOpen, setGoalEditOpen] = useState(false);
const [goalEditError, setGoalEditError] = useState<string | null>(null);
const connectionRef = useRef(connection);
connectionRef.current = connection;
const connectionGoalComplete =
connection.goalState?.goal?.status === 'complete';
const liveGoalSnapshot = connectionGoalComplete
? undefined
: connection.goalState;
useEffect(() => {
const owner = goalControlOwnerRef.current;
// Release the busy latch only when no control operation owns it, or when
// its owner belongs to a session we have left (that operation's `finally`
// can no longer release it here). Releasing it while an operation is still
// in flight — which a server-side goal replacement would otherwise do —
// re-enables the strip and lets a second control dispatch against the same
// expected revision, so one of the two loses with a 409.
if (!owner || owner.sessionId !== connection.sessionId) {
goalControlOwnerRef.current = undefined;
setGoalControlBusy(false);
}
setGoalEditOpen(false);
setGoalEditError(null);
}, [
connection.goalState?.goal?.goalId,
connection.sessionId,
connectionGoalComplete,
]);
const { artifacts } = useSessionArtifacts();
const openSubagentDetails = useCallback(
(tool: ACPToolCall, parentSessionId = connection.sessionId) => {
if (
!parentSessionId ||
!onRightPanelOpen ||
getSubagentDetailsUnavailableReason(tool)
)
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:${parentSessionId}:${tool.callId}`,
kind: 'subagent',
title: tool.title || subagentType || t('agent.label'),
turnId: tool.callId,
tool,
sessionId: parentSessionId,
workspaceCwd: connection.workspaceCwd ?? workspaceCwd,
});
},
[
connection.sessionId,
connection.workspaceCwd,
onRightPanelOpen,
t,
workspaceCwd,
],
);
const monitorSessionIdRef = useRef(connection.sessionId);
monitorSessionIdRef.current = connection.sessionId;
const monitorDetailsSupported =
connection.capabilities?.features.includes(
SESSION_MONITOR_TOOL_CORRELATION_FEATURE,
) === true && onOpenMonitor !== undefined;
const openMonitorDetails = useCallback(
async (tool: ACPToolCall): Promise<boolean> => {
const sessionId = monitorSessionIdRef.current;
if (!sessionId || !onOpenMonitor) return false;
try {
const snapshot = await actions.getTasks();
if (
monitorSessionIdRef.current !== sessionId ||
snapshot.sessionId !== sessionId
) {
return false;
}
const task = findMonitorTaskForTool(snapshot.tasks, tool);
if (!task) return false;
onOpenMonitor(task, sessionId, actions);
return true;
} catch {
return false;
}
},
[actions, onOpenMonitor],
);
useEffect(() => {
const sessionId = connection.sessionId;
if (!sessionId) return;
onPaneArtifactsChange?.(sessionId, artifacts);
return () => {
onPaneArtifactsChange?.(sessionId, []);
};
}, [artifacts, connection.sessionId, onPaneArtifactsChange]);
const streamingStateRef = useRef(streamingState);
streamingStateRef.current = streamingState;
const catalogOwnerCwd =
connection.workspaceCwd &&
workspaceCwd &&
connection.workspaceCwd !== workspaceCwd
? undefined
: (connection.workspaceCwd ?? workspaceCwd);
const previousCatalogStreamingStateRef = useRef(streamingState);
const catalogStreamingSessionIdRef = useRef<string | undefined>(
streamingState !== 'idle' ? connection.sessionId : undefined,
);
const catalogStreamingWorkspaceCwdRef = useRef<string | undefined>(
streamingState !== 'idle' ? catalogOwnerCwd : undefined,
);
useEffect(() => {
const previous = previousCatalogStreamingStateRef.current;
previousCatalogStreamingStateRef.current = streamingState;
if (
streamingState !== 'idle' &&
(previous === 'idle' ||
catalogStreamingSessionIdRef.current === undefined)
) {
catalogStreamingSessionIdRef.current = connection.sessionId;
catalogStreamingWorkspaceCwdRef.current = catalogOwnerCwd;
} else if (
streamingState !== 'idle' &&
connection.sessionId === catalogStreamingSessionIdRef.current &&
catalogStreamingWorkspaceCwdRef.current === undefined
) {
catalogStreamingWorkspaceCwdRef.current = catalogOwnerCwd;
}
if (
previous !== 'idle' &&
streamingState === 'idle' &&
connection.sessionId &&
connection.sessionId === catalogStreamingSessionIdRef.current &&
catalogOwnerCwd &&
catalogOwnerCwd === catalogStreamingWorkspaceCwdRef.current &&
reportCatalogTurnCompletion
) {
sessionCatalogController.turnCompleted(
catalogOwnerCwd,
connection.sessionId,
);
}
}, [
catalogOwnerCwd,
connection.sessionId,
reportCatalogTurnCompletion,
sessionCatalogController,
streamingState,
]);
const firstPromptAdmittedRef = useRef(false);
const [unknownPromptAdmission, setUnknownPromptAdmission] =
useState<UnknownPromptAdmission | null>(null);
const admissionOwnerRef = useRef({ sessionId: connection.sessionId });
if (admissionOwnerRef.current.sessionId !== connection.sessionId) {
admissionOwnerRef.current = { sessionId: connection.sessionId };
}
useEffect(() => {
firstPromptAdmittedRef.current = false;
setUnknownPromptAdmission(null);
}, [connection.sessionId]);
const admissionPayloadLocked =
unknownPromptAdmission?.payloadAvailable === true;
const discardUnknownPromptPayload = useCallback(() => {
const current = unknownPromptAdmission;
if (!current?.payloadAvailable) return;
if (admissionOwnerRef.current !== current.owner) {
setUnknownPromptAdmission(null);
return;
}
current.commitAccepted?.();
setUnknownPromptAdmission({
owner: current.owner,
payloadAvailable: false,
});
}, [unknownPromptAdmission]);
const continueEditingUnknownPrompt = useCallback(() => {
if (!window.confirm(t('queue.continueEditingConfirm'))) return;
const current = unknownPromptAdmission;
if (!current?.payloadAvailable) return;
if (admissionOwnerRef.current !== current.owner) {
setUnknownPromptAdmission(null);
return;
}
setUnknownPromptAdmission({
owner: current.owner,
payloadAvailable: false,
});
}, [t, unknownPromptAdmission]);
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 transcriptViewportRef = useRef<MessageListHandle>(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 modelManagementPolicy = resolveModelManagement(modelManagement);
const modelManagementRef = useRef(modelManagementPolicy);
modelManagementRef.current = modelManagementPolicy;
const onSlashCommandRef = useRef(onSlashCommand);
onSlashCommandRef.current = onSlashCommand;
const {
available: subscriptionAvailable,
handleCommand: handleSubscriptionLogout,
} = useSubscriptionLogout({
t,
onCommand: (text) =>
invokeSlashCommandHandler(text, onSlashCommandRef.current, reportError),
workspaceCwd: paneSettingsCwd,
isBlocked: () =>
streamingStateRef.current !== 'idle' ||
sessionHasActivePromptRef.current ||
isGoalGateBlocked({
sessionId: connectionRef.current.sessionId,
goalState: connectionRef.current.goalState,
}),
onBlocked: () => {
onImageIngestionNotice?.('warning', t('queue.commandBlocked'));
return false;
},
onNotice: (_kind, message) =>
store.dispatch([
{
type: 'status',
promptId: `local-command:${crypto.randomUUID()}`,
text: message,
},
]),
onError: reportError,
});
const pendingApproval = useMemo(
() => extractPendingPermission(blocks),
[blocks],
);
const isAskUser = isAskUserPermission(pendingApproval);
const pendingToolApproval =
pendingApproval && !isAskUser ? pendingApproval : null;
const pendingAskUserApproval =
pendingApproval && isAskUser ? pendingApproval : null;
const isExitPlanApproval = isExitPlanApprovalRequest(pendingToolApproval);
const planTodos = useMemo(
() =>
sessionWorkflowEnabled && isExitPlanApproval
? getActiveTodosForPlanRevision(messages, pendingToolApproval?.todoPlan)
: [],
[isExitPlanApproval, messages, pendingToolApproval, sessionWorkflowEnabled],
);
// 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;
useEffect(() => {
const sessionId = connection.sessionId;
if (!sessionId || !onApprovalChange) return;
onApprovalChange(sessionId, approvalActive);
return () => onApprovalChange(sessionId, false);
}, [connection.sessionId, approvalActive, onApprovalChange]);
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 canMutateMidTurn =
connection.capabilities?.features.includes(
'session_mid_turn_message_mutation',
) === true;
const canQueryMidTurn =
connection.capabilities?.features.includes(
'session_mid_turn_message_query',
) === true;
const canSendMidTurnNow =
connection.capabilities?.features.includes('session_mid_turn_send_now') ===
true;
const canInjectMidTurnMedia =
connection.capabilities?.features.includes('session_attachments') === true;
const {
queuedPrompts,
queuedTexts,
enqueuePrompt,
removeQueuedPrompt,
insertQueuedPrompt,
sendQueuedPromptNow,
editQueuedPrompt,
editLastQueuedPrompt,
clearQueuedPrompts,
} = useQueuedPrompts({
getPromptDispatchError: (text) =>
!modelManagementRef.current.allowAdd &&
isModelSetupCommand(text, connectionRef.current.commands)
? t('settings.models.addDisabled')
: undefined,
connected: connection.status === 'connected',
writeBlocked: connection.runtimeStopped,
runtimeStopped: connection.runtimeStopped,
sessionId: connection.sessionId,
workspaceCwd: connection.workspaceCwd,
clientId: connection.clientId,
canMutateMidTurn,
canQueryMidTurn,
canQueryPendingPrompts:
connection.capabilities?.features.includes('session_prompt') === true,
canInjectMidTurnMedia,
workspaceFileActions: attachmentWorkspaceTarget?.actions,
streamingState,
sessionHasActivePrompt,
sessionActions: actions,
store,
editorRef,
reportError,
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 (
connection.localPrompt &&
connection.localPrompt.sessionId === connection.sessionId
) {
return connection.localPrompt.startedAt;
}
if (connection.backgroundTurn) return connection.backgroundTurn.startedAt;
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,
connection.backgroundTurn,
connection.localPrompt,
connection.sessionId,
]);
const controlGoal = useCallback(
async (
action: 'replace' | 'edit' | 'pause' | 'resume' | 'clear',
objective?: string,
) => {
const busyOwner = sessionOwnerGuard.capture();
const busySessionId = connectionRef.current.sessionId;
const expectedGoalId = connectionRef.current.goalState?.goal?.goalId;
const opId = ++goalControlOpSeqRef.current;
goalControlOwnerRef.current = { opId, sessionId: busySessionId };
setGoalControlBusy(true);
try {
const snapshot = (await actions.getGoal()).snapshot;
const goal = snapshot.goal;
if (
(action === 'replace' || action === 'edit') &&
goal?.goalId !== expectedGoalId
) {
throw new Error(t('goals.error.goalUnavailable'));
}
const request = buildGoalControlRequest(action, goal, objective, {
emptyObjective: t('goals.error.emptyCondition'),
goalUnavailable: t('goals.error.goalUnavailable'),
});
if (!busyOwner.isCurrent()) {
throw new Error(t('goals.error.goalUnavailable'));
}
try {
return await actions.controlGoal(request);
} catch (error) {
await actions.getGoal().catch(() => undefined);
throw error;
}
} finally {
// A newer operation (or a session change) owns the latch now; leave it
// to whoever owns it rather than releasing it under them.
if (goalControlOwnerRef.current?.opId === opId) {
goalControlOwnerRef.current = undefined;
if (connectionRef.current.sessionId === busySessionId) {
setGoalControlBusy(false);
}
}
}
},
[actions, sessionOwnerGuard, t],
);
const runGoalControl = useCallback(
(action: 'pause' | 'resume' | 'clear') => {
const owner = sessionOwnerGuard.capture();
void controlGoal(action).catch((error: unknown) => {
// A control dropped because the pane moved to another session is not a
// failure the user needs to see — `handleGoalEditSave` and the main
// composer swallow the same race.
if (!owner.isCurrent()) return;
reportError(error, t(`goals.error.${action}Failed`));
});
},
[controlGoal, reportError, sessionOwnerGuard, t],
);
const handleGoalEditSave = useCallback(
(objective: string) => {
const owner = sessionOwnerGuard.capture();
setGoalEditError(null);
void controlGoal('edit', objective)
.then(() => {
if (owner.isCurrent()) setGoalEditOpen(false);
})
.catch((error: unknown) => {
if (!owner.isCurrent()) return;
setGoalEditError(
error instanceof Error ? error.message : String(error),
);
reportError(error, t('goals.error.editFailed'));
});
},
[controlGoal, reportError, sessionOwnerGuard, t],
);
const planPreparationRef = useRef<{ isCurrent: () => boolean } | null>(null);
const planMode = connection.currentMode === 'plan';
const executionMode = planMode
? (connection.planExecutionMode ?? 'default')
: (connection.currentMode ?? 'default');
const [modeControlsBusy, setModeControlsBusy] = useState(false);
const modeTransitionRef = useRef<{
owner: { isCurrent: () => boolean };
requestId?: string;
initialMode?: string;
hadActiveTurn?: boolean;
} | null>(null);
const releaseModeTransition = useCallback(
(transition: typeof modeTransitionRef.current) => {
if (modeTransitionRef.current !== transition) return;
modeTransitionRef.current = null;
setModeControlsBusy(false);
},
[],
);
useEffect(() => {
const transition = modeTransitionRef.current;
if (!transition) return;
const activeTurn = streamingState !== 'idle' || sessionHasActivePrompt;
if (
!transition.owner.isCurrent() ||
(transition.requestId &&
((connection.currentMode !== 'plan' &&
connection.currentMode !== transition.initialMode) ||
(isExitPlanApprovalRequest(pendingToolApproval) &&
pendingToolApproval?.id !== transition.requestId) ||
(transition.hadActiveTurn && !activeTurn)))
) {
releaseModeTransition(transition);
} else if (activeTurn) {
transition.hadActiveTurn = true;
}
});
const setComposerMode = useCallback(
async (modeId: string, enabled: boolean): Promise<boolean> => {
if (modeTransitionRef.current?.owner.isCurrent()) return false;
if (
connection.loadingTranscript ||
shouldBlockComposerSubmit({
connectionStatus: connection.status,
hasSession: Boolean(connection.sessionId),
})
)
return false;
if (!isDaemonApprovalMode(modeId) || modeId === 'plan') {
reportError(
new Error(`Unsupported execution approval mode: ${modeId}`),
'Failed to set approval mode',
);
return false;
}
const owner = sessionOwnerGuard.capture();
const transition = { owner };
modeTransitionRef.current = transition;
setModeControlsBusy(true);
try {
await actions.setApprovalMode(modeId, { planMode: enabled });
if (!owner.isCurrent()) return false;
const approval = pendingToolApprovalRef.current;
if (
!enabled &&
approval &&
!isExitPlanApprovalRequest(approval) &&
(modeId === 'yolo' ||
(modeId === 'auto-edit' && approval.toolKind === 'edit'))
) {
const allowOnce = approval.options.find(
(option) => option.kind === 'allow_once',
);
if (allowOnce)
void actions
.submitPermission(approval.id, allowOnce.id)
.catch((error: unknown) =>
reportError(error, 'Failed to auto-approve tool call'),
);
}
return true;
} catch (error) {
if (owner.isCurrent())
reportError(error, 'Failed to set approval mode');
return false;
} finally {
releaseModeTransition(transition);
}
},
[
actions,
connection.loadingTranscript,
connection.status,
connection.sessionId,
reportError,
sessionOwnerGuard,
releaseModeTransition,
],
);
const handleSubmit = useCallback(
(
text: string,
images?: PromptImage[],
files?: PromptFile[],
commitAccepted?: ComposerSubmitCommit,
metadata?: ComposerSubmitMetadata,
): boolean => {
let trimmed = text.trim();
if (!trimmed && (images?.length ?? 0) === 0 && (files?.length ?? 0) === 0)
return false;
if (admissionPayloadLocked || planPreparationRef.current?.isCurrent())
return false;
// Same fence as App's composer: a stopped runtime keeps the draft in
// the composer (the pane banner offers Resume); a submit here would
// only race the dead runtime. The parked state retains sessionId, so
// shouldBlockComposerSubmit alone cannot catch it.
if (connectionRef.current.runtimeStopped) {
onImageIngestionNotice?.('warning', t('capacityChoice.stopped'));
return false;
}
transcriptViewportRef.current?.scrollToBottom();
// Account commands must pass login and model checks before host overrides.
const subscriptionHandled = handleSubscriptionLogout(text);
if (subscriptionHandled !== undefined) return subscriptionHandled;
if (
trimmed &&
invokeSlashCommandHandler(text, onSlashCommandRef.current, reportError)
) {
return true;
}
// Only when the host declines does the policy consume a model-setup
// command — below the runtimeStopped fence so a stopped runtime keeps
// the draft instead of toasting, and ahead of any daemon dispatch.
if (
!modelManagementRef.current.allowAdd &&
isModelSetupCommand(text, connectionRef.current.commands)
) {
onImageIngestionNotice?.('warning', t('settings.models.addDisabled'));
return true;
}
const planCommand = trimmed.match(/^\/plan(?:\s+(.*))?$/is);
const planOperation = planCommand
? parsePlanCommand(planCommand[1] ?? '', planMode)
: undefined;
if (planOperation) {
if (modeTransitionRef.current?.owner.isCurrent()) {
reportError(
new Error(t('mode.changePending')),
t('local.approvalMode'),
);
return false;
}
if (
connection.loadingTranscript ||
shouldBlockComposerSubmit({
connectionStatus: connection.status,
hasSession: Boolean(connection.sessionId),
})
)
return false;
if (!planOperation.prompt) {
void setComposerMode(executionMode, planOperation.enabled);
return true;
}
if (
streamingStateRef.current !== 'idle' ||
sessionHasActivePromptRef.current
)
return false;
trimmed = planOperation.prompt;
if (
!modelManagementRef.current.allowAdd &&
isModelSetupCommand(trimmed, connectionRef.current.commands)
) {
onImageIngestionNotice?.('warning', t('settings.models.addDisabled'));
return true;
}
}
if (!planOperation && /^\/goal(?:\s|$)/i.test(trimmed)) {
// The same guard App.tsx applies before any slash handling: a control
// that cannot reach the daemon must leave the text in the composer
// instead of consuming it, appending a transcript entry, and failing
// later at `requireSessionForAction` with only a toast.
if (
shouldBlockComposerSubmit({
connectionStatus: connection.status,
hasSession: Boolean(connection.sessionId),
})
) {
return false;
}
if (
(images?.length ?? 0) > 0 ||
(files?.length ?? 0) > 0 ||
(metadata?.inputAnnotations?.length ?? 0) > 0
) {
const message = t('goals.error.attachmentsUnsupported');
reportError(new Error(message), message);
return false;
}
const operation = parseWebShellGoalCommand(trimmed);
if (operation.kind === 'status') {
// A pane without a Goals surface (the side-task pane passes no
// handler) would otherwise consume the text and open nothing.
if (!onOpenGoals) {
reportError(
new Error(t('goals.error.goalsUnavailable')),
t('goals.error.goalsUnavailable'),
);
return false;
}
onOpenGoals();
return true;
}
if (operation.kind === 'error') {
const message = t('goals.error.requiresObjective', {
keyword: operation.keyword,
});
reportError(new Error(message), message);
return false;
}
const action = operation.kind === 'set' ? 'replace' : operation.kind;
const objective =
operation.kind === 'set' || operation.kind === 'edit'
? operation.objective
: undefined;
store.appendLocalUserMessage(text, undefined, {
localCommandId: `local-command:${crypto.randomUUID()}`,
});
void controlGoal(action, objective).catch((error: unknown) => {
reportError(error, `Failed to ${operation.kind} /goal`);
});
return true;
}
if (
shouldBlockComposerSubmit({
connectionStatus: connection.status,
hasSession: Boolean(connection.sessionId),
})
) {
return false;
}
const inputAnnotations = metadata?.inputAnnotations;
const notifyFirstPromptAdmitted = () => {
if (
trimmed &&
!firstPromptAdmittedRef.current &&
onFirstPromptAdmitted
) {
firstPromptAdmittedRef.current = true;
onFirstPromptAdmitted(trimmed);
}
};
const commandBlockedByGoal =
text.trim().startsWith('/') &&
isGoalGateBlocked({
sessionId: connection.sessionId,
goalState: connection.goalState,
});
if (commandBlockedByGoal) return false;
if (
streamingStateRef.current === 'idle' &&
!sessionHasActivePromptRef.current
) {
const admissionOwner = admissionOwnerRef.current;
let admissionStarted = false;
let admitted = false;
const submit = () => {
if (
!modelManagementRef.current.allowAdd &&
isModelSetupCommand(trimmed, connectionRef.current.commands)
) {
onImageIngestionNotice?.(
'warning',
t('settings.models.addDisabled'),
);
return;
}
return actions
.sendPrompt(trimmed, {
submittedPrompt: text,
...(images && images.length ? { images } : {}),
...(files && files.length ? { files } : {}),
...(inputAnnotations ? { inputAnnotations } : {}),
onAdmissionStarted: () => {
admissionStarted = true;
},
onAdmitted: () => {
if (admissionOwnerRef.current !== admissionOwner) return;
if (connection.sessionId && catalogOwnerCwd) {
sessionCatalogController.promptAdmitted(
catalogOwnerCwd,
connection.sessionId,
);
}
admitted = true;
notifyFirstPromptAdmitted();
clearFollowup();
commitAccepted?.();
},
})
.catch((error: unknown) => {
if (admissionOwnerRef.current !== admissionOwner) return;
const definitelyRejected =
isDefinitelyRejectedPromptAdmission(error);
if (admitted || !admissionStarted || definitelyRejected) {
reportError(error, 'Failed to send prompt');
return;
}
if (catalogOwnerCwd) {
sessionCatalogController.promptAdmissionUncertain(
catalogOwnerCwd,
);
}
setUnknownPromptAdmission({
owner: admissionOwner,
commitAccepted,
payloadAvailable: true,
});
onImageIngestionNotice?.('warning', t('queue.admissionUnknown'));
console.warn(
'[ChatPane] prompt admission outcome is unknown',
error,
);
});
};
if (planOperation) {
const owner = sessionOwnerGuard.capture();
planPreparationRef.current = owner;
void setComposerMode(executionMode, planOperation.enabled).then(
(applied) => {
if (planPreparationRef.current === owner)
planPreparationRef.current = null;
const current = connectionRef.current;
if (
!applied ||
!owner.isCurrent() ||
current.runtimeStopped ||
current.loadingTranscript ||
shouldBlockComposerSubmit({
connectionStatus: current.status,
hasSession: Boolean(current.sessionId),
}) ||
streamingStateRef.current !== 'idle' ||
sessionHasActivePromptRef.current ||
isGoalGateBlocked({
sessionId: current.sessionId,
goalState: current.goalState,
})
)
return;
void submit();
},
);
} else {
void submit();
}
return false;
}
const queued =
!trimmed && !inputAnnotations
? enqueuePrompt(
trimmed,
images,
files,
undefined,
undefined,
undefined,
text,
)
: enqueuePrompt(
trimmed,
images,
files,
undefined,
inputAnnotations,
notifyFirstPromptAdmitted,
text,
);
if (queued !== false && catalogOwnerCwd) {
sessionCatalogController.invalidateWorkspace(catalogOwnerCwd);
}
return queued;
},
[
actions,
executionMode,
planMode,
setComposerMode,
sessionOwnerGuard,
admissionPayloadLocked,
catalogOwnerCwd,
clearFollowup,
connection.goalState,
connection.loadingTranscript,
connection.sessionId,
connection.status,
controlGoal,
handleSubscriptionLogout,
enqueuePrompt,
onFirstPromptAdmitted,
onImageIngestionNotice,
onOpenGoals,
reportError,
sessionCatalogController,
store,
t,
],
);
const handleConfirm = useCallback(
async (
id: string,
selectedOption: string,
answers?: Record<string, string>,
) => {
const request = pendingToolApprovalRef.current;
const isPlan = request?.id === id && isExitPlanApprovalRequest(request);
if (isPlan && modeTransitionRef.current?.owner.isCurrent()) {
throw new Error('Approval mode or plan confirmation is still pending');
}
const owner = sessionOwnerGuard.capture();
const option = request?.options.find(
(entry) => entry.id === selectedOption,
);
const approvesPlan =
isPlan &&
(option?.kind === 'allow_once' || option?.kind === 'allow_always');
const transition = isPlan
? {
owner,
...(approvesPlan
? {
requestId: id,
initialMode: connectionRef.current.currentMode,
hadActiveTurn:
streamingStateRef.current !== 'idle' ||
sessionHasActivePromptRef.current,
}
: {}),
}
: null;
if (transition) {
modeTransitionRef.current = transition;
setModeControlsBusy(true);
}
try {
if (approvesPlan && connection.planExecutionMode !== undefined) {
await actions.respondToPermission(id, {
outcome: { outcome: 'selected', optionId: selectedOption },
expectedPlanExecutionMode: connection.planExecutionMode,
});
} else {
await actions.submitPermission(id, selectedOption, answers);
}
if (transition && !approvesPlan) releaseModeTransition(transition);
} catch (error) {
if (transition) releaseModeTransition(transition);
if (owner.isCurrent())
reportError(error, 'Failed to submit permission choice');
throw error;
}
},
[
actions,
reportError,
sessionOwnerGuard,
releaseModeTransition,
connection.planExecutionMode,
],
);
const handleAskUserConfirm = useCallback(
(id: string, selectedOption: string, answers?: Record<string, string>) =>
actions.submitPermission(id, selectedOption, answers),
[actions],
);
const handleCancel = useCallback(() => {
actions
.cancel()
.catch((error: unknown) =>
reportError(error, 'Failed to cancel request'),
);
}, [actions, reportError]);
const handleRightPanelOpen = useCallback(
(request: TurnOutputOpenRequest) => {
if (!onRightPanelOpen) return;
onRightPanelOpen({
...request,
sourceSessionId: connection.sessionId,
});
},
[connection.sessionId, onRightPanelOpen],
);
const paneWorkspaceCwd = workspaceCwd ?? connection.workspaceCwd;
const previewSessionIdRef = useRef(connection.sessionId);
previewSessionIdRef.current = connection.sessionId;
const handleImagePreview = useCallback(
(
src: string,
alt?: string,
source?: { kind: 'attachment'; attachmentId: string },
) => {
if (!connection.sessionId) return;
handleRightPanelOpen({
id: 'image',
kind: 'image',
title: t('turnOutputs.imagePreview'),
turnId: connection.sessionId,
src,
...(alt ? { alt } : {}),
...(source ? { attachmentId: source.attachmentId } : {}),
});
},
[connection.sessionId, handleRightPanelOpen, t],
);
const handleAttachmentPreview = useCallback(
(file: AttachmentPreviewRequest) => {
const sessionId = connection.sessionId;
if (!sessionId) return;
const open = (resolvedFile: AttachmentPreviewRequest) =>
handleRightPanelOpen({
id: `attachment:${resolvedFile.attachmentId ?? resolvedFile.workspacePath ?? resolvedFile.name}`,
kind: 'attachment',
title: resolvedFile.name,
turnId: sessionId,
...(resolvedFile.mimeType ? { mimeType: resolvedFile.mimeType } : {}),
...(resolvedFile.data ? { data: resolvedFile.data } : {}),
...(resolvedFile.text !== undefined
? { text: resolvedFile.text }
: {}),
...(resolvedFile.attachmentId
? { attachmentId: resolvedFile.attachmentId }
: {}),
...(paneWorkspaceCwd ? { workspaceCwd: paneWorkspaceCwd } : {}),
...(resolvedFile.workspacePath
? { workspacePath: resolvedFile.workspacePath }
: {}),
});
if (
file.attachmentId &&
file.text === undefined &&
file.data === undefined
) {
void actions
.readAttachment(file.attachmentId)
.then((attachment) => {
if (previewSessionIdRef.current !== sessionId) return;
open({
...file,
data: base64ToBlob(attachment.data, attachment.mimeType),
mimeType: attachment.mimeType,
});
})
.catch((error: unknown) => {
if (previewSessionIdRef.current !== sessionId) return;
reportError(error, 'Failed to preview attachment');
});
return;
}
open(file);
},
[
actions,
connection.sessionId,
handleRightPanelOpen,
paneWorkspaceCwd,
reportError,
],
);
// 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, { subscriptionAvailable }),
),
t,
)
.filter(
(command) =>
subscriptionAvailable ||
!['logout', 'upgrade'].includes(command.name) ||
Boolean(command.source && command.source !== 'builtin-command'),
)
.filter(
(command) =>
modelManagementPolicy.allowAdd ||
!isModelSetupCommand(`/${command.name}`, connection.commands),
)
.map((command) => {
const skillKey = skillDescriptionKey(command.name);
if (!skillKey) return command;
return {
...command,
displayCategory: 'skill' as const,
description: t(skillKey),
};
});
}, [
connection.commands,
modelManagementPolicy.allowAdd,
subscriptionAvailable,
t,
]);
const skills = useMemo(() => {
const commandsByName = new Map(
commands.map((command) => [command.name.toLowerCase(), command]),
);
return (connection.skills ?? [])
.map((name) => {
const command = commandsByName.get(name.toLowerCase());
return {
name,
description: command?.description ?? '',
...(command?.argumentHint
? { argumentHint: command.argumentHint }
: {}),
};
})
.sort((a, b) => a.name.localeCompare(b.name));
}, [commands, connection.skills]);
const contextUsageAvailable = !shouldBlockComposerSubmit({
connectionStatus: connection.status,
hasSession: Boolean(connection.sessionId),
});
const handleShowContextUsage = useCallback(() => {
if (!contextUsageAvailable) {
return;
}
const owner = sessionOwnerGuard.capture();
const localCommandId = `local-command:${crypto.randomUUID()}`;
beginLocalCommand(store, localCommandId);
if (streamingStateRef.current === 'idle') {
store.appendLocalUserMessage('/context', undefined, { localCommandId });
}
actions
.getContextUsage({ detail: false })
.finally(() => finishLocalCommand(store, localCommandId))
.then((result) => {
if (!owner.isCurrent()) return;
store.dispatch([
{
type: 'status',
promptId: localCommandId,
text: t('contextUsage.title'),
data: createContextUsageMessageData(result),
clearActiveText: false,
},
]);
})
.catch((error: unknown) => {
if (!owner.isCurrent()) return;
reportError(error, 'Failed to load context usage');
});
}, [
actions,
contextUsageAvailable,
reportError,
sessionOwnerGuard,
store,
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) => {
if (
isDaemonApprovalMode(modeId) &&
modeId !== 'plan' &&
!availableApprovalModes.some((mode) => mode === modeId)
) {
reportError(
new Error(`Approval mode not offered here: ${modeId}`),
'Failed to set approval mode',
);
return;
}
void setComposerMode(modeId, planMode);
},
[availableApprovalModes, setComposerMode, planMode, reportError],
);
const handleCycleMode = useCallback(() => {
const next = nextApprovalMode(availableApprovalModes, executionMode);
if (next) handleSelectMode(next);
}, [availableApprovalModes, executionMode, handleSelectMode]);
const handleTogglePlan = useCallback(() => {
void setComposerMode(executionMode, !planMode);
}, [setComposerMode, executionMode, planMode]);
const handleSelectModel = useCallback(
(modelId: string) => {
actions
.setModel(modelId)
.catch((error: unknown) =>
reportError(error, 'Failed to switch model'),
);
},
[actions, reportError],
);
const handleWorkflowModeChange = useCallback(
(requested: boolean) => {
if (connection.loadingTranscript || connection.catchingUp) return;
const owner = sessionOwnerGuard.capture();
void actions.setWorkflowMode(requested).catch((error: unknown) => {
if (owner.isCurrent())
reportError(error, getWorkflowModeMessages(language).updateFailed);
});
},
[
actions,
connection.loadingTranscript,
connection.catchingUp,
sessionOwnerGuard,
reportError,
language,
],
);
const handleSelectReasoningEffort = useCallback(
(value: ReasoningSelection) =>
actions
.setReasoningEffort(value, {
persist: connection.sessionContext?.kind !== 'standalone',
})
.catch((error: unknown) =>
reportError(error, t('reasoning.updateFailed')),
),
[actions, connection.sessionContext?.kind, reportError, t],
);
const headerLabel =
title || connection.displayName || connection.sessionId?.slice(0, 8) || '';
const sessionStamp = sessionSummary?.updatedAt || sessionSummary?.createdAt;
// Multi-workspace-ness comes from the shared workspace provider (the
// pane's own session connection may not carry it).
const showWorkspaceChip =
hasMultipleWorkspaces(workspace.capabilities) && !!paneWorkspaceCwd;
const prepareContextCompression = useCallback(() => {
clearFollowup();
if (connection.sessionId) onBeforeContextCompress?.(connection.sessionId);
}, [clearFollowup, connection.sessionId, onBeforeContextCompress]);
const handleOpenContextUsage = useCallback(() => {
if (connection.sessionId)
onOpenContextUsage?.(connection.sessionId, actions);
}, [actions, connection.sessionId, onOpenContextUsage]);
const contextUsageControls = useContextUsageControls({
connection,
actions,
ownerGuard: sessionOwnerGuard,
onBeforeCompress: prepareContextCompression,
busy: streamingState !== 'idle' || sessionHasActivePrompt,
writeBlocked:
Boolean(connection.loadingTranscript) ||
admissionPayloadLocked ||
approvalActive ||
modeControlsBusy,
});
useEffect(() => {
if (contextUsageControls)
return registerContextUsageControls?.(contextUsageControls);
}, [contextUsageControls, registerContextUsageControls]);
// Memoized so the array identity is stable across renders — `ChatEditor` is
// `React.memo`, and a fresh `[...]` each render would defeat it.
const paneToolbarActions = useMemo(
() =>
(embedded && showWorkspaceChip
? [...PANE_TOOLBAR_ACTIONS, 'workspace' as const]
: PANE_TOOLBAR_ACTIONS
).filter((action) => action !== 'plan' || planControlVisible),
[embedded, showWorkspaceChip, planControlVisible],
);
const headerActions =
connection.sessionId && renderHeaderActions
? renderHeaderActions({
sessionId: connection.sessionId,
workspaceCwd: paneWorkspaceCwd || undefined,
sessionActions: actions,
})
: null;
// The header identifies each split pane's workspace; only embedded panes
// without a header need the composer chip. The accent matches sidebar dots.
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} ${embedded ? styles.paneEmbedded : ''}`.trim()}
data-testid="chat-pane"
data-pane-active={isActive ? '' : undefined}
aria-current={isActive ? 'location' : undefined}
aria-label={headerLabel}
>
{goalEditOpen && connection.goalState?.goal && (
<GoalEditDialog
objective={connection.goalState.goal.objective}
saving={goalControlBusy}
error={goalEditError}
onSave={handleGoalEditSave}
onClose={() => {
if (goalControlBusy) return;
setGoalEditOpen(false);
setGoalEditError(null);
}}
/>
)}
{!embedded && (
<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>
)}
{sessionSummary && !hidden ? (
<SessionDetailsTooltip
session={{
...sessionSummary,
hasActivePrompt: sessionHasActivePrompt,
}}
label={headerLabel}
time={sessionStamp ? formatDateTime(sessionStamp) : ''}
completedUnread={false}
workspaceLabel={workspaceLabel}
side="bottom"
>
<span className={styles.title}>{headerLabel}</span>
</SessionDetailsTooltip>
) : (
<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',
)}
>
{isMaximized ? (
<ShrinkIcon size={16} aria-hidden />
) : (
<ExpandIcon 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}>
<OptionalMonitorDetailsProvider
enabled={monitorDetailsSupported}
onOpen={openMonitorDetails}
>
<SubagentDetailsProvider
onOpen={openSubagentDetails}
onOpenBackground={
onRightPanelOpen && connection.sessionId
? (turn) => {
if (!connection.sessionId) return;
onRightPanelOpen({
id: `background:${connection.sessionId}:${turn.taskId}`,
kind: 'background_task',
title: turn.label ?? turn.kind,
turnId: turn.turnId,
backgroundTurn: turn,
sourceSessionId: connection.sessionId,
workspaceCwd: connection.workspaceCwd ?? workspaceCwd,
});
}
: undefined
}
>
<McpAppVisibilityContext.Provider
value={parentSurfaceVisible && !hidden}
>
<WorkflowDetailsProvider tasks={sessionTasks}>
<TranscriptViewport
ref={transcriptViewportRef}
messages={messages}
sourceSessionId={connection.sessionId}
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}
onImagePreview={handleImagePreview}
onAttachmentPreview={handleAttachmentPreview}
onError={reportError}
generateContent={
connection.capabilities?.features.includes(
'session_generation',
)
? actions.generateSessionContent
: undefined
}
/>
</WorkflowDetailsProvider>
</McpAppVisibilityContext.Provider>
</SubagentDetailsProvider>
</OptionalMonitorDetailsProvider>
</div>
<div className={styles.footer}>
{pendingToolApproval && (
<div className={styles.approval} data-testid="pane-approval">
<ToolApproval
showBrowserNotifications
disabled={isExitPlanApproval && modeControlsBusy}
planExecutionMode={connection.planExecutionMode}
request={pendingToolApproval}
onConfirm={handleConfirm}
variant="floating"
planTodos={planTodos}
generateContent={
connection.capabilities?.features.includes('session_generation')
? actions.generateSessionContent
: undefined
}
// 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={handleAskUserConfirm}
onError={reportError}
variant="floating"
keyboardActive={false}
customInputLabel={askUserFreeTextLabel}
/>
</div>
)}
{capacityRecovery.intent && (
<CapacityRecoveryDialog
intent={capacityRecovery.intent}
onClose={capacityRecovery.dismiss}
/>
)}
<div className={approvalActive ? styles.composerHidden : undefined}>
{connection.runtimeStopped && (
<div role="status" data-testid="workspace-runtime-stopped">
<span>
{t('capacityChoice.stopped')}{' '}
{connection.runtimeStopPersistenceUnconfirmed
? t('capacityChoice.persistenceUnconfirmed')
: ''}
</span>
<button
type="button"
onClick={() => {
if (connection.sessionId)
void actions
.loadSession(connection.sessionId, {
sessionContext: connection.sessionContext,
})
.catch((error: unknown) =>
reportError(error, 'Failed to resume session'),
);
}}
>
{t('capacityChoice.resume')}
</button>
</div>
)}
{/* Panes keep the composer status compact: spinner + elapsed time +
token count + cancel hint, but no rotating "witty" loading
phrase. */}
<StreamingStatus
showResponseTokensPerSecond={
showResponseTokensPerSecond && !approvalActive
}
startedAt={activeTurnStartedAt}
showPhrase={false}
hasActivePrompt={sessionHasActivePrompt}
backgroundLabel={
connection.backgroundTurn?.label ??
connection.backgroundTurn?.kind
}
/>
{(queuedPrompts.length > 0 || liveGoalSnapshot?.goal) && (
<div
className={composerStatusStyles.root}
data-testid="composer-status-stack"
>
<QueuedPromptDisplay
prompts={queuedPrompts}
t={t}
canMutateMidTurn={canMutateMidTurn}
canInsertMidTurn={
streamingState !== 'idle' || sessionHasActivePrompt
}
onDelete={removeQueuedPrompt}
canSendMidTurnNow={canSendMidTurnNow}
onInsert={insertQueuedPrompt}
onSendNow={sendQueuedPromptNow}
onEdit={editQueuedPrompt}
onImagePreview={handleImagePreview}
onAttachmentPreview={handleAttachmentPreview}
/>
{liveGoalSnapshot?.goal && (
<GoalStatusStrip
snapshot={liveGoalSnapshot}
busy={goalControlBusy}
onEdit={() => {
setGoalEditError(null);
setGoalEditOpen(true);
}}
onPause={() => runGoalControl('pause')}
onResume={() => runGoalControl('resume')}
onClear={() => runGoalControl('clear')}
/>
)}
</div>
)}
{unknownPromptAdmission && (
<div
className={styles.admissionUnknown}
role="status"
data-testid="pane-prompt-admission-unknown"
>
<span>{t('queue.admissionUnknown')}</span>
{unknownPromptAdmission.payloadAvailable && (
<span className={styles.admissionUnknownActions}>
<button type="button" onClick={continueEditingUnknownPrompt}>
{t('queue.continueEditing')}
</button>
<button type="button" onClick={discardUnknownPromptPayload}>
{t('queue.discardUnknown')}
</button>
</span>
)}
</div>
)}
<SessionRecoveryBanner
blocked={
approvalActive || admissionPayloadLocked || sessionHasActivePrompt
}
/>
<ChatEditor
ref={editorRef}
onSubmit={handleSubmit}
onCancel={handleCancel}
isRunning={isResponding || sessionHasActivePrompt}
commands={commands}
skills={skills}
queuedMessages={queuedTexts}
onPopQueuedMessages={editLastQueuedPrompt}
onClearQueuedMessages={clearQueuedPrompts}
visibleToolbarActions={paneToolbarActions}
tokenCount={
contextUsageAvailable ? (connection.tokenCount ?? 0) : 0
}
contextWindow={
contextUsageAvailable ? (connection.contextWindow ?? 0) : 0
}
onShowContextUsage={
contextUsageAvailable ? handleShowContextUsage : undefined
}
contextUsageControls={
onOpenContextUsage ? contextUsageControls : undefined
}
onOpenContextUsage={
contextUsageAvailable && onOpenContextUsage
? handleOpenContextUsage
: undefined
}
workspaceName={showWorkspaceChip ? workspaceLabel : undefined}
workspaceTitle={paneWorkspaceCwd}
workspaceColor={workspaceAccent}
currentMode={executionMode}
availableModes={availableApprovalModes}
onCycleMode={handleCycleMode}
modeControlsDisabled={modeControlsBusy}
planMode={planMode}
onTogglePlan={handleTogglePlan}
sessionWorkflowEnabled={sessionWorkflowEnabled}
currentModel={connection.currentModel ?? ''}
modelRefresh={modelRefresh}
availableModels={availableModels}
unavailableModels={unavailableModels}
mountUnavailableModelTooltip={mountUnavailableModelTooltip}
onSelectMode={handleSelectMode}
onSelectModel={handleSelectModel}
workflowMode={connection.workflowMode}
workflowModeStatus={
connection.loadingTranscript ||
connection.catchingUp ||
connection.status !== 'connected'
? 'syncing'
: (connection.workflowModeStatus ?? 'unknown')
}
workflowModePending={connection.workflowModePending}
onWorkflowModeChange={handleWorkflowModeChange}
reasoning={connection.reasoning}
onSelectReasoningEffort={handleSelectReasoningEffort}
dialogOpen={approvalActive}
disabled={approvalActive || admissionPayloadLocked}
voiceTarget={hidden ? undefined : voiceTarget}
voiceStatusRevision={voiceStatusRevision}
followupState={followupState}
onAcceptFollowup={onAcceptFollowup}
onDismissFollowup={onDismissFollowup}
onImageIngestionNotice={onImageIngestionNotice}
sessionId={connection.sessionId}
onImagePreview={handleImagePreview}
onAttachmentPreview={handleAttachmentPreview}
atWorkspaceCwd={paneWorkspaceCwd}
placeholderText={t('splitView.composerPlaceholder')}
/>
{CustomComposerFooter && (
<CustomComposerFooter
disabled={approvalActive || admissionPayloadLocked}
isRunning={isResponding || sessionHasActivePrompt}
currentMode={connection.currentMode ?? 'default'}
currentModel={connection.currentModel ?? ''}
sessionName={connection.displayName}
/>
)}
</div>
</div>
</section>
);
}