mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* fix(web-shell): wait for session before checking subscription commands * fix(cli): keep signed-out subscription startup in the composer * fix(subscription): preserve model selection across sign-in * fix(subscription): align sign-in with model selection scope * fix(subscription): apply reasoning defaults across models * fix(subscription): tag Qwen web links by client * fix(subscription): prefix Qwen web client tags * fix(subscription): attribute Qwen web links by paywall scene * fix(subscription): recover historical sessions with saved API models * fix(cli): remove the signed-out subscription startup notice * fix(subscription): support standalone desktop activation and expose errors * fix(subscription): isolate activation and session model selection * fix(subscription): enforce auth policy and restore home login * test(cli): preserve the default initializer mock on restore --------- Co-authored-by: luoyun <[email protected]> Co-authored-by: 易良 <[email protected]>
25764 lines
959 KiB
TypeScript
25764 lines
959 KiB
TypeScript
import { DaemonConnectionBanner } from './components/DaemonConnectionBanner.js';
|
|
import { ProjectHooksApprovalCard } from './components/ProjectHooksApprovalCard.js';
|
|
import { useDaemonAvailability } from './daemon/availability-context.js';
|
|
import { isDaemonUnavailableError } from './daemon/availability.js';
|
|
import {
|
|
beginLocalCommand,
|
|
finishLocalCommand,
|
|
clearPendingLocalCommands,
|
|
} from './utils/message-deletion';
|
|
import {
|
|
createComposerTagForItem,
|
|
fileReferenceInsertText,
|
|
FILE_PROVIDER_ID,
|
|
sanitizeAtMentionItem,
|
|
} from './hooks/useAtMentionSources';
|
|
import {
|
|
isModelSetupCommand,
|
|
resolveModelManagement,
|
|
type WebShellModelManagementOptions,
|
|
} from './modelManagement';
|
|
import type {
|
|
WebShellUnavailableModel,
|
|
WebShellUnavailableModelTooltipMount,
|
|
} from './unavailableModels';
|
|
import './styles/globals.css';
|
|
import {
|
|
useMessageNavigation,
|
|
type WebShellMessageNavigationRequest,
|
|
type WebShellMessageNavigationResult,
|
|
} from './hooks/useMessageNavigation';
|
|
import { ConversationSearch } from './components/ConversationSearch';
|
|
import { useWebShellNavigation } from './navigation';
|
|
import { isCoarsePointerDevice } from './hooks/useIsTouchComposer';
|
|
import { isWebShellPage, type WebShellPage } from './utils/navigationUrl';
|
|
import { getSourceEntries } from './components/sources/sourceEntries';
|
|
import { openSourceEntry } from './components/panels/SourcesSection';
|
|
import {
|
|
isSessionWriterBlockedCode,
|
|
sessionContextKey,
|
|
} from './daemon/session/session-context';
|
|
import { TurnNotificationNavigationContext } from './daemon/session/turn-notification-context';
|
|
import { useBrowserNotificationSettings } from './browser-turn-notifications';
|
|
import {
|
|
parseWebPreviewUrl,
|
|
type WebPreviewState,
|
|
} from './components/preview/web-preview';
|
|
import {
|
|
forwardRef,
|
|
memo,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type CSSProperties,
|
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
type PointerEvent as ReactPointerEvent,
|
|
} from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import {
|
|
useActions,
|
|
useConnection,
|
|
useDaemonFollowupSuggestion,
|
|
useSettings,
|
|
useProviders,
|
|
useSessionNotices,
|
|
useDaemonSessionOwnerGuard,
|
|
useStreamingState,
|
|
useTranscriptHistory,
|
|
useTranscriptStore,
|
|
useWorkspace,
|
|
useWorkspaceActions,
|
|
useWorkspaceEventSignals,
|
|
type DaemonContextMemoryDetail,
|
|
type DaemonSessionActions,
|
|
type DaemonSessionNotice,
|
|
type DaemonSessionOwnerSnapshot,
|
|
type DaemonReasoningControls,
|
|
type DaemonProductSessionContext,
|
|
type DaemonStreamingState,
|
|
} from '@qwen-code/web-shell/daemon-react-sdk';
|
|
import {
|
|
createDaemonTranscriptState,
|
|
findDaemonRewindUserBlock,
|
|
DaemonHttpError,
|
|
getSessionUpdatePayload,
|
|
isDaemonRewindUserBlock,
|
|
isDaemonTurnError,
|
|
isStandaloneSessionNotFoundError,
|
|
isStaleBranchPointError,
|
|
normalizeDaemonEvent,
|
|
reduceDaemonTranscriptEvents,
|
|
STANDALONE_SESSIONS_CAPABILITY,
|
|
} from '@qwen-code/sdk/daemon';
|
|
import type {
|
|
DaemonAgentTrace,
|
|
DaemonEvent,
|
|
DaemonInputAnnotation,
|
|
DaemonSessionAgentTaskStatus,
|
|
DaemonSessionAttachmentReference,
|
|
DaemonSkillToggleMutation,
|
|
DaemonTranscriptBlockChangeSummary,
|
|
DaemonTranscriptBlock,
|
|
DaemonSessionMonitorTaskStatus,
|
|
DaemonSessionShellTaskStatus,
|
|
DaemonSessionTaskWithWorkflowStatus,
|
|
DaemonSessionArtifact,
|
|
DaemonSessionSummary,
|
|
DaemonStandaloneCreationRecovery,
|
|
DaemonStandaloneSessionLookup,
|
|
DaemonWorkspaceCapability,
|
|
DaemonWorkspaceGitStatus,
|
|
DaemonWorkspaceSkillsStatus,
|
|
GoalSnapshotV2,
|
|
ReasoningSelection,
|
|
} from '@qwen-code/sdk/daemon';
|
|
|
|
import { isGoalGateBlocked as isGoalGateBlockedFor } from './utils/goalGate';
|
|
import { cancelledTurnProducedNothing } from './utils/cancelledTurn';
|
|
import { useDaemonPromptSettled } from './daemon/session/DaemonSessionProvider';
|
|
import {
|
|
keepWorkspaceSplitSessionIds,
|
|
type SplitSessionClassification,
|
|
type SplitSessionOutcome,
|
|
} from './utils/standalone-session-routing';
|
|
import { setBoundedMapEntry } from './utils/bounded-map';
|
|
import { type SessionGitIntent } from './components/GitModePopover';
|
|
import { gitModeIntentMustReset } from './utils/gitModeIntent';
|
|
import { LocalControlQrButton } from './components/LocalControlQrButton';
|
|
import {
|
|
SESSION_ARCHIVE_BATCH_SIZE,
|
|
SESSION_LIST_PAGE_SIZE,
|
|
SESSION_LIVE_STATE_FEATURE,
|
|
SESSION_MONITOR_TOOL_CORRELATION_FEATURE,
|
|
SESSION_SIDE_TASK_FEATURE,
|
|
SESSION_TRANSCRIPT_PAGINATION_FEATURE,
|
|
WEB_SHELL_SESSION_SOURCE_TYPE,
|
|
WEB_SHELL_SIDE_TASK_SOURCE_TYPE,
|
|
} from './constants/sessions';
|
|
import { extractPendingPermission } from './adapters/transcriptAdapter';
|
|
import {
|
|
isRetryableTurnErrorKind,
|
|
transcriptBlocksToDaemonMessages,
|
|
} from './adapters/transcriptToMessages';
|
|
import type {
|
|
MessageListProps,
|
|
MessageListHandle,
|
|
} from './components/MessageList';
|
|
import { TranscriptViewport } from './components/TranscriptViewport';
|
|
import { reorderChildrenUnderParents } from './components/messages/agentForest';
|
|
import { SubagentDetailsProvider } from './subagentDetailsContext';
|
|
import { TurnCallsProvider } from './turnCallsContext';
|
|
import { useModelConfigurations } from './hooks/useModelConfigurations';
|
|
import { useModelRefresh } from './hooks/useModelRefresh';
|
|
import { MonitorDetailsProvider } from './monitorDetailsContext';
|
|
import { WorkflowDetailsProvider } from './workflowDetailsContext';
|
|
import { findMonitorTaskForTool } from './utils/monitorTasks';
|
|
import {
|
|
hasActiveBackgroundAgent,
|
|
isComposerTask,
|
|
} from './utils/composerTasks';
|
|
import {
|
|
getTaskActivityKey,
|
|
hasActiveTaskActivity,
|
|
} from './utils/taskActivity';
|
|
import type { VoiceModelOption } from './voice/voiceModels';
|
|
import {
|
|
loadVoiceStatus,
|
|
resolveVoiceWorkspaceTarget,
|
|
setVoiceModelSetting,
|
|
supportsVoiceModelSettings,
|
|
type VoiceStatusRevision,
|
|
} from './voice/voice-workspace-target';
|
|
import { useVoiceWorkspaceSettings } from './voice/use-voice-workspace-settings';
|
|
import {
|
|
useSessionCatalogController,
|
|
useDaemonSessionActivityBridge,
|
|
} from './session-catalog/session-catalog-hooks';
|
|
import {
|
|
loadSessionCatalogOnce,
|
|
peekSessionCatalogDisplayName,
|
|
SESSION_CATALOG_TRAILING_REFRESH_MS,
|
|
} from './session-catalog/session-catalog-store';
|
|
import {
|
|
useContextUsageControls,
|
|
type ContextUsageControls,
|
|
type RegisterContextUsageControls,
|
|
} from './hooks/useContextUsageControls';
|
|
import { useWorkspaceSessionLiveState } from './session-catalog/workspace-session-live-state';
|
|
import { isAbsolutePath } from './components/sidebar/WorkspaceSection';
|
|
import { useLiveVoiceSetup } from './live/useLiveVoiceSetup';
|
|
import { LiveVoiceSettingsCard } from './live/LiveVoiceSettingsCard';
|
|
import {
|
|
ChatEditor,
|
|
type ComposerToolbarAction,
|
|
} from './components/ChatEditor';
|
|
import { getWorkflowModeMessages } from './components/workflow-mode-messages';
|
|
import {
|
|
mapRestoredInputAnnotationsAfterTextChange,
|
|
type ComposerSubmitCommit,
|
|
type ComposerSubmitMetadata,
|
|
type EditorHandle,
|
|
} from './hooks/useComposerCore';
|
|
import type { PromptFile, PromptImage } from './adapters/promptTypes';
|
|
import type { AttachmentPreviewRequest } from './adapters/messageTypes';
|
|
import { StatusBar, type StatusBarHandle } from './components/StatusBar';
|
|
import { GoalStatusStrip } from './components/GoalStatusStrip';
|
|
import { SessionRecoveryBanner } from './components/SessionRecoveryBanner';
|
|
import composerStatusStyles from './components/ComposerStatusStack.module.css';
|
|
import { GoalEditDialog } from './components/dialogs/GoalEditDialog';
|
|
import { StreamingStatus } from './components/StreamingStatus';
|
|
import {
|
|
ToastHost,
|
|
TOAST_REQUEST_EVENT,
|
|
type ToastRequestDetail,
|
|
type ToastTone,
|
|
type WebShellToast,
|
|
} from './components/ToastHost';
|
|
import { TodoPanel } from './components/panels/TodoPanel';
|
|
import {
|
|
EnvironmentPanel,
|
|
isTaskInProgress,
|
|
type EnvironmentAgentTask,
|
|
} from './components/panels/EnvironmentPanel';
|
|
import { ChatContextHeader } from './components/ChatContextHeader';
|
|
import { WelcomeHeader } from './components/WelcomeHeader';
|
|
import {
|
|
nextApprovalMode,
|
|
parsePlanCommand,
|
|
type WebShellApprovalMode,
|
|
} from './utils/planMode';
|
|
import { useApprovalModes } from './hooks/useApprovalModes';
|
|
import { ApprovalModeDialog } from './components/dialogs/ApprovalModeDialog';
|
|
import { ResumeDialog } from './components/dialogs/ResumeDialog';
|
|
import { DialogShell } from './components/dialogs/DialogShell';
|
|
import {
|
|
ModelDialog,
|
|
type ModelDialogModel,
|
|
type ModelDialogMode,
|
|
} from './components/dialogs/ModelDialog';
|
|
import { ModelFallbacksDialog } from './components/dialogs/ModelFallbacksDialog';
|
|
import { ManagedSessionsPage } from './components/managed/ManagedSessionsPage';
|
|
import type {
|
|
ManagedAgentProvider,
|
|
ManagedAgentWorkspace,
|
|
} from './components/managed/managed-agent-provider';
|
|
import type { WorkspaceSelectorOption } from './components/WorkspaceSelector';
|
|
import { DropdownMenuItem } from './components/ui/dropdown-menu';
|
|
import {
|
|
managedSelectionFromUrl,
|
|
saveManagedSelection,
|
|
} from './components/managed/managed-session-storage';
|
|
import { AgentsManagerPage } from './components/agents/AgentsManagerPage';
|
|
import {
|
|
recordedAgentMentionTexts,
|
|
useAgentChatEntry,
|
|
} from './components/workspace-agents/useAgentChatEntry';
|
|
import { createSessionAgentsHttpApi } from './components/workspace-agents/session-agents-api';
|
|
import {
|
|
settledAgentRunKey,
|
|
useSessionAgentRuns,
|
|
} from './components/workspace-agents/use-session-agent-runs';
|
|
import {
|
|
PendingAgentMentions,
|
|
SessionAgentLiveRuns,
|
|
StopAllAgentsButton,
|
|
} from './components/workspace-agents/session-agent-live-runs';
|
|
import { MemoryMessage } from './components/messages/MemoryMessage';
|
|
import { AuthMessage } from './components/messages/AuthMessage';
|
|
import { ToolsDialog } from './components/dialogs/ToolsDialog';
|
|
import { GitDialog, type GitDialogView } from './components/dialogs/GitDialog';
|
|
import {
|
|
BranchSessionDialog,
|
|
type BranchSessionIsolation,
|
|
} from './components/dialogs/BranchSessionDialog';
|
|
import { SkillsManagerPage } from './components/skills/SkillsManagerPage';
|
|
import {
|
|
DaemonConnectionsSettings,
|
|
DaemonStatusDialog,
|
|
} from './components/dialogs/DaemonStatusDialog';
|
|
import { SessionOverviewPanel } from './components/SessionOverviewPanel';
|
|
import { createTrajectoryPageLoader } from './trajectory/transcriptPageLoader';
|
|
import { WorkspacesOverviewPanel } from './components/workspaces/WorkspacesOverviewPanel';
|
|
import { WorkspaceLocation } from './components/workspaces/WorkspaceLocation';
|
|
import {
|
|
useDaemonTargetOptional,
|
|
useInterceptHostLinks,
|
|
} from './config/daemon-target';
|
|
import { useFanoutOrigins, useHostFanout } from './config/host-fanout';
|
|
import { SplitView } from './components/SplitView';
|
|
import { ChevronLeftIcon, GaugeIcon, LayersIcon } from 'lucide-react';
|
|
import type { PaneHeaderActionsRenderer } from './components/ChatPane';
|
|
import {
|
|
ArtifactPanel,
|
|
type ArtifactPanelTab,
|
|
type ImageTabSource,
|
|
type SideTaskListItem,
|
|
} from './components/artifacts/ArtifactPanel';
|
|
import {
|
|
releaseDetachedWebTerminal,
|
|
releaseWebTerminal,
|
|
} from './components/terminal/TerminalPanel';
|
|
import { Drawer, DrawerContent, DrawerTitle } from './components/ui/drawer';
|
|
import type {
|
|
TurnOutputFileChange,
|
|
ArtifactFilter,
|
|
TurnOutputKind,
|
|
TurnOutputOpenRequest,
|
|
TurnOutputScheduledTask,
|
|
} from './components/artifacts/TurnOutputs';
|
|
import {
|
|
displayPath,
|
|
getFileChangePreviewContent,
|
|
TURN_OUTPUT_KINDS,
|
|
} from './components/artifacts/TurnOutputs';
|
|
import {
|
|
resolveArtifactWorkspaceOwner,
|
|
useArtifactWorkspaceTarget,
|
|
} from './components/artifacts/useArtifactWorkspaceTarget';
|
|
import {
|
|
readContextInstructionFile,
|
|
type ContextInstructionFileContent,
|
|
} from './components/artifacts/readContextInstructionFile';
|
|
import {
|
|
getArtifactsByTurn,
|
|
getFileChangesByTurn,
|
|
getScheduledTasksByTurn,
|
|
} from './components/artifacts/turnOutputSelectors';
|
|
import { artifactDisplayName } from './components/artifacts/artifactUtils';
|
|
import { useIsLargeScreen } from './hooks/useIsLargeScreen';
|
|
import { usePrefersReducedMotion } from './hooks/usePrefersReducedMotion';
|
|
import {
|
|
clearSplitSessions,
|
|
loadSplitSessions,
|
|
MAX_SPLIT_PANES,
|
|
parseSplitSessionIds,
|
|
saveSplitSessions,
|
|
} from './utils/splitUrl';
|
|
import { ScheduledTasksDialog } from './components/dialogs/ScheduledTasksDialog';
|
|
import { GoalsDialog } from './components/dialogs/GoalsDialog';
|
|
import { AutofixPrWatchesDialog } from './components/dialogs/AutofixPrWatchesDialog';
|
|
import { WorkflowRunsPage } from './components/workflows/WorkflowRunsPage';
|
|
import { parseWebShellGoalCommand } from './utils/goalCondition';
|
|
import { buildGoalControlRequest } from './utils/goalControlRequest';
|
|
import { ExtensionsManagerPage } from './components/extensions/ExtensionsManagerPage';
|
|
import { PluginManagerPage } from './components/plugins/PluginManagerPage';
|
|
import { ChannelsManagerPage } from './components/channels/ChannelsManagerPage';
|
|
import { ChannelEventsPage } from './components/channels/ChannelEventsPage';
|
|
import { ShadowDomBoundary } from './components/ShadowDomBoundary';
|
|
import {
|
|
McpAppHostContext,
|
|
McpAppMessagesContext,
|
|
McpAppModelContextContext,
|
|
GENERATED_UI_CONTEXT_TAG_KIND,
|
|
type McpAppModelContext,
|
|
McpAppVisibilityContext,
|
|
} from './mcpAppHostContext';
|
|
import { GeneratedUiRuntimeProvider } from './generated-ui/GeneratedUiRuntimeProvider';
|
|
import {
|
|
useMcpAppMessages,
|
|
type McpAppPromptSubmission,
|
|
} from './hooks/useMcpAppMessages';
|
|
import {
|
|
isItemVisible,
|
|
isSettingVisible,
|
|
type WebShellSettingsOptions,
|
|
} from './settings';
|
|
import { SettingsMessage } from './components/messages/SettingsMessage';
|
|
import { WorkspaceDirectoriesSection } from './components/workspaces/WorkspaceDirectoriesSection';
|
|
import { isAskUserPermission } from './utils/askUserPermission';
|
|
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 { AddWorkspaceDialog } from './components/dialogs/AddWorkspaceDialog';
|
|
import { WorkspaceExecutionSelect } from './components/workspaces/WorkspaceExecutionSelect';
|
|
import { WorkspaceAddStatusDialog } from './components/dialogs/WorkspaceAddStatusDialog';
|
|
import { StandaloneContext } from './config/standalone';
|
|
import {
|
|
WorkspaceHostsEnabled,
|
|
rememberWorkspaceHost,
|
|
} from './config/workspace-hosts';
|
|
import {
|
|
addWorkspaceToDaemon,
|
|
clearRemoteWorkspaceAddStep,
|
|
completeRemoteWorkspaceAdd,
|
|
discardAbandonedRemoteWorkspaceAdd,
|
|
fetchRemotePathSuggestions,
|
|
isRemoteWorkspaceAddActive,
|
|
leaveRemoteWorkspaceAdd,
|
|
} from './config/remote-workspace-add';
|
|
import {
|
|
clearInitialConnectionsSettingsCategory,
|
|
formatOriginHost,
|
|
getInitialConnectionsSettingsCategory,
|
|
listRemoteComputers,
|
|
} from './config/remote-connections';
|
|
import {
|
|
getDaemonToken,
|
|
isPageOriginDaemon,
|
|
navigateToDaemon,
|
|
} from './config/daemon';
|
|
import { Button } from './components/ui/button';
|
|
import { isDesktopShell } from './utils/externalOpen';
|
|
import {
|
|
isPluginShadowPanel,
|
|
installWebShellShadowStyles,
|
|
resolveWebShellShadowDom,
|
|
type WebShellShadowDom,
|
|
} from './shadowDom';
|
|
import {
|
|
WebShellSidebar,
|
|
DEFAULT_SESSION_ACTION_ITEMS,
|
|
SIDEBAR_RAIL_WIDTH,
|
|
type WebShellSidebarBranding,
|
|
type WebShellSidebarFooterOptions,
|
|
type WebShellSidebarWorkspaceOverviewOptions,
|
|
type WebShellSidebarLockedWorkspace,
|
|
type WebShellSidebarPrimaryNavOptions,
|
|
type WebShellSidebarSessionActionsOptions,
|
|
} from './components/sidebar/WebShellSidebar';
|
|
import { isSidebarToggleShortcut } from './components/sidebar/sidebarToggleShortcut';
|
|
import {
|
|
isAgentCollaborationEnabledForWorkspace,
|
|
workspaceLabel,
|
|
disambiguateWorkspaceLabels,
|
|
workspaceLabelForCwd,
|
|
} from './utils/workspace';
|
|
import { loadReadyWorkspaceSkills } from './daemon/workspace/load-ready-skills';
|
|
import {
|
|
getLocalCommands,
|
|
localizeBuiltinDescriptions,
|
|
skillDescriptionKey,
|
|
} from './constants/localCommands';
|
|
import { mergeCommands } from './hooks/daemonSessionMappers';
|
|
import { useAnimationFrameTranscriptSnapshot } from './hooks/useAnimationFrameTranscriptBlocks';
|
|
import { useBackgroundTasks } from './hooks/useBackgroundTasks';
|
|
import { getSubagentDetailsUnavailableReason } from './components/messages/toolFormatting';
|
|
import { isSessionDisconnectedError } from './utils/sessionErrors';
|
|
import {
|
|
projectStreamingTailMessages,
|
|
useMessagesFromBlocks,
|
|
} from './hooks/useMessages';
|
|
import { useSessionSources } from './hooks/useSessionSources';
|
|
import { useContextCompressionReconcile } from './hooks/useContextCompressionReconcile';
|
|
import { useSubscriptionLogout } from './hooks/useSubscriptionLogout';
|
|
import type { SessionSource } from '@qwen-code/sdk/daemon';
|
|
import { useSessionArtifacts } from './hooks/useSessionArtifacts';
|
|
import { useSessionArtifactsChange } from './hooks/useSessionArtifactsChange';
|
|
import { useShallowMemo, useStableArray } from './hooks/useShallowMemo';
|
|
import {
|
|
I18nProvider,
|
|
getTranslator,
|
|
languageSettingToWebShellLanguage,
|
|
languageLabel,
|
|
normalizeLanguage,
|
|
WEB_SHELL_LANGUAGES,
|
|
type WebShellLanguage,
|
|
} from './i18n';
|
|
import { CapacityRecoveryDialog } from './components/workspaces/CapacityRecoveryDialog';
|
|
import { useCapacityRecovery } from './hooks/useCapacityRecovery';
|
|
import { isAcpChildCapacityError } from './daemon/session/httpErrors.js';
|
|
import {
|
|
copyFromLastAssistantMessage,
|
|
COPY_MESSAGES,
|
|
} from './utils/copyCommand';
|
|
import { getShadowAwareActiveElement, isEditableTarget } from './utils/dom';
|
|
import {
|
|
invokeSlashCommandHandler,
|
|
SLASH_COMMAND_PATTERN,
|
|
} from './utils/slash-command-action';
|
|
import { getModelDisplayName } from './utils/modelDisplay';
|
|
import {
|
|
isStandaloneModelPickerUnavailable,
|
|
isVisibleComposerModel,
|
|
} from './utils/composerModels';
|
|
import { filterModelSwitchMessages } from './utils/modelSwitchMessages';
|
|
import { decideEscapeIntent } from './utils/escapeIntent';
|
|
import type { SkillInfo } from './completions/slashCompletion';
|
|
import { collectSystemInfo } from './utils/systemInfo';
|
|
import {
|
|
decodeVisionModelForPicker,
|
|
encodeVisionModelForSetting,
|
|
extractBareModelId,
|
|
resolveFastModelForPicker,
|
|
} from './utils/modelEncoding';
|
|
import { appendOrDeferLocalUserMessage } from './utils/localCommandQueue';
|
|
import { QueuedPromptDisplay } from './components/QueuedPromptDisplay';
|
|
import { useQueuedPrompts } from './hooks/useQueuedPrompts';
|
|
import { useNewSessionSuggestion } from './hooks/useNewSessionSuggestion';
|
|
import {
|
|
shellTaskPanelTitle,
|
|
TasksStatusMessage,
|
|
type SerializedTasksMessage,
|
|
} from './components/messages/TasksStatusMessage';
|
|
import { SessionWorkflowCockpit } from './components/workflow/SessionWorkflowCockpit';
|
|
import { buildSessionWorkflowProjection } from './components/workflow/session-workflow-model';
|
|
import { createContextUsageMessageData } from './components/messages/ContextUsageMessage';
|
|
import {
|
|
createStatsMessageData,
|
|
type StatsView,
|
|
} from './components/messages/StatsMessage';
|
|
import {
|
|
serializeStatusMessage,
|
|
type StatusInfo,
|
|
} from './components/messages/StatusMessage';
|
|
import type { SerializedMcpStatusMessage } from './components/messages/McpStatusMessage';
|
|
import { McpManagerPage } from './components/mcp/McpManagerPage';
|
|
import { ManagedMcpManagerPage } from './components/managed/ManagedMcpManagerPage';
|
|
import { BtwMessage } from './components/messages/BtwMessage';
|
|
import {
|
|
createAndAttachSessionForPrompt,
|
|
isDaemonApprovalMode,
|
|
} from './utils/sessionPreparation';
|
|
import { titleFromPrompt } from './utils/sessionTitleFromPrompt';
|
|
import {
|
|
getComposerPlaceholderKey,
|
|
getComposerPlaceholderState,
|
|
shouldBlockComposerSubmit,
|
|
shouldDisableComposerInput,
|
|
type ComposerPlaceholderState,
|
|
} from './utils/composerInputState';
|
|
import {
|
|
isDefinitelyRejectedPromptAdmission,
|
|
isMcpAppPromptAdmissionRejected,
|
|
PromptDispatchCancelledError,
|
|
} from './utils/promptAdmission';
|
|
import { base64ToBlob } from './utils/base64';
|
|
import type {
|
|
ACPToolCall,
|
|
CommandInfo,
|
|
Message,
|
|
PermissionRequest,
|
|
} from './adapters/types';
|
|
import { isBackgroundSubAgentToolCall } from './adapters/toolClassification';
|
|
import {
|
|
computeTodoDetails,
|
|
computeTodoTimeline,
|
|
getAgentToolsForPlan,
|
|
getFloatingTodos,
|
|
getSessionWorkflowTodos,
|
|
getActiveTodosForPlanRevision,
|
|
isExitPlanApprovalRequest,
|
|
todoDetailSignature,
|
|
todoTimelineSignature,
|
|
type FloatingTodosState,
|
|
type TodoDetail,
|
|
type TodoSnapshotDiff,
|
|
} from './utils/todos';
|
|
import { ThemeProvider } from './themeContext';
|
|
import {
|
|
BrandProvider,
|
|
EMPTY_BRAND,
|
|
type WebShellBrand,
|
|
type WebShellResolvedBrand,
|
|
} from './brandContext';
|
|
import { InteractionBlockContext } from './interactionBlockContext';
|
|
import {
|
|
WebShellThemeId,
|
|
THEME_SETTING_KEY,
|
|
LANGUAGE_SETTING_KEY,
|
|
themeSettingToWebShellTheme,
|
|
webShellThemeToSettingValue,
|
|
type WebShellTheme,
|
|
} from './themeContext';
|
|
import {
|
|
WebShellCustomizationProvider,
|
|
type WebShellCustomization,
|
|
type WebShellWorkspaceDirectoryPicker,
|
|
type WebShellAssistantFeedbackOptions,
|
|
type WebShellComposerApi,
|
|
type WebShellComposerInput,
|
|
type WebShellMarkdownCustomization,
|
|
type WebShellSource,
|
|
type WebShellSourceIconResolver,
|
|
type WebShellSourceReference,
|
|
type ToolHeaderExtraRenderer,
|
|
type UserMessageContentRenderer,
|
|
type UserMessageContentParser,
|
|
type AssistantTurnFooterRenderer,
|
|
type WebShellArtifactCustomization,
|
|
type WelcomeHeaderRenderer,
|
|
type WelcomeFooterRenderer,
|
|
type ComposerToolbarStartRenderer,
|
|
type ComposerToolbarEndRenderer,
|
|
type ComposerToolbarRightRenderer,
|
|
type ComposerHeaderRenderer,
|
|
type ComposerFooterRenderer,
|
|
type ChatHeaderRenderer,
|
|
type WebShellChatHeaderItem,
|
|
type WebShellChatHeaderOptions,
|
|
type WebShellRightPanelItem,
|
|
type WebShellRightPanelOptions,
|
|
type WebShellEnvironmentPanelItem,
|
|
type WebShellEnvironmentPanelOptions,
|
|
type FooterRenderer,
|
|
type LoadingPhrasesResolver,
|
|
type MarkdownTableMode,
|
|
type WebShellTaskInfo,
|
|
type WebShellAtProvider,
|
|
type WebShellBuiltinAtProvidersConfig,
|
|
type ComposerTagClickHandler,
|
|
type ComposerTagRenderer,
|
|
type WebShellComposerTagIconMap,
|
|
type WebShellBottomStatusItem,
|
|
type WebShellPreparedSubmit,
|
|
type WebShellSubmitSnapshot,
|
|
type WebShellAssistantTurnSettledEvent,
|
|
type WebShellSessionArtifactsChange,
|
|
} from './customization';
|
|
import { useAssistantTurnSettlementProjection } from './assistant-turn-settlement';
|
|
import type { CommandDisplayCategoryOrder } from './utils/commandDisplay';
|
|
import { WebShellPortalRootContext } from './portalRoot';
|
|
import { CompactModeContext, TodoContextsProvider } from './WebShellContexts';
|
|
import styles from './App.module.css';
|
|
|
|
const MAX_TOASTS = 4;
|
|
const TOAST_AUTO_DISMISS_MS = 5000;
|
|
const DEFAULT_REVIEW_PANEL_WIDTH = 500;
|
|
const MIN_ARTIFACT_PANEL_WIDTH = 320;
|
|
const MIN_CHAT_PANE_WIDTH_WITH_ARTIFACT_PANEL = 500;
|
|
const SUBAGENT_PANEL_ANIMATION_FALLBACK_MS = 700;
|
|
const MIN_DOCKED_MESSAGE_AREA_WIDTH = 800;
|
|
const DOCKED_ENVIRONMENT_PANEL_WIDTH = 332;
|
|
|
|
function isWebTerminalTarget(event: Event): boolean {
|
|
const target = event.composedPath()[0] ?? event.target;
|
|
return (
|
|
target instanceof Element && target.closest('[data-web-terminal]') !== null
|
|
);
|
|
}
|
|
|
|
// Open content of the shell's modal primitives (ui/dialog, ui/alert-dialog,
|
|
// ui/drawer). Matching the primitives rather than ARIA roles skips non-modal
|
|
// popovers that carry role="dialog" (session/workspace details tooltips) and
|
|
// the inline tool-approval card's role="alertdialog". Every ui/drawer in the
|
|
// shell is modal: DrawerContent always mounts its overlay, both for the
|
|
// floating artifact panel (narrow viewport or split view, fullscreen or not)
|
|
// and for the composer's mobile add menu.
|
|
const OPEN_MODAL_SURFACE_SELECTOR =
|
|
'[data-slot="dialog-content"][data-state="open"], ' +
|
|
'[data-slot="alert-dialog-content"][data-state="open"], ' +
|
|
'[data-slot="drawer-content"][data-state="open"]';
|
|
|
|
// Modal confirms owned by a component (the Session Overview archive/delete
|
|
// AlertDialog) are not in the shell's own dialog state. They portal into
|
|
// the shell portal root, which a shadowDom `portals` embedding places inside
|
|
// a shadow root that document.querySelector cannot see, so query it too.
|
|
function hasOpenModalSurface(portalRoot: HTMLElement | null): boolean {
|
|
return (
|
|
document.querySelector(OPEN_MODAL_SURFACE_SELECTOR) !== null ||
|
|
(portalRoot?.querySelector(OPEN_MODAL_SURFACE_SELECTOR) ?? null) !== null
|
|
);
|
|
}
|
|
|
|
// The docked fullscreen surface contains Tab itself instead of going through
|
|
// Radix FocusScope: FocusScope registers every mounted scope in a
|
|
// module-global stack and pauses the current head even with trapped={false},
|
|
// so a docked panel mounting under an open DialogShell would pause the
|
|
// modal's trap. The surface covers the viewport, so the keyboard is the only
|
|
// escape route; wrap it at the tabbable edges like FocusScope's loop does.
|
|
function getArtifactPanelTabStops(
|
|
container: HTMLElement | ShadowRoot,
|
|
): HTMLElement[] {
|
|
const candidates: HTMLElement[] = [];
|
|
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
|
|
acceptNode(node) {
|
|
const element = node as HTMLElement;
|
|
if (element.hidden) return NodeFilter.FILTER_SKIP;
|
|
if (element instanceof HTMLInputElement && element.type === 'hidden') {
|
|
return NodeFilter.FILTER_SKIP;
|
|
}
|
|
if (
|
|
(element instanceof HTMLButtonElement ||
|
|
element instanceof HTMLInputElement ||
|
|
element instanceof HTMLSelectElement ||
|
|
element instanceof HTMLTextAreaElement) &&
|
|
element.disabled
|
|
) {
|
|
return NodeFilter.FILTER_SKIP;
|
|
}
|
|
return element.tabIndex >= 0 || element.shadowRoot
|
|
? NodeFilter.FILTER_ACCEPT
|
|
: NodeFilter.FILTER_SKIP;
|
|
},
|
|
});
|
|
while (walker.nextNode()) {
|
|
const element = walker.currentNode as HTMLElement;
|
|
if (element.tabIndex >= 0) candidates.push(element);
|
|
if (element.shadowRoot) {
|
|
candidates.push(...getArtifactPanelTabStops(element.shadowRoot));
|
|
}
|
|
}
|
|
return candidates.filter((element) =>
|
|
typeof element.checkVisibility === 'function'
|
|
? element.checkVisibility({ checkVisibilityCSS: true })
|
|
: true,
|
|
);
|
|
}
|
|
const DEFAULT_COMPOSER_TOOLBAR_ACTIONS = [
|
|
'addMenu',
|
|
'approvalMode',
|
|
'workflowMode',
|
|
'contextUsage',
|
|
'model',
|
|
'widthMode',
|
|
'voice',
|
|
'workspace',
|
|
] as const satisfies readonly ComposerToolbarAction[];
|
|
const DEFAULT_EMPTY_COMPOSER_TOOLBAR_ACTIONS = [
|
|
...DEFAULT_COMPOSER_TOOLBAR_ACTIONS,
|
|
'gitBranch',
|
|
] as const satisfies readonly ComposerToolbarAction[];
|
|
const MAX_ARTIFACT_PANEL_SESSION_STATES = 20;
|
|
// Malformed pagination stops at 250k events; add a targeted daemon lookup if
|
|
// real sessions approach this ceiling.
|
|
const MAX_ARTIFACT_PANEL_TRANSCRIPT_PAGES = 1_000;
|
|
interface ArtifactPanelSessionState {
|
|
open: boolean;
|
|
tabs: ArtifactPanelTab[];
|
|
activeTabId: string | null;
|
|
reviewChanges: readonly TurnOutputFileChange[];
|
|
selectedReviewPath: string | null;
|
|
extraArtifacts: DaemonSessionArtifact[];
|
|
width: number;
|
|
pendingRestore?: boolean;
|
|
}
|
|
interface PaneArtifactSnapshot {
|
|
artifacts: readonly DaemonSessionArtifact[];
|
|
}
|
|
const BOUND_RUN_SWITCH_TIMEOUT_MS = 30_000;
|
|
|
|
function availableSkillInfos(status: {
|
|
skills?: Array<{
|
|
status?: string;
|
|
name: string;
|
|
description?: string;
|
|
argumentHint?: string;
|
|
}>;
|
|
}): SkillInfo[] {
|
|
return (status.skills ?? [])
|
|
.filter((skill) => skill.status === 'ok')
|
|
.map((skill) => ({
|
|
name: skill.name,
|
|
description: skill.description ?? '',
|
|
...(skill.argumentHint ? { argumentHint: skill.argumentHint } : {}),
|
|
}))
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
function availableSessionSkillInfos(
|
|
skills: readonly string[],
|
|
commands: readonly {
|
|
name: string;
|
|
description: string;
|
|
argumentHint?: string;
|
|
}[],
|
|
): SkillInfo[] {
|
|
const commandsByName = new Map(
|
|
commands.map((command) => [command.name.toLowerCase(), command]),
|
|
);
|
|
return 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));
|
|
}
|
|
|
|
function sessionSkillsReflectToggle(
|
|
sessionSkills: readonly string[] | undefined,
|
|
mutationSkills: ReadonlyArray<{ name: string; enabled: boolean }>,
|
|
): boolean {
|
|
if (sessionSkills === undefined) {
|
|
return false;
|
|
}
|
|
const enabledNames = new Set(sessionSkills.map((name) => name.toLowerCase()));
|
|
return mutationSkills.every((skill) => {
|
|
const enabled = enabledNames.has(skill.name.toLowerCase());
|
|
return skill.enabled === enabled;
|
|
});
|
|
}
|
|
|
|
function skillMutationsForWorkspace(
|
|
lastSkillMutation: DaemonSkillToggleMutation | undefined,
|
|
skillMutationsByCwd: Record<string, DaemonSkillToggleMutation[]> | undefined,
|
|
workspaceCwd?: string,
|
|
): readonly DaemonSkillToggleMutation[] {
|
|
if (skillMutationsByCwd) {
|
|
return workspaceCwd ? (skillMutationsByCwd[workspaceCwd] ?? []) : [];
|
|
}
|
|
return lastSkillMutation ? [lastSkillMutation] : [];
|
|
}
|
|
|
|
function mergeSkillToggles(
|
|
current: ReadonlyArray<{ name: string; enabled: boolean }>,
|
|
incoming: ReadonlyArray<{ name: string; enabled: boolean }>,
|
|
): Array<{ name: string; enabled: boolean }> {
|
|
const byName = new Map(
|
|
current.map((skill) => [skill.name.toLowerCase(), skill] as const),
|
|
);
|
|
for (const skill of incoming) {
|
|
byName.set(skill.name.toLowerCase(), skill);
|
|
}
|
|
return [...byName.values()];
|
|
}
|
|
|
|
const HIDE_TIPS_SETTING_KEY = 'ui.hideTips';
|
|
|
|
/** Maps each ModelDialogMode to its i18n title key — single source of truth. */
|
|
const MODE_TITLE_KEY: Record<ModelDialogMode, string> = {
|
|
main: 'model.select',
|
|
fast: 'model.setFast',
|
|
voice: 'model.setVoice',
|
|
advisor: 'model.setAdvisor',
|
|
image: 'model.setImage',
|
|
vision: 'model.setVision',
|
|
};
|
|
|
|
function normalizeHiddenCommand(command: string): string {
|
|
return command.trim().replace(/^\/+/, '').toLowerCase();
|
|
}
|
|
|
|
function resolvePreparedSubmit(
|
|
prompt: string,
|
|
inputAnnotations: readonly DaemonInputAnnotation[],
|
|
prepared: void | WebShellPreparedSubmit,
|
|
): WebShellPreparedSubmit {
|
|
if (!prepared || typeof prepared.prompt !== 'string') {
|
|
return { prompt, inputAnnotations };
|
|
}
|
|
return {
|
|
prompt: prepared.prompt,
|
|
inputAnnotations: prepared.inputAnnotations ?? inputAnnotations,
|
|
};
|
|
}
|
|
|
|
interface SendPromptOptionsWithRetry {
|
|
submittedPrompt?: string;
|
|
source?: 'mcp-app';
|
|
optimisticUserMessage?: boolean;
|
|
bindBlockId?: string;
|
|
images?: PromptImage[];
|
|
files?: PromptFile[];
|
|
inputAnnotations?: DaemonInputAnnotation[];
|
|
retry?: boolean;
|
|
beforeDispatch?: (clientId: string | undefined) => void;
|
|
onAdmissionStarted?: () => void;
|
|
clearComposerOnPromptStart?: boolean;
|
|
commitComposerAccepted?: ComposerSubmitCommit;
|
|
onAdmitted?: (admission: { promptId: string }) => void;
|
|
}
|
|
|
|
interface OptimisticUserMessage {
|
|
sessionId: string;
|
|
messageId: string;
|
|
identity: TranscriptUserMessageIdentity;
|
|
previousIdentity?: TranscriptUserMessageIdentity;
|
|
owner: CancelledRetryOwner;
|
|
}
|
|
|
|
interface FailedPrompt {
|
|
sessionId: string;
|
|
messageId: string;
|
|
identity: TranscriptUserMessageIdentity;
|
|
previousIdentity?: TranscriptUserMessageIdentity;
|
|
text: string;
|
|
images?: PromptImage[];
|
|
files?: PromptFile[];
|
|
inputAnnotations?: DaemonInputAnnotation[];
|
|
owner: CancelledRetryOwner;
|
|
}
|
|
|
|
interface TranscriptUserMessageIdentity {
|
|
block: DaemonTranscriptBlock;
|
|
}
|
|
|
|
interface InFlightPrompt {
|
|
block: DaemonTranscriptBlock;
|
|
text: string;
|
|
images?: PromptImage[];
|
|
files?: PromptFile[];
|
|
inputAnnotations?: DaemonInputAnnotation[];
|
|
/** The daemon's id for the prompt, known once it is admitted. */
|
|
promptId?: string;
|
|
}
|
|
|
|
/**
|
|
* A prompt this tab cancelled and may take back once the daemon reports the
|
|
* turn as ended. Nothing is decided before that: output the turn produced
|
|
* before the cancel landed can still be on its way when the cancel returns.
|
|
* The turn is recognised by the prompt's id, or — when the cancel cut the
|
|
* admission response short — as the next turn of this client's to settle.
|
|
*/
|
|
interface CancelledPromptTakeBack {
|
|
prompt: InFlightPrompt;
|
|
promptId: string | undefined;
|
|
clientId: string | undefined;
|
|
sessionId: string | undefined;
|
|
owner: DaemonSessionOwnerSnapshot;
|
|
historyComplete: boolean;
|
|
requestedAt: number;
|
|
}
|
|
|
|
function settlesCancelledPrompt(
|
|
event: { promptId: string; originatorClientId?: string },
|
|
takeBack: CancelledPromptTakeBack,
|
|
): boolean {
|
|
if (takeBack.promptId !== undefined) {
|
|
return event.promptId === takeBack.promptId;
|
|
}
|
|
return (
|
|
takeBack.clientId !== undefined &&
|
|
event.originatorClientId === takeBack.clientId
|
|
);
|
|
}
|
|
|
|
/** Longest a cancelled turn may take to settle before its take-back lapses. */
|
|
const CANCELLED_TURN_SETTLE_TIMEOUT_MS = 10_000;
|
|
|
|
interface TranscriptTurnErrorIdentity {
|
|
block: DaemonTranscriptBlock;
|
|
}
|
|
|
|
interface FailedPromptRetry {
|
|
sessionId: string;
|
|
messageId: string;
|
|
startedAt: number;
|
|
admitted: boolean;
|
|
settled: boolean;
|
|
owner: CancelledRetryOwner;
|
|
transcriptIdentity:
|
|
| { kind: 'failed-prompt'; identity: TranscriptUserMessageIdentity }
|
|
| { kind: 'turn-error'; identity: TranscriptTurnErrorIdentity };
|
|
}
|
|
|
|
type CancelledRetryState =
|
|
| {
|
|
kind: 'failed-prompt';
|
|
attemptId: number;
|
|
failed: FailedPrompt;
|
|
}
|
|
| {
|
|
kind: 'turn-error';
|
|
attemptId: number;
|
|
errorId: string;
|
|
identity: TranscriptTurnErrorIdentity;
|
|
text: string;
|
|
images?: PromptImage[];
|
|
files?: PromptFile[];
|
|
inputAnnotations?: DaemonInputAnnotation[];
|
|
previousRetriedTurnErrorId: string | null;
|
|
previousShowRetryHint: boolean;
|
|
};
|
|
|
|
type CancelledRetryRestoreResult = 'restored' | 'pending' | 'invalid';
|
|
|
|
interface CancelledRetryOwner {
|
|
sessionId?: string;
|
|
workspaceCwd?: string;
|
|
sessionKey?: string;
|
|
sourceVersion: number;
|
|
snapshot: DaemonSessionOwnerSnapshot;
|
|
}
|
|
|
|
function retryOwnerMatchesCurrent(
|
|
owner: CancelledRetryOwner,
|
|
sessionId: string | undefined,
|
|
workspaceCwd: string | undefined,
|
|
sourceVersion: number,
|
|
): boolean {
|
|
const workspaceMatches =
|
|
(owner.workspaceCwd !== undefined && owner.workspaceCwd === workspaceCwd) ||
|
|
owner.snapshot.isCurrent();
|
|
return (
|
|
owner.sessionId === sessionId &&
|
|
owner.sourceVersion === sourceVersion &&
|
|
workspaceMatches
|
|
);
|
|
}
|
|
|
|
interface CancelledRetryEntry {
|
|
owner?: CancelledRetryOwner;
|
|
state: CancelledRetryState;
|
|
}
|
|
|
|
function mergeCancelledRetryEntries(
|
|
current: readonly CancelledRetryEntry[],
|
|
incoming: readonly CancelledRetryEntry[],
|
|
): CancelledRetryEntry[] {
|
|
return incoming.reduce<CancelledRetryEntry[]>(
|
|
(merged, candidate) => {
|
|
const existing = merged.find(
|
|
(entry) => entry.state.kind === candidate.state.kind,
|
|
);
|
|
if (existing && existing.state.attemptId >= candidate.state.attemptId) {
|
|
return merged;
|
|
}
|
|
return [
|
|
...merged.filter((entry) => entry.state.kind !== candidate.state.kind),
|
|
candidate,
|
|
];
|
|
},
|
|
[...current],
|
|
);
|
|
}
|
|
|
|
interface UnknownPromptAdmission {
|
|
sessionId: string;
|
|
messageId?: string;
|
|
text?: string;
|
|
images?: PromptImage[];
|
|
files?: PromptFile[];
|
|
inputAnnotations?: DaemonInputAnnotation[];
|
|
payloadAvailable: boolean;
|
|
}
|
|
|
|
function getLatestUserBlockId(
|
|
blocks: readonly DaemonTranscriptBlock[],
|
|
): string | undefined {
|
|
return getLatestUserBlock(blocks)?.id;
|
|
}
|
|
|
|
function getLatestUserBlock(
|
|
blocks: readonly DaemonTranscriptBlock[],
|
|
): DaemonTranscriptBlock | undefined {
|
|
for (let index = blocks.length - 1; index >= 0; index -= 1) {
|
|
const block = blocks[index];
|
|
if (isDaemonRewindUserBlock(block)) {
|
|
return block;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Conversational user turns, i.e. the blocks a rewind indexes against.
|
|
* Background notifications are injected as user blocks but are not turns.
|
|
*/
|
|
function countUserTurns(blocks: readonly DaemonTranscriptBlock[]): number {
|
|
let count = 0;
|
|
for (const block of blocks) {
|
|
if (isDaemonRewindUserBlock(block)) {
|
|
count += 1;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
/** Longest a resend waits for the rewind to land in the transcript. */
|
|
const REWIND_APPLIED_TIMEOUT_MS = 2000;
|
|
|
|
/** Wait for the rewind event before adding any new optimistic message. */
|
|
function waitForRewindApplied(
|
|
getBlocks: () => readonly DaemonTranscriptBlock[],
|
|
targetTurnIndex: number,
|
|
isCurrent: () => boolean,
|
|
): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
const deadline = Date.now() + REWIND_APPLIED_TIMEOUT_MS;
|
|
const poll = () => {
|
|
if (!isCurrent()) {
|
|
resolve(false);
|
|
} else if (countUserTurns(getBlocks()) <= targetTurnIndex) {
|
|
resolve(true);
|
|
} else if (Date.now() >= deadline) {
|
|
resolve(false);
|
|
} else {
|
|
setTimeout(poll, 16);
|
|
}
|
|
};
|
|
poll();
|
|
});
|
|
}
|
|
|
|
/** Pause before asking the daemon again whether a rewind landed. */
|
|
const REWIND_OUTCOME_RECHECK_MS = 500;
|
|
/** Longest pause between attempts to reach the daemon about it. */
|
|
const REWIND_OUTCOME_RETRY_MAX_MS = 2_000;
|
|
|
|
/**
|
|
* Whether a rewind the tab stopped waiting for missed the daemon. The daemon
|
|
* cannot take back a rewind it has admitted, so only it can tell: it lists
|
|
* a turn's snapshot while the turn is in history, never reuses a snapshot
|
|
* id, and answers the listing only after every rewind admitted before it
|
|
* has run. The target still listed on two readings — the first can be
|
|
* answered while the daemon is still reading the rewind's own request —
|
|
* means no rewind happened. Keeps asking while the daemon cannot be
|
|
* reached. Resolves `false` once the daemon shows the rewind landed (the
|
|
* transcript then lifts the hold) or `stillHeld` says the hold is gone.
|
|
*/
|
|
async function rewindMissed(
|
|
target: string,
|
|
listSnapshots: () => Promise<{
|
|
snapshots: ReadonlyArray<{ promptId: string }>;
|
|
}>,
|
|
stillHeld: () => boolean,
|
|
): Promise<boolean> {
|
|
let listedBefore = false;
|
|
let failures = 0;
|
|
for (;;) {
|
|
if (!stillHeld()) return false;
|
|
let listed: boolean | undefined;
|
|
try {
|
|
const { snapshots } = await listSnapshots();
|
|
listed = snapshots.some((entry) => entry.promptId === target);
|
|
} catch {
|
|
failures += 1;
|
|
}
|
|
if (listed === false) return false;
|
|
if (listed && listedBefore) return true;
|
|
listedBefore = listed === true;
|
|
const pause = listed
|
|
? REWIND_OUTCOME_RECHECK_MS
|
|
: Math.min(
|
|
REWIND_OUTCOME_RETRY_MAX_MS,
|
|
REWIND_OUTCOME_RECHECK_MS * 2 ** (failures - 1),
|
|
);
|
|
await new Promise<void>((resolve) => window.setTimeout(resolve, pause));
|
|
}
|
|
}
|
|
|
|
function matchesUserMessageIdentity(
|
|
block: DaemonTranscriptBlock | undefined,
|
|
identity: TranscriptUserMessageIdentity | undefined,
|
|
allowLocalId = false,
|
|
): boolean {
|
|
if (!identity) return block === undefined;
|
|
if (!block || block.kind !== 'user' || identity.block.kind !== 'user') {
|
|
return false;
|
|
}
|
|
if (block === identity.block) return true;
|
|
if (allowLocalId && block.id === identity.block.id) return true;
|
|
const expectedRecords = identity.block.sourceRecordIds;
|
|
const currentRecords = block.sourceRecordIds;
|
|
return (
|
|
expectedRecords !== undefined &&
|
|
expectedRecords.length > 0 &&
|
|
currentRecords !== undefined &&
|
|
currentRecords.length === expectedRecords.length &&
|
|
currentRecords.every((record, index) => record === expectedRecords[index])
|
|
);
|
|
}
|
|
|
|
function findUserMessageByIdentity(
|
|
blocks: readonly DaemonTranscriptBlock[],
|
|
identity: TranscriptUserMessageIdentity,
|
|
allowLocalId = false,
|
|
): DaemonTranscriptBlock | undefined {
|
|
for (let index = blocks.length - 1; index >= 0; index -= 1) {
|
|
const block = blocks[index];
|
|
if (
|
|
block?.kind === 'user' &&
|
|
block.meta?.['source'] !== 'background_notification' &&
|
|
matchesUserMessageIdentity(block, identity, allowLocalId)
|
|
) {
|
|
return block;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function getLogicalSessionKey(
|
|
sessionId: string | undefined,
|
|
workspaceCwd: string | undefined,
|
|
): string | undefined {
|
|
return sessionId ? `${workspaceCwd ?? ''}\0${sessionId}` : undefined;
|
|
}
|
|
|
|
function getRetryableTurnError(
|
|
blocks: readonly DaemonTranscriptBlock[],
|
|
): DaemonTranscriptBlock | undefined {
|
|
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
const block = blocks[i];
|
|
if (block?.kind === 'user') {
|
|
if (block.meta?.['source'] === 'background_notification') continue;
|
|
break;
|
|
}
|
|
if (block?.kind === 'error' && block.source === 'turn_error') {
|
|
return block;
|
|
}
|
|
if (block?.kind !== 'debug') break;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function matchesTurnErrorIdentity(
|
|
block: DaemonTranscriptBlock | undefined,
|
|
identity: TranscriptTurnErrorIdentity,
|
|
): boolean {
|
|
if (
|
|
!block ||
|
|
block.kind !== 'error' ||
|
|
block.source !== 'turn_error' ||
|
|
identity.block.kind !== 'error' ||
|
|
identity.block.source !== 'turn_error'
|
|
) {
|
|
return false;
|
|
}
|
|
if (block === identity.block) return true;
|
|
const expectedPromptId = identity.block.promptId;
|
|
if (expectedPromptId) return block.promptId === expectedPromptId;
|
|
return (
|
|
identity.block.eventId !== undefined &&
|
|
block.eventId === identity.block.eventId
|
|
);
|
|
}
|
|
|
|
function retryTranscriptIdentityMatches(
|
|
blocks: readonly DaemonTranscriptBlock[],
|
|
transcriptIdentity: FailedPromptRetry['transcriptIdentity'],
|
|
allowLocalUserId = false,
|
|
): boolean {
|
|
return transcriptIdentity.kind === 'failed-prompt'
|
|
? matchesUserMessageIdentity(
|
|
getLatestUserBlock(blocks),
|
|
transcriptIdentity.identity,
|
|
allowLocalUserId,
|
|
)
|
|
: matchesTurnErrorIdentity(
|
|
getRetryableTurnError(blocks),
|
|
transcriptIdentity.identity,
|
|
);
|
|
}
|
|
|
|
interface LocalAnchoredMessage {
|
|
anchorAfterId?: string;
|
|
anchorIndex: number;
|
|
message: Message;
|
|
}
|
|
|
|
function buildDisplayMessages(
|
|
messages: Message[],
|
|
recapMessage: LocalAnchoredMessage | null,
|
|
): Message[] {
|
|
if (!recapMessage) return filterModelSwitchMessages(messages);
|
|
const result = [...messages];
|
|
const anchorIndex = recapMessage.anchorAfterId
|
|
? result.findIndex((message) => message.id === recapMessage.anchorAfterId)
|
|
: -1;
|
|
result.splice(
|
|
anchorIndex >= 0
|
|
? anchorIndex + 1
|
|
: Math.min(recapMessage.anchorIndex, result.length),
|
|
0,
|
|
recapMessage.message,
|
|
);
|
|
return filterModelSwitchMessages(result);
|
|
}
|
|
|
|
type LiveMessageListProps = Omit<
|
|
MessageListProps,
|
|
'messages' | 'transcriptBlockCount'
|
|
> & {
|
|
baselineBlocks: readonly DaemonTranscriptBlock[];
|
|
baselineSummary?: DaemonTranscriptBlockChangeSummary;
|
|
baselineMessages: Message[];
|
|
recapMessage: LocalAnchoredMessage | null;
|
|
messagesRef: { current: Message[] };
|
|
onTranscriptChange?: (blocks: readonly DaemonTranscriptBlock[]) => void;
|
|
t: ReturnType<typeof getTranslator>;
|
|
};
|
|
|
|
const LiveMessageList = memo(
|
|
forwardRef<MessageListHandle, LiveMessageListProps>(function LiveMessageList(
|
|
{
|
|
baselineBlocks,
|
|
baselineSummary,
|
|
baselineMessages,
|
|
recapMessage,
|
|
messagesRef,
|
|
onTranscriptChange,
|
|
t,
|
|
...props
|
|
},
|
|
ref,
|
|
) {
|
|
const live = useAnimationFrameTranscriptSnapshot();
|
|
const messages = useMemo(
|
|
() =>
|
|
projectStreamingTailMessages(
|
|
{
|
|
blocks: baselineBlocks,
|
|
messages: baselineMessages,
|
|
t,
|
|
blockChangeSummary: baselineSummary,
|
|
},
|
|
live.blocks,
|
|
t,
|
|
live.blockChangeSummary,
|
|
) ?? baselineMessages,
|
|
[
|
|
baselineBlocks,
|
|
baselineMessages,
|
|
baselineSummary,
|
|
live.blockChangeSummary,
|
|
live.blocks,
|
|
t,
|
|
],
|
|
);
|
|
const displayMessages = useMemo(
|
|
() => buildDisplayMessages(messages, recapMessage),
|
|
[messages, recapMessage],
|
|
);
|
|
useLayoutEffect(() => {
|
|
messagesRef.current = messages;
|
|
}, [messages, messagesRef]);
|
|
useEffect(() => {
|
|
onTranscriptChange?.(live.blocks);
|
|
}, [live.blocks, onTranscriptChange]);
|
|
|
|
return (
|
|
<TranscriptViewport
|
|
{...props}
|
|
ref={ref}
|
|
messages={displayMessages}
|
|
transcriptBlockCount={live.blocks.length}
|
|
/>
|
|
);
|
|
}),
|
|
);
|
|
|
|
interface ModelSwitchSummary {
|
|
authType: string;
|
|
modelId: string;
|
|
baseUrl: string;
|
|
apiKey: string;
|
|
isRuntime?: boolean;
|
|
}
|
|
|
|
export interface BugReportInfo {
|
|
title: string;
|
|
systemInfo: Record<string, string>;
|
|
}
|
|
|
|
export interface WebShellSidebarOptions {
|
|
enabled?: boolean;
|
|
defaultCollapsed?: boolean;
|
|
/** Whether to show WebShell's built-in compact drawer toggle. Defaults to true. */
|
|
showCompactToggle?: boolean;
|
|
/** Whether to show the Tasks/Channels session-source switch. Defaults to true. */
|
|
showSessionSourceSwitch?: boolean;
|
|
/** Show Live conversations and, in rail layout, the Live entry. Defaults to false. */
|
|
showLive?: boolean;
|
|
/** Hide or replace the complete sidebar branding row. */
|
|
branding?: false | WebShellSidebarBranding;
|
|
/** Customize the primary navigation area (new session button, custom entries). */
|
|
primaryNav?: WebShellSidebarPrimaryNavOptions;
|
|
/** Whether to hide the "Workspaces" header row (with search and add workspace). Defaults to false (shown). */
|
|
hideProjectHeader?: boolean;
|
|
/** Customize which action buttons appear on session rows. */
|
|
sessionActions?: WebShellSidebarSessionActionsOptions;
|
|
/** Hide the footer completely or select the built-in entries it exposes. */
|
|
footer?: false | WebShellSidebarFooterOptions;
|
|
/** Customize the workspace row shown when lockWorkspaceCwd is active. */
|
|
lockedWorkspace?: WebShellSidebarLockedWorkspace;
|
|
/**
|
|
* Session counts, full path and facet chips (MCP, skills, …) on workspace
|
|
* rows. Pass `false` to keep plain folder headers.
|
|
*/
|
|
workspaceOverview?: false | WebShellSidebarWorkspaceOverviewOptions;
|
|
}
|
|
|
|
export type SessionChangeEvent =
|
|
| { type: 'rename'; sessionId: string; newName: string }
|
|
| { type: 'submit'; sessionId: string; prompt: string; queued: boolean }
|
|
| { type: 'turn_complete'; sessionId: string; error?: Error };
|
|
|
|
export interface WebShellApi {
|
|
refreshModels: () => Promise<void>;
|
|
/** 按持久化记录 ID 定位当前会话消息,包含尚未渲染的历史。宿主先切换会话。 */
|
|
navigateToMessage: (
|
|
request: WebShellMessageNavigationRequest,
|
|
) => Promise<WebShellMessageNavigationResult>;
|
|
/** Open the in-window split view, matching the built-in sidebar button. */
|
|
openSplitView: () => void;
|
|
/** Open the Session Overview panel, matching the built-in sidebar button. */
|
|
openSessionOverview: () => void;
|
|
/** Open the compact session drawer, matching the hamburger control. */
|
|
openSessionDrawer: () => void;
|
|
/** Start a new session using the same lifecycle as the built-in New Chat action. */
|
|
createNewSession: () => Promise<boolean>;
|
|
/** Open the right panel with a new side-task draft. */
|
|
createSideTask: () => boolean;
|
|
/** Resolve the visible approval, optionally binding it to an exact request. */
|
|
respondToPendingPermission?: {
|
|
(decision: 'allow' | 'reject'): Promise<boolean>;
|
|
(requestId: string, decision: 'allow' | 'reject'): Promise<boolean>;
|
|
};
|
|
}
|
|
|
|
export type WebShellComposerPlaceholderState = ComposerPlaceholderState;
|
|
|
|
export type WebShellComposerPlaceholders = Readonly<
|
|
Partial<Record<WebShellComposerPlaceholderState, string>>
|
|
>;
|
|
|
|
export interface WebShellSlashCommand {
|
|
/** Slash command name without the leading slash, normalized to lower case. */
|
|
command: string;
|
|
/** Trimmed text following the command name. */
|
|
args: string;
|
|
/** Original text submitted from the composer. */
|
|
input: string;
|
|
}
|
|
|
|
export type WebShellSlashCommandHandler = (
|
|
command: WebShellSlashCommand,
|
|
) => boolean | void;
|
|
|
|
export interface WebShellProps {
|
|
/** Refresh and persist the host catalog before Qwen reloads models. No return data is required. */
|
|
onRefreshModels?: WebShellCustomization['onRefreshModels'];
|
|
/** Native settings-page presentation. Does not restrict commands or daemon access. */
|
|
settings?: WebShellSettingsOptions;
|
|
/** Model add/delete interactions across WebShell. Not a backend permission policy. */
|
|
modelManagement?: WebShellModelManagementOptions;
|
|
/** Extra catalog models shown disabled in the composer picker. */
|
|
unavailableModels?: readonly WebShellUnavailableModel[];
|
|
/** Host-owned hint for a disabled model. DOM/Solid hosts mount into the container. */
|
|
mountUnavailableModelTooltip?: WebShellUnavailableModelTooltipMount;
|
|
/** Host-specific label for the Ask User Question free-text choice. */
|
|
askUserFreeTextLabel?: string;
|
|
/** Called whenever the attached daemon session or workspace changes. */
|
|
onSessionIdChange?: (
|
|
sessionId: string | undefined,
|
|
workspaceId?: string,
|
|
workspaceCwd?: string,
|
|
sessionContext?: DaemonProductSessionContext,
|
|
) => void;
|
|
/** Called when the active session id or display name changes. */
|
|
onSessionInfoChange?: (session: {
|
|
sessionId?: string;
|
|
sessionName?: string;
|
|
}) => void;
|
|
/** Called when the active model connection changes. */
|
|
onModelChange?: (
|
|
modelId: string | undefined,
|
|
authType: string | undefined,
|
|
) => void;
|
|
/** Called after a new session is created. Session setup waits up to 30 seconds. */
|
|
onSessionCreated?: (sessionId: string) => Promise<void> | void;
|
|
/** Explicit Managed Agent backend. Omit to keep the daemon provider unchanged. */
|
|
managedAgentProvider?: ManagedAgentProvider;
|
|
/** Visual theme for the embedded shell. */
|
|
theme?: WebShellTheme;
|
|
/** Called when `/theme` changes the web-shell theme. */
|
|
onThemeChange?: (theme: WebShellTheme) => void;
|
|
/**
|
|
* Called when no `theme` prop was provided and the shell resolved a theme
|
|
* from the daemon's effective `ui.theme` setting. Unlike `onThemeChange`
|
|
* this is not a user action: a host that mirrors the theme onto document
|
|
* chrome (html class, `theme-color` meta) should follow it without
|
|
* persisting the value as its own preference, or the next settings edit
|
|
* would be shadowed by the stale copy.
|
|
*/
|
|
onThemeResolved?: (theme: WebShellTheme) => void;
|
|
/** UI language for the web-shell. Defaults to `?language=` or browser language. */
|
|
language?: 'en' | 'zh-CN' | 'zh' | 'zh-cn' | 'ru' | 'ru-ru';
|
|
/** Called when `/language ui` changes the web-shell UI language. */
|
|
onLanguageChange?: (language: WebShellLanguage) => void;
|
|
/**
|
|
* Called when no `language` prop was provided and the effective UI language
|
|
* changes without becoming a host opinion, including settings resolution and
|
|
* optimistic changes or rollbacks. Hosts may mirror document chrome but must
|
|
* not persist this value.
|
|
*/
|
|
onLanguageResolved?: (language: WebShellLanguage) => void;
|
|
/**
|
|
* Product branding for the embedded shell. Replaces the daemon-resolved brand
|
|
* wholesale when provided: a host that sets `brand` owns both the name and the
|
|
* logo. `logo` may be any node, because the host owns its own document and
|
|
* Content Security Policy.
|
|
*/
|
|
brand?: WebShellBrand;
|
|
/**
|
|
* Called with the resolved brand's name and logo URI once the brand is known,
|
|
* and again only when one of those two values changes — so a host may pass an
|
|
* inline handler alongside an inline `brand` object without re-firing on every
|
|
* render. The shell itself never writes `document.title` or the favicon; an
|
|
* embedded shell must not hijack its host page's tab.
|
|
*/
|
|
onBrandResolved?: (brand: WebShellResolvedBrand) => void;
|
|
/** Additional CSS class name appended to the root element. */
|
|
className?: string;
|
|
/** Inline styles applied to the root element. */
|
|
style?: React.CSSProperties;
|
|
/** Optional Shadow DOM isolation for plugin content and/or all portals. */
|
|
shadowDom?: WebShellShadowDom;
|
|
/** Maximum chat content width in regular mode. Defaults to 1000px. */
|
|
chatMaxWidth?: number;
|
|
/** Workspace sidebar. Defaults to Home only; false hides it. */
|
|
sidebar?: boolean | WebShellSidebarOptions;
|
|
/** Persistent chat header options. */
|
|
header?: WebShellChatHeaderOptions;
|
|
/** Right extension panel options. */
|
|
rightPanel?: WebShellRightPanelOptions;
|
|
/** Show the tool-call entry on user messages. Defaults to false. */
|
|
showToolCalls?: boolean;
|
|
/** Environment information panel options. */
|
|
environmentPanel?: WebShellEnvironmentPanelOptions;
|
|
/** Session ids to control the split view; an empty array closes it. */
|
|
splitSessionIds?: readonly string[];
|
|
/** Called when the split pane list changes from inside WebShell. */
|
|
onSplitSessionIdsChange?: (sessionIds: string[]) => void;
|
|
/**
|
|
* Extra actions rendered in each split-pane header, before the built-in
|
|
* close button. Receives the pane's session id (and workspace when known).
|
|
* When the actions no longer fit they collapse into a `…` overflow menu.
|
|
*/
|
|
renderPaneHeaderActions?: PaneHeaderActionsRenderer;
|
|
/**
|
|
* Called instead of the built-in right panel open behavior when a user clicks
|
|
* a turn output such as review changes, an artifact, or a scheduled task.
|
|
* Return false to use the built-in behavior; true or undefined claims the open.
|
|
*/
|
|
onRightPanelOpen?:
|
|
| ((request: TurnOutputOpenRequest) => void)
|
|
| ((request: TurnOutputOpenRequest) => boolean);
|
|
/** Override file-review links without replacing the other right panels. */
|
|
onFileReviewOpen?: (
|
|
request: Extract<TurnOutputOpenRequest, { kind: 'review' }>,
|
|
) => void;
|
|
/** Open a workspace file in the embedding host instead of WebShell preview. */
|
|
onWorkspaceFileOpen?: (path: string) => void;
|
|
/** Select a directory on the connected daemon. Overrides native picking without registering or trusting the path. */
|
|
onPickWorkspaceDirectory?: WebShellWorkspaceDirectoryPicker;
|
|
/** Open a completed Insight report in a host-native surface. */
|
|
onInsightReportOpen?: (path: string) => void;
|
|
/** Open context usage details in the host instead of the built-in panel. */
|
|
onContextUsageOpen?: (sessionId: string) => void;
|
|
/**
|
|
* Controls which turn output cards appear below messages. Defaults to all.
|
|
*/
|
|
messageTurnOutputs?: readonly TurnOutputKind[];
|
|
filterArtifact?: ArtifactFilter;
|
|
/** Imperative handle for externally opening WebShell surfaces. */
|
|
shellRef?: React.Ref<WebShellApi>;
|
|
/**
|
|
* Built-in composer toolbar actions to show. Omitted uses the defaults.
|
|
* Excluding gitBranch skips chat Git reads unless the environment panel
|
|
* needs them. Plan must be explicitly included.
|
|
*/
|
|
composerToolbarActions?: readonly ComposerToolbarAction[];
|
|
/** Execution mode choices. Overrides ui.allowedApprovalModes; [] offers none. Presentation only. */
|
|
allowedApprovalModes?: readonly WebShellApprovalMode[];
|
|
/** Composer presentation shared by the main chat, split panes, and side tasks. */
|
|
composerVariant?: 'default' | 'simple';
|
|
/** Optionally filter main-model entries without changing shared defaults. */
|
|
mainModelFilter?: (model: ModelDialogModel) => boolean;
|
|
/** Stack completion details vertically for narrow embedded hosts. */
|
|
compactComposerOverlays?: boolean;
|
|
/** Submit slash items marked as immediate actions when selected. */
|
|
autoSubmitSlashCommands?: boolean;
|
|
/** Host-only slash entries or presentation overrides. */
|
|
additionalSlashCommands?: readonly CommandInfo[];
|
|
/** Keep Context Usage available while restored-session usage is loading. */
|
|
contextUsageAlwaysVisible?: boolean;
|
|
/**
|
|
* Offer the last user turn's edit-and-resend action. On by default. Editing
|
|
* happens in place in the message bubble, and the rewind is deferred to send
|
|
* time, so abandoning the edit keeps the turns after it. Hosts that own the
|
|
* lifecycle pass `false`, or answer `onUserMessageEditRequest` with `true` to
|
|
* keep their own editor.
|
|
*/
|
|
userMessageEditing?: boolean;
|
|
/**
|
|
* Called before WebShell starts its built-in user-message edit flow. Return
|
|
* true when the host owns the edit-and-resend lifecycle.
|
|
*/
|
|
onUserMessageEditRequest?: (
|
|
turnIndex: number,
|
|
content: string,
|
|
) => boolean | void;
|
|
/** Cycle the approval mode when an otherwise-unhandled Tab is pressed. */
|
|
cycleModeOnTab?: boolean;
|
|
/**
|
|
* Creator attribution recorded on sessions this shell creates, and the
|
|
* source filter embedded hosts use to list only their own sessions. Defaults
|
|
* to the browser Web Shell's `'default'`; the VS Code companion overrides it
|
|
* so its sessions stay distinguishable from CLI and browser ones.
|
|
*/
|
|
sessionSourceType?: string;
|
|
/** Built-in actions appended to the context-sensitive default toolbar. */
|
|
composerToolbarAdditionalActions?: readonly ComposerToolbarAction[];
|
|
/**
|
|
* Main-composer copy by semantic state. Omitted or blank entries retain the
|
|
* WebShell localized default; shell-mode and follow-up copy still wins.
|
|
*/
|
|
composerPlaceholders?: WebShellComposerPlaceholders;
|
|
/** Called when connection status changes (idle/connecting/connected/disconnected/error). */
|
|
onConnectionChange?: (status: string) => void;
|
|
/** Called when prompt status changes (idle/waiting/responding). */
|
|
onStreamingStateChange?: (state: DaemonStreamingState) => void;
|
|
/**
|
|
* Called with the initial merged agent task snapshot and when its roster,
|
|
* status, or stable metadata changes. Poll-only runtime, stats, and activity
|
|
* updates are suppressed.
|
|
*/
|
|
onAgentTasksChange?: (tasks: readonly DaemonSessionAgentTaskStatus[]) => void;
|
|
/**
|
|
* Called whenever transcript blocks change. Receives the full blocks array
|
|
* at most once per animation frame during active generation.
|
|
*/
|
|
onTranscriptChange?: (blocks: readonly DaemonTranscriptBlock[]) => void;
|
|
/** Called with the complete Artifact snapshot after restore or change. */
|
|
onSessionArtifactsChange?: (change: WebShellSessionArtifactsChange) => void;
|
|
/**
|
|
* Called when a critical error occurs (auth failure, session gone, etc).
|
|
* Each distinct connection error value is reported once; reporting resets
|
|
* when the connection recovers. Replacing the handler while an error
|
|
* persists does not re-deliver that error.
|
|
*/
|
|
onError?: (error: Error) => void;
|
|
/** Called when `/bug` is invoked. Receives system info. If omitted, web-shell opens the report URL itself. */
|
|
onBugReport?: (info: BugReportInfo) => void;
|
|
/** Slash command names to hide from completion/help, for example `['approval-mode']`. */
|
|
hiddenSlashCommands?: string[];
|
|
/** Slash command category order. Defaults to custom, skill, system. */
|
|
slashCommandCategoryOrder?: CommandDisplayCategoryOrder;
|
|
/**
|
|
* Called before Web Shell handles a slash command. Return true to skip the
|
|
* built-in or daemon behavior after handling the command in the host.
|
|
*/
|
|
onSlashCommand?: WebShellSlashCommandHandler;
|
|
/** Built-in @ mention providers to enable. Defaults to all built-ins. */
|
|
builtinAtProviders?: WebShellBuiltinAtProvidersConfig;
|
|
/**
|
|
* Controls composer uploads (drag-and-drop and the @ panel upload item)
|
|
* and Files tree drag-and-drop uploads. Does not disable attachments.
|
|
* Works alongside the daemon's
|
|
* `workspace_file_upload` capability, not instead of it: `false` force-
|
|
* disables upload even when the daemon advertises the capability, while
|
|
* `true`/omitted still requires the capability to be satisfied.
|
|
*/
|
|
fileUploadEnabled?: boolean;
|
|
/** Preferred file-drop destination. Omitted: ask only when both are available.
|
|
* If the preference is unavailable, use the sole available destination.
|
|
*/
|
|
fileDropAction?: 'upload' | 'attach';
|
|
/**
|
|
* Directory that composer-dropped files upload into, **relative to the
|
|
* workspace root**. Use a relative path WITHOUT a leading `/` — e.g.
|
|
* `'uploads'`, `'uploads/images'`, or omit it to upload into the
|
|
* workspace root (the default). A leading-slash path like `'/uploads'`
|
|
* is rejected by the daemon as outside the workspace. The directory
|
|
* (including intermediate components) is created automatically on upload
|
|
* when it does not exist. Files tree drops use the explicit tree destination
|
|
* instead of this setting.
|
|
*/
|
|
fileUploadDirectory?: string;
|
|
/** Additional @ mention categories shown alongside built-in files/extensions. */
|
|
atProviders?: readonly WebShellAtProvider[];
|
|
/** Icon URLs for custom composer tag kinds used by @ mention chips. */
|
|
composerTagIcons?: WebShellComposerTagIconMap;
|
|
/** Custom renderer for the tool-card header content after the status icon and tool name. */
|
|
renderToolHeaderExtra?: ToolHeaderExtraRenderer;
|
|
/** Artifact-card rendering overrides. */
|
|
artifact?: WebShellArtifactCustomization;
|
|
/** 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;
|
|
/**
|
|
* Show renderWelcomeFooter between the welcome header and composer on
|
|
* mobile empty state. Requires renderWelcomeFooter to be provided for the
|
|
* mobile CSS reordering to take effect.
|
|
*/
|
|
mobileWelcomeFooterMiddle?: boolean;
|
|
/** Parse user-message text into display parts such as chips. */
|
|
parseUserMessageContent?: UserMessageContentParser;
|
|
/** Custom renderer for the inside of user chat bubbles. Defaults to plain text. */
|
|
renderUserMessageContent?: UserMessageContentRenderer;
|
|
/** Custom renderer for composer and user-message tags. */
|
|
renderComposerTag?: ComposerTagRenderer;
|
|
/** Custom hover content for composer and user-message tags. */
|
|
renderComposerTagTooltip?: ComposerTagRenderer;
|
|
/** Click handler for composer and user-message tags. */
|
|
onComposerTagClick?: ComposerTagClickHandler;
|
|
/** Custom renderer displayed after the final assistant message of each turn. */
|
|
renderAssistantTurnFooter?: AssistantTurnFooterRenderer;
|
|
/**
|
|
* Satisfied / not-satisfied marks on each completed assistant turn. Omit the
|
|
* object to leave the answer footer unchanged.
|
|
*/
|
|
assistantFeedback?: WebShellAssistantFeedbackOptions;
|
|
getAssistantSourcesIcon?: WebShellSourceIconResolver;
|
|
sourceReferences?: readonly WebShellSourceReference[];
|
|
/** 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 renderer inserted into the composer toolbar's right-side action area. */
|
|
renderComposerToolbarRight?: ComposerToolbarRightRenderer;
|
|
/** Custom renderer shown directly above the chat composer input. */
|
|
renderComposerHeader?: ComposerHeaderRenderer;
|
|
/** Custom renderer shown directly below the chat composer input. */
|
|
renderComposerFooter?: ComposerFooterRenderer;
|
|
/**
|
|
* Replaces the complete persistent chat header. Only rendered when a
|
|
* session is active (not in the welcome/empty state).
|
|
*/
|
|
renderChatHeader?: ChatHeaderRenderer;
|
|
/** Custom component for the footer area below the Editor. Replaces the built-in StatusBar. */
|
|
renderFooter?: FooterRenderer;
|
|
/** Extra status items shown in the floating bottom panel beside the TODO summary. */
|
|
bottomStatusItems?: readonly WebShellBottomStatusItem[];
|
|
/** Collapse thinking blocks to 5 lines with a click-to-expand toggle. */
|
|
compactThinking?: boolean;
|
|
/** Let the host own pending Edit diff previews instead of the inline tool row. */
|
|
hostOwnsEditDiffPreview?: boolean;
|
|
/** Auto-collapse completed turns to just the prompt and final answer, with a per-turn toggle. Defaults to true. */
|
|
collapseCompletedTurns?: boolean;
|
|
/** Markdown table rendering mode. Defaults to basic. */
|
|
markdownTableMode?: MarkdownTableMode;
|
|
/** Enable virtual scrolling only when rendered transcript rows exceed this threshold. Defaults to 200. */
|
|
virtualScrollThreshold?: number;
|
|
/** 会话消息数超过此阈值时显示搜索入口,默认 10。 */
|
|
conversationSearchThreshold?: number;
|
|
/** Custom Markdown behavior for assistant content only. */
|
|
markdown?: WebShellMarkdownCustomization;
|
|
/**
|
|
* Override the witty phrases cycled while a prompt is streaming. Receives the
|
|
* resolved UI language; return phrases to replace the built-in defaults, an
|
|
* empty array to hide the phrase, or `undefined`/`null` to keep the defaults.
|
|
* `ui.accessibility.enableLoadingPhrases: false` hides even custom phrases.
|
|
*/
|
|
loadingPhrases?: LoadingPhrasesResolver;
|
|
/** When provided, all toast notifications are forwarded to this callback and the built-in ToastHost is hidden. */
|
|
onToast?: (tone: ToastTone, message: string) => void;
|
|
/** Imperative handle for externally controlling the composer input. */
|
|
composerRef?: React.Ref<WebShellComposerApi>;
|
|
/** 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;
|
|
/** Called when a session-level event occurs (rename, submit, turn complete). */
|
|
onSessionChange?: (event: SessionChangeEvent) => void;
|
|
/**
|
|
* Called for authoritative terminals observed live, or replayed for a prompt
|
|
* this provider admitted. Multiple mounted providers can report the same
|
|
* `(sessionId, promptId)`, so hosts should deduplicate by that key.
|
|
*/
|
|
onAssistantTurnSettled?: (event: WebShellAssistantTurnSettledEvent) => void;
|
|
/**
|
|
* Prepare the immutable payload for a daemon submission. Called once for a
|
|
* direct or queued logical submit, after local command routing and before
|
|
* session creation, composer commit, optimistic rendering, or admission.
|
|
* Retries reuse the previously prepared payload and skip this callback.
|
|
* If this callback rejects, the submission is cancelled and the rejection's
|
|
* `Error.message` is surfaced to the user, so hosts must localize it.
|
|
*/
|
|
prepareSubmit?: (
|
|
submission: WebShellSubmitSnapshot,
|
|
) => Promise<void | WebShellPreparedSubmit>;
|
|
/**
|
|
* Called before a prompt is submitted. Return a Promise — the prompt is held
|
|
* until the Promise resolves. If the Promise rejects, the prompt is cancelled.
|
|
* `sessionId` is `undefined` when the session has not yet been created (deferred).
|
|
* Also called for queued prompts (submitted while a turn is streaming).
|
|
* A rejection's `Error.message` is surfaced to the user, so hosts must
|
|
* localize it.
|
|
*/
|
|
onSubmitBefore?: (params: {
|
|
sessionId: string | undefined;
|
|
prompt: string;
|
|
source?: 'mcp-app';
|
|
}) => Promise<void>;
|
|
}
|
|
|
|
interface AppProps extends WebShellProps {
|
|
initialSelectedWorkspaceCwd?: string;
|
|
lockedWorkspaceCwd?: string;
|
|
lockedWorkspaceCapability?: DaemonWorkspaceCapability;
|
|
restartSseOnPrompt?: boolean;
|
|
historyPageSize?: number;
|
|
}
|
|
|
|
type SessionActionsWithCreate = {
|
|
createSession: (options?: {
|
|
workspaceCwd?: string;
|
|
getCurrentWorkspaceCwd?: () => string | undefined;
|
|
sessionContext?: DaemonProductSessionContext;
|
|
approvalMode?: string;
|
|
sourceType?: string;
|
|
worktree?: { slug?: string };
|
|
branch?: { name: string };
|
|
}) => Promise<{
|
|
sessionId: string;
|
|
worktree?: { slug: string; path: string; branch: string };
|
|
branch?: { name: string; baseBranch: string };
|
|
}>;
|
|
attachSession: () => Promise<void>;
|
|
clearSession: (options?: { dropSessionContext?: boolean }) => Promise<void>;
|
|
releaseSession: (sessionId: string) => Promise<void>;
|
|
};
|
|
|
|
type NewSessionIntent =
|
|
| { kind: 'global' }
|
|
/**
|
|
* Stay in the current context. From a Live chat that means a fresh Live
|
|
* conversation, unless `leaveLive` sends the new chat to the trusted
|
|
* primary workspace the way a cold draft starts (or to a standalone draft
|
|
* when there is no trusted primary, or to a plain cwd-less draft when the
|
|
* daemon offers neither). Leaving also drops the connection's live session
|
|
* context, so the resulting draft does not inherit Live on its first
|
|
* prompt.
|
|
*/
|
|
| { kind: 'inherit'; leaveLive?: boolean }
|
|
| { kind: 'workspace'; cwd: string };
|
|
|
|
type StandaloneRecoveryResolution =
|
|
| 'idle'
|
|
| 'checking'
|
|
| 'creating'
|
|
| 'absent'
|
|
| 'unknown'
|
|
| 'archived';
|
|
|
|
const STANDALONE_RECOVERY_POLL_DELAYS_MS = [250, 500, 1000, 2000, 4000];
|
|
|
|
function getStandaloneRecoverySessionId(
|
|
recovery: DaemonStandaloneCreationRecovery | undefined,
|
|
): string | undefined {
|
|
return recovery?.state === 'existing'
|
|
? recovery.session.sessionId
|
|
: recovery?.sessionId;
|
|
}
|
|
|
|
type PendingReasoningIntent = {
|
|
modelId: string;
|
|
value: ReasoningSelection;
|
|
fromToggle?: true;
|
|
};
|
|
|
|
function reasoningPreviewSupports(
|
|
reasoning: DaemonReasoningControls,
|
|
value: ReasoningSelection,
|
|
): boolean {
|
|
if (value === 'none') return reasoning.canDisable !== false;
|
|
if (reasoning.canEnable === false && !reasoning.enabled) return false;
|
|
if (value === 'default') return true;
|
|
return (
|
|
reasoning.canEnable !== false &&
|
|
reasoning.enableValue !== 'default' &&
|
|
reasoning.efforts.includes(value)
|
|
);
|
|
}
|
|
|
|
const emptyComposerApi: WebShellComposerApi = {
|
|
focus: () => {},
|
|
insertText: () => {},
|
|
setText: () => {},
|
|
addTags: () => {},
|
|
removeTag: () => {},
|
|
clear: () => {},
|
|
submit: () => {},
|
|
};
|
|
|
|
const NON_WORKSPACE_BLOCKED_COMMANDS = new Set([
|
|
'agents',
|
|
'branch',
|
|
'delete',
|
|
'diff',
|
|
'extensions',
|
|
'goal',
|
|
'log',
|
|
'logout',
|
|
'memory',
|
|
'mcp',
|
|
'prs',
|
|
'release',
|
|
'resume',
|
|
'schedule',
|
|
'settings',
|
|
'skills',
|
|
'tools',
|
|
]);
|
|
|
|
const EMPTY_BOTTOM_STATUS_ITEMS: readonly WebShellBottomStatusItem[] = [];
|
|
const EMPTY_SESSION_WORKFLOW_TODOS: FloatingTodosState = {
|
|
todos: [],
|
|
planId: null,
|
|
allCompleted: false,
|
|
sourceMessageId: null,
|
|
};
|
|
const EMPTY_ADDITIONAL_SLASH_COMMANDS: readonly CommandInfo[] = [];
|
|
const DEFAULT_CHAT_MAX_WIDTH = 1000;
|
|
const DEFAULT_CHAT_HEADER_ITEMS: readonly WebShellChatHeaderItem[] = [
|
|
'title',
|
|
'environment',
|
|
'rightPanel',
|
|
];
|
|
const DEFAULT_RIGHT_PANEL_ITEMS: readonly WebShellRightPanelItem[] = [
|
|
'review',
|
|
'sideTask',
|
|
];
|
|
/**
|
|
* Right-panel entries that work without a session; the empty new-task page
|
|
* offers only these.
|
|
*/
|
|
const EMPTY_STATE_RIGHT_PANEL_ITEMS: readonly WebShellRightPanelItem[] = [
|
|
'files',
|
|
'terminal',
|
|
'webPreview',
|
|
];
|
|
/**
|
|
* Key prefix backing the empty new-task page's panel state: one draft entry
|
|
* per workspace, migrated to the session's key when the first prompt
|
|
* materializes one.
|
|
*/
|
|
const DRAFT_PANEL_KEY_PREFIX = 'draft\0';
|
|
/**
|
|
* One trajectory tab per session, so re-opening an already-open one reveals it
|
|
* rather than stacking a second. Shared with the entry, which hides itself
|
|
* once this session's tab is open.
|
|
*/
|
|
const trajectoryTabId = (sessionId: string) => `trajectory:${sessionId}`;
|
|
const DEFAULT_ENVIRONMENT_PANEL_ITEMS: readonly WebShellEnvironmentPanelItem[] =
|
|
['environment', 'sources', 'subagents', 'backgroundTasks', 'artifacts'];
|
|
const ATTACHMENTS_REFRESH_INTERVAL_MS = 1000;
|
|
const SESSION_AGENTS_REFRESH_INTERVAL_MS = 3000;
|
|
const SESSION_AGENTS_MAX_RETRY_INTERVAL_MS = 30_000;
|
|
const SESSION_AGENTS_FEATURE = 'session_agents';
|
|
const SESSION_AGENT_TRACE_FEATURE = 'session_agent_trace';
|
|
const SESSION_ATTACHMENT_LIST_FEATURE = 'session_attachment_list';
|
|
const BOTTOM_PANEL_GAP_PX = 6;
|
|
const BOTTOM_PANEL_FALLBACK_INSET_PX = 40;
|
|
|
|
// One preview tab per image, keyed by its content, so opening several images
|
|
// keeps a tab each while re-clicking the same image just focuses its tab.
|
|
function imageTabId(src: string): string {
|
|
let hash = 0;
|
|
for (let i = 0; i < src.length; i++) {
|
|
hash = (hash * 31 + src.charCodeAt(i)) | 0;
|
|
}
|
|
return `image:${hash.toString(36)}`;
|
|
}
|
|
|
|
type ChatWidthMode = `${typeof DEFAULT_CHAT_MAX_WIDTH}` | 'wide';
|
|
type MainView =
|
|
| 'chat'
|
|
| 'cockpit'
|
|
| 'scheduledTasks'
|
|
| 'prWatches'
|
|
| 'workflows'
|
|
| 'goals'
|
|
| 'split';
|
|
|
|
const CHAT_WIDTH_STORAGE_KEY = 'qwen-code-web-shell-chat-width';
|
|
const CHAT_SHELL_HORIZONTAL_PADDING = 40;
|
|
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'qwen-code-web-shell-sidebar-collapsed';
|
|
const ENVIRONMENT_PANEL_OPEN_STORAGE_KEY =
|
|
'qwen-code-web-shell-environment-panel-open';
|
|
const RIGHT_PANEL_STATE_STORAGE_KEY = 'qwen-code-web-shell-right-panel-state';
|
|
|
|
function resolveSidebarOptions(sidebar: WebShellProps['sidebar']): {
|
|
enabled: boolean;
|
|
defaultCollapsed: boolean;
|
|
showCompactToggle: boolean;
|
|
showSessionSourceSwitch: boolean;
|
|
showLive: boolean;
|
|
branding?: false | WebShellSidebarBranding;
|
|
primaryNav?: WebShellSidebarPrimaryNavOptions;
|
|
hideProjectHeader?: boolean;
|
|
sessionActions?: WebShellSidebarSessionActionsOptions;
|
|
footer?: false | WebShellSidebarFooterOptions;
|
|
lockedWorkspace?: WebShellSidebarLockedWorkspace;
|
|
workspaceOverview?: false | WebShellSidebarWorkspaceOverviewOptions;
|
|
} {
|
|
const options = typeof sidebar === 'object' ? sidebar : {};
|
|
return {
|
|
enabled: sidebar !== false && (options.enabled ?? true),
|
|
defaultCollapsed: options.defaultCollapsed ?? false,
|
|
showCompactToggle: options.showCompactToggle ?? true,
|
|
showSessionSourceSwitch: options.showSessionSourceSwitch ?? true,
|
|
showLive: options.showLive ?? false,
|
|
branding: options.branding,
|
|
primaryNav: {
|
|
...options.primaryNav,
|
|
items: options.primaryNav?.items ?? ['newTask'],
|
|
},
|
|
hideProjectHeader: options.hideProjectHeader,
|
|
sessionActions: options.sessionActions,
|
|
footer:
|
|
options.footer === false
|
|
? false
|
|
: { ...options.footer, items: options.footer?.items ?? ['collapse'] },
|
|
lockedWorkspace: options.lockedWorkspace,
|
|
workspaceOverview: options.workspaceOverview,
|
|
};
|
|
}
|
|
|
|
// One availability rule for the two footer items the view navigation hotkeys
|
|
// govern (#13175): the rail table and the chord gates both read this
|
|
// predicate, so a key never opens a view the sidebar withholds, and an edit
|
|
// in one place cannot leave the other reading stale.
|
|
function isSidebarFooterItemAvailable(
|
|
item: 'sessionsOverview' | 'splitView',
|
|
projectFeaturesAvailable: boolean,
|
|
isLargeScreen: boolean,
|
|
): boolean {
|
|
if (item === 'sessionsOverview') return projectFeaturesAvailable;
|
|
return projectFeaturesAvailable && isLargeScreen;
|
|
}
|
|
|
|
function readSidebarCollapsed(defaultCollapsed: boolean): boolean {
|
|
if (typeof window === 'undefined') return defaultCollapsed;
|
|
try {
|
|
const stored = window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY);
|
|
if (stored === 'true') return true;
|
|
if (stored === 'false') return false;
|
|
} catch {
|
|
// localStorage can be unavailable in private or embedded contexts.
|
|
}
|
|
return defaultCollapsed;
|
|
}
|
|
|
|
function writeSidebarCollapsed(collapsed: boolean): void {
|
|
try {
|
|
window.localStorage.setItem(
|
|
SIDEBAR_COLLAPSED_STORAGE_KEY,
|
|
String(collapsed),
|
|
);
|
|
} catch {
|
|
// localStorage can be unavailable in private or embedded contexts.
|
|
}
|
|
}
|
|
|
|
function readStoredBooleanMap(key: string): Record<string, boolean> {
|
|
if (typeof window === 'undefined') return {};
|
|
try {
|
|
const stored = window.localStorage.getItem(key);
|
|
if (!stored) return {};
|
|
const parsed = JSON.parse(stored) as unknown;
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
return {};
|
|
}
|
|
const storedStates = { ...(parsed as Record<string, unknown>) };
|
|
if (storedStates['v'] !== undefined && storedStates['v'] !== 1) return {};
|
|
delete storedStates['v'];
|
|
const result: Record<string, boolean> = {};
|
|
for (const [sessionKey, value] of Object.entries(storedStates).slice(
|
|
-MAX_ARTIFACT_PANEL_SESSION_STATES,
|
|
)) {
|
|
if (typeof value === 'boolean') result[sessionKey] = value;
|
|
}
|
|
return result;
|
|
} catch {
|
|
// localStorage can be unavailable in private or embedded contexts.
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function writeStoredBooleanMap(
|
|
key: string,
|
|
value: Record<string, boolean>,
|
|
): void {
|
|
try {
|
|
while (Object.keys(value).length > MAX_ARTIFACT_PANEL_SESSION_STATES) {
|
|
const oldestSessionKey = Object.keys(value)[0];
|
|
if (!oldestSessionKey) break;
|
|
delete value[oldestSessionKey];
|
|
}
|
|
window.localStorage.setItem(key, JSON.stringify({ v: 1, ...value }));
|
|
} catch (error) {
|
|
console.warn(
|
|
'[web-shell] failed to persist environment panel state:',
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
interface ArtifactPanelPersistedState {
|
|
open: boolean;
|
|
activeTabId: string | null;
|
|
tabs: PersistedArtifactPanelTab[];
|
|
}
|
|
|
|
type PersistedArtifactPanelTab =
|
|
| Extract<ArtifactPanelTab, { kind: 'files' }>
|
|
| Extract<ArtifactPanelTab, { kind: 'web_preview' }>
|
|
| Omit<Extract<ArtifactPanelTab, { kind: 'turn_calls' }>, 'promptLabel'>
|
|
| Pick<
|
|
Extract<ArtifactPanelTab, { kind: 'review' }>,
|
|
| 'id'
|
|
| 'kind'
|
|
| 'title'
|
|
| 'workspaceCwd'
|
|
| 'workspaceId'
|
|
| 'selectedPath'
|
|
| 'sourceTurnId'
|
|
| 'sourceSessionId'
|
|
| 'sourceToolCallIds'
|
|
>
|
|
| Pick<
|
|
Extract<ArtifactPanelTab, { kind: 'file' }>,
|
|
| 'id'
|
|
| 'kind'
|
|
| 'title'
|
|
| 'workspacePath'
|
|
| 'workspaceCwd'
|
|
| 'workspaceId'
|
|
| 'previewMimeType'
|
|
| 'previewOnly'
|
|
| 'sourcePreview'
|
|
| 'attachmentId'
|
|
| 'sourceSessionId'
|
|
>
|
|
| Pick<
|
|
Extract<ArtifactPanelTab, { kind: 'artifact' }>,
|
|
| 'id'
|
|
| 'kind'
|
|
| 'title'
|
|
| 'artifactId'
|
|
| 'workspaceCwd'
|
|
| 'workspaceId'
|
|
| 'sourceSessionId'
|
|
>
|
|
| {
|
|
id: string;
|
|
kind: 'scheduled_task';
|
|
title: string;
|
|
toolCallId: string;
|
|
sourceSessionId?: string;
|
|
workspaceCwd?: string;
|
|
workspaceId?: string;
|
|
}
|
|
| Pick<
|
|
Extract<ArtifactPanelTab, { kind: 'image' }>,
|
|
'id' | 'kind' | 'title' | 'alt' | 'source'
|
|
>
|
|
| Pick<
|
|
Extract<ArtifactPanelTab, { kind: 'subagent' }>,
|
|
'id' | 'kind' | 'title' | 'sessionId' | 'rootToolCallId' | 'workspaceCwd'
|
|
>
|
|
| {
|
|
id: string;
|
|
kind: 'monitor' | 'shell';
|
|
title: string;
|
|
taskId: string;
|
|
sessionId?: string;
|
|
}
|
|
| Pick<
|
|
Extract<ArtifactPanelTab, { kind: 'side_task' }>,
|
|
'id' | 'kind' | 'title' | 'sessionId' | 'parentSessionId' | 'workspaceCwd'
|
|
>
|
|
| Pick<
|
|
Extract<ArtifactPanelTab, { kind: 'terminal' }>,
|
|
'id' | 'kind' | 'title' | 'workspaceCwd'
|
|
>
|
|
| Pick<
|
|
Extract<ArtifactPanelTab, { kind: 'trajectory' }>,
|
|
'id' | 'kind' | 'title' | 'sessionId'
|
|
>
|
|
| Pick<
|
|
Extract<ArtifactPanelTab, { kind: 'token_usage' }>,
|
|
'id' | 'kind' | 'title' | 'sessionId' | 'closeWithPane'
|
|
>
|
|
| Pick<
|
|
Extract<ArtifactPanelTab, { kind: 'context_usage' }>,
|
|
'id' | 'kind' | 'title' | 'sessionId' | 'closeWithPane'
|
|
>
|
|
| Pick<
|
|
Extract<ArtifactPanelTab, { kind: 'workflow' }>,
|
|
'id' | 'kind' | 'title' | 'sessionId'
|
|
>;
|
|
|
|
function parsePersistedArtifactPanelTab(
|
|
value: unknown,
|
|
): PersistedArtifactPanelTab | undefined {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return;
|
|
const tab = value as Record<string, unknown>;
|
|
if (typeof tab['id'] !== 'string' || typeof tab['title'] !== 'string') return;
|
|
const optionalStrings = [
|
|
'workspaceCwd',
|
|
'workspaceId',
|
|
'selectedPath',
|
|
'sourceTurnId',
|
|
'sourceSessionId',
|
|
'workspacePath',
|
|
'previewMimeType',
|
|
'attachmentId',
|
|
'artifactId',
|
|
'toolCallId',
|
|
'alt',
|
|
'sessionId',
|
|
'rootToolCallId',
|
|
'taskId',
|
|
'parentSessionId',
|
|
'recordId',
|
|
'promptId',
|
|
];
|
|
if (
|
|
optionalStrings.some(
|
|
(key) => tab[key] !== undefined && typeof tab[key] !== 'string',
|
|
) ||
|
|
(tab['sourcePreview'] !== undefined &&
|
|
typeof tab['sourcePreview'] !== 'boolean') ||
|
|
(tab['previewOnly'] !== undefined &&
|
|
typeof tab['previewOnly'] !== 'boolean') ||
|
|
(tab['closeWithPane'] !== undefined &&
|
|
typeof tab['closeWithPane'] !== 'boolean') ||
|
|
(tab['sourceToolCallIds'] !== undefined &&
|
|
(!Array.isArray(tab['sourceToolCallIds']) ||
|
|
!tab['sourceToolCallIds'].every((id) => typeof id === 'string')))
|
|
) {
|
|
return;
|
|
}
|
|
const common = { id: tab['id'], title: tab['title'] };
|
|
switch (tab['kind']) {
|
|
case 'files':
|
|
if (typeof tab['workspaceCwd'] !== 'string' || !tab['workspaceCwd'])
|
|
return;
|
|
return {
|
|
...common,
|
|
kind: 'files',
|
|
workspaceCwd: tab['workspaceCwd'],
|
|
workspaceId: tab['workspaceId'] as string | undefined,
|
|
};
|
|
case 'turn_calls':
|
|
if (
|
|
tab['id'] !== 'turn_calls' ||
|
|
typeof tab['turnId'] !== 'string' ||
|
|
(!tab['recordId'] && !tab['promptId'])
|
|
)
|
|
return;
|
|
return {
|
|
...common,
|
|
id: 'turn_calls',
|
|
kind: 'turn_calls',
|
|
turnId: tab['turnId'],
|
|
recordId: tab['recordId'] as string | undefined,
|
|
promptId: tab['promptId'] as string | undefined,
|
|
};
|
|
case 'review':
|
|
return {
|
|
...common,
|
|
kind: 'review',
|
|
workspaceCwd: tab['workspaceCwd'],
|
|
workspaceId: tab['workspaceId'],
|
|
selectedPath: tab['selectedPath'],
|
|
sourceTurnId: tab['sourceTurnId'],
|
|
sourceSessionId: tab['sourceSessionId'],
|
|
sourceToolCallIds: tab['sourceToolCallIds'],
|
|
} as PersistedArtifactPanelTab;
|
|
case 'file':
|
|
if (typeof tab['workspacePath'] !== 'string') return;
|
|
return {
|
|
...common,
|
|
kind: 'file',
|
|
workspacePath: tab['workspacePath'],
|
|
workspaceCwd: tab['workspaceCwd'],
|
|
workspaceId: tab['workspaceId'],
|
|
previewMimeType: tab['previewMimeType'],
|
|
previewOnly: tab['previewOnly'],
|
|
sourcePreview: tab['sourcePreview'],
|
|
attachmentId: tab['attachmentId'],
|
|
sourceSessionId: tab['sourceSessionId'],
|
|
} as PersistedArtifactPanelTab;
|
|
case 'artifact':
|
|
if (typeof tab['artifactId'] !== 'string') return;
|
|
return {
|
|
...common,
|
|
kind: 'artifact',
|
|
artifactId: tab['artifactId'],
|
|
workspaceCwd: tab['workspaceCwd'],
|
|
workspaceId: tab['workspaceId'],
|
|
sourceSessionId: tab['sourceSessionId'],
|
|
} as PersistedArtifactPanelTab;
|
|
case 'scheduled_task':
|
|
if (typeof tab['toolCallId'] !== 'string') return;
|
|
return {
|
|
...common,
|
|
kind: 'scheduled_task',
|
|
toolCallId: tab['toolCallId'],
|
|
sourceSessionId: tab['sourceSessionId'],
|
|
workspaceCwd: tab['workspaceCwd'],
|
|
workspaceId: tab['workspaceId'],
|
|
} as PersistedArtifactPanelTab;
|
|
case 'image': {
|
|
const source = tab['source'];
|
|
if (
|
|
!source ||
|
|
typeof source !== 'object' ||
|
|
Array.isArray(source) ||
|
|
(source as Record<string, unknown>)['kind'] !== 'attachment' ||
|
|
typeof (source as Record<string, unknown>)['attachmentId'] !==
|
|
'string' ||
|
|
((source as Record<string, unknown>)['sessionId'] !== undefined &&
|
|
typeof (source as Record<string, unknown>)['sessionId'] !== 'string')
|
|
) {
|
|
return;
|
|
}
|
|
const attachmentSource = source as Record<string, unknown>;
|
|
return {
|
|
...common,
|
|
kind: 'image',
|
|
alt: tab['alt'],
|
|
source: {
|
|
kind: 'attachment',
|
|
attachmentId: attachmentSource['attachmentId'],
|
|
sessionId: attachmentSource['sessionId'],
|
|
},
|
|
} as PersistedArtifactPanelTab;
|
|
}
|
|
case 'subagent':
|
|
if (
|
|
typeof tab['sessionId'] !== 'string' ||
|
|
typeof tab['rootToolCallId'] !== 'string'
|
|
) {
|
|
return;
|
|
}
|
|
return {
|
|
...common,
|
|
kind: 'subagent',
|
|
sessionId: tab['sessionId'],
|
|
rootToolCallId: tab['rootToolCallId'],
|
|
workspaceCwd: tab['workspaceCwd'],
|
|
} as PersistedArtifactPanelTab;
|
|
case 'monitor':
|
|
case 'shell':
|
|
if (typeof tab['taskId'] !== 'string') return;
|
|
return {
|
|
...common,
|
|
kind: tab['kind'],
|
|
taskId: tab['taskId'],
|
|
sessionId: tab['sessionId'],
|
|
} as PersistedArtifactPanelTab;
|
|
case 'side_task':
|
|
if (
|
|
typeof tab['sessionId'] !== 'string' ||
|
|
typeof tab['parentSessionId'] !== 'string'
|
|
) {
|
|
return;
|
|
}
|
|
return {
|
|
...common,
|
|
kind: 'side_task',
|
|
sessionId: tab['sessionId'],
|
|
parentSessionId: tab['parentSessionId'],
|
|
workspaceCwd: tab['workspaceCwd'],
|
|
} as PersistedArtifactPanelTab;
|
|
case 'web_preview':
|
|
if (
|
|
typeof tab['url'] !== 'string' ||
|
|
(tab['viewport'] !== 'desktop' && tab['viewport'] !== 'mobile')
|
|
) {
|
|
return;
|
|
}
|
|
return {
|
|
...common,
|
|
kind: 'web_preview',
|
|
url: tab['url'],
|
|
viewport: tab['viewport'],
|
|
};
|
|
case 'terminal':
|
|
return {
|
|
...common,
|
|
kind: 'terminal',
|
|
workspaceCwd: tab['workspaceCwd'],
|
|
} as PersistedArtifactPanelTab;
|
|
case 'trajectory':
|
|
if (typeof tab['sessionId'] !== 'string') return;
|
|
return {
|
|
...common,
|
|
kind: 'trajectory',
|
|
sessionId: tab['sessionId'],
|
|
} as PersistedArtifactPanelTab;
|
|
case 'token_usage':
|
|
case 'context_usage':
|
|
if (typeof tab['sessionId'] !== 'string') return;
|
|
return {
|
|
...common,
|
|
kind: tab['kind'],
|
|
sessionId: tab['sessionId'],
|
|
closeWithPane: tab['closeWithPane'],
|
|
} as PersistedArtifactPanelTab;
|
|
case 'workflow':
|
|
return {
|
|
...common,
|
|
kind: 'workflow',
|
|
sessionId: tab['sessionId'],
|
|
} as PersistedArtifactPanelTab;
|
|
default:
|
|
return;
|
|
}
|
|
}
|
|
|
|
// localStorage holds only stable lookup keys. Runtime snapshots, transcript
|
|
// content, Blobs, data URLs and live actions are rebuilt after session load.
|
|
function serializeArtifactPanelTabs(
|
|
tabs: readonly ArtifactPanelTab[],
|
|
): PersistedArtifactPanelTab[] {
|
|
return tabs.flatMap((tab): PersistedArtifactPanelTab[] => {
|
|
const { id, title } = tab;
|
|
switch (tab.kind) {
|
|
case 'files':
|
|
return [tab];
|
|
case 'web_preview':
|
|
return [
|
|
{ id, title, kind: tab.kind, url: tab.url, viewport: tab.viewport },
|
|
];
|
|
case 'source':
|
|
return [];
|
|
case 'turn_calls':
|
|
// Projection-local turn IDs can identify a different turn after reload.
|
|
if (!tab.recordId && !tab.promptId) return [];
|
|
return [
|
|
{
|
|
id: tab.id,
|
|
title,
|
|
kind: tab.kind,
|
|
turnId: tab.turnId,
|
|
recordId: tab.recordId,
|
|
promptId: tab.promptId,
|
|
},
|
|
];
|
|
case 'review':
|
|
return [
|
|
{
|
|
id,
|
|
kind: tab.kind,
|
|
title,
|
|
workspaceCwd: tab.workspaceCwd,
|
|
workspaceId: tab.workspaceId,
|
|
selectedPath: tab.selectedPath,
|
|
sourceTurnId: tab.sourceTurnId,
|
|
sourceSessionId: tab.sourceSessionId,
|
|
sourceToolCallIds: tab.sourceToolCallIds,
|
|
},
|
|
];
|
|
case 'file':
|
|
return tab.previewOnly && !tab.attachmentId
|
|
? []
|
|
: [
|
|
{
|
|
id,
|
|
kind: tab.kind,
|
|
title,
|
|
workspacePath: tab.workspacePath,
|
|
workspaceCwd: tab.workspaceCwd,
|
|
workspaceId: tab.workspaceId,
|
|
previewMimeType: tab.previewMimeType,
|
|
previewOnly: tab.previewOnly,
|
|
sourcePreview: tab.sourcePreview,
|
|
attachmentId: tab.attachmentId,
|
|
sourceSessionId: tab.sourceSessionId,
|
|
},
|
|
];
|
|
case 'artifact':
|
|
return [
|
|
{
|
|
id,
|
|
kind: tab.kind,
|
|
title,
|
|
artifactId: tab.artifactId,
|
|
workspaceCwd: tab.workspaceCwd,
|
|
workspaceId: tab.workspaceId,
|
|
sourceSessionId: tab.sourceSessionId,
|
|
},
|
|
];
|
|
case 'scheduled_task':
|
|
return [
|
|
{
|
|
id,
|
|
kind: tab.kind,
|
|
title,
|
|
toolCallId: tab.task.toolCallId,
|
|
sourceSessionId: tab.sourceSessionId,
|
|
workspaceCwd: tab.workspaceCwd,
|
|
workspaceId: tab.workspaceId,
|
|
},
|
|
];
|
|
case 'image':
|
|
return tab.source
|
|
? [
|
|
{
|
|
id,
|
|
kind: tab.kind,
|
|
title,
|
|
alt: tab.alt,
|
|
source: tab.source,
|
|
},
|
|
]
|
|
: [];
|
|
case 'subagent':
|
|
return [
|
|
{
|
|
id,
|
|
kind: tab.kind,
|
|
title,
|
|
sessionId: tab.sessionId,
|
|
rootToolCallId: tab.rootToolCallId,
|
|
workspaceCwd: tab.workspaceCwd,
|
|
},
|
|
];
|
|
case 'monitor':
|
|
case 'shell':
|
|
return [
|
|
{
|
|
id,
|
|
kind: tab.kind,
|
|
title,
|
|
taskId: tab.task.id,
|
|
sessionId: tab.sessionId,
|
|
},
|
|
];
|
|
case 'side_task':
|
|
return tab.sessionId
|
|
? [
|
|
{
|
|
id,
|
|
kind: tab.kind,
|
|
title,
|
|
sessionId: tab.sessionId,
|
|
parentSessionId: tab.parentSessionId,
|
|
workspaceCwd: tab.workspaceCwd,
|
|
},
|
|
]
|
|
: [];
|
|
case 'terminal':
|
|
return [
|
|
{
|
|
id,
|
|
kind: tab.kind,
|
|
title,
|
|
workspaceCwd: tab.workspaceCwd,
|
|
},
|
|
];
|
|
case 'trajectory':
|
|
return [{ id, kind: tab.kind, title, sessionId: tab.sessionId }];
|
|
case 'token_usage':
|
|
case 'context_usage':
|
|
return tab.sessionId
|
|
? [
|
|
{
|
|
id,
|
|
kind: tab.kind,
|
|
title,
|
|
sessionId: tab.sessionId,
|
|
closeWithPane: tab.closeWithPane,
|
|
},
|
|
]
|
|
: [];
|
|
case 'workflow':
|
|
return [{ id, kind: tab.kind, title, sessionId: tab.sessionId }];
|
|
case 'pending': {
|
|
const common = {
|
|
id,
|
|
title,
|
|
};
|
|
switch (tab.targetKind) {
|
|
case 'review':
|
|
return [
|
|
{
|
|
...common,
|
|
kind: 'review',
|
|
sourceSessionId: tab.sourceSessionId,
|
|
sourceTurnId: tab.sourceTurnId,
|
|
sourceToolCallIds: tab.sourceToolCallIds,
|
|
selectedPath: tab.selectedPath,
|
|
workspaceCwd: tab.workspaceCwd,
|
|
workspaceId: tab.workspaceId,
|
|
},
|
|
];
|
|
case 'artifact':
|
|
return tab.artifactId
|
|
? [
|
|
{
|
|
...common,
|
|
kind: 'artifact',
|
|
artifactId: tab.artifactId,
|
|
sourceSessionId: tab.sourceSessionId,
|
|
workspaceCwd: tab.workspaceCwd,
|
|
workspaceId: tab.workspaceId,
|
|
},
|
|
]
|
|
: [];
|
|
case 'scheduled_task':
|
|
return tab.toolCallId
|
|
? [
|
|
{
|
|
...common,
|
|
kind: 'scheduled_task',
|
|
sourceSessionId: tab.sourceSessionId,
|
|
toolCallId: tab.toolCallId,
|
|
workspaceCwd: tab.workspaceCwd,
|
|
workspaceId: tab.workspaceId,
|
|
},
|
|
]
|
|
: [];
|
|
case 'subagent':
|
|
return tab.rootToolCallId
|
|
? [
|
|
{
|
|
id,
|
|
kind: 'subagent',
|
|
title,
|
|
sessionId: tab.sourceSessionId,
|
|
rootToolCallId: tab.rootToolCallId,
|
|
workspaceCwd: tab.workspaceCwd,
|
|
},
|
|
]
|
|
: [];
|
|
case 'monitor':
|
|
case 'shell':
|
|
return tab.taskId
|
|
? [
|
|
{
|
|
...common,
|
|
kind: tab.targetKind,
|
|
taskId: tab.taskId,
|
|
sessionId: tab.sourceSessionId,
|
|
},
|
|
]
|
|
: [];
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function mergePersistedArtifactPanelTabs(
|
|
tabs: readonly PersistedArtifactPanelTab[],
|
|
retainedTabs: readonly PersistedArtifactPanelTab[],
|
|
previousOrder: readonly PersistedArtifactPanelTab[] = retainedTabs,
|
|
): PersistedArtifactPanelTab[] {
|
|
const byId = new Map(retainedTabs.map((tab) => [tab.id, tab]));
|
|
for (const tab of tabs) byId.set(tab.id, tab);
|
|
const merged = previousOrder.flatMap((tab) => {
|
|
const current = byId.get(tab.id);
|
|
if (!current) return [];
|
|
byId.delete(tab.id);
|
|
return [current];
|
|
});
|
|
return [...merged, ...byId.values()];
|
|
}
|
|
|
|
function findReviewChangesByToolCallIds(
|
|
changesByTurn: ReadonlyMap<string, readonly TurnOutputFileChange[]>,
|
|
toolCallIds: readonly string[] | undefined,
|
|
): readonly TurnOutputFileChange[] | undefined {
|
|
if (!toolCallIds?.length) return undefined;
|
|
const ids = new Set(toolCallIds);
|
|
return [...changesByTurn.values()].find((changes) =>
|
|
changes.some((change) => ids.has(change.toolCallId)),
|
|
);
|
|
}
|
|
|
|
function getEvictedArtifactPanelStateKeys(keys: Iterable<string>): string[] {
|
|
const entries = [...keys];
|
|
return [false, true].flatMap((draft) =>
|
|
entries
|
|
.filter((key) => key.startsWith(DRAFT_PANEL_KEY_PREFIX) === draft)
|
|
.slice(0, -MAX_ARTIFACT_PANEL_SESSION_STATES),
|
|
);
|
|
}
|
|
|
|
function readStoredArtifactPanelStates(
|
|
key: string,
|
|
): Record<string, ArtifactPanelPersistedState> {
|
|
if (typeof window === 'undefined') return {};
|
|
try {
|
|
const stored = window.localStorage.getItem(key);
|
|
if (!stored) return {};
|
|
const parsed = JSON.parse(stored) as unknown;
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
return {};
|
|
}
|
|
const storedStates = { ...(parsed as Record<string, unknown>) };
|
|
if (storedStates['v'] !== undefined && storedStates['v'] !== 1) return {};
|
|
delete storedStates['v'];
|
|
const result: Record<string, ArtifactPanelPersistedState> = {};
|
|
for (const [sessionKey, value] of Object.entries(storedStates)) {
|
|
if (!value || typeof value !== 'object') continue;
|
|
const state = value as Record<string, unknown>;
|
|
if (typeof state['open'] !== 'boolean') continue;
|
|
result[sessionKey] = {
|
|
open: state['open'],
|
|
activeTabId:
|
|
typeof state['activeTabId'] === 'string'
|
|
? state['activeTabId']
|
|
: null,
|
|
tabs: Array.isArray(state['tabs'])
|
|
? state['tabs']
|
|
.map(parsePersistedArtifactPanelTab)
|
|
.filter((tab): tab is PersistedArtifactPanelTab => Boolean(tab))
|
|
: [],
|
|
};
|
|
}
|
|
for (const evictedKey of getEvictedArtifactPanelStateKeys(
|
|
Object.keys(result),
|
|
)) {
|
|
delete result[evictedKey];
|
|
}
|
|
return result;
|
|
} catch {
|
|
// localStorage can be unavailable in private or embedded contexts.
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function writeStoredArtifactPanelStates(
|
|
key: string,
|
|
states: Record<string, ArtifactPanelPersistedState>,
|
|
): void {
|
|
if (typeof window === 'undefined') return;
|
|
try {
|
|
for (const evictedKey of getEvictedArtifactPanelStateKeys(
|
|
Object.keys(states),
|
|
)) {
|
|
delete states[evictedKey];
|
|
}
|
|
window.localStorage.setItem(key, JSON.stringify({ v: 1, ...states }));
|
|
} catch (error) {
|
|
console.warn('[web-shell] failed to persist right panel state:', error);
|
|
}
|
|
}
|
|
|
|
function getDefaultChatWidthMode(): ChatWidthMode {
|
|
return `${DEFAULT_CHAT_MAX_WIDTH}`;
|
|
}
|
|
|
|
function readChatWidthMode(): ChatWidthMode {
|
|
if (typeof window === 'undefined') return getDefaultChatWidthMode();
|
|
try {
|
|
return window.localStorage.getItem(CHAT_WIDTH_STORAGE_KEY) === 'wide'
|
|
? 'wide'
|
|
: getDefaultChatWidthMode();
|
|
} catch {
|
|
return getDefaultChatWidthMode();
|
|
}
|
|
}
|
|
|
|
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<WebShellComposerApi> | undefined,
|
|
value: WebShellComposerApi,
|
|
): void {
|
|
if (!ref) return;
|
|
if (typeof ref === 'function') {
|
|
ref(value);
|
|
return;
|
|
}
|
|
(ref as React.MutableRefObject<WebShellComposerApi | null>).current = value;
|
|
}
|
|
|
|
function assignShellRef(
|
|
ref: React.Ref<WebShellApi> | undefined,
|
|
value: WebShellApi | null,
|
|
): void {
|
|
if (!ref) return;
|
|
if (typeof ref === 'function') {
|
|
ref(value);
|
|
return;
|
|
}
|
|
(ref as React.MutableRefObject<WebShellApi | null>).current = value;
|
|
}
|
|
|
|
function areSessionIdsEqual(
|
|
a: readonly string[],
|
|
b: readonly string[],
|
|
): boolean {
|
|
return a.length === b.length && a.every((id, index) => id === b[index]);
|
|
}
|
|
|
|
function getInitialLanguage(): WebShellLanguage {
|
|
if (typeof window === 'undefined') return 'en';
|
|
const params = new URLSearchParams(window.location.search);
|
|
return normalizeLanguage(
|
|
params.get('language') ?? params.get('lang') ?? navigator.language,
|
|
);
|
|
}
|
|
|
|
function cockpitViewRequested(): boolean {
|
|
return (
|
|
typeof window !== 'undefined' &&
|
|
new URLSearchParams(window.location.search).get('view') === 'cockpit'
|
|
);
|
|
}
|
|
|
|
function updateCockpitLocation(open: boolean, replace = false): void {
|
|
const url = new URL(window.location.href);
|
|
if (open) {
|
|
url.searchParams.set('view', 'cockpit');
|
|
} else {
|
|
url.searchParams.delete('view');
|
|
}
|
|
window.history[replace ? 'replaceState' : 'pushState'](
|
|
window.history.state,
|
|
'',
|
|
url,
|
|
);
|
|
}
|
|
|
|
function formatError(error: unknown, fallback: string): string {
|
|
return error instanceof Error ? error.message : fallback;
|
|
}
|
|
|
|
function isAbortError(error: unknown): boolean {
|
|
return (
|
|
(error instanceof DOMException && error.name === 'AbortError') ||
|
|
(error instanceof Error && error.name === 'AbortError')
|
|
);
|
|
}
|
|
|
|
interface AlreadyDispatchedError extends Error {
|
|
_alreadyDispatched: true;
|
|
}
|
|
|
|
function isAlreadyDispatched(error: unknown): error is AlreadyDispatchedError {
|
|
return (
|
|
typeof error === 'object' &&
|
|
error !== null &&
|
|
(error as AlreadyDispatchedError)._alreadyDispatched === true
|
|
);
|
|
}
|
|
|
|
function shouldToastNotice(notice: DaemonSessionNotice): boolean {
|
|
return (
|
|
notice.category === 'validation' ||
|
|
notice.category === 'user_action' ||
|
|
notice.category === 'system'
|
|
);
|
|
}
|
|
|
|
function toastToneFromNotice(notice: DaemonSessionNotice): ToastTone {
|
|
if (notice.severity === 'warning') return 'warning';
|
|
if (notice.severity === 'info') return 'info';
|
|
return 'error';
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null;
|
|
}
|
|
|
|
function getModelSwitchSummary(result: unknown): ModelSwitchSummary | null {
|
|
if (!isRecord(result)) return null;
|
|
const meta = result._meta;
|
|
if (!isRecord(meta)) return null;
|
|
const summary = meta.qwenModelSwitch;
|
|
if (!isRecord(summary)) return null;
|
|
const authType = summary.authType;
|
|
const modelId = summary.modelId;
|
|
const baseUrl = summary.baseUrl;
|
|
const apiKey = summary.apiKey;
|
|
if (
|
|
typeof authType !== 'string' ||
|
|
typeof modelId !== 'string' ||
|
|
typeof baseUrl !== 'string' ||
|
|
typeof apiKey !== 'string'
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
authType,
|
|
modelId,
|
|
baseUrl,
|
|
apiKey,
|
|
...(typeof summary.isRuntime === 'boolean'
|
|
? { isRuntime: summary.isRuntime }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
function serializeModelSwitchSummary(
|
|
summary: ModelSwitchSummary,
|
|
displayName: string,
|
|
t: (key: string, vars?: Record<string, string | number>) => string,
|
|
): string {
|
|
return t('model.usingModel', {
|
|
isRuntime: summary.isRuntime ? 1 : 0,
|
|
modelId: displayName,
|
|
});
|
|
}
|
|
|
|
function isEditToolPermission(request: PermissionRequest): boolean {
|
|
return request.toolKind === 'edit';
|
|
}
|
|
|
|
function parseRenameArgument(
|
|
raw: string,
|
|
):
|
|
| { type: 'auto' }
|
|
| { type: 'manual'; displayName: string }
|
|
| { type: 'delegate' } {
|
|
const trimmed = raw.trim().replace(/[\r\n]+/g, ' ');
|
|
if (!trimmed) return { type: 'auto' };
|
|
if (trimmed === '--') return { type: 'manual', displayName: '' };
|
|
if (trimmed.startsWith('-- ')) {
|
|
return { type: 'manual', displayName: trimmed.slice(3).trim() };
|
|
}
|
|
if (trimmed.toLowerCase() === '--auto') return { type: 'auto' };
|
|
if (trimmed.startsWith('--')) return { type: 'delegate' };
|
|
return { type: 'manual', displayName: trimmed };
|
|
}
|
|
|
|
export { getTaskActivityKey } from './utils/taskActivity';
|
|
|
|
export function mergeMonitorTaskSnapshot(
|
|
current: DaemonSessionMonitorTaskStatus,
|
|
next: DaemonSessionMonitorTaskStatus,
|
|
): DaemonSessionMonitorTaskStatus {
|
|
return current.status !== 'running' && next.status === 'running'
|
|
? current
|
|
: next;
|
|
}
|
|
|
|
function mergeShellTaskSnapshot(
|
|
current: DaemonSessionShellTaskStatus,
|
|
next: DaemonSessionShellTaskStatus,
|
|
): DaemonSessionShellTaskStatus {
|
|
return current.status !== 'running' && next.status === 'running'
|
|
? current
|
|
: next;
|
|
}
|
|
|
|
interface SideTaskCatalogState {
|
|
parentSessionId?: string;
|
|
items: SideTaskListItem[];
|
|
loaded: boolean;
|
|
}
|
|
|
|
export function canArchiveSideTaskSummary(
|
|
session: Pick<
|
|
DaemonSessionSummary,
|
|
'activeWorkState' | 'hasActivePrompt' | 'hasRunningBackgroundTasks'
|
|
>,
|
|
): boolean {
|
|
return (
|
|
!session.hasActivePrompt &&
|
|
!session.hasRunningBackgroundTasks &&
|
|
(session.activeWorkState === undefined ||
|
|
session.activeWorkState === 'idle')
|
|
);
|
|
}
|
|
|
|
// Merge a fresh side-task listing into the cached catalog. The listing is
|
|
// authoritative: a cached item survives only while it is still listed or is a
|
|
// locally created draft the daemon has not echoed back yet (optimisticIds).
|
|
// Without the optimistic guard, a task deleted or archived on another client
|
|
// would be re-added from the cache forever.
|
|
export function mergeSideTaskCatalog(
|
|
catalog: SideTaskCatalogState,
|
|
parentSessionId: string,
|
|
listedItems: SideTaskListItem[],
|
|
optimisticIds: ReadonlySet<string>,
|
|
): SideTaskCatalogState {
|
|
if (catalog.parentSessionId !== parentSessionId) {
|
|
return { parentSessionId, items: listedItems, loaded: true };
|
|
}
|
|
const listedIds = new Set(listedItems.map((item) => item.sessionId));
|
|
return {
|
|
parentSessionId,
|
|
loaded: true,
|
|
items: [
|
|
...listedItems,
|
|
...catalog.items.filter(
|
|
(item) =>
|
|
!listedIds.has(item.sessionId) && optimisticIds.has(item.sessionId),
|
|
),
|
|
],
|
|
};
|
|
}
|
|
|
|
function agentStatusFromTool(
|
|
tool: ACPToolCall,
|
|
): DaemonSessionAgentTaskStatus['status'] {
|
|
if (tool.status === 'pending' || tool.status === 'in_progress') {
|
|
return 'running';
|
|
}
|
|
if (tool.status === 'failed') return 'failed';
|
|
const rawOutput = isRecord(tool.rawOutput) ? tool.rawOutput : undefined;
|
|
if (rawOutput?.['status'] === 'cancelled') return 'cancelled';
|
|
return rawOutput?.['status'] === 'failed' ? 'failed' : 'completed';
|
|
}
|
|
|
|
function agentTaskAsToolCall(task: DaemonSessionAgentTaskStatus): ACPToolCall {
|
|
const status =
|
|
task.status === 'running' || task.status === 'paused'
|
|
? 'in_progress'
|
|
: task.status === 'failed'
|
|
? 'failed'
|
|
: 'completed';
|
|
return {
|
|
callId: task.toolUseId ?? task.id,
|
|
toolName: 'agent',
|
|
title: `Agent: ${task.label}`,
|
|
status,
|
|
args: {
|
|
description: task.description,
|
|
...(task.prompt ? { prompt: task.prompt } : {}),
|
|
...(task.subagentType ? { subagent_type: task.subagentType } : {}),
|
|
run_in_background: task.isBackgrounded,
|
|
},
|
|
rawOutput: {
|
|
type: 'task_execution',
|
|
subagentName: task.subagentType,
|
|
status: task.status,
|
|
},
|
|
startTime: task.startTime,
|
|
...(task.endTime !== undefined ? { endTime: task.endTime } : {}),
|
|
};
|
|
}
|
|
|
|
function isEnvironmentAgentToolCall(tool: ACPToolCall): boolean {
|
|
const name = tool.toolName.toLowerCase();
|
|
if (name === 'agent' || name === 'task') return true;
|
|
if (typeof tool.args?.subagent_type === 'string') return true;
|
|
return (
|
|
isRecord(tool.rawOutput) && tool.rawOutput['type'] === 'task_execution'
|
|
);
|
|
}
|
|
|
|
function derivedTaskIdForTool(tool: ACPToolCall): string | undefined {
|
|
const rawOutput = isRecord(tool.rawOutput) ? tool.rawOutput : undefined;
|
|
const subagentName =
|
|
typeof rawOutput?.['subagentName'] === 'string'
|
|
? rawOutput['subagentName']
|
|
: typeof tool.args?.name === 'string'
|
|
? tool.args.name
|
|
: undefined;
|
|
const subagentType =
|
|
typeof tool.args?.subagent_type === 'string'
|
|
? tool.args.subagent_type
|
|
: undefined;
|
|
return subagentName
|
|
? `${subagentName}-${tool.callId}`
|
|
: subagentType
|
|
? `${subagentType}-${tool.callId}`
|
|
: undefined;
|
|
}
|
|
|
|
export function getEnvironmentAgentTasks(
|
|
messages: readonly Message[],
|
|
sessionTasks: readonly DaemonSessionTaskWithWorkflowStatus[],
|
|
): EnvironmentAgentTask[] {
|
|
const liveAgents = sessionTasks.filter(
|
|
(task): task is DaemonSessionAgentTaskStatus => task.kind === 'agent',
|
|
);
|
|
const taskIdsByToolUseId = new Map<string, string>();
|
|
for (const message of messages) {
|
|
if (message.role !== 'system' || !isRecord(message.data)) continue;
|
|
const taskId = message.data['taskId'];
|
|
const toolUseId = message.data['toolUseId'];
|
|
if (typeof taskId === 'string' && typeof toolUseId === 'string') {
|
|
taskIdsByToolUseId.set(toolUseId, taskId);
|
|
}
|
|
}
|
|
|
|
// A live task already linked precisely (by toolUseId, message taskId, or
|
|
// derived id) to some transcript tool call must never be claimed by the loose
|
|
// content fallback: two agents sharing a description would otherwise collapse
|
|
// into one (the fallback steals the linked task, its owner re-matches it, and
|
|
// the orphan is dropped).
|
|
const envToolCallIds = new Set<string>();
|
|
const preciselyClaimedTaskIds = new Set<string>(taskIdsByToolUseId.values());
|
|
const collectPreciseLinks = (tools: readonly ACPToolCall[]) => {
|
|
for (const tool of tools) {
|
|
if (
|
|
isEnvironmentAgentToolCall(tool) &&
|
|
!envToolCallIds.has(tool.callId)
|
|
) {
|
|
envToolCallIds.add(tool.callId);
|
|
const derivedTaskId = derivedTaskIdForTool(tool);
|
|
if (derivedTaskId) preciselyClaimedTaskIds.add(derivedTaskId);
|
|
}
|
|
if (tool.subTools) collectPreciseLinks(tool.subTools);
|
|
}
|
|
};
|
|
for (const message of messages) {
|
|
if (message.role === 'tool_group') collectPreciseLinks(message.tools);
|
|
}
|
|
const isPreciselyClaimed = (task: DaemonSessionAgentTaskStatus): boolean =>
|
|
(task.toolUseId != null && envToolCallIds.has(task.toolUseId)) ||
|
|
preciselyClaimedTaskIds.has(task.id);
|
|
|
|
const agents: EnvironmentAgentTask[] = [];
|
|
const seenTaskIds = new Set<string>();
|
|
const seenToolCallIds = new Set<string>();
|
|
const visit = (tools: readonly ACPToolCall[]) => {
|
|
for (const tool of tools) {
|
|
if (
|
|
isEnvironmentAgentToolCall(tool) &&
|
|
!seenToolCallIds.has(tool.callId)
|
|
) {
|
|
seenToolCallIds.add(tool.callId);
|
|
const rawOutput = isRecord(tool.rawOutput) ? tool.rawOutput : undefined;
|
|
const color =
|
|
typeof rawOutput?.['subagentColor'] === 'string'
|
|
? rawOutput['subagentColor']
|
|
: undefined;
|
|
const description =
|
|
typeof tool.args?.description === 'string'
|
|
? tool.args.description
|
|
: undefined;
|
|
const prompt =
|
|
typeof tool.args?.prompt === 'string' ? tool.args.prompt : undefined;
|
|
const subagentType =
|
|
typeof tool.args?.subagent_type === 'string'
|
|
? tool.args.subagent_type
|
|
: undefined;
|
|
const subagentName =
|
|
typeof rawOutput?.['subagentName'] === 'string'
|
|
? rawOutput['subagentName']
|
|
: typeof tool.args?.name === 'string'
|
|
? tool.args.name
|
|
: undefined;
|
|
const taskId = taskIdsByToolUseId.get(tool.callId);
|
|
const derivedTaskId = derivedTaskIdForTool(tool);
|
|
// Completed background agents can lose their toolUseId / derived-id
|
|
// linkage (e.g. across a daemon reload); fall back to content matching,
|
|
// the same signal the daemon's legacy resolver uses.
|
|
const matchesLiveTaskContent = (
|
|
task: DaemonSessionAgentTaskStatus,
|
|
): boolean => {
|
|
if (prompt && task.prompt === prompt) return true;
|
|
if (
|
|
description &&
|
|
task.description === description &&
|
|
subagentType &&
|
|
task.subagentType === subagentType
|
|
) {
|
|
return true;
|
|
}
|
|
return !!description && task.description === description;
|
|
};
|
|
const liveTask = liveAgents.find(
|
|
(task) =>
|
|
task.toolUseId === tool.callId ||
|
|
task.id === taskId ||
|
|
task.id === derivedTaskId ||
|
|
(!seenTaskIds.has(task.id) &&
|
|
!isPreciselyClaimed(task) &&
|
|
matchesLiveTaskContent(task)),
|
|
);
|
|
const title = tool.title?.replace(/^Agent:\s*/i, '').trim();
|
|
const meaningfulTitle =
|
|
title && title.toLowerCase() !== 'agent' ? title : undefined;
|
|
const label =
|
|
meaningfulTitle ??
|
|
description ??
|
|
prompt ??
|
|
subagentName ??
|
|
subagentType ??
|
|
'';
|
|
const taskDescription =
|
|
description ?? prompt ?? subagentName ?? subagentType ?? '';
|
|
const startTime = tool.startTime ?? 0;
|
|
|
|
agents.push(
|
|
liveTask
|
|
? {
|
|
...liveTask,
|
|
toolUseId: tool.callId,
|
|
label,
|
|
description: taskDescription || liveTask.description,
|
|
...(subagentType ? { subagentType } : {}),
|
|
...(color ? { color } : {}),
|
|
}
|
|
: {
|
|
kind: 'agent',
|
|
id: taskId ?? derivedTaskId ?? tool.callId,
|
|
label,
|
|
description: taskDescription,
|
|
status: agentStatusFromTool(tool),
|
|
startTime,
|
|
...(tool.endTime !== undefined
|
|
? { endTime: tool.endTime }
|
|
: {}),
|
|
runtimeMs: Math.max(
|
|
0,
|
|
(tool.endTime ?? tool.startTime ?? startTime) - startTime,
|
|
),
|
|
...(subagentType ? { subagentType } : {}),
|
|
...(color ? { color } : {}),
|
|
isBackgrounded: isBackgroundSubAgentToolCall(tool),
|
|
toolUseId: tool.callId,
|
|
},
|
|
);
|
|
if (liveTask) seenTaskIds.add(liveTask.id);
|
|
}
|
|
if (tool.subTools) visit(tool.subTools);
|
|
}
|
|
};
|
|
|
|
for (const message of messages) {
|
|
if (message.role === 'tool_group') visit(message.tools);
|
|
}
|
|
for (const task of liveAgents) {
|
|
if (
|
|
seenTaskIds.has(task.id) ||
|
|
(task.toolUseId && seenToolCallIds.has(task.toolUseId))
|
|
) {
|
|
continue;
|
|
}
|
|
const alreadyListed = agents.some(
|
|
(agent) =>
|
|
agent.id === task.id ||
|
|
(agent.toolUseId != null && agent.toolUseId === task.toolUseId),
|
|
);
|
|
if (alreadyListed) continue;
|
|
agents.push(task);
|
|
}
|
|
return agents;
|
|
}
|
|
|
|
export function mergeAgentTrace(
|
|
liveAgents: readonly EnvironmentAgentTask[],
|
|
trace: DaemonAgentTrace | undefined,
|
|
now = Date.now(),
|
|
): EnvironmentAgentTask[] {
|
|
if (!trace) return [...liveAgents];
|
|
const liveById = new Map(liveAgents.map((task) => [task.id, task]));
|
|
const liveByToolUseId = new Map(
|
|
liveAgents.flatMap((task) =>
|
|
task.toolUseId ? [[task.toolUseId, task] as const] : [],
|
|
),
|
|
);
|
|
const tracedIds = new Set(trace.nodes.map((node) => node.agentId));
|
|
const tracedToolUseIds = new Set(
|
|
trace.nodes.flatMap((node) => (node.toolUseId ? [node.toolUseId] : [])),
|
|
);
|
|
const traced = [...trace.nodes]
|
|
.sort(
|
|
(left, right) =>
|
|
left.createdAt.localeCompare(right.createdAt) ||
|
|
left.agentId.localeCompare(right.agentId),
|
|
)
|
|
.map((node): EnvironmentAgentTask => {
|
|
const live =
|
|
liveById.get(node.agentId) ??
|
|
(node.toolUseId ? liveByToolUseId.get(node.toolUseId) : undefined);
|
|
const {
|
|
parentAgentId: _liveParentAgentId,
|
|
depth: _liveDepth,
|
|
...liveFields
|
|
} = live ?? {};
|
|
const startTime = Date.parse(node.createdAt) || live?.startTime || 0;
|
|
const status =
|
|
live?.status ??
|
|
(node.status === 'running' ? 'paused' : (node.status ?? 'completed'));
|
|
const persistedEndTime =
|
|
status !== 'running' && node.lastUpdatedAt
|
|
? Date.parse(node.lastUpdatedAt) || undefined
|
|
: undefined;
|
|
const endTime = live?.endTime ?? persistedEndTime;
|
|
return {
|
|
...liveFields,
|
|
kind: 'agent',
|
|
id: node.agentId,
|
|
isBackgrounded: live?.isBackgrounded ?? true,
|
|
label: live?.label ?? node.agentType,
|
|
description: live?.description ?? node.description,
|
|
status,
|
|
startTime,
|
|
runtimeMs:
|
|
live?.runtimeMs ??
|
|
(endTime !== undefined
|
|
? Math.max(0, endTime - startTime)
|
|
: status === 'running'
|
|
? Math.max(0, now - startTime)
|
|
: 0),
|
|
...(endTime !== undefined ? { endTime } : {}),
|
|
...(node.parentAgentId ? { parentAgentId: node.parentAgentId } : {}),
|
|
...(node.depth !== undefined ? { depth: node.depth } : {}),
|
|
...(node.toolUseId ? { toolUseId: node.toolUseId } : {}),
|
|
lineageState: node.lineageState,
|
|
};
|
|
});
|
|
return reorderChildrenUnderParents([
|
|
...traced,
|
|
...liveAgents.filter(
|
|
(task) =>
|
|
!tracedIds.has(task.id) &&
|
|
(!task.toolUseId || !tracedToolUseIds.has(task.toolUseId)),
|
|
),
|
|
]);
|
|
}
|
|
|
|
function findToolCall(
|
|
messages: readonly Message[],
|
|
callId: string,
|
|
): ACPToolCall | undefined {
|
|
const findNested = (
|
|
tools: readonly ACPToolCall[],
|
|
): ACPToolCall | undefined => {
|
|
for (const tool of tools) {
|
|
if (tool.callId === callId) return tool;
|
|
const nested = tool.subTools ? findNested(tool.subTools) : undefined;
|
|
if (nested) return nested;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
for (const message of messages) {
|
|
if (message.role !== 'tool_group') continue;
|
|
const tool = findNested(message.tools);
|
|
if (tool) return tool;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function transcriptEventsToMessages(events: readonly DaemonEvent[]): Message[] {
|
|
const state = createDaemonTranscriptState({
|
|
maxBlocks: Math.max(2_000, events.length),
|
|
maxRetainedBytes: Number.POSITIVE_INFINITY,
|
|
});
|
|
return transcriptBlocksToDaemonMessages(
|
|
reduceDaemonTranscriptEvents(
|
|
state,
|
|
events.flatMap((event) => normalizeDaemonEvent(event)),
|
|
).blocks,
|
|
);
|
|
}
|
|
|
|
function mapToWebShellTaskInfo(
|
|
task: DaemonSessionTaskWithWorkflowStatus,
|
|
): WebShellTaskInfo {
|
|
const base = {
|
|
id: task.id,
|
|
label: task.label,
|
|
description: task.description,
|
|
runtimeMs: task.runtimeMs,
|
|
startTime: task.startTime,
|
|
endTime: task.endTime,
|
|
error: task.error,
|
|
};
|
|
|
|
switch (task.kind) {
|
|
case 'agent':
|
|
return {
|
|
...base,
|
|
kind: 'agent',
|
|
status: task.status,
|
|
subagentType: task.subagentType,
|
|
isBackgrounded: task.isBackgrounded,
|
|
prompt: task.prompt,
|
|
};
|
|
case 'shell':
|
|
return {
|
|
...base,
|
|
kind: 'shell',
|
|
status: task.status,
|
|
command: task.command,
|
|
cwd: task.cwd,
|
|
pid: task.pid,
|
|
exitCode: task.exitCode,
|
|
};
|
|
case 'monitor':
|
|
return {
|
|
...base,
|
|
kind: 'monitor',
|
|
status: task.status,
|
|
command: task.command,
|
|
pid: task.pid,
|
|
exitCode: task.exitCode,
|
|
};
|
|
case 'workflow':
|
|
return {
|
|
...base,
|
|
kind: 'workflow',
|
|
status: task.status,
|
|
currentPhase: task.currentPhase ?? undefined,
|
|
agentsDispatched: task.agentsDispatched,
|
|
agentsCompleted: task.agentsCompleted,
|
|
tokensSpent: task.tokensSpent,
|
|
tokenBudgetTotal: task.tokenBudgetTotal ?? undefined,
|
|
};
|
|
default:
|
|
return task satisfies never;
|
|
}
|
|
}
|
|
|
|
function translateCopyMessage(
|
|
message: string,
|
|
t: ReturnType<typeof getTranslator>,
|
|
): string {
|
|
if (message === COPY_MESSAGES.NO_OUTPUT) return t('copy.noOutput');
|
|
if (message === COPY_MESSAGES.NO_TEXT) return t('copy.noText');
|
|
if (message === COPY_MESSAGES.CODE_MISSING) return t('copy.codeMissing');
|
|
if (message === COPY_MESSAGES.LATEX_MISSING) return t('copy.latexMissing');
|
|
if (message === COPY_MESSAGES.INLINE_LATEX_MISSING) {
|
|
return t('copy.inlineLatexMissing');
|
|
}
|
|
if (message === COPY_MESSAGES.OUTPUT_COPIED) return t('copy.outputCopied');
|
|
if (message.startsWith(COPY_MESSAGES.CLIPBOARD_PREFIX)) {
|
|
return `${t('copy.failedFallback')}. ${message.slice(
|
|
COPY_MESSAGES.CLIPBOARD_PREFIX.length,
|
|
)}`;
|
|
}
|
|
if (message.endsWith(COPY_MESSAGES.COPIED_SUFFIX)) {
|
|
return t('copy.toClipboard', {
|
|
label: message.slice(0, -COPY_MESSAGES.COPIED_SUFFIX.length),
|
|
});
|
|
}
|
|
return message;
|
|
}
|
|
|
|
function isSameGitStatus(
|
|
current: DaemonWorkspaceGitStatus | undefined,
|
|
next: DaemonWorkspaceGitStatus | undefined,
|
|
): boolean {
|
|
if (current === next) return true;
|
|
if (!current || !next) return false;
|
|
return (
|
|
current.v === next.v &&
|
|
current.workspaceCwd === next.workspaceCwd &&
|
|
current.branch === next.branch &&
|
|
current.detached === next.detached &&
|
|
current.staged === next.staged &&
|
|
current.unstaged === next.unstaged &&
|
|
current.untracked === next.untracked &&
|
|
current.conflicted === next.conflicted &&
|
|
current.hasUpstream === next.hasUpstream &&
|
|
current.ahead === next.ahead &&
|
|
current.behind === next.behind &&
|
|
current.stashCount === next.stashCount &&
|
|
current.operation === next.operation &&
|
|
current.worktreeSupported === next.worktreeSupported
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Read a model setting's value for the scope currently being edited. Model
|
|
* pickers persist to `modelSettingScope`, so their "current" value reflects
|
|
* only that scope's own value (not the merged/effective one) — otherwise the
|
|
* User tab would show, and appear to clear, an inherited workspace value.
|
|
*/
|
|
function readScopedModelSetting(
|
|
settings: ReadonlyArray<{
|
|
key: string;
|
|
values: { effective: unknown; user?: unknown; workspace?: unknown };
|
|
}>,
|
|
scope: 'workspace' | 'user',
|
|
key: string,
|
|
): unknown {
|
|
const setting = settings.find((s) => s.key === key);
|
|
if (!setting) return undefined;
|
|
return scope === 'user' ? setting.values.user : setting.values.workspace;
|
|
}
|
|
|
|
export function App({
|
|
onRefreshModels,
|
|
askUserFreeTextLabel,
|
|
onSessionIdChange,
|
|
onSessionInfoChange,
|
|
onModelChange,
|
|
onSessionCreated,
|
|
managedAgentProvider,
|
|
theme: providedTheme,
|
|
onThemeChange,
|
|
onThemeResolved,
|
|
language: providedLanguage,
|
|
onLanguageChange,
|
|
onLanguageResolved,
|
|
brand: providedBrand,
|
|
onBrandResolved,
|
|
className: externalClassName,
|
|
style: externalStyle,
|
|
shadowDom,
|
|
onConnectionChange,
|
|
onStreamingStateChange,
|
|
onError,
|
|
onBugReport,
|
|
hiddenSlashCommands,
|
|
slashCommandCategoryOrder,
|
|
onSlashCommand,
|
|
builtinAtProviders,
|
|
atProviders,
|
|
composerTagIcons,
|
|
fileUploadEnabled,
|
|
fileUploadDirectory,
|
|
artifact,
|
|
fileDropAction,
|
|
renderToolHeaderExtra,
|
|
renderWelcomeHeader,
|
|
renderWelcomeFooter,
|
|
mobileWelcomeFooterMiddle = false,
|
|
parseUserMessageContent,
|
|
renderUserMessageContent,
|
|
renderComposerTag,
|
|
renderComposerTagTooltip,
|
|
onComposerTagClick,
|
|
renderAssistantTurnFooter,
|
|
assistantFeedback,
|
|
getAssistantSourcesIcon,
|
|
sourceReferences,
|
|
renderComposerToolbarStart,
|
|
renderComposerToolbarEnd,
|
|
renderComposerToolbarRight,
|
|
renderComposerHeader,
|
|
renderComposerFooter,
|
|
renderChatHeader,
|
|
renderFooter,
|
|
bottomStatusItems,
|
|
chatMaxWidth,
|
|
sidebar,
|
|
settings: settingsPresentation,
|
|
modelManagement,
|
|
unavailableModels,
|
|
mountUnavailableModelTooltip,
|
|
header,
|
|
rightPanel,
|
|
showToolCalls = false,
|
|
environmentPanel,
|
|
splitSessionIds: externalSplitSessionIds,
|
|
onSplitSessionIdsChange,
|
|
renderPaneHeaderActions,
|
|
onRightPanelOpen,
|
|
onFileReviewOpen,
|
|
onWorkspaceFileOpen,
|
|
onPickWorkspaceDirectory,
|
|
onInsightReportOpen,
|
|
onContextUsageOpen,
|
|
messageTurnOutputs,
|
|
filterArtifact,
|
|
shellRef,
|
|
composerToolbarActions,
|
|
allowedApprovalModes: allowedApprovalModesProp,
|
|
composerVariant = 'default',
|
|
mainModelFilter,
|
|
compactComposerOverlays = false,
|
|
autoSubmitSlashCommands = false,
|
|
additionalSlashCommands = EMPTY_ADDITIONAL_SLASH_COMMANDS,
|
|
contextUsageAlwaysVisible = false,
|
|
userMessageEditing = true,
|
|
onUserMessageEditRequest,
|
|
cycleModeOnTab = false,
|
|
sessionSourceType = WEB_SHELL_SESSION_SOURCE_TYPE,
|
|
composerToolbarAdditionalActions,
|
|
composerPlaceholders,
|
|
compactThinking = false,
|
|
hostOwnsEditDiffPreview = false,
|
|
collapseCompletedTurns = true,
|
|
markdownTableMode = 'basic',
|
|
virtualScrollThreshold,
|
|
conversationSearchThreshold = 10,
|
|
markdown,
|
|
loadingPhrases,
|
|
onAgentTasksChange,
|
|
onTranscriptChange,
|
|
onSessionArtifactsChange,
|
|
onToast,
|
|
composerRef,
|
|
onComposerReady,
|
|
composerInput,
|
|
composerInputVersion,
|
|
onSessionChange,
|
|
onAssistantTurnSettled,
|
|
prepareSubmit,
|
|
onSubmitBefore,
|
|
restartSseOnPrompt,
|
|
historyPageSize,
|
|
initialSelectedWorkspaceCwd,
|
|
lockedWorkspaceCwd,
|
|
lockedWorkspaceCapability,
|
|
}: AppProps = {}) {
|
|
const modelManagementPolicy = resolveModelManagement(modelManagement);
|
|
const modelManagementRef = useRef(modelManagementPolicy);
|
|
modelManagementRef.current = modelManagementPolicy;
|
|
const navigation = useWebShellNavigation();
|
|
const navigationRef = useRef(navigation);
|
|
navigationRef.current = navigation;
|
|
const [managedHome, setManagedHome] = useState(
|
|
() =>
|
|
managedAgentProvider !== undefined &&
|
|
managedSelectionFromUrl(
|
|
navigation?.route.page !== 'chat' || !navigation.route.sessionId,
|
|
).open,
|
|
);
|
|
const managedMode = managedAgentProvider !== undefined && managedHome;
|
|
const managedModeRef = useRef(managedMode);
|
|
managedModeRef.current = managedMode;
|
|
const [managedNavigationTarget, setManagedNavigationTarget] =
|
|
useState<HTMLDivElement | null>(null);
|
|
const [archivedSessionsTarget, setArchivedSessionsTarget] =
|
|
useState<HTMLElement | null>(null);
|
|
const [managedArchivedNavigationTarget, setManagedArchivedNavigationTarget] =
|
|
useState<HTMLDivElement | null>(null);
|
|
const [managedArchivedStatus, setManagedArchivedStatus] = useState({
|
|
count: 0,
|
|
loading: false,
|
|
error: false,
|
|
});
|
|
const [managedNavigationSearch, setManagedNavigationSearch] = useState('');
|
|
const [managedWorkspaceId, setManagedWorkspaceId] = useState<string | null>();
|
|
const [managedWorkspaceCreationOpen, setManagedWorkspaceCreationOpen] =
|
|
useState(false);
|
|
const managedWorkspaceCreationOpenRef = useRef(managedWorkspaceCreationOpen);
|
|
managedWorkspaceCreationOpenRef.current = managedWorkspaceCreationOpen;
|
|
const managedProviderKey = managedAgentProvider?.storageKey;
|
|
const [managedWorkspaceCatalog, setManagedWorkspaceCatalog] = useState<{
|
|
providerKey: string;
|
|
workspaces: ManagedAgentWorkspace[];
|
|
}>();
|
|
const handleManagedWorkspacesChange = useCallback(
|
|
(workspaces: ManagedAgentWorkspace[]) => {
|
|
if (managedProviderKey) {
|
|
setManagedWorkspaceCatalog({
|
|
providerKey: managedProviderKey,
|
|
workspaces,
|
|
});
|
|
}
|
|
},
|
|
[managedProviderKey],
|
|
);
|
|
useAssistantTurnSettlementProjection(onAssistantTurnSettled);
|
|
const [chatWidthMode, setChatWidthMode] =
|
|
useState<ChatWidthMode>(readChatWidthMode);
|
|
const [selectedLanguage, setSelectedLanguage] = useState<WebShellLanguage>(
|
|
() =>
|
|
providedLanguage === undefined
|
|
? getInitialLanguage()
|
|
: normalizeLanguage(providedLanguage),
|
|
);
|
|
const t = useMemo(() => getTranslator(selectedLanguage), [selectedLanguage]);
|
|
const syncNotificationLanguage =
|
|
useBrowserNotificationSettings()?.syncLanguage;
|
|
useLayoutEffect(() => {
|
|
syncNotificationLanguage?.(selectedLanguage);
|
|
}, [selectedLanguage, syncNotificationLanguage]);
|
|
const shadowDomOptions = useMemo(
|
|
() => resolveWebShellShadowDom(shadowDom),
|
|
[shadowDom],
|
|
);
|
|
const sidebarOptions = useMemo(
|
|
() => resolveSidebarOptions(sidebar),
|
|
[sidebar],
|
|
);
|
|
// Menu defaults do not restrict page URLs; only explicit host lists do.
|
|
const hostSidebar = typeof sidebar === 'object' ? sidebar : undefined;
|
|
const showMobileAccess = header?.showMobileAccess ?? false;
|
|
const chatHeaderItems = header?.items ?? DEFAULT_CHAT_HEADER_ITEMS;
|
|
const chatHeaderEnabled =
|
|
chatHeaderItems.length > 0 && Boolean(header || renderChatHeader);
|
|
const titleHeaderItemVisible = chatHeaderItems.includes('title');
|
|
const environmentHeaderItemVisible = chatHeaderItems.includes('environment');
|
|
const rightPanelHeaderItemVisible = chatHeaderItems.includes('rightPanel');
|
|
const tokenUsageHeaderItemVisible = chatHeaderItems.includes('tokenUsage');
|
|
const contextUsageHeaderItemVisible =
|
|
chatHeaderItems.includes('contextUsage');
|
|
const rightPanelItems = rightPanel?.items ?? DEFAULT_RIGHT_PANEL_ITEMS;
|
|
// Workspace-scoped entries are the only ones usable before a session
|
|
// exists; the empty new-task page offers just those.
|
|
const emptyStateRightPanelItems = rightPanelItems.filter((item) =>
|
|
EMPTY_STATE_RIGHT_PANEL_ITEMS.includes(item),
|
|
);
|
|
const environmentPanelItems =
|
|
environmentPanel?.items ?? DEFAULT_ENVIRONMENT_PANEL_ITEMS;
|
|
// The environment panel is only reachable through the chat header toggle,
|
|
// so its sections replace the composer git entry / footer task pills only
|
|
// when that header is actually enabled. Embeddings that omit the header keep
|
|
// the legacy entries.
|
|
const environmentPanelReachable =
|
|
chatHeaderEnabled &&
|
|
environmentHeaderItemVisible &&
|
|
(!renderChatHeader || Boolean(header));
|
|
const environmentSourcesEnabled =
|
|
environmentPanelItems.includes('sources') ||
|
|
environmentPanelItems.includes('attachments');
|
|
const environmentGitReplacementEnabled =
|
|
environmentPanelReachable && environmentPanelItems.includes('environment');
|
|
const environmentTasksReplacementEnabled =
|
|
environmentPanelReachable &&
|
|
environmentPanelItems.includes('backgroundTasks');
|
|
const sidebarLayoutRef = useRef<HTMLDivElement>(null);
|
|
const [sidebarLayoutWidth, setSidebarLayoutWidth] = useState<number>();
|
|
const [liveVoiceSlot, setLiveVoiceSlot] = useState<HTMLDivElement | null>(
|
|
null,
|
|
);
|
|
// Fallback portal target in the Live page header: the sidebar's slot
|
|
// unmounts when the column is hidden (collapsed rail, closed compact
|
|
// drawer), and the chat composer is display:none on the Live page, so
|
|
// without it an ongoing call loses every visible control.
|
|
const [liveVoicePageSlot, setLiveVoicePageSlot] =
|
|
useState<HTMLDivElement | null>(null);
|
|
const [sidebarSection, setSidebarSection] = useState<
|
|
'home' | 'channels' | 'live'
|
|
>('home');
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() =>
|
|
readSidebarCollapsed(sidebarOptions.defaultCollapsed),
|
|
);
|
|
const [sidebarSwitchingSessionId, setSidebarSwitchingSessionId] = useState<
|
|
string | null
|
|
>(null);
|
|
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
|
const [forceMobileDrawer, setForceMobileDrawer] = useState(false);
|
|
const closeMobileDrawer = useCallback(() => {
|
|
setMobileDrawerOpen(false);
|
|
setForceMobileDrawer(false);
|
|
}, []);
|
|
// Split view still needs desktop-scale horizontal room.
|
|
const largeViewport = useIsLargeScreen();
|
|
const isLargeScreen =
|
|
sidebarLayoutWidth === undefined
|
|
? largeViewport
|
|
: sidebarLayoutWidth >= 1024;
|
|
const canDockArtifactPanel = useIsLargeScreen('(min-width: 1001px)');
|
|
const prefersReducedMotion = usePrefersReducedMotion();
|
|
// In split view the session sidebar competes with the panes for width. Below
|
|
// this width it auto-collapses to its icon rail so the panes get the room, and
|
|
// expands again once the container grows back. A wide split keeps the full
|
|
// sidebar (and the user's own collapse preference).
|
|
const wideViewport = useIsLargeScreen('(min-width: 1200px)');
|
|
const splitSidebarHasRoom =
|
|
sidebarLayoutWidth === undefined
|
|
? wideViewport
|
|
: sidebarLayoutWidth >= 1200;
|
|
|
|
useEffect(() => {
|
|
if (!sidebarOptions.enabled) closeMobileDrawer();
|
|
}, [closeMobileDrawer, sidebarOptions.enabled]);
|
|
|
|
useLayoutEffect(() => {
|
|
const layout = sidebarLayoutRef.current;
|
|
if (!layout) return;
|
|
let previousWidth = layout.clientWidth;
|
|
const updateWidth = () => {
|
|
const width = layout.clientWidth;
|
|
if (width <= 0) return;
|
|
setSidebarLayoutWidth(width);
|
|
if (previousWidth <= 760 && width > 760) closeMobileDrawer();
|
|
previousWidth = width;
|
|
};
|
|
updateWidth();
|
|
const observer = new ResizeObserver(updateWidth);
|
|
observer.observe(layout);
|
|
return () => observer.disconnect();
|
|
}, [closeMobileDrawer]);
|
|
|
|
useEffect(() => {
|
|
if (!mobileDrawerOpen) return;
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key !== 'Escape') return;
|
|
if (isWebTerminalTarget(e)) return;
|
|
// A pending tool/permission approval owns Escape (it rejects the call),
|
|
// so don't let the drawer swallow it while a prompt is visible.
|
|
if (pendingApprovalRef.current) return;
|
|
// The fullscreen artifact surface owns Escape too (it shrinks back);
|
|
// a force-hidden drawer must not swallow the key first.
|
|
if (artifactPanelFullscreenRef.current) return;
|
|
const target = (e.composedPath()[0] ?? e.target) as HTMLElement | null;
|
|
if (
|
|
target instanceof Element &&
|
|
target.closest(
|
|
'[data-web-shell-sidebar-more], [data-web-shell-local-files-panel], [data-web-shell-desktop-relay-panel], [data-web-shell-update-manual]',
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
// Only let an editable element keep Escape for itself when it lives
|
|
// outside the drawer; the drawer's own search input should still close
|
|
// the drawer on the first Escape.
|
|
if (
|
|
isEditableTarget(target) &&
|
|
!target?.closest('[data-sidebar-shell]')
|
|
) {
|
|
return;
|
|
}
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
closeMobileDrawer();
|
|
};
|
|
const prevOverflow = document.body.style.overflow;
|
|
document.body.style.overflow = 'hidden';
|
|
const preventScroll = (e: TouchEvent) => {
|
|
// Allow native scrolling inside the drawer panel (e.g. the session list).
|
|
// The dim backdrop also lives under [data-sidebar-shell], so exclude it:
|
|
// a touchmove starting on the backdrop must still be blocked, otherwise
|
|
// iOS Safari scrolls the page behind the open drawer.
|
|
const el = (e.composedPath()[0] ?? e.target) as HTMLElement | null;
|
|
// [data-web-shell-dialog] joins this allowlist because DialogShell
|
|
// portals outside the drawer subtree but stacks above it — without the
|
|
// exemption, a dialog opened over the drawer loses all touch scrolling
|
|
// on a phone. The exemption covers DialogShell only, not other
|
|
// portaled overlays.
|
|
if (
|
|
el instanceof Element &&
|
|
el.closest(
|
|
'[data-web-shell-dialog], [data-web-shell-sidebar-more], [data-web-shell-local-files-panel], [data-web-shell-desktop-relay-panel], [data-web-shell-update-manual]',
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
if (
|
|
el?.closest('[data-sidebar-shell]') &&
|
|
!el.closest(`.${styles.mobileBackdrop}`)
|
|
) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
};
|
|
document.addEventListener('touchmove', preventScroll, { passive: false });
|
|
window.addEventListener('keydown', onKey, true);
|
|
return () => {
|
|
document.body.style.overflow = prevOverflow;
|
|
document.removeEventListener('touchmove', preventScroll);
|
|
window.removeEventListener('keydown', onKey, true);
|
|
};
|
|
}, [mobileDrawerOpen, closeMobileDrawer]);
|
|
// Shared writer for every collapse entrance — its inputs need refs:
|
|
// `sidebarCollapsed` would close over the mount-time value, and
|
|
// `splitFoldedSidebar` (declared around 9447) sits after this callback in
|
|
// the component body, so a dependency on it would throw TDZ during render.
|
|
const sidebarCollapsedRef = useRef(sidebarCollapsed);
|
|
sidebarCollapsedRef.current = sidebarCollapsed;
|
|
const splitFoldedSidebarRef = useRef(false);
|
|
const handleSidebarCollapsedChange = useCallback(
|
|
(collapsed: boolean, options?: { exitFoldBand?: boolean }) => {
|
|
// In the split-view fold band the auto-fold, not the user, owns the
|
|
// rendered state. Every entrance — the Cmd/Ctrl+B shortcut AND the
|
|
// rail's Collapse/Expand button — must preserve the stored preference
|
|
// there: a visually no-op interaction that rewrote it to `false` would
|
|
// be unrecoverable in the band (the button can only ever request that
|
|
// value), so the writer re-stores the current preference instead.
|
|
// The carve-out, `exitFoldBand`, marks entrances that reorganize the
|
|
// layout away from split view: the write is exactly what the user
|
|
// asked for, not a no-op tap in place, so it bypasses preservation.
|
|
const preserve =
|
|
options?.exitFoldBand !== true && splitFoldedSidebarRef.current;
|
|
const target = preserve ? sidebarCollapsedRef.current : collapsed;
|
|
const layout = sidebarLayoutRef.current;
|
|
const root = layout?.getRootNode();
|
|
const focused =
|
|
root instanceof ShadowRoot
|
|
? root.activeElement
|
|
: document.activeElement;
|
|
if (
|
|
collapsed &&
|
|
focused instanceof Element &&
|
|
layout?.contains(focused) &&
|
|
focused.closest('[data-web-shell-home-column]')
|
|
) {
|
|
const focusTarget =
|
|
layout.querySelector<HTMLElement>(
|
|
'[data-web-shell-navigation-rail] [data-web-shell-sidebar-collapse]',
|
|
) ??
|
|
layout.querySelector<HTMLElement>('[data-web-shell-home-trigger]');
|
|
focusTarget?.focus();
|
|
}
|
|
setSidebarCollapsed(target);
|
|
writeSidebarCollapsed(target);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const allowedApprovalModes = useShallowMemo(allowedApprovalModesProp);
|
|
const customization = useMemo(
|
|
() => ({
|
|
onRefreshModels,
|
|
allowedApprovalModes,
|
|
artifact,
|
|
filterArtifact,
|
|
askUserFreeTextLabel,
|
|
composerTagIcons,
|
|
composerVariant,
|
|
builtinAtProviders,
|
|
atProviders,
|
|
renderToolHeaderExtra,
|
|
renderWelcomeHeader,
|
|
renderWelcomeFooter,
|
|
parseUserMessageContent,
|
|
renderUserMessageContent,
|
|
renderComposerTag,
|
|
renderComposerTagTooltip,
|
|
onComposerTagClick,
|
|
renderAssistantTurnFooter,
|
|
assistantFeedback,
|
|
getAssistantSourcesIcon,
|
|
sourceReferences,
|
|
renderComposerToolbarStart,
|
|
renderComposerToolbarEnd,
|
|
renderComposerToolbarRight,
|
|
renderComposerHeader,
|
|
renderComposerFooter,
|
|
renderFooter,
|
|
compactThinking,
|
|
collapseCompletedTurns,
|
|
markdownTableMode,
|
|
markdown,
|
|
loadingPhrases,
|
|
fileUploadEnabled,
|
|
fileUploadDirectory,
|
|
fileDropAction,
|
|
}),
|
|
[
|
|
onRefreshModels,
|
|
allowedApprovalModes,
|
|
artifact,
|
|
filterArtifact,
|
|
askUserFreeTextLabel,
|
|
composerTagIcons,
|
|
composerVariant,
|
|
builtinAtProviders,
|
|
atProviders,
|
|
renderToolHeaderExtra,
|
|
renderWelcomeHeader,
|
|
renderWelcomeFooter,
|
|
parseUserMessageContent,
|
|
renderUserMessageContent,
|
|
renderComposerTag,
|
|
renderComposerTagTooltip,
|
|
onComposerTagClick,
|
|
renderAssistantTurnFooter,
|
|
assistantFeedback,
|
|
getAssistantSourcesIcon,
|
|
sourceReferences,
|
|
renderComposerToolbarStart,
|
|
renderComposerToolbarEnd,
|
|
renderComposerToolbarRight,
|
|
renderComposerHeader,
|
|
renderComposerFooter,
|
|
renderFooter,
|
|
compactThinking,
|
|
collapseCompletedTurns,
|
|
markdownTableMode,
|
|
markdown,
|
|
loadingPhrases,
|
|
fileUploadEnabled,
|
|
fileUploadDirectory,
|
|
fileDropAction,
|
|
],
|
|
);
|
|
const mainChatCustomization = useMemo(
|
|
() => ({ ...customization, hostOwnsEditDiffPreview }),
|
|
[customization, hostOwnsEditDiffPreview],
|
|
);
|
|
const CustomFooter = renderFooter;
|
|
const CustomComposerHeader = renderComposerHeader;
|
|
const CustomComposerFooter = renderComposerFooter;
|
|
const store = useTranscriptStore();
|
|
const { blocks, blockChangeSummary } = useAnimationFrameTranscriptSnapshot({
|
|
structuralOnly: true,
|
|
});
|
|
const connection = useConnection();
|
|
const modelRefresh = useModelRefresh(onRefreshModels);
|
|
const standalone = useContext(StandaloneContext);
|
|
const logicalSessionKey = getLogicalSessionKey(
|
|
connection.sessionId,
|
|
connection.workspaceCwd,
|
|
);
|
|
// A rewind this tab asked for whose `session_rewound` has not reached the
|
|
// transcript yet. Prompts wait for it: one sent in between would land after
|
|
// the turn the daemon is dropping, and be dropped with it.
|
|
const [pendingRewind, setPendingRewind] = useState<{
|
|
sessionKey: string | undefined;
|
|
turnIndex: number;
|
|
owner: DaemonSessionOwnerSnapshot;
|
|
/** Runs once the hold lifts with the session still current. */
|
|
recover?: () => void;
|
|
} | null>(null);
|
|
const rewindSyncBlocked = Boolean(
|
|
pendingRewind &&
|
|
pendingRewind.sessionKey === logicalSessionKey &&
|
|
pendingRewind.owner.isCurrent() &&
|
|
countUserTurns(blocks) > pendingRewind.turnIndex,
|
|
);
|
|
useLayoutEffect(() => {
|
|
if (!pendingRewind || rewindSyncBlocked) return;
|
|
setPendingRewind(null);
|
|
if (
|
|
pendingRewind.sessionKey === logicalSessionKey &&
|
|
pendingRewind.owner.isCurrent()
|
|
) {
|
|
pendingRewind.recover?.();
|
|
}
|
|
}, [pendingRewind, rewindSyncBlocked, logicalSessionKey]);
|
|
const sessionWriteBlocked =
|
|
Boolean(connection.loadingTranscript) || rewindSyncBlocked;
|
|
const sessionWriteBlockedRef = useRef(sessionWriteBlocked);
|
|
const sessionWriteBlockGenerationRef = useRef(0);
|
|
if (sessionWriteBlocked && !sessionWriteBlockedRef.current) {
|
|
sessionWriteBlockGenerationRef.current += 1;
|
|
}
|
|
sessionWriteBlockedRef.current = sessionWriteBlocked;
|
|
const appMountedRef = useRef(true);
|
|
useLayoutEffect(() => {
|
|
appMountedRef.current = true;
|
|
return () => {
|
|
appMountedRef.current = false;
|
|
};
|
|
}, []);
|
|
const sessionOwnerGuard = useDaemonSessionOwnerGuard();
|
|
const transcriptHistory = useTranscriptHistory();
|
|
const workspace = useWorkspace();
|
|
const [skillsSource, setSkillsSource] = useState<{
|
|
client: typeof workspace.client;
|
|
cwd: string;
|
|
}>();
|
|
const [pluginInitialTab, setPluginInitialTab] = useState<
|
|
'extensions' | 'skills'
|
|
>('extensions');
|
|
const skillsSourceCwd =
|
|
skillsSource?.client === workspace.client
|
|
? skillsSource.cwd
|
|
: workspace.workspaceCwd;
|
|
const selectSkillsWorkspace = useCallback(
|
|
(cwd: string) => {
|
|
setSkillsSource((current) =>
|
|
current?.client === workspace.client && current.cwd === cwd
|
|
? current
|
|
: { client: workspace.client, cwd },
|
|
);
|
|
},
|
|
[workspace.client],
|
|
);
|
|
const managedCreationSkills = useMemo(() => {
|
|
if (!managedAgentProvider || !skillsSourceCwd) return undefined;
|
|
const sourceCwd = skillsSourceCwd;
|
|
return {
|
|
workspaceCwd: sourceCwd,
|
|
async list(signal: AbortSignal) {
|
|
const status = await workspace.client
|
|
.workspaceByCwd(sourceCwd)
|
|
.workspaceConfigSkills();
|
|
signal.throwIfAborted();
|
|
if (status.workspaceCwd !== sourceCwd)
|
|
throw new Error('Skills source Workspace identity changed.');
|
|
if (!status.initialized || status.errors?.length)
|
|
throw new Error('Skills source Workspace is unavailable.');
|
|
return status.skills
|
|
.filter(
|
|
(skill) => skill.status === 'ok' && skill.userInvocable !== false,
|
|
)
|
|
.map(({ name, description }) => ({ name, description }));
|
|
},
|
|
async load(signal: AbortSignal) {
|
|
const snapshot = await workspace.client
|
|
.workspaceByCwd(sourceCwd)
|
|
.workspaceSkillsSnapshot(signal);
|
|
if (snapshot.workspaceCwd !== sourceCwd)
|
|
throw new Error('Skills source Workspace identity changed.');
|
|
return snapshot.skills;
|
|
},
|
|
};
|
|
}, [managedAgentProvider, skillsSourceCwd, workspace.client]);
|
|
|
|
// Multi-daemon (#13727): saved hosts stay connected read-only alongside
|
|
// the provider that owns this App subtree; the picker and sidebar consume
|
|
// their live workspaces/sessions; embedded shells have no controller and
|
|
// fan out to nothing.
|
|
const daemonTarget = useDaemonTargetOptional();
|
|
// Same-origin links pointing at a connected host become in-app focus
|
|
// switches instead of document reloads (#13727).
|
|
useInterceptHostLinks();
|
|
const fanoutOrigins = useFanoutOrigins();
|
|
const { workspacesByOrigin, refreshAll: refreshAllHosts } =
|
|
useHostFanout(fanoutOrigins);
|
|
const focusedHostOrigin = useMemo(
|
|
() =>
|
|
new URL(
|
|
workspace.baseUrl || window.location.origin,
|
|
window.location.origin,
|
|
).origin,
|
|
[workspace.baseUrl],
|
|
);
|
|
const daemonAvailable = useDaemonAvailability() === 'available';
|
|
const daemonAvailableRef = useRef(daemonAvailable);
|
|
daemonAvailableRef.current = daemonAvailable;
|
|
const sessionCatalogController = useSessionCatalogController(
|
|
workspace.client,
|
|
);
|
|
|
|
const refreshWorkspaceCapabilities = workspace.refreshCapabilities;
|
|
const refreshWorkspaceBrand = workspace.refreshBrand;
|
|
const workspaces = useMemo(() => {
|
|
const capabilityWorkspaces = workspace.capabilities?.workspaces ?? [];
|
|
if (
|
|
lockedWorkspaceCapability &&
|
|
!capabilityWorkspaces.some(
|
|
(entry) => entry.cwd === lockedWorkspaceCapability.cwd,
|
|
)
|
|
) {
|
|
return [...capabilityWorkspaces, lockedWorkspaceCapability];
|
|
}
|
|
return capabilityWorkspaces;
|
|
}, [lockedWorkspaceCapability, workspace.capabilities?.workspaces]);
|
|
const workspaceHostsEnabled = useContext(WorkspaceHostsEnabled);
|
|
// Keep the connected host's saved project identities fresh so the sidebar
|
|
// can group local and remote projects and navigate back across hosts.
|
|
useEffect(() => {
|
|
if (!workspaceHostsEnabled || !workspace.capabilities?.workspaces) return;
|
|
rememberWorkspaceHost(
|
|
new URL(
|
|
workspace.baseUrl || window.location.origin,
|
|
window.location.origin,
|
|
).origin,
|
|
workspace.capabilities.workspaces.filter((ws) => ws.kind !== 'live'),
|
|
);
|
|
}, [
|
|
workspaceHostsEnabled,
|
|
workspace.baseUrl,
|
|
workspace.capabilities?.workspaces,
|
|
]);
|
|
const ordinaryWorkspaces = useMemo(
|
|
() => workspaces.filter((entry) => entry.kind !== 'live'),
|
|
[workspaces],
|
|
);
|
|
const sidebarlessLiveStateWorkspaceCwds = useMemo(() => {
|
|
if (sidebarOptions.enabled) return [];
|
|
return workspaces
|
|
.filter((entry) => entry.trusted && isAbsolutePath(entry.cwd))
|
|
.map((entry) => entry.cwd);
|
|
}, [sidebarOptions.enabled, workspaces]);
|
|
useWorkspaceSessionLiveState(workspace.client, {
|
|
pollIntervalMs: workspace.capabilities?.sessionLiveStatePollIntervalMs,
|
|
enabled: Boolean(
|
|
sidebarlessLiveStateWorkspaceCwds.length > 0 &&
|
|
connection.capabilities?.features?.includes(
|
|
SESSION_LIVE_STATE_FEATURE,
|
|
) &&
|
|
typeof workspace.client.getWorkspaceSessionLiveState === 'function',
|
|
),
|
|
workspaceCwds: sidebarlessLiveStateWorkspaceCwds,
|
|
groupWorkspaceCwds: [],
|
|
});
|
|
// Daemon-authoritative "turn is running" signal for the connected session:
|
|
// keeps the conversation indicator (and its cancel affordances) alive
|
|
// through >3s silent tool gaps where streamingState drops to idle (#9487).
|
|
const trustedLiveWorkspaces = workspaces.filter(
|
|
(entry) =>
|
|
entry.kind === 'live' && entry.trusted && isAbsolutePath(entry.cwd),
|
|
);
|
|
const activePromptWorkspaceCwd =
|
|
connection.sessionContext?.kind === 'live'
|
|
? trustedLiveWorkspaces.length === 1
|
|
? trustedLiveWorkspaces[0]?.cwd
|
|
: undefined
|
|
: connection.workspaceCwd;
|
|
const {
|
|
hasActivePrompt: daemonHasActivePrompt,
|
|
activeWorkState: sessionActiveWorkState,
|
|
} = useDaemonSessionActivityBridge(
|
|
workspace.client,
|
|
activePromptWorkspaceCwd,
|
|
connection.sessionId,
|
|
);
|
|
const sessionHasActivePrompt =
|
|
daemonHasActivePrompt || !!connection.backgroundTurn;
|
|
const sessionHasActivePromptRef = useRef(sessionHasActivePrompt);
|
|
sessionHasActivePromptRef.current = sessionHasActivePrompt;
|
|
const trustedPrimaryWorkspaceCwd = useMemo(
|
|
() =>
|
|
ordinaryWorkspaces.find(
|
|
(entry) => entry.primary && entry.trusted !== false,
|
|
)?.cwd,
|
|
[ordinaryWorkspaces],
|
|
);
|
|
const isKnownLiveWorkspaceCwd = useCallback(
|
|
(cwd: string | undefined) =>
|
|
cwd !== undefined &&
|
|
workspaces.some((entry) => entry.kind === 'live' && entry.cwd === cwd),
|
|
[workspaces],
|
|
);
|
|
const composerWorkspacesRef = useRef<
|
|
| Array<{
|
|
id: string;
|
|
cwd: string;
|
|
label: string;
|
|
primary: boolean;
|
|
trusted: boolean;
|
|
hostOrigin?: string;
|
|
}>
|
|
| undefined
|
|
>(undefined);
|
|
const nextComposerWorkspaces = !lockedWorkspaceCwd
|
|
? [
|
|
...ordinaryWorkspaces.map((entry) => ({
|
|
id: entry.id,
|
|
cwd: entry.cwd,
|
|
label: managedAgentProvider
|
|
? `${workspaceLabel(entry)} · ${t('managed.localMode')}`
|
|
: workspaceLabel(entry),
|
|
primary: entry.primary,
|
|
trusted: entry.trusted,
|
|
hostOrigin: focusedHostOrigin,
|
|
})),
|
|
// Fan-out hosts offer their own live workspaces in the same picker
|
|
// (#13727); composite ids keep a cwd that exists on two hosts one
|
|
// selectable option each — selection routes through the hosted
|
|
// callback with the option's hostOrigin.
|
|
...fanoutOrigins.flatMap((origin) =>
|
|
(workspacesByOrigin.get(origin) ?? [])
|
|
.filter((entry) => entry.kind !== 'live')
|
|
.map((entry) => ({
|
|
id: `${origin}${entry.id}`,
|
|
cwd: entry.cwd,
|
|
label: managedAgentProvider
|
|
? `${workspaceLabel(entry)} · ${t('managed.localMode')}`
|
|
: workspaceLabel(entry),
|
|
primary: entry.primary,
|
|
trusted: entry.trusted,
|
|
hostOrigin: origin,
|
|
})),
|
|
),
|
|
]
|
|
: undefined;
|
|
const currentComposerWorkspaces = composerWorkspacesRef.current;
|
|
if (
|
|
currentComposerWorkspaces?.length !== nextComposerWorkspaces?.length ||
|
|
currentComposerWorkspaces?.some((current, index) => {
|
|
const next = nextComposerWorkspaces?.[index];
|
|
return (
|
|
!next ||
|
|
current.id !== next.id ||
|
|
current.cwd !== next.cwd ||
|
|
current.label !== next.label ||
|
|
current.primary !== next.primary ||
|
|
current.trusted !== next.trusted ||
|
|
current.hostOrigin !== next.hostOrigin
|
|
);
|
|
})
|
|
) {
|
|
composerWorkspacesRef.current = nextComposerWorkspaces
|
|
? disambiguateWorkspaceLabels(nextComposerWorkspaces)
|
|
: undefined;
|
|
}
|
|
const composerWorkspaces = composerWorkspacesRef.current;
|
|
const workspacesRef = useRef(ordinaryWorkspaces);
|
|
workspacesRef.current = ordinaryWorkspaces;
|
|
const visibleWorkspaces = useMemo(
|
|
() =>
|
|
lockedWorkspaceCwd
|
|
? ordinaryWorkspaces.filter((entry) => entry.cwd === lockedWorkspaceCwd)
|
|
: ordinaryWorkspaces,
|
|
[lockedWorkspaceCwd, ordinaryWorkspaces],
|
|
);
|
|
const sessionActions = useActions();
|
|
const {
|
|
intent: capacityRecovery,
|
|
offer: offerCapacityRecovery,
|
|
dismiss: dismissCapacityRecovery,
|
|
} = useCapacityRecovery(
|
|
workspace.client,
|
|
workspace.capabilities?.features,
|
|
connection,
|
|
sessionActions,
|
|
);
|
|
const reloadTranscript = useCallback(
|
|
async (signal: AbortSignal) => {
|
|
if (!connection.sessionId) return;
|
|
await sessionActions.reloadSession(signal);
|
|
},
|
|
[connection.sessionId, sessionActions],
|
|
);
|
|
const transcriptReloadSupported =
|
|
connection.capabilities?.features.includes(
|
|
SESSION_TRANSCRIPT_PAGINATION_FEATURE,
|
|
) === true;
|
|
const monitorDetailsSupported =
|
|
connection.capabilities?.features.includes(
|
|
SESSION_MONITOR_TOOL_CORRELATION_FEATURE,
|
|
) === true;
|
|
const { notices, dismissNotice } = useSessionNotices();
|
|
const workspaceActions = useWorkspaceActions();
|
|
const artifactWorkspaceTarget = useArtifactWorkspaceTarget(
|
|
connection.workspaceCwd,
|
|
);
|
|
const artifactWorkspaceCwd = artifactWorkspaceTarget?.workspaceCwd;
|
|
const artifactWorkspaceActions = artifactWorkspaceTarget?.actions;
|
|
const artifactWorkspaceCwdRef = useRef(artifactWorkspaceCwd);
|
|
const artifactWorkspaceActionsRef = useRef(artifactWorkspaceActions);
|
|
const workspaceCapabilitiesRef = useRef(workspace.capabilities);
|
|
artifactWorkspaceCwdRef.current = artifactWorkspaceCwd;
|
|
artifactWorkspaceActionsRef.current = artifactWorkspaceActions;
|
|
workspaceCapabilitiesRef.current = workspace.capabilities;
|
|
const workspaceCapabilitiesReady =
|
|
workspace.capabilities !== undefined && workspace.status !== 'error';
|
|
const standaloneSessionsSupported =
|
|
workspace.capabilities?.features?.includes(
|
|
STANDALONE_SESSIONS_CAPABILITY,
|
|
) === true;
|
|
const dynamicWorkspaceRegistrationSupported =
|
|
workspace.capabilities?.features?.includes(
|
|
'dynamic_workspace_registration',
|
|
) === true;
|
|
const persistentWorkspaceRegistrationSupported =
|
|
workspace.capabilities?.features?.includes(
|
|
'persistent_workspace_registration',
|
|
) === true;
|
|
const scratchWorkspaceRegistrationSupported =
|
|
workspace.capabilities?.features?.includes(
|
|
'scratch_workspace_registration',
|
|
) === true;
|
|
// SSH workspaces validate paths on the remote host; the daemon route
|
|
// refuses them, so do not offer the section at all. Registration rewrites an
|
|
// `ssh://` URL to a local mirror directory, so the cwd never carries that
|
|
// scheme: the selected workspace entry's SSH marker is the signal.
|
|
const workspaceDirectoriesSupported =
|
|
workspace.capabilities?.features?.includes('workspace_directories') ===
|
|
true &&
|
|
workspace.workspaceCwd !== undefined &&
|
|
workspaces.find((entry) => entry.cwd === workspace.workspaceCwd)?.ssh ===
|
|
undefined;
|
|
const gitWorkspaceCloneSupported =
|
|
workspace.capabilities?.features?.includes('git_workspace_clone') === true;
|
|
const workspaceDisplayNameSupported =
|
|
workspace.capabilities?.features?.includes('workspace_display_name') ===
|
|
true;
|
|
// Headless daemon hosts omit the tag so the Browse affordance stays
|
|
// hidden instead of failing on every click.
|
|
const nativeDirectoryPickerSupported =
|
|
workspace.capabilities?.features?.includes('native_directory_picker') ===
|
|
true;
|
|
const gitHubPrsSupported =
|
|
workspace.capabilities?.features?.includes('workspace_github_prs') === true;
|
|
const gitWorktreesSupported =
|
|
workspace.capabilities?.features?.includes('workspace_git_worktrees') ===
|
|
true;
|
|
const [initialRemoteWorkspaceAddActive] = useState(
|
|
() => standalone && isRemoteWorkspaceAddActive(),
|
|
);
|
|
const [showAddWorkspaceDialog, setShowAddWorkspaceDialog] = useState(
|
|
initialRemoteWorkspaceAddActive,
|
|
);
|
|
// Browsing the daemon's folders, whether this tab navigated here for it or
|
|
// opened the browser in place.
|
|
const workspaceBrowseActiveRef = useRef(initialRemoteWorkspaceAddActive);
|
|
useEffect(() => {
|
|
// No marker on a standalone boot means the hand-over that wrote the return
|
|
// location was abandoned (reload or Back), not resumed. Dropping it here
|
|
// keeps a later Cancel in an unrelated Add-workspace dialog from consuming
|
|
// the stale location and navigating the shell away.
|
|
if (initialRemoteWorkspaceAddActive) clearRemoteWorkspaceAddStep();
|
|
else if (standalone) discardAbandonedRemoteWorkspaceAdd();
|
|
}, [initialRemoteWorkspaceAddActive, standalone]);
|
|
const [workspaceMutationBusy, setWorkspaceMutationBusy] = useState(false);
|
|
const workspaceMutationTokenRef = useRef<symbol | null>(null);
|
|
const workspaceSwitchTokenRef = useRef<symbol | null>(null);
|
|
// Changes before async session clearing updates connection state.
|
|
const composerSourceVersionRef = useRef(0);
|
|
type ScratchOutcomeState = 'clear' | 'refreshing' | 'awaiting-ack';
|
|
const scratchOutcomeUnknownRef = useRef<ScratchOutcomeState>('clear');
|
|
const committedScratchCwdRef = useRef<string | undefined>(undefined);
|
|
const [scratchOutcomeUnknown, setScratchOutcomeUnknown] =
|
|
useState<ScratchOutcomeState>('clear');
|
|
// The ref is the synchronous authority: a second click can arrive before
|
|
// React commits the disabled state rendered from its state mirror.
|
|
const setScratchOutcome = useCallback((state: ScratchOutcomeState) => {
|
|
scratchOutcomeUnknownRef.current = state;
|
|
setScratchOutcomeUnknown(state);
|
|
}, []);
|
|
// Phase 4: the workspace picked for the *next* new session on multi-workspace
|
|
// daemons. Kept in a ref too because session creation is lazy (first prompt),
|
|
// so the ensureSessionForPrompt callback must read the latest value.
|
|
const [selectedWorkspaceCwd, setSelectedWorkspaceCwd] = useState<
|
|
string | undefined
|
|
>(initialSelectedWorkspaceCwd);
|
|
const selectedWorkspaceCwdRef = useRef(selectedWorkspaceCwd);
|
|
selectedWorkspaceCwdRef.current = selectedWorkspaceCwd;
|
|
const lockedWorkspaceCwdRef = useRef(lockedWorkspaceCwd);
|
|
lockedWorkspaceCwdRef.current = lockedWorkspaceCwd;
|
|
const resolveWorkspaceMaintenanceTargetCwd = useCallback(() => {
|
|
const preferredCwd = lockedWorkspaceCwd ?? selectedWorkspaceCwdRef.current;
|
|
if (preferredCwd) {
|
|
return ordinaryWorkspaces.find(
|
|
(entry) => entry.cwd === preferredCwd && entry.trusted !== false,
|
|
)?.cwd;
|
|
}
|
|
return trustedPrimaryWorkspaceCwd;
|
|
}, [lockedWorkspaceCwd, ordinaryWorkspaces, trustedPrimaryWorkspaceCwd]);
|
|
const projectFeaturesAvailable =
|
|
ordinaryWorkspaces.length > 0 ||
|
|
(workspace.capabilities?.workspaces === undefined &&
|
|
Boolean(workspace.capabilities?.workspaceCwd));
|
|
const archiveManagementAvailable = Boolean(
|
|
sidebarOptions.enabled &&
|
|
(connection.capabilities?.features?.includes('session_archive') ||
|
|
(!lockedWorkspaceCwd &&
|
|
workspace.capabilities?.features?.includes(
|
|
STANDALONE_SESSIONS_CAPABILITY,
|
|
)) ||
|
|
managedAgentProvider?.lifecycle),
|
|
);
|
|
const settingsAvailable =
|
|
projectFeaturesAvailable || archiveManagementAvailable;
|
|
const externalManagedAgentAvailable = managedAgentProvider !== undefined;
|
|
// The daemon lists the `/autofix-pr` watches of its workspaces' clones.
|
|
const prWatchesAvailable =
|
|
workspace.capabilities?.features?.includes('autofix_pr_watches') === true;
|
|
const [pendingSessionContext, setPendingSessionContextState] = useState<
|
|
DaemonProductSessionContext | undefined
|
|
>(undefined);
|
|
const pendingSessionContextRef = useRef(pendingSessionContext);
|
|
const setPendingSessionContext = useCallback(
|
|
(context: DaemonProductSessionContext | undefined) => {
|
|
pendingSessionContextRef.current = context;
|
|
setPendingSessionContextState(context);
|
|
},
|
|
[],
|
|
);
|
|
const legacyWorkspaceContextCwd =
|
|
connection.workspaceCwd ||
|
|
lockedWorkspaceCwd ||
|
|
selectedWorkspaceCwd ||
|
|
trustedPrimaryWorkspaceCwd ||
|
|
workspace.capabilities?.workspaceCwd;
|
|
const settledSessionContext = useMemo<
|
|
DaemonProductSessionContext | undefined
|
|
>(
|
|
() =>
|
|
connection.sessionContext ??
|
|
(isKnownLiveWorkspaceCwd(connection.workspaceCwd)
|
|
? { kind: 'live' }
|
|
: legacyWorkspaceContextCwd
|
|
? { kind: 'workspace', cwd: legacyWorkspaceContextCwd }
|
|
: undefined),
|
|
[
|
|
connection.sessionContext,
|
|
connection.workspaceCwd,
|
|
isKnownLiveWorkspaceCwd,
|
|
legacyWorkspaceContextCwd,
|
|
],
|
|
);
|
|
const effectiveSessionContext = useMemo(
|
|
() => pendingSessionContext ?? settledSessionContext,
|
|
[pendingSessionContext, settledSessionContext],
|
|
);
|
|
const sessionIsStandalone = effectiveSessionContext?.kind === 'standalone';
|
|
const workspaceContextActive = effectiveSessionContext?.kind === 'workspace';
|
|
// The composer picker chooses the target of the next session, so a
|
|
// projectless draft keeps it enabled: that is where "no workspace" is
|
|
// chosen. An attached projectless session hides it again.
|
|
const composerWorkspaceSelectEnabled =
|
|
workspaceContextActive || connection.sessionId === undefined;
|
|
const effectiveStandaloneSession =
|
|
effectiveSessionContext?.kind === 'standalone'
|
|
? connection.standaloneSession
|
|
: undefined;
|
|
const standaloneCreationRecovery =
|
|
effectiveStandaloneSession?.creationRecovery;
|
|
const standaloneWorkingDirectory =
|
|
effectiveStandaloneSession?.workingDirectory;
|
|
const standaloneDirectoryErrorCode = effectiveStandaloneSession?.errorCode;
|
|
const standaloneWriterBlocked = Boolean(
|
|
connection.sessionId &&
|
|
connection.error &&
|
|
connection.sessionContext?.kind === 'standalone' &&
|
|
isSessionWriterBlockedCode(connection.standaloneSession?.errorCode),
|
|
);
|
|
const standaloneRetryRef = useRef<{ sessionId: string } | undefined>(
|
|
undefined,
|
|
);
|
|
const [standaloneRetrySessionId, setStandaloneRetrySessionId] =
|
|
useState<string>();
|
|
const [standaloneRecoveryResolution, setStandaloneRecoveryResolution] =
|
|
useState<StandaloneRecoveryResolution>('idle');
|
|
const standaloneRecoveryRequestRef = useRef(0);
|
|
const [standaloneRepairBusy, setStandaloneRepairBusy] = useState(false);
|
|
const standaloneRepairRequestRef = useRef(0);
|
|
useEffect(() => {
|
|
standaloneRecoveryRequestRef.current += 1;
|
|
standaloneRepairRequestRef.current += 1;
|
|
setStandaloneRecoveryResolution('idle');
|
|
setStandaloneRepairBusy(false);
|
|
}, [connection.sessionId]);
|
|
const [selectedWorkspaceGitStatus, setSelectedWorkspaceGitStatus] = useState<
|
|
DaemonWorkspaceGitStatus | undefined
|
|
>(undefined);
|
|
/** Git mode intent for the next lazily-created session (branch or worktree). */
|
|
const [gitModeIntent, setGitModeIntent] = useState<SessionGitIntent>({
|
|
mode: 'current',
|
|
});
|
|
const gitModeIntentRef = useRef(gitModeIntent);
|
|
useEffect(() => {
|
|
gitModeIntentRef.current = gitModeIntent;
|
|
}, [gitModeIntent]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!workspace.capabilities ||
|
|
!selectedWorkspaceCwd ||
|
|
!workspaceContextActive
|
|
) {
|
|
return;
|
|
}
|
|
const selected = ordinaryWorkspaces.find(
|
|
(entry) => entry.cwd === selectedWorkspaceCwd,
|
|
);
|
|
if (selected?.trusted) return;
|
|
const primaryCwd = ordinaryWorkspaces.find(
|
|
(entry) => entry.primary && entry.trusted !== false,
|
|
)?.cwd;
|
|
composerSourceVersionRef.current += 1;
|
|
selectedWorkspaceCwdRef.current = undefined;
|
|
setSelectedWorkspaceCwd(undefined);
|
|
setPendingSessionContext(
|
|
primaryCwd ? { kind: 'workspace', cwd: primaryCwd } : undefined,
|
|
);
|
|
gitModeIntentRef.current = { mode: 'current' };
|
|
setGitModeIntent({ mode: 'current' });
|
|
}, [
|
|
ordinaryWorkspaces,
|
|
selectedWorkspaceCwd,
|
|
setPendingSessionContext,
|
|
workspace.capabilities,
|
|
workspaceContextActive,
|
|
]);
|
|
// The workspace the chip's status was last fetched for. On a workspace switch
|
|
// we clear the status immediately so the chip never shows the previous repo's
|
|
// branch/dirty counts while the new fetch is in flight; same-workspace
|
|
// re-runs (branch change, focus, poll) keep the live value to avoid flicker.
|
|
const gitStatusWorkspaceCwdRef = useRef<string | undefined>(undefined);
|
|
/** Worktree metadata for the current session (set after creation). */
|
|
const [sessionWorktree, setSessionWorktree] = useState<
|
|
{ slug: string; path: string; branch: string } | undefined
|
|
>(undefined);
|
|
/** Branch metadata for the current session (set after creation with branch mode). */
|
|
const [sessionBranch, setSessionBranch] = useState<
|
|
{ name: string; baseBranch: string } | undefined
|
|
>(undefined);
|
|
const [sessionStatusDisplayName, setSessionStatusDisplayName] = useState<
|
|
string | undefined
|
|
>(undefined);
|
|
const [currentSessionSummary, setCurrentSessionSummary] = useState<
|
|
DaemonSessionSummary | undefined
|
|
>(undefined);
|
|
const currentSessionSummaryRef = useRef(currentSessionSummary);
|
|
currentSessionSummaryRef.current = currentSessionSummary;
|
|
// Tracks the logical session from the latest effect run. In-flight fetches
|
|
// compare their captured key against this ref on resolve: a match means
|
|
// the response is still relevant and may set OR clear the worktree state;
|
|
// a mismatch means connection.sessionId moved on (reconnect cycling or a
|
|
// user-initiated switch) and the stale response is dropped.
|
|
const worktreeSessionKeyRef = useRef<string | undefined>(undefined);
|
|
const sidebarStandaloneTitleRef = useRef<
|
|
| {
|
|
sessionId: string;
|
|
displayName?: string;
|
|
client: typeof workspace.client;
|
|
}
|
|
| undefined
|
|
>(undefined);
|
|
const sessionStatusRequestRef = useRef(0);
|
|
useLayoutEffect(() => {
|
|
setSessionWorktree(undefined);
|
|
setSessionBranch(undefined);
|
|
setSessionStatusDisplayName(undefined);
|
|
setCurrentSessionSummary(undefined);
|
|
}, [logicalSessionKey]);
|
|
// Declared after the reset above so it wins in the same layout pass. A
|
|
// history session's name exists only in the session catalog — neither the
|
|
// load response nor the metadata events carry it — so seeding it from the
|
|
// cache here keeps the header from flashing the "New session" placeholder
|
|
// while the load round-trip is in flight.
|
|
useLayoutEffect(() => {
|
|
// `?? prev` keeps this from ever blanking a title the catalog already
|
|
// resolved; the reset above runs first in the same layout pass, so a
|
|
// session switch still starts from an empty title.
|
|
setSessionStatusDisplayName(
|
|
(prev) =>
|
|
(connection.sessionContext?.kind === 'standalone' &&
|
|
sidebarStandaloneTitleRef.current?.client === workspace.client &&
|
|
sidebarStandaloneTitleRef.current.sessionId === connection.sessionId
|
|
? sidebarStandaloneTitleRef.current.displayName
|
|
: peekSessionCatalogDisplayName(
|
|
workspace.client,
|
|
connection.sessionId,
|
|
connection.workspaceCwd,
|
|
)) ?? prev,
|
|
);
|
|
}, [
|
|
logicalSessionKey,
|
|
workspace.client,
|
|
connection.sessionId,
|
|
connection.workspaceCwd,
|
|
connection.sessionContext?.kind,
|
|
]);
|
|
// Restore worktree info from the server when switching to an existing
|
|
// session. The effect intentionally does NOT cancel in-flight fetches on
|
|
// cleanup: connection.sessionId can cycle through several sessions during
|
|
// the DaemonSessionProvider reconnection loop, and cancelling would
|
|
// discard the one response we actually need.
|
|
useEffect(() => {
|
|
const sid = connection.sessionId;
|
|
const sessionKey = logicalSessionKey;
|
|
const owner = sessionOwnerGuard.capture();
|
|
const requestId = ++sessionStatusRequestRef.current;
|
|
worktreeSessionKeyRef.current = sessionKey;
|
|
if (!sid) {
|
|
setSessionWorktree(undefined);
|
|
setSessionBranch(undefined);
|
|
setSessionStatusDisplayName(undefined);
|
|
setCurrentSessionSummary(undefined);
|
|
return;
|
|
}
|
|
if (
|
|
connection.status !== 'connected' ||
|
|
connection.loadingTranscript ||
|
|
connection.catchingUp
|
|
) {
|
|
return;
|
|
}
|
|
if (connection.sessionContext?.kind === 'standalone') {
|
|
workspace.client
|
|
.getStandaloneSession(sid)
|
|
.then((summary) => {
|
|
if (
|
|
'state' in summary ||
|
|
sessionStatusRequestRef.current !== requestId ||
|
|
worktreeSessionKeyRef.current !== sessionKey ||
|
|
!owner.isCurrent()
|
|
) {
|
|
return;
|
|
}
|
|
setSessionWorktree(undefined);
|
|
setSessionBranch(undefined);
|
|
setSessionStatusDisplayName((prev) => summary.displayName ?? prev);
|
|
setCurrentSessionSummary(summary);
|
|
})
|
|
.catch(() => {
|
|
if (
|
|
sessionStatusRequestRef.current === requestId &&
|
|
worktreeSessionKeyRef.current === sessionKey &&
|
|
owner.isCurrent()
|
|
) {
|
|
// A failed refresh must not blank a title the catalog already
|
|
// resolved.
|
|
setCurrentSessionSummary(undefined);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
workspace.client
|
|
.sessionStatus(sid)
|
|
.then((summary) => {
|
|
if (connection.sessionContext?.kind === 'live') {
|
|
if (
|
|
sessionStatusRequestRef.current === requestId &&
|
|
worktreeSessionKeyRef.current === sessionKey &&
|
|
owner.isCurrent()
|
|
) {
|
|
setSessionWorktree(undefined);
|
|
setSessionBranch(undefined);
|
|
setSessionStatusDisplayName((prev) => summary.displayName ?? prev);
|
|
setCurrentSessionSummary(summary);
|
|
}
|
|
return;
|
|
}
|
|
if (
|
|
sessionStatusRequestRef.current === requestId &&
|
|
worktreeSessionKeyRef.current === sessionKey &&
|
|
owner.isCurrent()
|
|
) {
|
|
setSessionWorktree(summary.worktree);
|
|
setSessionBranch(summary.branch);
|
|
setSessionStatusDisplayName((prev) => summary.displayName ?? prev);
|
|
setCurrentSessionSummary(summary);
|
|
}
|
|
return loadSessionCatalogOnce(
|
|
workspace.client,
|
|
{
|
|
routeKind: 'legacy',
|
|
workspaceCwd: summary.workspaceCwd,
|
|
options: { pageSize: 200 },
|
|
},
|
|
{ fresh: true },
|
|
)
|
|
.then((page) => {
|
|
if (
|
|
worktreeSessionKeyRef.current !== sessionKey ||
|
|
sessionStatusRequestRef.current !== requestId ||
|
|
!owner.isCurrent()
|
|
) {
|
|
return;
|
|
}
|
|
const listedSession = page.sessions.find(
|
|
(session) => session.sessionId === sid,
|
|
);
|
|
setSessionStatusDisplayName(
|
|
(prev) =>
|
|
listedSession?.displayName ?? summary.displayName ?? prev,
|
|
);
|
|
setCurrentSessionSummary(listedSession ?? summary);
|
|
})
|
|
.catch(() => undefined);
|
|
})
|
|
.catch(() => {
|
|
if (
|
|
sessionStatusRequestRef.current === requestId &&
|
|
worktreeSessionKeyRef.current === sessionKey &&
|
|
owner.isCurrent()
|
|
) {
|
|
// The live summary does not carry a history session's name, and a
|
|
// failed refresh must not blank a title the catalog already
|
|
// resolved.
|
|
setSessionWorktree(undefined);
|
|
setSessionBranch(undefined);
|
|
setCurrentSessionSummary(undefined);
|
|
}
|
|
});
|
|
}, [
|
|
connection.catchingUp,
|
|
connection.clientId,
|
|
connection.loadingTranscript,
|
|
connection.sessionId,
|
|
connection.sessionContext,
|
|
connection.status,
|
|
connection.workspaceCwd,
|
|
logicalSessionKey,
|
|
sessionOwnerGuard,
|
|
sessionHasActivePrompt,
|
|
workspace.client,
|
|
]);
|
|
// Active workspace: the connected workspace session's workspace, else the
|
|
// workspace picked for the next workspace session. Maintenance views resolve
|
|
// their target separately and standalone sessions never borrow that repo.
|
|
const activeWorkspaceCwd = useMemo(() => {
|
|
if (!workspaceContextActive) return undefined;
|
|
const explicitContext = pendingSessionContext ?? connection.sessionContext;
|
|
if (explicitContext?.kind === 'workspace') {
|
|
return explicitContext.cwd;
|
|
}
|
|
return connection.sessionId
|
|
? connection.workspaceCwd
|
|
: resolveWorkspaceMaintenanceTargetCwd();
|
|
}, [
|
|
connection.sessionId,
|
|
connection.workspaceCwd,
|
|
connection.sessionContext,
|
|
pendingSessionContext,
|
|
resolveWorkspaceMaintenanceTargetCwd,
|
|
workspaceContextActive,
|
|
]);
|
|
// The chat header always answers "which workspace is this session in?": the
|
|
// workspace's name when there is one, and nothing when the session lives
|
|
// outside every workspace (standalone, Live), which the header shows as the
|
|
// no-workspace icon.
|
|
const headerWorkspaceName = activeWorkspaceCwd
|
|
? workspaceLabelForCwd(activeWorkspaceCwd, ordinaryWorkspaces)
|
|
: undefined;
|
|
// The artifact panel keys its state per session; the empty new-task page
|
|
// gets a per-workspace draft key so its files/terminal/web-preview tabs
|
|
// persist and restore like a session's.
|
|
const panelWorkspaceCwd = connection.sessionId
|
|
? connection.workspaceCwd
|
|
: activeWorkspaceCwd;
|
|
const panelStateKey =
|
|
logicalSessionKey ??
|
|
(workspaceContextActive && activeWorkspaceCwd
|
|
? `${DRAFT_PANEL_KEY_PREFIX}${activeWorkspaceCwd}`
|
|
: undefined);
|
|
const workspaceWorkflowsEnabled =
|
|
workspaces.find(
|
|
(entry) =>
|
|
entry.cwd ===
|
|
(activeWorkspaceCwd ?? resolveWorkspaceMaintenanceTargetCwd()),
|
|
)?.workflowsEnabled ?? false;
|
|
const workflowsEnabled =
|
|
workspaceContextActive && connection.sessionId
|
|
? (connection.supportedCommands?.workflowsEnabled ??
|
|
workspaceWorkflowsEnabled)
|
|
: workspaceWorkflowsEnabled;
|
|
// Mirror the daemon's `git_status_changed` push (surfaced as
|
|
// connection.gitStatus by the session provider) into the chip state so the
|
|
// enriched counters fill in right after the branch-only first paint.
|
|
// Worktree sessions bypass the daemon cache/SSE path — their status comes
|
|
// from the worktree-qualified ?cwd= fetch.
|
|
useEffect(() => {
|
|
const status = connection.gitStatus;
|
|
if (!status || sessionWorktree) return;
|
|
if (status.workspaceCwd !== activeWorkspaceCwd) return;
|
|
setSelectedWorkspaceGitStatus((current) => {
|
|
const next =
|
|
status.worktreeSupported === undefined &&
|
|
current?.worktreeSupported !== undefined
|
|
? {
|
|
...status,
|
|
worktreeSupported: current.worktreeSupported,
|
|
}
|
|
: status;
|
|
return isSameGitStatus(current, next) ? current : next;
|
|
});
|
|
}, [connection.gitStatus, activeWorkspaceCwd, sessionWorktree]);
|
|
const onToastRef = useRef(onToast);
|
|
onToastRef.current = onToast;
|
|
const toastIdRef = useRef(0);
|
|
const [toasts, setToasts] = useState<WebShellToast[]>([]);
|
|
const dismissToast = useCallback((id: string) => {
|
|
setToasts((current) => current.filter((toast) => toast.id !== id));
|
|
}, []);
|
|
const pushToast = useCallback((tone: ToastTone, message: string) => {
|
|
if (onToastRef.current) {
|
|
onToastRef.current(tone, message);
|
|
return;
|
|
}
|
|
const toast: WebShellToast = {
|
|
id: `web-shell-toast-${Date.now()}-${++toastIdRef.current}`,
|
|
tone,
|
|
message,
|
|
// Deadline instead of duration: the host remounts its items when the
|
|
// elevated portal moves, and a fresh timer per remount would keep a
|
|
// toast on screen indefinitely across repeated fullscreen toggles.
|
|
dismissAt: Date.now() + TOAST_AUTO_DISMISS_MS,
|
|
};
|
|
setToasts((current) => {
|
|
const withoutDuplicate = current.filter(
|
|
(item) => item.tone !== tone || item.message !== message,
|
|
);
|
|
return [...withoutDuplicate, toast].slice(-MAX_TOASTS);
|
|
});
|
|
}, []);
|
|
|
|
const messages = useMessagesFromBlocks(t, blocks, blockChangeSummary);
|
|
const messagesRef = useRef(messages);
|
|
const [failedPrompt, setFailedPrompt] = useState<FailedPrompt | null>(null);
|
|
const failedPromptRef = useRef<FailedPrompt | null>(failedPrompt);
|
|
const [failedPromptRetry, setFailedPromptRetry] =
|
|
useState<FailedPromptRetry | null>(null);
|
|
const cancelledRetryStatesRef = useRef(
|
|
new Map<string, CancelledRetryEntry[]>(),
|
|
);
|
|
const cancelledRetryAttemptRef = useRef(0);
|
|
const [cancelledRetryRevision, setCancelledRetryRevision] = useState(0);
|
|
const [unknownPromptAdmission, setUnknownPromptAdmission] =
|
|
useState<UnknownPromptAdmission | null>(null);
|
|
const unknownPromptAdmissionRef = useRef<UnknownPromptAdmission | null>(null);
|
|
const updateUnknownPromptAdmission = useCallback(
|
|
(next: UnknownPromptAdmission | null) => {
|
|
unknownPromptAdmissionRef.current = next;
|
|
setUnknownPromptAdmission(next);
|
|
},
|
|
[],
|
|
);
|
|
const updateFailedPrompt = useCallback((next: FailedPrompt | null) => {
|
|
failedPromptRef.current = next;
|
|
setFailedPrompt(next);
|
|
}, []);
|
|
const failedPromptOwnerRef = useRef<{
|
|
sessionId?: string;
|
|
workspaceCwd?: string;
|
|
snapshot: DaemonSessionOwnerSnapshot;
|
|
}>({
|
|
sessionId: connection.sessionId,
|
|
workspaceCwd: connection.workspaceCwd,
|
|
snapshot: sessionOwnerGuard.capture(),
|
|
});
|
|
useLayoutEffect(() => {
|
|
const previousOwner = failedPromptOwnerRef.current;
|
|
failedPromptOwnerRef.current = {
|
|
sessionId: connection.sessionId,
|
|
workspaceCwd: connection.workspaceCwd,
|
|
snapshot: sessionOwnerGuard.capture(),
|
|
};
|
|
if (
|
|
previousOwner.sessionId === connection.sessionId &&
|
|
previousOwner.workspaceCwd === undefined &&
|
|
connection.workspaceCwd !== undefined &&
|
|
previousOwner.snapshot.isCurrent()
|
|
) {
|
|
return;
|
|
}
|
|
updateFailedPrompt(null);
|
|
setFailedPromptRetry(null);
|
|
updateUnknownPromptAdmission(null);
|
|
}, [
|
|
connection.sessionId,
|
|
connection.workspaceCwd,
|
|
sessionOwnerGuard,
|
|
updateFailedPrompt,
|
|
updateUnknownPromptAdmission,
|
|
]);
|
|
const [recapMessage, setRecapMessage] = useState<LocalAnchoredMessage | null>(
|
|
null,
|
|
);
|
|
const [btwMessage, setBtwMessage] = useState<Message | null>(null);
|
|
const nextRecapMessageIdRef = useRef(1);
|
|
const nextBtwMessageIdRef = useRef(1);
|
|
const btwAbortControllerRef = useRef<AbortController | null>(null);
|
|
const chatPaneRef = useRef<HTMLDivElement | null>(null);
|
|
const contextBodyRef = useRef<HTMLDivElement | null>(null);
|
|
const [contextBodyWidth, setContextBodyWidth] = useState<number | null>(null);
|
|
const currentSessionIdRef = useRef(connection.sessionId);
|
|
const lastNotifiedSessionIdRef = useRef<string | undefined>(undefined);
|
|
const lastNotifiedWorkspaceIdRef = useRef<string | undefined>(undefined);
|
|
const lastNotifiedWorkspaceCwdRef = useRef<string | undefined>(undefined);
|
|
const lastNotifiedNonWorkspaceContextRef = useRef<
|
|
'standalone' | 'live' | undefined
|
|
>(undefined);
|
|
const displayMessages = useMemo(
|
|
() => buildDisplayMessages(messages, recapMessage),
|
|
[messages, recapMessage],
|
|
);
|
|
useEffect(() => {
|
|
const failed = failedPromptRef.current;
|
|
if (!failed) return;
|
|
const currentFailedBlock = findUserMessageByIdentity(
|
|
store.getSnapshot().blocks,
|
|
failed.identity,
|
|
failed.owner.snapshot.isCurrent(),
|
|
);
|
|
const failedBlock = findUserMessageByIdentity(
|
|
blocks,
|
|
failed.identity,
|
|
failed.owner.snapshot.isCurrent(),
|
|
);
|
|
if (failed.sessionId !== connection.sessionId || !currentFailedBlock) {
|
|
updateFailedPrompt(null);
|
|
return;
|
|
}
|
|
if (!failedBlock) return;
|
|
const failedIndex = displayMessages.findIndex(
|
|
(message) => message.id === failedBlock.id,
|
|
);
|
|
if (failedIndex < 0) return;
|
|
if (
|
|
displayMessages
|
|
.slice(failedIndex + 1)
|
|
.some((message) => message.role === 'user')
|
|
) {
|
|
updateFailedPrompt(null);
|
|
return;
|
|
}
|
|
if (
|
|
failed.messageId !== failedBlock.id ||
|
|
failed.identity.block !== failedBlock
|
|
) {
|
|
updateFailedPrompt({
|
|
...failed,
|
|
messageId: failedBlock.id,
|
|
identity: { block: failedBlock },
|
|
});
|
|
}
|
|
}, [
|
|
blocks,
|
|
connection.sessionId,
|
|
displayMessages,
|
|
failedPrompt,
|
|
store,
|
|
updateFailedPrompt,
|
|
]);
|
|
useEffect(() => {
|
|
const unknown = unknownPromptAdmissionRef.current;
|
|
if (unknown && unknown.sessionId !== connection.sessionId) {
|
|
updateUnknownPromptAdmission(null);
|
|
}
|
|
}, [connection.sessionId, updateUnknownPromptAdmission]);
|
|
const {
|
|
artifacts,
|
|
loading: artifactsLoading,
|
|
error: artifactsError,
|
|
refresh: refreshArtifacts,
|
|
hydrated: artifactsHydrated,
|
|
} = useSessionArtifacts(t);
|
|
const sourcesState = useSessionSources();
|
|
const refreshSources = sourcesState.refresh;
|
|
const [sourceRegistrationRetries, setSourceRegistrationRetries] = useState<
|
|
Array<() => Promise<void>>
|
|
>([]);
|
|
useEffect(() => {
|
|
setSourceRegistrationRetries([]);
|
|
}, [sourcesState.owner]);
|
|
const retrySourceRegistrations = useCallback(async () => {
|
|
setSourceRegistrationRetries([]);
|
|
await Promise.allSettled(sourceRegistrationRetries.map((retry) => retry()));
|
|
await refreshSources();
|
|
}, [sourceRegistrationRetries, refreshSources]);
|
|
const artifactsRef = useRef(artifacts);
|
|
artifactsRef.current = artifacts;
|
|
const [artifactPanelExtraArtifacts, setArtifactPanelExtraArtifacts] =
|
|
useState<DaemonSessionArtifact[]>([]);
|
|
const [paneArtifactSnapshots, setPaneArtifactSnapshots] = useState<
|
|
Map<string, PaneArtifactSnapshot>
|
|
>(() => new Map());
|
|
const [artifactPanelTabs, setArtifactPanelTabs] = useState<
|
|
ArtifactPanelTab[]
|
|
>([]);
|
|
const artifactPanelTabsRef = useRef(artifactPanelTabs);
|
|
artifactPanelTabsRef.current = artifactPanelTabs;
|
|
useEffect(() => {
|
|
if (artifactPanelExtraArtifacts.length === 0 || artifacts.length === 0) {
|
|
return;
|
|
}
|
|
const artifactIds = new Set(artifacts.map((artifact) => artifact.id));
|
|
// Extras referenced by open artifact tabs must survive the reconcile:
|
|
// they keep those tabs renderable through transient gaps in the live
|
|
// list, and stay inert while the live list (merged first) covers them.
|
|
const openArtifactIds = new Set(
|
|
artifactPanelTabs
|
|
.filter((tab) => tab.kind === 'artifact')
|
|
.map((tab) => (tab.kind === 'artifact' ? tab.artifactId : '')),
|
|
);
|
|
setArtifactPanelExtraArtifacts((previous) => {
|
|
const next = previous.filter(
|
|
(artifact) =>
|
|
!artifactIds.has(artifact.id) || openArtifactIds.has(artifact.id),
|
|
);
|
|
return next.length === previous.length ? previous : next;
|
|
});
|
|
}, [artifacts, artifactPanelExtraArtifacts.length, artifactPanelTabs]);
|
|
const paneArtifactExtras = useMemo(
|
|
() =>
|
|
Array.from(paneArtifactSnapshots.values()).flatMap((snapshot) => [
|
|
...snapshot.artifacts,
|
|
]),
|
|
[paneArtifactSnapshots],
|
|
);
|
|
const artifactPanelArtifacts = useMemo(() => {
|
|
if (
|
|
artifactPanelExtraArtifacts.length === 0 &&
|
|
paneArtifactExtras.length === 0
|
|
) {
|
|
return artifacts;
|
|
}
|
|
const merged = [...artifacts];
|
|
// Pane snapshots outrank open-time extras so a retained extra never
|
|
// shadows a fresher pane report for the same artifact id.
|
|
for (const artifact of [
|
|
...paneArtifactExtras,
|
|
...artifactPanelExtraArtifacts,
|
|
]) {
|
|
const index = merged.findIndex((item) => item.id === artifact.id);
|
|
if (index < 0) {
|
|
merged.push(artifact);
|
|
}
|
|
}
|
|
return merged;
|
|
}, [artifacts, artifactPanelExtraArtifacts, paneArtifactExtras]);
|
|
const handlePaneArtifactsChange = useCallback(
|
|
(
|
|
paneSessionId: string,
|
|
paneArtifacts: readonly DaemonSessionArtifact[],
|
|
) => {
|
|
if (paneArtifacts.length > 0) {
|
|
const paneArtifactIds = new Set(
|
|
paneArtifacts.map((artifact) => artifact.id),
|
|
);
|
|
// Keep extras whose artifact tab is still open: once the pane closes
|
|
// and its snapshot is dropped, the extra is the tab's only copy.
|
|
const openArtifactIds = new Set(
|
|
artifactPanelTabsRef.current
|
|
.filter((tab) => tab.kind === 'artifact')
|
|
.map((tab) => (tab.kind === 'artifact' ? tab.artifactId : '')),
|
|
);
|
|
setArtifactPanelExtraArtifacts((current) => {
|
|
const next = current.filter(
|
|
(artifact) =>
|
|
!paneArtifactIds.has(artifact.id) ||
|
|
openArtifactIds.has(artifact.id),
|
|
);
|
|
return next.length === current.length ? current : next;
|
|
});
|
|
}
|
|
setPaneArtifactSnapshots((current) => {
|
|
const previous = current.get(paneSessionId);
|
|
const unchanged =
|
|
previous?.artifacts.length === paneArtifacts.length &&
|
|
previous.artifacts.every((artifact, index) => {
|
|
const nextArtifact = paneArtifacts[index];
|
|
// `metadata` is deliberately not compared: artifact events carry
|
|
// freshly parsed objects, so an identity check here would defeat
|
|
// the guard without catching a realistic change.
|
|
return (
|
|
nextArtifact?.id === artifact.id &&
|
|
nextArtifact.status === artifact.status &&
|
|
nextArtifact.updatedAt === artifact.updatedAt &&
|
|
nextArtifact.sizeBytes === artifact.sizeBytes &&
|
|
nextArtifact.title === artifact.title &&
|
|
nextArtifact.workspacePath === artifact.workspacePath
|
|
);
|
|
});
|
|
if (unchanged) return current;
|
|
const next = new Map(current);
|
|
if (paneArtifacts.length === 0) {
|
|
next.delete(paneSessionId);
|
|
} else {
|
|
next.set(paneSessionId, {
|
|
artifacts: [...paneArtifacts],
|
|
});
|
|
}
|
|
return next;
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
const artifactsByTurn = useMemo(
|
|
() =>
|
|
getArtifactsByTurn(
|
|
displayMessages,
|
|
artifacts,
|
|
connection.workspaceCwd || '',
|
|
),
|
|
[displayMessages, artifacts, connection.workspaceCwd],
|
|
);
|
|
useSessionArtifactsChange({
|
|
sessionId: connection.sessionId,
|
|
ready:
|
|
connection.status === 'connected' &&
|
|
!connection.loadingTranscript &&
|
|
!connection.catchingUp &&
|
|
!artifactsLoading,
|
|
hydrated: artifactsHydrated,
|
|
artifacts,
|
|
artifactsByTurn,
|
|
onChange: onSessionArtifactsChange,
|
|
});
|
|
const fileChangesByTurn = useMemo(
|
|
() =>
|
|
getFileChangesByTurn(
|
|
displayMessages,
|
|
artifactsByTurn,
|
|
connection.workspaceCwd || '',
|
|
),
|
|
[displayMessages, artifactsByTurn, connection.workspaceCwd],
|
|
);
|
|
const latestReviewChanges = useMemo(() => {
|
|
let latest: readonly TurnOutputFileChange[] = [];
|
|
for (const changes of fileChangesByTurn.values()) {
|
|
if (changes.length > 0) latest = changes;
|
|
}
|
|
return latest;
|
|
}, [fileChangesByTurn]);
|
|
const latestReviewTurnId = useMemo(() => {
|
|
let latest: string | undefined;
|
|
for (const [turnId, changes] of fileChangesByTurn) {
|
|
if (changes.length > 0) latest = turnId;
|
|
}
|
|
return latest;
|
|
}, [fileChangesByTurn]);
|
|
const scheduledTasksByTurn = useMemo(
|
|
() => getScheduledTasksByTurn(displayMessages),
|
|
[displayMessages],
|
|
);
|
|
const artifactPanelRestoreInputsRef = useRef({
|
|
displayMessages,
|
|
fileChangesByTurn,
|
|
latestReviewChanges,
|
|
scheduledTasksByTurn,
|
|
});
|
|
artifactPanelRestoreInputsRef.current = {
|
|
displayMessages,
|
|
fileChangesByTurn,
|
|
latestReviewChanges,
|
|
scheduledTasksByTurn,
|
|
};
|
|
const visibleTurnOutputKinds = useMemo(
|
|
() => new Set<TurnOutputKind>(messageTurnOutputs ?? TURN_OUTPUT_KINDS),
|
|
[messageTurnOutputs],
|
|
);
|
|
// The artifact panel's open state and restorable tabs survive reloads per
|
|
// session; tab content itself re-fetches from the daemon where possible.
|
|
const [initialArtifactPanelPersistedStates] = useState(() =>
|
|
readStoredArtifactPanelStates(RIGHT_PANEL_STATE_STORAGE_KEY),
|
|
);
|
|
const artifactPanelPersistedStatesRef = useRef(
|
|
initialArtifactPanelPersistedStates,
|
|
);
|
|
const initialArtifactPanelOpen = Boolean(
|
|
panelStateKey && initialArtifactPanelPersistedStates[panelStateKey]?.open,
|
|
);
|
|
const [artifactPanelOpen, setArtifactPanelOpen] = useState(
|
|
initialArtifactPanelOpen,
|
|
);
|
|
const [artifactPanelRestoring, setArtifactPanelRestoring] = useState(
|
|
initialArtifactPanelOpen,
|
|
);
|
|
// The environment panel's open state is per session: opening it in one
|
|
// session must not leak into another, while switching back restores it.
|
|
const [initialEnvironmentPanelOpenBySession] = useState(() =>
|
|
readStoredBooleanMap(ENVIRONMENT_PANEL_OPEN_STORAGE_KEY),
|
|
);
|
|
const environmentPanelOpenBySessionRef = useRef(
|
|
initialEnvironmentPanelOpenBySession,
|
|
);
|
|
const [environmentPanelOpen, setEnvironmentPanelOpen] = useState(() =>
|
|
environmentPanelReachable && logicalSessionKey
|
|
? (environmentPanelOpenBySessionRef.current[logicalSessionKey] ?? false)
|
|
: false,
|
|
);
|
|
const environmentArtifactsOpenRef = useRef({
|
|
sessionKey: logicalSessionKey,
|
|
open: environmentPanelOpen,
|
|
});
|
|
const preserveEnvironmentPanelOnArtifactOpenRef = useRef(false);
|
|
useLayoutEffect(() => {
|
|
preserveEnvironmentPanelOnArtifactOpenRef.current = false;
|
|
const open =
|
|
environmentPanelReachable && logicalSessionKey
|
|
? (environmentPanelOpenBySessionRef.current[logicalSessionKey] ?? false)
|
|
: false;
|
|
setEnvironmentPanelOpen(open);
|
|
}, [environmentPanelReachable, logicalSessionKey]);
|
|
const persistEnvironmentPanelOpen = useCallback(
|
|
(open: boolean) => {
|
|
if (!logicalSessionKey) return;
|
|
delete environmentPanelOpenBySessionRef.current[logicalSessionKey];
|
|
environmentPanelOpenBySessionRef.current[logicalSessionKey] = open;
|
|
writeStoredBooleanMap(
|
|
ENVIRONMENT_PANEL_OPEN_STORAGE_KEY,
|
|
environmentPanelOpenBySessionRef.current,
|
|
);
|
|
},
|
|
[logicalSessionKey],
|
|
);
|
|
useEffect(() => {
|
|
const previous = environmentArtifactsOpenRef.current;
|
|
environmentArtifactsOpenRef.current = {
|
|
sessionKey: logicalSessionKey,
|
|
open: environmentPanelOpen,
|
|
};
|
|
if (
|
|
previous.sessionKey === logicalSessionKey &&
|
|
!previous.open &&
|
|
environmentPanelOpen &&
|
|
environmentPanelItems.includes('artifacts')
|
|
) {
|
|
void refreshArtifacts();
|
|
}
|
|
}, [
|
|
environmentPanelItems,
|
|
environmentPanelOpen,
|
|
logicalSessionKey,
|
|
refreshArtifacts,
|
|
]);
|
|
const [, setSessionAttachments] = useState<
|
|
DaemonSessionAttachmentReference[]
|
|
>([]);
|
|
const [sessionAttachmentsLoading, setSessionAttachmentsLoading] =
|
|
useState(false);
|
|
const sessionAttachmentsOwnerRef = useRef(sessionOwnerGuard.capture());
|
|
if (!sessionAttachmentsOwnerRef.current.isCurrent()) {
|
|
sessionAttachmentsOwnerRef.current = sessionOwnerGuard.capture();
|
|
}
|
|
const sessionAttachmentsOwner = sessionAttachmentsOwnerRef.current;
|
|
const [sessionAttachmentsError, setSessionAttachmentsError] = useState<{
|
|
owner: DaemonSessionOwnerSnapshot;
|
|
message: string;
|
|
}>();
|
|
const sessionAttachmentsBySessionRef = useRef(
|
|
new Map<string, DaemonSessionAttachmentReference[]>(),
|
|
);
|
|
const sessionAttachmentsSkeletonLoading =
|
|
environmentPanelReachable &&
|
|
environmentSourcesEnabled &&
|
|
(sessionAttachmentsLoading ||
|
|
Boolean(
|
|
environmentPanelOpen &&
|
|
logicalSessionKey &&
|
|
!sessionAttachmentsBySessionRef.current.has(logicalSessionKey),
|
|
));
|
|
const lastAttachmentsFetchRef = useRef({
|
|
sessionKey: '',
|
|
fetchedAt: 0,
|
|
});
|
|
const sessionAttachmentsRequestIdRef = useRef(0);
|
|
const attachmentRetryCountRef = useRef(new Map<string, number>());
|
|
const [attachmentRefreshNonce, setAttachmentRefreshNonce] = useState(0);
|
|
// The source panel and turn footers share the attachment inventory.
|
|
// Refresh on transcript updates, throttled during streaming.
|
|
const transcriptRevision = blockChangeSummary?.revision ?? 0;
|
|
const sessionAttachmentsRequestEligibleRef = useRef(false);
|
|
sessionAttachmentsRequestEligibleRef.current =
|
|
connection.status === 'connected' &&
|
|
Boolean(connection.sessionId && logicalSessionKey) &&
|
|
connection.capabilities?.features.includes(
|
|
SESSION_ATTACHMENT_LIST_FEATURE,
|
|
) === true;
|
|
useEffect(() => {
|
|
if (!daemonAvailable) {
|
|
if (logicalSessionKey) {
|
|
attachmentRetryCountRef.current.delete(logicalSessionKey);
|
|
}
|
|
setSessionAttachmentsLoading(false);
|
|
setSessionAttachmentsError(undefined);
|
|
return;
|
|
}
|
|
const attachmentsSupported =
|
|
connection.capabilities?.features.includes(
|
|
SESSION_ATTACHMENT_LIST_FEATURE,
|
|
) === true;
|
|
if (
|
|
connection.status !== 'connected' ||
|
|
!connection.sessionId ||
|
|
!logicalSessionKey
|
|
) {
|
|
if (logicalSessionKey) {
|
|
attachmentRetryCountRef.current.delete(logicalSessionKey);
|
|
}
|
|
setSessionAttachmentsLoading(false);
|
|
setSessionAttachmentsError(undefined);
|
|
return;
|
|
}
|
|
if (!attachmentsSupported) {
|
|
setSessionAttachmentsError(undefined);
|
|
attachmentRetryCountRef.current.delete(logicalSessionKey);
|
|
setBoundedMapEntry(
|
|
sessionAttachmentsBySessionRef.current,
|
|
logicalSessionKey,
|
|
[],
|
|
MAX_ARTIFACT_PANEL_SESSION_STATES,
|
|
);
|
|
setSessionAttachments([]);
|
|
setSessionAttachmentsLoading(false);
|
|
return;
|
|
}
|
|
const firstLoad =
|
|
!sessionAttachmentsBySessionRef.current.has(logicalSessionKey);
|
|
if (connection.loadingTranscript) {
|
|
setSessionAttachmentsLoading(firstLoad);
|
|
return;
|
|
}
|
|
setSessionAttachmentsLoading(firstLoad);
|
|
const elapsed =
|
|
lastAttachmentsFetchRef.current.sessionKey === logicalSessionKey
|
|
? Date.now() - lastAttachmentsFetchRef.current.fetchedAt
|
|
: ATTACHMENTS_REFRESH_INTERVAL_MS;
|
|
const delay = Math.max(0, ATTACHMENTS_REFRESH_INTERVAL_MS - elapsed);
|
|
let cancelled = false;
|
|
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
const timer = setTimeout(() => {
|
|
lastAttachmentsFetchRef.current = {
|
|
sessionKey: logicalSessionKey,
|
|
fetchedAt: Date.now(),
|
|
};
|
|
const requestId = ++sessionAttachmentsRequestIdRef.current;
|
|
setSessionAttachmentsError(undefined);
|
|
const listing = sessionActions.listAttachments();
|
|
void listing
|
|
.then((attachments) => {
|
|
if (
|
|
sessionAttachmentsRequestIdRef.current === requestId &&
|
|
sessionAttachmentsOwner.isCurrent() &&
|
|
sessionAttachmentsRequestEligibleRef.current
|
|
) {
|
|
attachmentRetryCountRef.current.delete(logicalSessionKey);
|
|
setBoundedMapEntry(
|
|
sessionAttachmentsBySessionRef.current,
|
|
logicalSessionKey,
|
|
attachments,
|
|
MAX_ARTIFACT_PANEL_SESSION_STATES,
|
|
);
|
|
setSessionAttachments(attachments);
|
|
setSessionAttachmentsLoading(false);
|
|
}
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!cancelled && sessionAttachmentsOwner.isCurrent()) {
|
|
if (firstLoad) {
|
|
const failures =
|
|
(attachmentRetryCountRef.current.get(logicalSessionKey) ?? 0) +
|
|
1;
|
|
if (failures < 3) {
|
|
setBoundedMapEntry(
|
|
attachmentRetryCountRef.current,
|
|
logicalSessionKey,
|
|
failures,
|
|
MAX_ARTIFACT_PANEL_SESSION_STATES,
|
|
);
|
|
retryTimer = setTimeout(
|
|
() => setAttachmentRefreshNonce((nonce) => nonce + 1),
|
|
ATTACHMENTS_REFRESH_INTERVAL_MS,
|
|
);
|
|
return;
|
|
}
|
|
attachmentRetryCountRef.current.delete(logicalSessionKey);
|
|
setBoundedMapEntry(
|
|
sessionAttachmentsBySessionRef.current,
|
|
logicalSessionKey,
|
|
[],
|
|
MAX_ARTIFACT_PANEL_SESSION_STATES,
|
|
);
|
|
setSessionAttachments([]);
|
|
}
|
|
setSessionAttachmentsError({
|
|
owner: sessionAttachmentsOwner,
|
|
message: formatError(error, t('environment.unavailable')),
|
|
});
|
|
setSessionAttachmentsLoading(false);
|
|
}
|
|
});
|
|
}, delay);
|
|
return () => {
|
|
cancelled = true;
|
|
clearTimeout(timer);
|
|
clearTimeout(retryTimer);
|
|
};
|
|
}, [
|
|
connection.capabilities,
|
|
connection.sessionId,
|
|
connection.loadingTranscript,
|
|
connection.status,
|
|
attachmentRefreshNonce,
|
|
daemonAvailable,
|
|
environmentPanelOpen,
|
|
logicalSessionKey,
|
|
transcriptRevision,
|
|
sessionActions,
|
|
sessionAttachmentsOwner,
|
|
t,
|
|
]);
|
|
const artifactPanelOpenRef = useRef(artifactPanelOpen);
|
|
artifactPanelOpenRef.current = artifactPanelOpen;
|
|
const artifactPanelTriggerRef = useRef<HTMLElement | null>(null);
|
|
const focusComposerAfterArtifactPanelCloseRef = useRef(false);
|
|
const rememberArtifactPanelTrigger = useCallback(() => {
|
|
if (
|
|
!artifactPanelOpenRef.current &&
|
|
document.activeElement instanceof HTMLElement
|
|
) {
|
|
artifactPanelTriggerRef.current = document.activeElement;
|
|
}
|
|
}, []);
|
|
const [activeArtifactPanelTabId, setActiveArtifactPanelTabId] = useState<
|
|
string | null
|
|
>(null);
|
|
const activeArtifactPanelTabIdRef = useRef(activeArtifactPanelTabId);
|
|
activeArtifactPanelTabIdRef.current = activeArtifactPanelTabId;
|
|
const artifactPanelRestoredSessionKeyRef = useRef<string | undefined>(
|
|
undefined,
|
|
);
|
|
const artifactPanelDeferredPersistedTabsRef = useRef(
|
|
new Map<string, PersistedArtifactPanelTab[]>(),
|
|
);
|
|
useEffect(() => {
|
|
if (artifactPanelRestoredSessionKeyRef.current !== logicalSessionKey)
|
|
return;
|
|
const tab = artifactPanelTabs.find((item) => item.kind === 'turn_calls');
|
|
if (!tab || tab.kind !== 'turn_calls' || tab.recordId) return;
|
|
const recordId = blocks.find(
|
|
(block) =>
|
|
block.kind === 'user' &&
|
|
(tab.promptId
|
|
? block.promptId === tab.promptId
|
|
: block.id === tab.turnId),
|
|
)?.sourceRecordIds?.[0];
|
|
if (!recordId) return;
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.map((item) => (item === tab ? { ...tab, recordId } : item)),
|
|
);
|
|
}, [artifactPanelTabs, blocks, logicalSessionKey]);
|
|
useLayoutEffect(() => {
|
|
if (
|
|
!panelStateKey ||
|
|
artifactPanelRestoredSessionKeyRef.current !== panelStateKey
|
|
)
|
|
return;
|
|
const previousPersistedState =
|
|
artifactPanelPersistedStatesRef.current[panelStateKey];
|
|
delete artifactPanelPersistedStatesRef.current[panelStateKey];
|
|
const serializedTabs = serializeArtifactPanelTabs(artifactPanelTabs);
|
|
const deferredTabs =
|
|
artifactPanelDeferredPersistedTabsRef.current.get(panelStateKey) ?? [];
|
|
artifactPanelPersistedStatesRef.current[panelStateKey] = {
|
|
open: artifactPanelOpen,
|
|
activeTabId:
|
|
activeArtifactPanelTabId ??
|
|
(deferredTabs.some(
|
|
(tab) => tab.id === previousPersistedState?.activeTabId,
|
|
)
|
|
? (previousPersistedState?.activeTabId ?? null)
|
|
: null),
|
|
tabs: mergePersistedArtifactPanelTabs(
|
|
serializedTabs,
|
|
deferredTabs,
|
|
previousPersistedState?.tabs,
|
|
),
|
|
};
|
|
writeStoredArtifactPanelStates(
|
|
RIGHT_PANEL_STATE_STORAGE_KEY,
|
|
artifactPanelPersistedStatesRef.current,
|
|
);
|
|
}, [
|
|
activeArtifactPanelTabId,
|
|
artifactPanelOpen,
|
|
artifactPanelTabs,
|
|
panelStateKey,
|
|
]);
|
|
const [reviewChanges, setReviewChanges] = useState<
|
|
readonly TurnOutputFileChange[]
|
|
>([]);
|
|
const [selectedReviewPath, setSelectedReviewPath] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [artifactPanelWidth, setArtifactPanelWidth] = useState(
|
|
DEFAULT_REVIEW_PANEL_WIDTH,
|
|
);
|
|
const [artifactPanelFullscreen, setArtifactPanelFullscreen] = useState(false);
|
|
useEffect(() => {
|
|
if (managedMode && artifactPanelFullscreen)
|
|
setArtifactPanelFullscreen(false);
|
|
}, [managedMode, artifactPanelFullscreen]);
|
|
const artifactPanelFullscreenRef = useRef(false);
|
|
artifactPanelFullscreenRef.current = artifactPanelFullscreen;
|
|
// Exiting fullscreen swaps the same DOM node back to .artifactPanelDock,
|
|
// whose open animation would re-play on the already-open panel; suppress it
|
|
// until the dock remounts for a genuine open (panel close, or the floating
|
|
// drawer handing back).
|
|
const [
|
|
suppressArtifactDockOpenAnimation,
|
|
setSuppressArtifactDockOpenAnimation,
|
|
] = useState(initialArtifactPanelOpen);
|
|
const resetEmptyArtifactPanel = useCallback(() => {
|
|
setArtifactPanelOpen(false);
|
|
setArtifactPanelFullscreen(false);
|
|
setSuppressArtifactDockOpenAnimation(false);
|
|
setActiveArtifactPanelTabId(null);
|
|
setReviewChanges([]);
|
|
setSelectedReviewPath(null);
|
|
setArtifactPanelExtraArtifacts([]);
|
|
setPaneArtifactSnapshots(new Map());
|
|
}, []);
|
|
const [waitForSubagentPanelAnimation, setWaitForSubagentPanelAnimation] =
|
|
useState(false);
|
|
// In-tree portal target for the docked panel (display:contents keeps the
|
|
// portaled wrapper a direct flex item of .appShell). Held in state so the
|
|
// portal container exists before the wrapper renders.
|
|
const [artifactPanelSlotEl, setArtifactPanelSlotEl] =
|
|
useState<HTMLDivElement | null>(null);
|
|
const artifactPanelFullscreenSurfaceRef = useRef<HTMLDivElement | null>(null);
|
|
const artifactPanelResizeCleanupRef = useRef<(() => void) | null>(null);
|
|
const artifactPanelSessionStateRef = useRef<ArtifactPanelSessionState | null>(
|
|
null,
|
|
);
|
|
const artifactPanelStateBySessionRef = useRef(
|
|
new Map<string, ArtifactPanelSessionState>(),
|
|
);
|
|
const artifactPanelSessionIdRef = useRef(panelStateKey);
|
|
// Set when a session materialized out of the empty new-task state and
|
|
// adopted the panel as-is; cleared on the next real session switch.
|
|
const artifactPanelAdoptedSessionRef = useRef<string | undefined>(undefined);
|
|
// Set when the lazy first-prompt creation allocates a session id: the panel
|
|
// effect then treats the draft→session transition as a migration (panel
|
|
// carries over, draft entry dropped) instead of a session switch.
|
|
const draftMaterializedSessionIdRef = useRef<string | undefined>(undefined);
|
|
artifactPanelSessionStateRef.current = {
|
|
open: artifactPanelOpen,
|
|
tabs: artifactPanelTabs,
|
|
activeTabId: activeArtifactPanelTabId,
|
|
reviewChanges,
|
|
selectedReviewPath,
|
|
extraArtifacts: artifactPanelExtraArtifacts,
|
|
width: artifactPanelWidth,
|
|
};
|
|
const sideTasksAvailable =
|
|
workspaceContextActive &&
|
|
Boolean(connection.sessionId && connection.workspaceCwd) &&
|
|
connection.capabilities?.features.includes(SESSION_SIDE_TASK_FEATURE) ===
|
|
true;
|
|
const sideTaskRestoreAvailable =
|
|
sideTasksAvailable &&
|
|
connection.capabilities?.features.includes('session_archive') === true;
|
|
const sideTaskArchiveAvailable =
|
|
sideTaskRestoreAvailable &&
|
|
connection.capabilities?.features.includes('session_archive_idle_guard') ===
|
|
true;
|
|
const filesAvailable =
|
|
workspaceContextActive &&
|
|
rightPanelItems.includes('files') &&
|
|
Boolean(
|
|
resolveArtifactWorkspaceOwner(workspace.capabilities, panelWorkspaceCwd),
|
|
);
|
|
const webPreviewAvailable =
|
|
workspaceContextActive && rightPanelItems.includes('webPreview');
|
|
const trajectoryAvailable = rightPanelItems.includes('trajectory');
|
|
const collaborationAvailable = isAgentCollaborationEnabledForWorkspace(
|
|
workspace.capabilities,
|
|
legacyWorkspaceContextCwd,
|
|
);
|
|
const webTerminalAvailable =
|
|
workspaceContextActive &&
|
|
rightPanelItems.includes('terminal') &&
|
|
connection.capabilities?.features.includes('web_terminal') === true;
|
|
useEffect(() => {
|
|
if (workspaceContextActive) return;
|
|
const terminalIds = artifactPanelTabs
|
|
.filter((tab) => tab.kind === 'terminal')
|
|
.map((tab) => tab.id);
|
|
if (terminalIds.length === 0) return;
|
|
for (const terminalId of terminalIds) releaseWebTerminal(terminalId);
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.filter((tab) => tab.kind !== 'terminal'),
|
|
);
|
|
if (
|
|
activeArtifactPanelTabId &&
|
|
terminalIds.includes(activeArtifactPanelTabId)
|
|
) {
|
|
setActiveArtifactPanelTabId(null);
|
|
setArtifactPanelOpen(false);
|
|
}
|
|
}, [activeArtifactPanelTabId, artifactPanelTabs, workspaceContextActive]);
|
|
const [sideTaskCatalog, setSideTaskCatalog] = useState<SideTaskCatalogState>({
|
|
items: [],
|
|
loaded: false,
|
|
});
|
|
const [archivedSideTaskCatalog, setArchivedSideTaskCatalog] =
|
|
useState<SideTaskCatalogState>({
|
|
items: [],
|
|
loaded: false,
|
|
});
|
|
const [sideTaskCatalogRefreshNonce, setSideTaskCatalogRefreshNonce] =
|
|
useState(0);
|
|
const refreshSideTaskCatalog = useCallback(() => {
|
|
setSideTaskCatalogRefreshNonce((nonce) => nonce + 1);
|
|
}, []);
|
|
const sideTaskCatalogRequestGenerationRef = useRef(0);
|
|
const removedSideTaskSessionIdsRef = useRef(new Set<string>());
|
|
useLayoutEffect(() => {
|
|
sideTaskCatalogRequestGenerationRef.current += 1;
|
|
removedSideTaskSessionIdsRef.current.clear();
|
|
setSideTaskCatalog({ items: [], loaded: false });
|
|
setArchivedSideTaskCatalog({ items: [], loaded: false });
|
|
}, [logicalSessionKey]);
|
|
const optimisticSideTaskIdsRef = useRef(new Set<string>());
|
|
const visibleSideTasks =
|
|
sideTaskCatalog.parentSessionId === connection.sessionId
|
|
? sideTaskCatalog.items
|
|
: [];
|
|
const visibleArchivedSideTasks =
|
|
archivedSideTaskCatalog.parentSessionId === connection.sessionId
|
|
? archivedSideTaskCatalog.items
|
|
: [];
|
|
const sideTasksLoading =
|
|
visibleSideTasks.length === 0 &&
|
|
visibleArchivedSideTasks.length === 0 &&
|
|
(sideTaskCatalog.parentSessionId !== connection.sessionId ||
|
|
!sideTaskCatalog.loaded ||
|
|
(sideTaskRestoreAvailable &&
|
|
(archivedSideTaskCatalog.parentSessionId !== connection.sessionId ||
|
|
!archivedSideTaskCatalog.loaded)));
|
|
const nextSideTaskTabIdRef = useRef(0);
|
|
const createSideTask = useCallback(
|
|
(initialPrompt?: string) => {
|
|
const parentSessionId = connection.sessionId;
|
|
if (!parentSessionId || !sideTasksAvailable) return false;
|
|
const tab: ArtifactPanelTab = {
|
|
id: `side-task:draft:${Date.now()}:${++nextSideTaskTabIdRef.current}`,
|
|
kind: 'side_task',
|
|
title: t('sideTask.title'),
|
|
parentSessionId,
|
|
workspaceCwd: connection.workspaceCwd,
|
|
nameFromFirstPrompt: true,
|
|
...(initialPrompt?.trim()
|
|
? { initialPrompt: initialPrompt.trim() }
|
|
: {}),
|
|
};
|
|
setArtifactPanelTabs((tabs) => [...tabs, tab]);
|
|
setActiveArtifactPanelTabId(tab.id);
|
|
setArtifactPanelOpen(true);
|
|
return true;
|
|
},
|
|
[connection.sessionId, connection.workspaceCwd, sideTasksAvailable, t],
|
|
);
|
|
const createEmptySideTask = useCallback(() => {
|
|
if (createSideTask()) return;
|
|
pushToast('error', t('sideTask.createFailed'));
|
|
}, [createSideTask, pushToast, t]);
|
|
const openWebPreviewTab = useCallback(() => {
|
|
const id = `web-preview:${crypto.randomUUID()}`;
|
|
setArtifactPanelTabs((tabs) => [
|
|
...tabs,
|
|
{
|
|
id,
|
|
kind: 'web_preview',
|
|
title: t('webPreview.title'),
|
|
url: '',
|
|
viewport: 'desktop',
|
|
},
|
|
]);
|
|
setActiveArtifactPanelTabId(id);
|
|
setArtifactPanelOpen(true);
|
|
}, [t]);
|
|
const updateWebPreviewTab = useCallback(
|
|
(tabId: string, state: WebPreviewState) => {
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.map((tab) =>
|
|
tab.id === tabId && tab.kind === 'web_preview'
|
|
? {
|
|
...tab,
|
|
title:
|
|
state.url === tab.url ? tab.title : state.url || tab.title,
|
|
url: state.url,
|
|
viewport: state.viewport,
|
|
}
|
|
: tab,
|
|
),
|
|
);
|
|
},
|
|
[],
|
|
);
|
|
const openTerminalTab = useCallback(() => {
|
|
const id = `terminal:${crypto.randomUUID()}`;
|
|
const count = artifactPanelTabsRef.current.filter(
|
|
(tab) => tab.kind === 'terminal',
|
|
).length;
|
|
const base = t('terminal.title');
|
|
const tab: ArtifactPanelTab = {
|
|
id,
|
|
kind: 'terminal',
|
|
title: count === 0 ? base : `${base} (${count + 1})`,
|
|
workspaceCwd: panelWorkspaceCwd,
|
|
initialized: true,
|
|
};
|
|
setArtifactPanelTabs((tabs) => [...tabs, tab]);
|
|
setActiveArtifactPanelTabId(tab.id);
|
|
setArtifactPanelOpen(true);
|
|
}, [panelWorkspaceCwd, t]);
|
|
const sideTaskCreationPromisesRef = useRef(
|
|
new Map<string, Promise<{ sessionId: string; displayName?: string }>>(),
|
|
);
|
|
const createSideTaskSession = useCallback(
|
|
(tabId: string, parentSessionId: string, title: string) => {
|
|
const pending = sideTaskCreationPromisesRef.current.get(tabId);
|
|
if (pending) return pending;
|
|
const creation = (async () => {
|
|
const owner = sessionOwnerGuard.capture();
|
|
const ownerCwd = connection.workspaceCwd;
|
|
const parentClientId =
|
|
connection.sessionId === parentSessionId
|
|
? connection.clientId
|
|
: undefined;
|
|
const session = await workspace.client.createSideTaskSession(
|
|
parentSessionId,
|
|
{
|
|
name: title,
|
|
},
|
|
parentClientId,
|
|
);
|
|
if (owner.isCurrent() && session.sourceWarnings?.length) {
|
|
pushToast('warning', session.sourceWarnings.join(' '));
|
|
}
|
|
if (ownerCwd) {
|
|
sessionCatalogController.sessionCreated(ownerCwd, session.sessionId);
|
|
}
|
|
await workspace.client
|
|
.detachSession(session.sessionId, session.clientId)
|
|
.catch(() => undefined);
|
|
return {
|
|
sessionId: session.sessionId,
|
|
displayName: session.displayName,
|
|
};
|
|
})().finally(() => sideTaskCreationPromisesRef.current.delete(tabId));
|
|
sideTaskCreationPromisesRef.current.set(tabId, creation);
|
|
return creation;
|
|
},
|
|
[
|
|
connection.clientId,
|
|
connection.sessionId,
|
|
connection.workspaceCwd,
|
|
sessionOwnerGuard,
|
|
pushToast,
|
|
sessionCatalogController,
|
|
workspace.client,
|
|
],
|
|
);
|
|
const handleSideTaskCreated = useCallback(
|
|
(tabId: string, sessionId: string) => {
|
|
let createdTab = artifactPanelTabsRef.current.find(
|
|
(candidate) => candidate.id === tabId,
|
|
);
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.map((tab) =>
|
|
tab.id === tabId && tab.kind === 'side_task'
|
|
? { ...tab, sessionId }
|
|
: tab,
|
|
),
|
|
);
|
|
if (!createdTab) {
|
|
// Creation can resolve after we navigate away from the parent session;
|
|
// the draft tab then lives in a saved per-session bucket rather than the
|
|
// live tabs, so write the sessionId there too or reopening the parent
|
|
// creates a duplicate side task.
|
|
for (const [
|
|
sessionKey,
|
|
state,
|
|
] of artifactPanelStateBySessionRef.current) {
|
|
const candidate = state.tabs.find(
|
|
(bucketTab) => bucketTab.id === tabId,
|
|
);
|
|
if (!candidate) continue;
|
|
createdTab = candidate;
|
|
state.tabs = state.tabs.map((bucketTab) =>
|
|
bucketTab.id === tabId && bucketTab.kind === 'side_task'
|
|
? { ...bucketTab, sessionId }
|
|
: bucketTab,
|
|
);
|
|
const previousPersistedState =
|
|
artifactPanelPersistedStatesRef.current[sessionKey];
|
|
const retainedTabs = [
|
|
...(state.pendingRestore
|
|
? (previousPersistedState?.tabs ?? [])
|
|
: []),
|
|
...(artifactPanelDeferredPersistedTabsRef.current.get(sessionKey) ??
|
|
[]),
|
|
];
|
|
const serializedTabs = serializeArtifactPanelTabs(state.tabs);
|
|
delete artifactPanelPersistedStatesRef.current[sessionKey];
|
|
artifactPanelPersistedStatesRef.current[sessionKey] = {
|
|
open: state.open,
|
|
activeTabId: state.activeTabId,
|
|
tabs: mergePersistedArtifactPanelTabs(
|
|
serializedTabs,
|
|
retainedTabs,
|
|
previousPersistedState?.tabs,
|
|
),
|
|
};
|
|
writeStoredArtifactPanelStates(
|
|
RIGHT_PANEL_STATE_STORAGE_KEY,
|
|
artifactPanelPersistedStatesRef.current,
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
const sideTaskTab =
|
|
createdTab?.kind === 'side_task' ? createdTab : undefined;
|
|
if (!sideTaskTab) return;
|
|
optimisticSideTaskIdsRef.current.add(sessionId);
|
|
setSideTaskCatalog((catalog) => {
|
|
if (catalog.parentSessionId !== sideTaskTab.parentSessionId) {
|
|
return catalog;
|
|
}
|
|
if (catalog.items.some((item) => item.sessionId === sessionId)) {
|
|
return catalog;
|
|
}
|
|
return {
|
|
...catalog,
|
|
items: [
|
|
...catalog.items,
|
|
{
|
|
sessionId,
|
|
title: sideTaskTab.title,
|
|
workspaceCwd: sideTaskTab.workspaceCwd,
|
|
updatedAt: new Date().toISOString(),
|
|
archivable: false,
|
|
},
|
|
],
|
|
};
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
const handleSideTaskTitleChange = useCallback(
|
|
(tabId: string, title: string, fromFirstPrompt = false) => {
|
|
const sideTaskTab = artifactPanelTabsRef.current.find(
|
|
(tab) => tab.id === tabId && tab.kind === 'side_task',
|
|
);
|
|
const sessionId =
|
|
sideTaskTab?.kind === 'side_task' ? sideTaskTab.sessionId : undefined;
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.map((tab) => {
|
|
if (tab.id !== tabId || tab.kind !== 'side_task') return tab;
|
|
if (!fromFirstPrompt && tab.title === title) return tab;
|
|
return {
|
|
...tab,
|
|
title,
|
|
...(fromFirstPrompt
|
|
? {
|
|
nameFromFirstPrompt: false,
|
|
initialPrompt: undefined,
|
|
}
|
|
: {}),
|
|
};
|
|
}),
|
|
);
|
|
if (sessionId) {
|
|
setSideTaskCatalog((catalog) => ({
|
|
...catalog,
|
|
items: catalog.items.map((item) =>
|
|
item.sessionId === sessionId ? { ...item, title } : item,
|
|
),
|
|
}));
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
// A policy-refused initial prompt must die with the refusal: drop it from the
|
|
// live tabs AND the per-session bucket mirror, or switching away and back
|
|
// re-arms the toast and a later policy relaxation replays the command.
|
|
const handleSideTaskInitialPromptRefused = useCallback((tabId: string) => {
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.map((tab) =>
|
|
tab.id === tabId && tab.kind === 'side_task'
|
|
? { ...tab, initialPrompt: undefined }
|
|
: tab,
|
|
),
|
|
);
|
|
for (const state of artifactPanelStateBySessionRef.current.values()) {
|
|
if (!state.tabs.some((tab) => tab.id === tabId)) continue;
|
|
state.tabs = state.tabs.map((tab) =>
|
|
tab.id === tabId && tab.kind === 'side_task'
|
|
? { ...tab, initialPrompt: undefined }
|
|
: tab,
|
|
);
|
|
break;
|
|
}
|
|
}, []);
|
|
const openSideTask = useCallback(
|
|
(sideTask: SideTaskListItem) => {
|
|
const parentSessionId = connection.sessionId;
|
|
if (!parentSessionId) return;
|
|
removedSideTaskSessionIdsRef.current.delete(sideTask.sessionId);
|
|
const tab: ArtifactPanelTab = {
|
|
id: `side-task:${sideTask.sessionId}`,
|
|
kind: 'side_task',
|
|
title: sideTask.title,
|
|
sessionId: sideTask.sessionId,
|
|
parentSessionId,
|
|
workspaceCwd: sideTask.workspaceCwd ?? connection.workspaceCwd,
|
|
};
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.some(
|
|
(item) =>
|
|
item.kind === 'side_task' && item.sessionId === sideTask.sessionId,
|
|
)
|
|
? tabs
|
|
: [...tabs, tab],
|
|
);
|
|
const existingTab = artifactPanelTabsRef.current.find(
|
|
(item) =>
|
|
item.kind === 'side_task' && item.sessionId === sideTask.sessionId,
|
|
);
|
|
setActiveArtifactPanelTabId(existingTab?.id ?? tab.id);
|
|
setArtifactPanelOpen(true);
|
|
},
|
|
[connection.sessionId, connection.workspaceCwd],
|
|
);
|
|
useEffect(() => {
|
|
const parentSessionId = connection.sessionId;
|
|
const workspaceCwd = connection.workspaceCwd;
|
|
if (!sideTasksAvailable || !parentSessionId || !workspaceCwd) {
|
|
setSideTaskCatalog({ items: [], loaded: false });
|
|
setArchivedSideTaskCatalog({ items: [], loaded: false });
|
|
return;
|
|
}
|
|
if (!artifactPanelOpen) return;
|
|
const requestGeneration = ++sideTaskCatalogRequestGenerationRef.current;
|
|
setSideTaskCatalog((catalog) =>
|
|
catalog.parentSessionId === parentSessionId
|
|
? { ...catalog, loaded: false }
|
|
: { parentSessionId, items: [], loaded: false },
|
|
);
|
|
setArchivedSideTaskCatalog((catalog) =>
|
|
catalog.parentSessionId === parentSessionId
|
|
? { ...catalog, loaded: !sideTaskRestoreAvailable }
|
|
: {
|
|
parentSessionId,
|
|
items: [],
|
|
loaded: !sideTaskRestoreAvailable,
|
|
},
|
|
);
|
|
let cancelled = false;
|
|
const requestIsCurrent = () =>
|
|
!cancelled &&
|
|
sideTaskCatalogRequestGenerationRef.current === requestGeneration;
|
|
const toSideTaskListItem = (
|
|
session: DaemonSessionSummary,
|
|
): SideTaskListItem => ({
|
|
sessionId: session.sessionId,
|
|
title:
|
|
session.displayName?.trim() ||
|
|
`${t('sideTask.title')} ${session.sessionId.slice(0, 8)}`,
|
|
workspaceCwd: session.workspaceCwd || workspaceCwd,
|
|
updatedAt: session.updatedAt || session.createdAt,
|
|
});
|
|
const loadSideTasks = (archiveState: 'active' | 'archived') =>
|
|
loadSessionCatalogOnce(
|
|
workspace.client,
|
|
{
|
|
routeKind: 'legacy',
|
|
workspaceCwd,
|
|
options: {
|
|
pageSize: SESSION_LIST_PAGE_SIZE,
|
|
archiveState,
|
|
sourceType: WEB_SHELL_SIDE_TASK_SOURCE_TYPE,
|
|
sourceId: parentSessionId,
|
|
},
|
|
},
|
|
{ fresh: true },
|
|
);
|
|
void loadSideTasks('active')
|
|
.then((page) => {
|
|
if (!requestIsCurrent()) return;
|
|
const listedItems = page.sessions.map((session) => ({
|
|
...toSideTaskListItem(session),
|
|
archivable: canArchiveSideTaskSummary(session),
|
|
}));
|
|
for (const item of listedItems) {
|
|
optimisticSideTaskIdsRef.current.delete(item.sessionId);
|
|
}
|
|
setSideTaskCatalog((catalog) =>
|
|
mergeSideTaskCatalog(
|
|
catalog,
|
|
parentSessionId,
|
|
listedItems,
|
|
optimisticSideTaskIdsRef.current,
|
|
),
|
|
);
|
|
})
|
|
.catch(() => {
|
|
if (!requestIsCurrent()) return;
|
|
setSideTaskCatalog((catalog) =>
|
|
catalog.parentSessionId === parentSessionId
|
|
? { ...catalog, loaded: true }
|
|
: catalog,
|
|
);
|
|
});
|
|
if (sideTaskRestoreAvailable) {
|
|
void loadSideTasks('archived')
|
|
.then((page) => {
|
|
if (!requestIsCurrent()) return;
|
|
setArchivedSideTaskCatalog({
|
|
parentSessionId,
|
|
loaded: true,
|
|
items: page.sessions.map(toSideTaskListItem),
|
|
});
|
|
})
|
|
.catch(() => {
|
|
if (!requestIsCurrent()) return;
|
|
setArchivedSideTaskCatalog((catalog) =>
|
|
catalog.parentSessionId === parentSessionId
|
|
? { ...catalog, loaded: true }
|
|
: catalog,
|
|
);
|
|
});
|
|
}
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [
|
|
connection.sessionId,
|
|
connection.workspaceCwd,
|
|
artifactPanelOpen,
|
|
sideTaskCatalogRefreshNonce,
|
|
sideTaskRestoreAvailable,
|
|
sideTasksAvailable,
|
|
t,
|
|
workspace.client,
|
|
]);
|
|
const getMaxArtifactPanelWidth = useCallback(() => {
|
|
const chatPaneWidth = chatPaneRef.current?.getBoundingClientRect().width;
|
|
if (!chatPaneWidth) {
|
|
return Math.max(
|
|
MIN_ARTIFACT_PANEL_WIDTH,
|
|
window.innerWidth - MIN_CHAT_PANE_WIDTH_WITH_ARTIFACT_PANEL,
|
|
);
|
|
}
|
|
return Math.max(
|
|
MIN_ARTIFACT_PANEL_WIDTH,
|
|
artifactPanelWidth +
|
|
chatPaneWidth -
|
|
MIN_CHAT_PANE_WIDTH_WITH_ARTIFACT_PANEL,
|
|
);
|
|
}, [artifactPanelWidth]);
|
|
const getDefaultReviewPanelWidth = useCallback(() => {
|
|
const chatPaneWidth =
|
|
chatPaneRef.current?.getBoundingClientRect().width ?? window.innerWidth;
|
|
const maxWidth = Math.max(
|
|
MIN_ARTIFACT_PANEL_WIDTH,
|
|
chatPaneWidth - MIN_CHAT_PANE_WIDTH_WITH_ARTIFACT_PANEL,
|
|
);
|
|
return Math.min(
|
|
maxWidth,
|
|
Math.max(DEFAULT_REVIEW_PANEL_WIDTH, Math.round(chatPaneWidth * 0.56)),
|
|
);
|
|
}, []);
|
|
const openArtifactPanel = useCallback(
|
|
(artifactId: string, previewContent?: string) => {
|
|
if (!artifactId) return;
|
|
rememberArtifactPanelTrigger();
|
|
const artifact = artifactPanelArtifacts.find(
|
|
(item) => item.id === artifactId,
|
|
);
|
|
const tab: ArtifactPanelTab = {
|
|
id: `artifact:${artifactId}`,
|
|
kind: 'artifact',
|
|
artifactId,
|
|
title: artifact ? artifactDisplayName(artifact) : 'Artifact',
|
|
...(connection.workspaceCwd
|
|
? { workspaceCwd: connection.workspaceCwd }
|
|
: {}),
|
|
...(artifactWorkspaceTarget?.workspaceId
|
|
? { workspaceId: artifactWorkspaceTarget.workspaceId }
|
|
: {}),
|
|
...(previewContent !== undefined ? { previewContent } : {}),
|
|
};
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.some((item) => item.id === tab.id)
|
|
? tabs.map((item) =>
|
|
item.id === tab.id ? { ...item, ...tab } : item,
|
|
)
|
|
: [...tabs, tab],
|
|
);
|
|
setActiveArtifactPanelTabId(tab.id);
|
|
setArtifactPanelWidth((width) =>
|
|
artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
|
|
);
|
|
setArtifactPanelOpen(true);
|
|
},
|
|
[
|
|
artifactPanelArtifacts,
|
|
artifactWorkspaceTarget?.workspaceId,
|
|
connection.workspaceCwd,
|
|
getDefaultReviewPanelWidth,
|
|
rememberArtifactPanelTrigger,
|
|
],
|
|
);
|
|
const openSourcePanel = useCallback(
|
|
(source: SessionSource) => {
|
|
if (!sourcesState.owner.isCurrent() || !connection.sessionId) return;
|
|
const tab: ArtifactPanelTab = {
|
|
id: `source:${connection.sessionId}:${source.id}`,
|
|
kind: 'source',
|
|
title: source.title,
|
|
source,
|
|
sourceSessionId: connection.sessionId,
|
|
workspaceCwd: connection.workspaceCwd,
|
|
workspaceId: artifactWorkspaceTarget?.workspaceId,
|
|
owner: sourcesState.owner,
|
|
sessionActions,
|
|
};
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.some((item) => item.id === tab.id)
|
|
? tabs.map((item) => (item.id === tab.id ? tab : item))
|
|
: [...tabs, tab],
|
|
);
|
|
setActiveArtifactPanelTabId(tab.id);
|
|
setArtifactPanelWidth((width) =>
|
|
artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
|
|
);
|
|
setArtifactPanelOpen(true);
|
|
},
|
|
[
|
|
sourcesState.owner,
|
|
connection.sessionId,
|
|
connection.workspaceCwd,
|
|
artifactWorkspaceTarget?.workspaceId,
|
|
sessionActions,
|
|
getDefaultReviewPanelWidth,
|
|
],
|
|
);
|
|
useEffect(() => {
|
|
const tabs = artifactPanelTabsRef.current;
|
|
const next = tabs.flatMap<ArtifactPanelTab>((tab) => {
|
|
if (tab.kind !== 'source') return [tab];
|
|
const fresh = sourcesState.sources.find(
|
|
(source) => source.id === tab.source.id,
|
|
);
|
|
if (
|
|
!tab.owner.isCurrent() ||
|
|
(tab.workspaceCwd !== undefined &&
|
|
artifactWorkspaceCwd === undefined) ||
|
|
tab.workspaceId !== artifactWorkspaceTarget?.workspaceId ||
|
|
!sourcesState.supported ||
|
|
(tab.sourceSessionId === connection.sessionId &&
|
|
sourcesState.hydrated &&
|
|
!fresh)
|
|
)
|
|
return [];
|
|
return fresh && fresh !== tab.source
|
|
? [{ ...tab, source: fresh, title: fresh.title }]
|
|
: [tab];
|
|
});
|
|
if (next.length === tabs.length && next.every((tab, i) => tab === tabs[i]))
|
|
return;
|
|
setArtifactPanelTabs(next);
|
|
if (
|
|
activeArtifactPanelTabId &&
|
|
!next.some((tab) => tab.id === activeArtifactPanelTabId)
|
|
) {
|
|
setActiveArtifactPanelTabId(null);
|
|
setArtifactPanelOpen(false);
|
|
}
|
|
}, [
|
|
activeArtifactPanelTabId,
|
|
artifactWorkspaceCwd,
|
|
artifactWorkspaceTarget?.workspaceId,
|
|
sourcesState.owner,
|
|
sourcesState.sources,
|
|
sourcesState.hydrated,
|
|
sourcesState.supported,
|
|
connection.sessionId,
|
|
]);
|
|
|
|
const openReviewPanel = useCallback(
|
|
(
|
|
changes: readonly TurnOutputFileChange[],
|
|
selectedPath?: string,
|
|
reviewWorkspaceCwd?: string,
|
|
reviewWorkspaceId?: string,
|
|
tabId = 'review',
|
|
sourceTurnId?: string,
|
|
sourceSessionId?: string,
|
|
) => {
|
|
const reviewTab: ArtifactPanelTab = {
|
|
id: tabId,
|
|
kind: 'review',
|
|
title: t('turnOutputs.review'),
|
|
changes,
|
|
...(selectedPath ? { selectedPath } : {}),
|
|
...(reviewWorkspaceCwd ? { workspaceCwd: reviewWorkspaceCwd } : {}),
|
|
...(reviewWorkspaceId ? { workspaceId: reviewWorkspaceId } : {}),
|
|
...(sourceTurnId ? { sourceTurnId } : {}),
|
|
...(sourceSessionId ? { sourceSessionId } : {}),
|
|
sourceToolCallIds: changes.map((change) => change.toolCallId),
|
|
};
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.some((item) => item.id === reviewTab.id)
|
|
? tabs.map((item) => (item.id === reviewTab.id ? reviewTab : item))
|
|
: [reviewTab, ...tabs],
|
|
);
|
|
setActiveArtifactPanelTabId(reviewTab.id);
|
|
setReviewChanges(changes);
|
|
setSelectedReviewPath(selectedPath ?? null);
|
|
setArtifactPanelWidth((width) =>
|
|
artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
|
|
);
|
|
setArtifactPanelOpen(true);
|
|
},
|
|
[getDefaultReviewPanelWidth, t],
|
|
);
|
|
const openLatestReviewPanel = useCallback(() => {
|
|
if (latestReviewChanges.length === 0) return;
|
|
openReviewPanel(
|
|
latestReviewChanges,
|
|
undefined,
|
|
connection.workspaceCwd,
|
|
artifactWorkspaceTarget?.workspaceId,
|
|
undefined,
|
|
latestReviewTurnId,
|
|
connection.sessionId,
|
|
);
|
|
}, [
|
|
artifactWorkspaceTarget?.workspaceId,
|
|
connection.workspaceCwd,
|
|
connection.sessionId,
|
|
latestReviewChanges,
|
|
latestReviewTurnId,
|
|
openReviewPanel,
|
|
]);
|
|
const openScheduledTaskPanel = useCallback(
|
|
(
|
|
task: TurnOutputScheduledTask,
|
|
tabWorkspaceCwd?: string,
|
|
tabWorkspaceId?: string,
|
|
sourceSessionId?: string,
|
|
) => {
|
|
const tab: ArtifactPanelTab = {
|
|
id: sourceSessionId
|
|
? `scheduled-task:${sourceSessionId}:${task.toolCallId}`
|
|
: `scheduled-task:${task.toolCallId}`,
|
|
kind: 'scheduled_task',
|
|
title: t('scheduledTasks.title'),
|
|
task: tabWorkspaceId ? { ...task, workspaceId: tabWorkspaceId } : task,
|
|
...(tabWorkspaceCwd ? { workspaceCwd: tabWorkspaceCwd } : {}),
|
|
...(tabWorkspaceId ? { workspaceId: tabWorkspaceId } : {}),
|
|
...(sourceSessionId ? { sourceSessionId } : {}),
|
|
};
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.some((item) => item.id === tab.id)
|
|
? tabs.map((item) => (item.id === tab.id ? tab : item))
|
|
: [...tabs, tab],
|
|
);
|
|
setActiveArtifactPanelTabId(tab.id);
|
|
setArtifactPanelWidth((width) =>
|
|
artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
|
|
);
|
|
setArtifactPanelOpen(true);
|
|
},
|
|
[getDefaultReviewPanelWidth, t],
|
|
);
|
|
const openMonitorPanel = useCallback(
|
|
(
|
|
task: DaemonSessionMonitorTaskStatus,
|
|
sourceSessionId?: string,
|
|
sourceSessionActions?: DaemonSessionActions,
|
|
) => {
|
|
const tab: ArtifactPanelTab = {
|
|
id: sourceSessionId
|
|
? `monitor:${sourceSessionId}:${task.id}`
|
|
: `monitor:${task.id}`,
|
|
kind: 'monitor',
|
|
title: task.description,
|
|
task,
|
|
...(sourceSessionId ? { sessionId: sourceSessionId } : {}),
|
|
...(sourceSessionActions
|
|
? { sessionActions: sourceSessionActions }
|
|
: {}),
|
|
};
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.some((item) => item.id === tab.id)
|
|
? tabs.map((item) => {
|
|
if (item.id !== tab.id || item.kind !== 'monitor') return item;
|
|
const mergedTask = mergeMonitorTaskSnapshot(item.task, task);
|
|
return {
|
|
...tab,
|
|
title: mergedTask.description,
|
|
task: mergedTask,
|
|
};
|
|
})
|
|
: [...tabs, tab],
|
|
);
|
|
setActiveArtifactPanelTabId(tab.id);
|
|
setArtifactPanelWidth((width) =>
|
|
artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
|
|
);
|
|
setArtifactPanelOpen(true);
|
|
},
|
|
[getDefaultReviewPanelWidth],
|
|
);
|
|
const openImagePanel = useCallback(
|
|
(src: string, alt?: string, source?: ImageTabSource) => {
|
|
const resolvedSource =
|
|
source && !source.sessionId && connection.sessionId
|
|
? { ...source, sessionId: connection.sessionId }
|
|
: source;
|
|
const tab: ArtifactPanelTab = {
|
|
id: resolvedSource
|
|
? `image:${resolvedSource.sessionId ?? ''}:${resolvedSource.attachmentId}`
|
|
: imageTabId(src),
|
|
kind: 'image',
|
|
title: t('turnOutputs.imagePreview'),
|
|
src,
|
|
...(alt ? { alt } : {}),
|
|
...(resolvedSource ? { source: resolvedSource } : {}),
|
|
};
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.some((item) => item.id === tab.id)
|
|
? tabs.map((item) => (item.id === tab.id ? tab : item))
|
|
: [
|
|
...tabs.filter((item) => item.kind === 'turn_calls'),
|
|
tab,
|
|
...tabs.filter((item) => item.kind !== 'turn_calls'),
|
|
],
|
|
);
|
|
setActiveArtifactPanelTabId(tab.id);
|
|
setArtifactPanelWidth((width) =>
|
|
artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
|
|
);
|
|
setArtifactPanelOpen(true);
|
|
},
|
|
[connection.sessionId, getDefaultReviewPanelWidth, t],
|
|
);
|
|
const readSessionImage = useCallback(
|
|
async (attachmentId: string): Promise<string> => {
|
|
const attachment = await sessionActions.readAttachment(attachmentId);
|
|
return `data:${attachment.mimeType};base64,${attachment.data}`;
|
|
},
|
|
[sessionActions],
|
|
);
|
|
const openTokenUsagePanel = useCallback(
|
|
(
|
|
sourceSessionId: string,
|
|
sourceSessionActions?: DaemonSessionActions,
|
|
closeWithPane = false,
|
|
) => {
|
|
const tab: ArtifactPanelTab = {
|
|
id: `token-usage:${sourceSessionId}`,
|
|
kind: 'token_usage',
|
|
title: t('tokenUsage.title'),
|
|
sessionId: sourceSessionId,
|
|
...(closeWithPane ? { closeWithPane: true } : {}),
|
|
...(sourceSessionActions
|
|
? { sessionActions: sourceSessionActions }
|
|
: {}),
|
|
};
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.some((item) => item.id === tab.id)
|
|
? tabs.map((item) => (item.id === tab.id ? tab : item))
|
|
: [...tabs, tab],
|
|
);
|
|
setActiveArtifactPanelTabId(tab.id);
|
|
setArtifactPanelWidth((width) =>
|
|
artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
|
|
);
|
|
setArtifactPanelOpen(true);
|
|
},
|
|
[getDefaultReviewPanelWidth, t],
|
|
);
|
|
const openTrajectoryPanel = useCallback(
|
|
(sourceSessionId: string) => {
|
|
const tab: ArtifactPanelTab = {
|
|
id: trajectoryTabId(sourceSessionId),
|
|
kind: 'trajectory',
|
|
title: t('trajectory.title'),
|
|
sessionId: sourceSessionId,
|
|
loadPage: createTrajectoryPageLoader(
|
|
workspace.client,
|
|
sourceSessionId,
|
|
sessionContextKey(connection.sessionContext),
|
|
),
|
|
};
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.some((item) => item.id === tab.id)
|
|
? tabs.map((item) => (item.id === tab.id ? tab : item))
|
|
: [...tabs, tab],
|
|
);
|
|
setActiveArtifactPanelTabId(tab.id);
|
|
setArtifactPanelWidth((width) =>
|
|
artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
|
|
);
|
|
setArtifactPanelOpen(true);
|
|
},
|
|
[
|
|
getDefaultReviewPanelWidth,
|
|
t,
|
|
workspace.client,
|
|
connection.sessionContext,
|
|
],
|
|
);
|
|
const openContextUsagePanel = useCallback(
|
|
(
|
|
sourceSessionId: string,
|
|
sourceSessionActions?: DaemonSessionActions,
|
|
closeWithPane = false,
|
|
) => {
|
|
if (onContextUsageOpen) {
|
|
onContextUsageOpen(sourceSessionId);
|
|
return;
|
|
}
|
|
const tab: ArtifactPanelTab = {
|
|
id: `context-usage:${sourceSessionId}`,
|
|
kind: 'context_usage',
|
|
title: t('contextUsage.title'),
|
|
sessionId: sourceSessionId,
|
|
...(closeWithPane ? { closeWithPane: true } : {}),
|
|
...(sourceSessionActions
|
|
? { sessionActions: sourceSessionActions }
|
|
: {}),
|
|
};
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.some((item) => item.id === tab.id)
|
|
? tabs.map((item) => (item.id === tab.id ? tab : item))
|
|
: [...tabs, tab],
|
|
);
|
|
setActiveArtifactPanelTabId(tab.id);
|
|
setArtifactPanelWidth((width) =>
|
|
artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
|
|
);
|
|
setArtifactPanelOpen(true);
|
|
},
|
|
[getDefaultReviewPanelWidth, onContextUsageOpen, t],
|
|
);
|
|
const openCurrentContextUsagePanel = useCallback(() => {
|
|
if (connection.sessionId)
|
|
openContextUsagePanel(connection.sessionId, sessionActions);
|
|
}, [connection.sessionId, openContextUsagePanel, sessionActions]);
|
|
const openPaneContextUsagePanel = useCallback(
|
|
(sessionId: string, actions: DaemonSessionActions) =>
|
|
openContextUsagePanel(sessionId, actions, true),
|
|
[openContextUsagePanel],
|
|
);
|
|
const openAttachmentPanel = useCallback(
|
|
(
|
|
file: AttachmentPreviewRequest,
|
|
workspaceCwd = connection.workspaceCwd,
|
|
sourceSessionId = connection.sessionId,
|
|
sourcePreview = false,
|
|
silentUnavailable = false,
|
|
) => {
|
|
if (
|
|
onWorkspaceFileOpen &&
|
|
file.workspacePath &&
|
|
file.text === undefined &&
|
|
file.data === undefined
|
|
) {
|
|
onWorkspaceFileOpen(file.workspacePath);
|
|
return;
|
|
}
|
|
const open = (resolvedFile: AttachmentPreviewRequest) => {
|
|
const workspacePath = resolvedFile.workspacePath ?? resolvedFile.name;
|
|
const workspaceId = resolveArtifactWorkspaceOwner(
|
|
workspaceCapabilitiesRef.current,
|
|
workspaceCwd,
|
|
)?.id;
|
|
const previewOnly =
|
|
resolvedFile.text !== undefined ||
|
|
resolvedFile.data !== undefined ||
|
|
resolvedFile.attachmentId !== undefined;
|
|
const tab: ArtifactPanelTab = {
|
|
id: previewOnly
|
|
? `${sourcePreview ? 'source-attachment' : 'attachment'}:${sourceSessionId ?? ''}:${resolvedFile.attachmentId ?? workspacePath}`
|
|
: `file:${workspaceCwd ?? ''}:${workspacePath}`,
|
|
kind: 'file',
|
|
title: resolvedFile.name,
|
|
workspacePath,
|
|
...(resolvedFile.text !== undefined
|
|
? { previewContent: resolvedFile.text }
|
|
: {}),
|
|
...(resolvedFile.data ? { previewData: resolvedFile.data } : {}),
|
|
...(resolvedFile.mimeType
|
|
? { previewMimeType: resolvedFile.mimeType }
|
|
: {}),
|
|
...(resolvedFile.attachmentId
|
|
? { attachmentId: resolvedFile.attachmentId }
|
|
: {}),
|
|
...(sourceSessionId ? { sourceSessionId } : {}),
|
|
...(previewOnly ? { previewOnly: true } : {}),
|
|
...(sourcePreview ? { sourcePreview: true } : {}),
|
|
...(workspaceCwd ? { workspaceCwd } : {}),
|
|
...(workspaceId ? { workspaceId } : {}),
|
|
};
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.some((item) => item.id === tab.id)
|
|
? tabs.map((item) =>
|
|
item.id === tab.id && item.kind === 'file'
|
|
? { ...tab, previewVersion: (item.previewVersion ?? 0) + 1 }
|
|
: item,
|
|
)
|
|
: [
|
|
...tabs.filter((item) => item.kind === 'turn_calls'),
|
|
tab,
|
|
...tabs.filter((item) => item.kind !== 'turn_calls'),
|
|
],
|
|
);
|
|
setActiveArtifactPanelTabId(tab.id);
|
|
setArtifactPanelWidth((width) =>
|
|
artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
|
|
);
|
|
setArtifactPanelOpen(true);
|
|
};
|
|
if (
|
|
file.attachmentId &&
|
|
file.text === undefined &&
|
|
file.data === undefined
|
|
) {
|
|
const owner = sessionOwnerGuard.capture();
|
|
void sessionActions
|
|
.readAttachment(file.attachmentId)
|
|
.then((attachment) => {
|
|
if (!owner.isCurrent()) return;
|
|
open({
|
|
...file,
|
|
data: base64ToBlob(attachment.data, attachment.mimeType),
|
|
mimeType: attachment.mimeType,
|
|
});
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!owner.isCurrent()) return;
|
|
pushToast(
|
|
'error',
|
|
formatError(error, 'Failed to preview attachment'),
|
|
);
|
|
});
|
|
return;
|
|
}
|
|
if (
|
|
file.workspacePath &&
|
|
file.text === undefined &&
|
|
file.data === undefined &&
|
|
artifactWorkspaceCwdRef.current === workspaceCwd &&
|
|
artifactWorkspaceActionsRef.current
|
|
) {
|
|
const owner = sessionOwnerGuard.capture();
|
|
void artifactWorkspaceActionsRef.current
|
|
.stat(file.workspacePath)
|
|
.then((stat) => {
|
|
if (!owner.isCurrent() || stat.type !== 'file') return;
|
|
open(file);
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!owner.isCurrent()) return;
|
|
if (!silentUnavailable)
|
|
pushToast('error', formatError(error, 'Failed to preview file'));
|
|
});
|
|
return;
|
|
}
|
|
open(file);
|
|
},
|
|
[
|
|
connection.workspaceCwd,
|
|
connection.sessionId,
|
|
getDefaultReviewPanelWidth,
|
|
onWorkspaceFileOpen,
|
|
pushToast,
|
|
sessionActions,
|
|
sessionOwnerGuard,
|
|
],
|
|
);
|
|
const hydrateRestoredAttachmentTab = useCallback(
|
|
async (tab: ArtifactPanelTab | undefined) => {
|
|
if (!tab || (tab.kind !== 'image' && tab.kind !== 'file')) return;
|
|
const attachmentId =
|
|
tab.kind === 'image' ? tab.source?.attachmentId : tab.attachmentId;
|
|
if (!attachmentId) return;
|
|
if (tab.kind === 'image' && tab.src) return;
|
|
if (tab.kind === 'file' && tab.previewData) return;
|
|
const sourceSessionId =
|
|
tab.kind === 'image' ? tab.source?.sessionId : tab.sourceSessionId;
|
|
const owner = sessionOwnerGuard.capture();
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.map((item) =>
|
|
item.id === tab.id && item.kind === tab.kind
|
|
? { ...item, loadError: undefined }
|
|
: item,
|
|
),
|
|
);
|
|
let attachment:
|
|
| Awaited<ReturnType<typeof sessionActions.readAttachment>>
|
|
| undefined;
|
|
try {
|
|
attachment = await (sourceSessionId &&
|
|
sourceSessionId !== connection.sessionId
|
|
? workspace.client.readSessionAttachment(
|
|
sourceSessionId,
|
|
attachmentId,
|
|
)
|
|
: sessionActions.readAttachment(attachmentId));
|
|
} catch (error) {
|
|
if (!owner.isCurrent()) return;
|
|
const loadError = t('rightPanel.attachmentLoadFailed', {
|
|
error: formatError(error, t('environment.unavailable')),
|
|
});
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.map((item) =>
|
|
item.id === tab.id && item.kind === tab.kind
|
|
? { ...item, loadError }
|
|
: item,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
if (!owner.isCurrent()) return;
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.map((item) => {
|
|
if (item.id !== tab.id || item.kind !== tab.kind) return item;
|
|
return item.kind === 'image'
|
|
? {
|
|
...item,
|
|
src: `data:${attachment.mimeType};base64,${attachment.data}`,
|
|
}
|
|
: {
|
|
...item,
|
|
previewData: base64ToBlob(attachment.data, attachment.mimeType),
|
|
previewMimeType: attachment.mimeType,
|
|
previewOnly: true,
|
|
};
|
|
}),
|
|
);
|
|
},
|
|
[
|
|
connection.sessionId,
|
|
sessionActions,
|
|
sessionOwnerGuard,
|
|
t,
|
|
workspace.client,
|
|
],
|
|
);
|
|
const hydratePendingArtifactPanelTab = useCallback(
|
|
async (tab: Extract<ArtifactPanelTab, { kind: 'pending' }> | undefined) => {
|
|
if (!tab) return;
|
|
const owner = sessionOwnerGuard.capture();
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.map((item) =>
|
|
item.id === tab.id && item.kind === 'pending'
|
|
? { ...item, loadError: undefined }
|
|
: item,
|
|
),
|
|
);
|
|
try {
|
|
let restored: ArtifactPanelTab | undefined;
|
|
let restoredArtifact: DaemonSessionArtifact | undefined;
|
|
if (tab.targetKind === 'artifact') {
|
|
const artifact = (
|
|
await workspace.client.listSessionArtifacts(tab.sourceSessionId)
|
|
).artifacts.find((item) => item.id === tab.artifactId);
|
|
if (artifact) {
|
|
restoredArtifact = artifact;
|
|
restored = {
|
|
id: tab.id,
|
|
kind: 'artifact',
|
|
title: tab.title,
|
|
artifactId: artifact.id,
|
|
sourceSessionId: tab.sourceSessionId,
|
|
workspaceCwd: tab.workspaceCwd,
|
|
workspaceId: tab.workspaceId,
|
|
};
|
|
}
|
|
} else if (tab.targetKind === 'monitor' || tab.targetKind === 'shell') {
|
|
const snapshot = await workspace.client.sessionTasks(
|
|
tab.sourceSessionId,
|
|
);
|
|
if (snapshot.sessionId !== tab.sourceSessionId) {
|
|
throw new Error(t('rightPanel.savedContentUnavailable'));
|
|
}
|
|
const task = snapshot.tasks.find(
|
|
(item) => item.kind === tab.targetKind && item.id === tab.taskId,
|
|
);
|
|
if (task?.kind === 'monitor' || task?.kind === 'shell') {
|
|
const crossSessionActions: DaemonSessionActions = {
|
|
...sessionActions,
|
|
getTasks: () =>
|
|
workspace.client.sessionTasks(tab.sourceSessionId),
|
|
getTaskRuntime: (taskId) =>
|
|
workspace.client.sessionTaskRuntime(
|
|
tab.sourceSessionId,
|
|
taskId,
|
|
),
|
|
cancelTask: (taskId, kind) =>
|
|
workspace.client.sessionTaskCancel(
|
|
tab.sourceSessionId,
|
|
taskId,
|
|
kind,
|
|
),
|
|
};
|
|
restored =
|
|
task.kind === 'monitor'
|
|
? {
|
|
id: tab.id,
|
|
kind: 'monitor',
|
|
title: tab.title,
|
|
task,
|
|
sessionId: tab.sourceSessionId,
|
|
sessionActions: crossSessionActions,
|
|
}
|
|
: {
|
|
id: tab.id,
|
|
kind: 'shell',
|
|
title: shellTaskPanelTitle(task.command),
|
|
task,
|
|
sessionId: tab.sourceSessionId,
|
|
sessionActions: crossSessionActions,
|
|
};
|
|
}
|
|
} else {
|
|
let cursor: string | undefined;
|
|
const events: DaemonEvent[] = [];
|
|
const seenCursors = new Set<string>();
|
|
const reviewToolCallIds = new Set(
|
|
tab.targetKind === 'review' ? (tab.sourceToolCallIds ?? []) : [],
|
|
);
|
|
const targetToolCallIds = new Set(
|
|
tab.targetKind === 'review'
|
|
? reviewToolCallIds
|
|
: tab.targetKind === 'scheduled_task'
|
|
? tab.toolCallId
|
|
? [tab.toolCallId]
|
|
: []
|
|
: tab.targetKind === 'subagent'
|
|
? tab.rootToolCallId
|
|
? [tab.rootToolCallId]
|
|
: []
|
|
: [],
|
|
);
|
|
const stopWhenToolCallsFound = targetToolCallIds.size > 0;
|
|
let pageCount = 0;
|
|
for (;;) {
|
|
if (!owner.isCurrent()) return;
|
|
const page = await workspace.client.getSessionTranscriptPage(
|
|
tab.sourceSessionId,
|
|
{
|
|
...(cursor ? { cursor } : { direction: 'backward' }),
|
|
limit: 250,
|
|
},
|
|
);
|
|
if (!owner.isCurrent()) return;
|
|
if (page.partial || page.replayError) {
|
|
throw new Error(t('rightPanel.savedContentUnavailable'));
|
|
}
|
|
events.unshift(...page.events);
|
|
pageCount += 1;
|
|
for (const event of page.events) {
|
|
if (event.type === 'session_update') {
|
|
const update = getSessionUpdatePayload(event.data);
|
|
const toolCallId = update?.['toolCallId'];
|
|
if (
|
|
update?.['sessionUpdate'] === 'tool_call' &&
|
|
typeof toolCallId === 'string'
|
|
) {
|
|
targetToolCallIds.delete(toolCallId);
|
|
}
|
|
}
|
|
}
|
|
cursor = page.nextCursor;
|
|
if (
|
|
!page.hasMore ||
|
|
!cursor ||
|
|
(stopWhenToolCallsFound && targetToolCallIds.size === 0)
|
|
) {
|
|
break;
|
|
}
|
|
if (seenCursors.has(cursor)) {
|
|
throw new Error(t('rightPanel.savedContentUnavailable'));
|
|
}
|
|
if (pageCount >= MAX_ARTIFACT_PANEL_TRANSCRIPT_PAGES) {
|
|
throw new Error(t('rightPanel.savedContentUnavailable'));
|
|
}
|
|
seenCursors.add(cursor);
|
|
}
|
|
const sourceMessages = transcriptEventsToMessages(events);
|
|
if (tab.targetKind === 'review') {
|
|
const sourceArtifacts =
|
|
tab.sourceSessionId === connection.sessionId
|
|
? artifactsRef.current
|
|
: ((
|
|
await workspace.client
|
|
.listSessionArtifacts(tab.sourceSessionId)
|
|
.catch(() => undefined)
|
|
)?.artifacts ?? []);
|
|
if (!owner.isCurrent()) return;
|
|
const changesByTurn = getFileChangesByTurn(
|
|
sourceMessages,
|
|
getArtifactsByTurn(
|
|
sourceMessages,
|
|
sourceArtifacts,
|
|
tab.workspaceCwd ?? '',
|
|
),
|
|
tab.workspaceCwd ?? '',
|
|
);
|
|
const changes =
|
|
findReviewChangesByToolCallIds(
|
|
changesByTurn,
|
|
tab.sourceToolCallIds,
|
|
) ?? changesByTurn.get(tab.sourceTurnId ?? '');
|
|
if (changes) {
|
|
restored = {
|
|
id: tab.id,
|
|
kind: 'review',
|
|
title: tab.title,
|
|
changes,
|
|
selectedPath: tab.selectedPath,
|
|
sourceTurnId: tab.sourceTurnId,
|
|
sourceSessionId: tab.sourceSessionId,
|
|
sourceToolCallIds: tab.sourceToolCallIds,
|
|
workspaceCwd: tab.workspaceCwd,
|
|
workspaceId: tab.workspaceId,
|
|
};
|
|
}
|
|
} else if (tab.targetKind === 'scheduled_task') {
|
|
const task = Array.from(
|
|
getScheduledTasksByTurn(sourceMessages).values(),
|
|
)
|
|
.flat()
|
|
.find((item) => item.toolCallId === tab.toolCallId);
|
|
if (task) {
|
|
restored = {
|
|
id: tab.id,
|
|
kind: 'scheduled_task',
|
|
title: tab.title,
|
|
task,
|
|
sourceSessionId: tab.sourceSessionId,
|
|
workspaceCwd: tab.workspaceCwd,
|
|
workspaceId: tab.workspaceId,
|
|
};
|
|
}
|
|
} else {
|
|
const rootTool = findToolCall(
|
|
sourceMessages,
|
|
tab.rootToolCallId ?? '',
|
|
);
|
|
if (rootTool) {
|
|
restored = {
|
|
id: tab.id,
|
|
kind: 'subagent',
|
|
title: tab.title,
|
|
sessionId: tab.sourceSessionId,
|
|
rootToolCallId: rootTool.callId,
|
|
rootTool,
|
|
workspaceCwd: tab.workspaceCwd,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
if (!restored && tab.targetKind === 'subagent' && tab.taskId) {
|
|
const snapshot = await workspace.client.sessionTasks(
|
|
tab.sourceSessionId,
|
|
);
|
|
const task =
|
|
snapshot.sessionId === tab.sourceSessionId
|
|
? snapshot.tasks.find(
|
|
(item) => item.kind === 'agent' && item.id === tab.taskId,
|
|
)
|
|
: undefined;
|
|
if (task?.kind === 'agent') {
|
|
const rootTool = agentTaskAsToolCall(task);
|
|
restored = {
|
|
id: tab.id,
|
|
kind: 'subagent',
|
|
title: tab.title,
|
|
sessionId: tab.sourceSessionId,
|
|
rootToolCallId: rootTool.callId,
|
|
rootTool,
|
|
workspaceCwd: tab.workspaceCwd,
|
|
};
|
|
}
|
|
}
|
|
if (!restored) {
|
|
throw new Error(t('rightPanel.savedContentUnavailable'));
|
|
}
|
|
if (!owner.isCurrent()) return;
|
|
if (restoredArtifact) {
|
|
setArtifactPanelExtraArtifacts((current) =>
|
|
current.some((item) => item.id === restoredArtifact.id)
|
|
? current.map((item) =>
|
|
item.id === restoredArtifact.id ? restoredArtifact : item,
|
|
)
|
|
: [...current, restoredArtifact],
|
|
);
|
|
}
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.map((item) =>
|
|
item.id === tab.id && item.kind === 'pending' ? restored : item,
|
|
),
|
|
);
|
|
} catch (error) {
|
|
if (!owner.isCurrent()) return;
|
|
const loadError = t('rightPanel.restoreFailed', {
|
|
error: formatError(error, t('environment.unavailable')),
|
|
});
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.map((item) =>
|
|
item.id === tab.id && item.kind === 'pending'
|
|
? { ...item, loadError }
|
|
: item,
|
|
),
|
|
);
|
|
}
|
|
},
|
|
[
|
|
connection.sessionId,
|
|
sessionActions,
|
|
sessionOwnerGuard,
|
|
t,
|
|
workspace.client,
|
|
],
|
|
);
|
|
const selectArtifactPanelTab = useCallback(
|
|
(tabId: string) => {
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.map((tab) =>
|
|
tab.id === tabId && tab.kind === 'terminal' && !tab.initialized
|
|
? { ...tab, initialized: true }
|
|
: tab,
|
|
),
|
|
);
|
|
setActiveArtifactPanelTabId(tabId);
|
|
const tab = artifactPanelTabsRef.current.find(
|
|
(item) => item.id === tabId,
|
|
);
|
|
void hydrateRestoredAttachmentTab(tab);
|
|
void hydratePendingArtifactPanelTab(
|
|
tab?.kind === 'pending' ? tab : undefined,
|
|
);
|
|
},
|
|
[hydratePendingArtifactPanelTab, hydrateRestoredAttachmentTab],
|
|
);
|
|
const sessionAgentTraceSupported =
|
|
connection.capabilities?.features.includes(SESSION_AGENT_TRACE_FEATURE) ===
|
|
true;
|
|
useLayoutEffect(() => {
|
|
const previousSessionId = artifactPanelSessionIdRef.current;
|
|
const nextSessionId = panelStateKey;
|
|
const materializedSessionId = draftMaterializedSessionIdRef.current;
|
|
if (previousSessionId !== nextSessionId) {
|
|
draftMaterializedSessionIdRef.current = undefined;
|
|
}
|
|
setArtifactPanelRestoring(false);
|
|
if (
|
|
previousSessionId &&
|
|
previousSessionId !== nextSessionId &&
|
|
(artifactPanelRestoredSessionKeyRef.current === previousSessionId ||
|
|
(artifactPanelSessionStateRef.current?.tabs.length ?? 0) > 0)
|
|
) {
|
|
const currentState = artifactPanelSessionStateRef.current;
|
|
if (currentState) {
|
|
artifactPanelStateBySessionRef.current.delete(previousSessionId);
|
|
artifactPanelStateBySessionRef.current.set(previousSessionId, {
|
|
...currentState,
|
|
pendingRestore:
|
|
artifactPanelRestoredSessionKeyRef.current !== previousSessionId,
|
|
});
|
|
const nextState = nextSessionId
|
|
? artifactPanelStateBySessionRef.current.get(nextSessionId)
|
|
: undefined;
|
|
if (nextSessionId && nextState) {
|
|
artifactPanelStateBySessionRef.current.delete(nextSessionId);
|
|
artifactPanelStateBySessionRef.current.set(nextSessionId, nextState);
|
|
}
|
|
for (const evictedKey of getEvictedArtifactPanelStateKeys(
|
|
artifactPanelStateBySessionRef.current.keys(),
|
|
)) {
|
|
const evictedState =
|
|
artifactPanelStateBySessionRef.current.get(evictedKey);
|
|
for (const tab of evictedState?.tabs ?? []) {
|
|
if (tab.kind === 'terminal') {
|
|
releaseDetachedWebTerminal(
|
|
workspace.baseUrl,
|
|
tab.id,
|
|
tab.workspaceCwd,
|
|
);
|
|
}
|
|
}
|
|
artifactPanelStateBySessionRef.current.delete(evictedKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (previousSessionId !== nextSessionId) {
|
|
const nextSavedState = nextSessionId
|
|
? artifactPanelStateBySessionRef.current.get(nextSessionId)
|
|
: undefined;
|
|
setReviewChanges([]);
|
|
setSelectedReviewPath(null);
|
|
setArtifactPanelExtraArtifacts(nextSavedState?.extraArtifacts ?? []);
|
|
setPaneArtifactSnapshots(new Map());
|
|
setArtifactPanelWidth(
|
|
nextSavedState?.width ?? getDefaultReviewPanelWidth(),
|
|
);
|
|
setArtifactPanelFullscreen(false);
|
|
artifactPanelAdoptedSessionRef.current = undefined;
|
|
}
|
|
|
|
artifactPanelSessionIdRef.current = nextSessionId;
|
|
if (!nextSessionId) {
|
|
artifactPanelRestoredSessionKeyRef.current = undefined;
|
|
setArtifactPanelRestoring(false);
|
|
// Steady no-key states keep an already-open panel usable; only a real
|
|
// key change (leaving a session, or a draft losing its workspace
|
|
// context) drops the live panel content — persisted entries survive.
|
|
if (previousSessionId !== nextSessionId) {
|
|
setArtifactPanelOpen(false);
|
|
setArtifactPanelTabs([]);
|
|
setActiveArtifactPanelTabId(null);
|
|
}
|
|
return;
|
|
}
|
|
if (
|
|
previousSessionId !== undefined &&
|
|
previousSessionId.startsWith(DRAFT_PANEL_KEY_PREFIX) &&
|
|
materializedSessionId !== undefined &&
|
|
materializedSessionId === connection.sessionId
|
|
) {
|
|
// The first prompt consumed the draft: the panel carries into the new
|
|
// session, and the workspace's draft resets to empty for the next
|
|
// new-task visit.
|
|
artifactPanelStateBySessionRef.current.delete(previousSessionId);
|
|
const previousDraftPersisted =
|
|
artifactPanelPersistedStatesRef.current[previousSessionId];
|
|
delete artifactPanelPersistedStatesRef.current[previousSessionId];
|
|
// Deferred tabs belong to the draft too: move them under the session
|
|
// key so a capability flip can still restore them later.
|
|
const deferredDraftTabs =
|
|
artifactPanelDeferredPersistedTabsRef.current.get(previousSessionId) ??
|
|
[];
|
|
artifactPanelDeferredPersistedTabsRef.current.delete(previousSessionId);
|
|
if (deferredDraftTabs.length > 0) {
|
|
setBoundedMapEntry(
|
|
artifactPanelDeferredPersistedTabsRef.current,
|
|
nextSessionId,
|
|
deferredDraftTabs,
|
|
MAX_ARTIFACT_PANEL_SESSION_STATES * 2,
|
|
);
|
|
}
|
|
// Persist the carried-over state under the session key immediately: the
|
|
// persist writer already ran earlier in this commit (before this
|
|
// effect) and would otherwise leave the session unrecorded until the
|
|
// next panel change.
|
|
const carriedState = artifactPanelSessionStateRef.current;
|
|
if (carriedState) {
|
|
setArtifactPanelWidth(carriedState.width);
|
|
setArtifactPanelFullscreen(artifactPanelFullscreenRef.current);
|
|
artifactPanelPersistedStatesRef.current[nextSessionId] = {
|
|
open: carriedState.open,
|
|
activeTabId: carriedState.activeTabId,
|
|
tabs: mergePersistedArtifactPanelTabs(
|
|
serializeArtifactPanelTabs(carriedState.tabs),
|
|
deferredDraftTabs,
|
|
previousDraftPersisted?.tabs,
|
|
),
|
|
};
|
|
}
|
|
writeStoredArtifactPanelStates(
|
|
RIGHT_PANEL_STATE_STORAGE_KEY,
|
|
artifactPanelPersistedStatesRef.current,
|
|
);
|
|
artifactPanelAdoptedSessionRef.current = nextSessionId;
|
|
artifactPanelRestoredSessionKeyRef.current = nextSessionId;
|
|
return;
|
|
}
|
|
const newlyAvailableDeferredTab = (
|
|
artifactPanelDeferredPersistedTabsRef.current.get(nextSessionId) ?? []
|
|
).some(
|
|
(tab) =>
|
|
(tab.kind === 'terminal' && webTerminalAvailable) ||
|
|
(tab.kind === 'files' && filesAvailable) ||
|
|
(tab.kind === 'web_preview' && webPreviewAvailable) ||
|
|
(tab.kind === 'workflow' &&
|
|
tab.sessionId === connection.sessionId &&
|
|
sessionAgentTraceSupported),
|
|
);
|
|
if (
|
|
artifactPanelRestoredSessionKeyRef.current === nextSessionId &&
|
|
!connection.loadingTranscript &&
|
|
!newlyAvailableDeferredTab
|
|
) {
|
|
return;
|
|
}
|
|
if (
|
|
artifactPanelAdoptedSessionRef.current === nextSessionId &&
|
|
!newlyAvailableDeferredTab
|
|
) {
|
|
// An adopted new-task panel stays untouched through the session's
|
|
// first load (transcript/artifact flips included).
|
|
return;
|
|
}
|
|
const saveCurrentState = () => {
|
|
if (artifactPanelRestoredSessionKeyRef.current !== nextSessionId) return;
|
|
const currentState = artifactPanelSessionStateRef.current;
|
|
if (!currentState) return;
|
|
artifactPanelStateBySessionRef.current.delete(nextSessionId);
|
|
artifactPanelStateBySessionRef.current.set(nextSessionId, currentState);
|
|
};
|
|
const retainCurrentTrajectory = () => {
|
|
const retainedTabs = artifactPanelTabsRef.current.filter(
|
|
(tab) =>
|
|
previousSessionId === nextSessionId &&
|
|
trajectoryAvailable &&
|
|
tab.kind === 'trajectory' &&
|
|
tab.loadPage ===
|
|
createTrajectoryPageLoader(
|
|
workspace.client,
|
|
tab.sessionId,
|
|
sessionContextKey(connection.sessionContext),
|
|
),
|
|
);
|
|
setArtifactPanelTabs(
|
|
retainedTabs.map((tab) =>
|
|
tab.kind === 'trajectory'
|
|
? { ...tab, historyGapRevision: (tab.historyGapRevision ?? 0) + 1 }
|
|
: tab,
|
|
),
|
|
);
|
|
setActiveArtifactPanelTabId(
|
|
retainedTabs.some(
|
|
(tab) => tab.id === activeArtifactPanelTabIdRef.current,
|
|
)
|
|
? activeArtifactPanelTabIdRef.current
|
|
: null,
|
|
);
|
|
};
|
|
if (connection.loadingTranscript) {
|
|
saveCurrentState();
|
|
artifactPanelRestoredSessionKeyRef.current = undefined;
|
|
const pendingState =
|
|
artifactPanelStateBySessionRef.current.get(nextSessionId) ??
|
|
artifactPanelPersistedStatesRef.current[nextSessionId];
|
|
const shouldOpen = Boolean(pendingState?.open);
|
|
setArtifactPanelRestoring(shouldOpen);
|
|
setSuppressArtifactDockOpenAnimation(shouldOpen);
|
|
setArtifactPanelOpen(shouldOpen);
|
|
retainCurrentTrajectory();
|
|
return;
|
|
}
|
|
if (connection.status !== 'connected') {
|
|
saveCurrentState();
|
|
artifactPanelRestoredSessionKeyRef.current = undefined;
|
|
const pendingState =
|
|
artifactPanelStateBySessionRef.current.get(nextSessionId) ??
|
|
artifactPanelPersistedStatesRef.current[nextSessionId];
|
|
const shouldOpen = Boolean(pendingState?.open);
|
|
setArtifactPanelRestoring(shouldOpen);
|
|
setSuppressArtifactDockOpenAnimation(shouldOpen);
|
|
setArtifactPanelOpen(shouldOpen);
|
|
retainCurrentTrajectory();
|
|
return;
|
|
}
|
|
if (artifactsLoading) {
|
|
saveCurrentState();
|
|
artifactPanelRestoredSessionKeyRef.current = undefined;
|
|
const pendingState =
|
|
artifactPanelStateBySessionRef.current.get(nextSessionId) ??
|
|
artifactPanelPersistedStatesRef.current[nextSessionId];
|
|
const shouldOpen = Boolean(pendingState?.open);
|
|
setArtifactPanelRestoring(shouldOpen);
|
|
setSuppressArtifactDockOpenAnimation(shouldOpen);
|
|
setArtifactPanelOpen(shouldOpen);
|
|
retainCurrentTrajectory();
|
|
return;
|
|
}
|
|
|
|
let savedState = artifactPanelStateBySessionRef.current.get(nextSessionId);
|
|
let savedRuntimeTabs =
|
|
savedState?.tabs.filter(
|
|
(tab) =>
|
|
savedState?.pendingRestore ||
|
|
(tab.kind === 'side_task' && !tab.sessionId),
|
|
) ?? [];
|
|
let persisted =
|
|
artifactPanelPersistedStatesRef.current[nextSessionId] ?? undefined;
|
|
if (!persisted && savedRuntimeTabs.length === 0) {
|
|
artifactPanelDeferredPersistedTabsRef.current.delete(nextSessionId);
|
|
for (const tab of savedState?.tabs ?? []) {
|
|
if (tab.kind === 'terminal') {
|
|
releaseDetachedWebTerminal(
|
|
workspace.baseUrl,
|
|
tab.id,
|
|
tab.workspaceCwd,
|
|
);
|
|
}
|
|
}
|
|
artifactPanelStateBySessionRef.current.delete(nextSessionId);
|
|
artifactPanelRestoredSessionKeyRef.current = nextSessionId;
|
|
setArtifactPanelRestoring(false);
|
|
if (previousSessionId !== nextSessionId) {
|
|
setArtifactPanelOpen(false);
|
|
setArtifactPanelTabs([]);
|
|
setActiveArtifactPanelTabId(null);
|
|
}
|
|
return;
|
|
}
|
|
const deferredPersistedTabs = (persisted?.tabs ?? []).filter(
|
|
(tab) =>
|
|
(tab.kind === 'terminal' && !webTerminalAvailable) ||
|
|
(tab.kind === 'files' && !filesAvailable) ||
|
|
(tab.kind === 'web_preview' && !webPreviewAvailable) ||
|
|
(tab.kind === 'workflow' &&
|
|
tab.sessionId === connection.sessionId &&
|
|
!sessionAgentTraceSupported),
|
|
);
|
|
if (deferredPersistedTabs.length > 0) {
|
|
setBoundedMapEntry(
|
|
artifactPanelDeferredPersistedTabsRef.current,
|
|
nextSessionId,
|
|
deferredPersistedTabs,
|
|
MAX_ARTIFACT_PANEL_SESSION_STATES * 2,
|
|
);
|
|
} else {
|
|
artifactPanelDeferredPersistedTabsRef.current.delete(nextSessionId);
|
|
}
|
|
const restoredOpen = savedState?.open ?? persisted?.open ?? false;
|
|
setArtifactPanelRestoring(restoredOpen);
|
|
setSuppressArtifactDockOpenAnimation(restoredOpen);
|
|
if (previousSessionId !== nextSessionId) {
|
|
setArtifactPanelOpen(restoredOpen);
|
|
setArtifactPanelTabs([]);
|
|
setActiveArtifactPanelTabId(null);
|
|
}
|
|
let cancelled = false;
|
|
const restore = async () => {
|
|
const needsTasks = persisted?.tabs.some(
|
|
(tab) =>
|
|
(tab.kind === 'monitor' || tab.kind === 'shell') &&
|
|
(!tab.sessionId || tab.sessionId === connection.sessionId),
|
|
);
|
|
const taskSnapshot = needsTasks
|
|
? await sessionActions.getTasks({ silent: true }).catch(() => null)
|
|
: undefined;
|
|
if (cancelled) return;
|
|
savedState = artifactPanelStateBySessionRef.current.get(nextSessionId);
|
|
savedRuntimeTabs =
|
|
savedState?.tabs.filter(
|
|
(tab) =>
|
|
savedState?.pendingRestore ||
|
|
(tab.kind === 'side_task' && !tab.sessionId),
|
|
) ?? [];
|
|
persisted =
|
|
artifactPanelPersistedStatesRef.current[nextSessionId] ?? undefined;
|
|
const restoreInputs = artifactPanelRestoreInputsRef.current;
|
|
const restoredTabs = (
|
|
await Promise.all(
|
|
(persisted?.tabs ?? []).map(
|
|
async (tab): Promise<ArtifactPanelTab | undefined> => {
|
|
switch (tab.kind) {
|
|
case 'turn_calls':
|
|
return { ...tab, sessionId: connection.sessionId };
|
|
case 'review': {
|
|
if (
|
|
tab.sourceSessionId &&
|
|
tab.sourceSessionId !== connection.sessionId
|
|
) {
|
|
return {
|
|
...tab,
|
|
kind: 'pending',
|
|
targetKind: 'review',
|
|
sourceSessionId: tab.sourceSessionId,
|
|
};
|
|
}
|
|
const changes =
|
|
findReviewChangesByToolCallIds(
|
|
restoreInputs.fileChangesByTurn,
|
|
tab.sourceToolCallIds,
|
|
) ??
|
|
(tab.sourceTurnId
|
|
? restoreInputs.fileChangesByTurn.get(tab.sourceTurnId)
|
|
: restoreInputs.latestReviewChanges);
|
|
const sourceSessionId =
|
|
tab.sourceSessionId ?? connection.sessionId;
|
|
return changes
|
|
? { ...tab, changes }
|
|
: tab.sourceTurnId && sourceSessionId
|
|
? {
|
|
...tab,
|
|
kind: 'pending',
|
|
targetKind: 'review',
|
|
sourceSessionId,
|
|
}
|
|
: undefined;
|
|
}
|
|
case 'files':
|
|
return filesAvailable ? tab : undefined;
|
|
case 'web_preview':
|
|
return webPreviewAvailable ? tab : undefined;
|
|
case 'file': {
|
|
return tab;
|
|
}
|
|
case 'artifact':
|
|
return tab.sourceSessionId &&
|
|
tab.sourceSessionId !== connection.sessionId
|
|
? {
|
|
...tab,
|
|
kind: 'pending',
|
|
targetKind: 'artifact',
|
|
sourceSessionId: tab.sourceSessionId,
|
|
}
|
|
: tab;
|
|
case 'scheduled_task': {
|
|
if (
|
|
tab.sourceSessionId &&
|
|
tab.sourceSessionId !== connection.sessionId
|
|
) {
|
|
return {
|
|
...tab,
|
|
kind: 'pending',
|
|
targetKind: 'scheduled_task',
|
|
sourceSessionId: tab.sourceSessionId,
|
|
};
|
|
}
|
|
const task = Array.from(
|
|
restoreInputs.scheduledTasksByTurn.values(),
|
|
)
|
|
.flat()
|
|
.find((item) => item.toolCallId === tab.toolCallId);
|
|
if (!task) {
|
|
const sourceSessionId =
|
|
tab.sourceSessionId ?? connection.sessionId;
|
|
return sourceSessionId
|
|
? {
|
|
...tab,
|
|
kind: 'pending',
|
|
targetKind: 'scheduled_task',
|
|
sourceSessionId,
|
|
}
|
|
: undefined;
|
|
}
|
|
const { toolCallId: _toolCallId, ...rest } = tab;
|
|
return { ...rest, task };
|
|
}
|
|
case 'image': {
|
|
if (!tab.source) return undefined;
|
|
return { ...tab, src: '' };
|
|
}
|
|
case 'subagent': {
|
|
if (tab.sessionId !== connection.sessionId) {
|
|
return {
|
|
id: tab.id,
|
|
kind: 'pending',
|
|
title: tab.title,
|
|
targetKind: 'subagent',
|
|
sourceSessionId: tab.sessionId,
|
|
rootToolCallId: tab.rootToolCallId,
|
|
workspaceCwd: tab.workspaceCwd,
|
|
};
|
|
}
|
|
const rootTool = findToolCall(
|
|
restoreInputs.displayMessages,
|
|
tab.rootToolCallId,
|
|
);
|
|
return {
|
|
...tab,
|
|
rootTool: rootTool ?? {
|
|
callId: tab.rootToolCallId,
|
|
toolName: 'agent',
|
|
title: tab.title,
|
|
status: 'completed',
|
|
args: {},
|
|
},
|
|
};
|
|
}
|
|
case 'monitor':
|
|
case 'shell': {
|
|
if (tab.sessionId && tab.sessionId !== connection.sessionId) {
|
|
return {
|
|
id: tab.id,
|
|
kind: 'pending',
|
|
title: tab.title,
|
|
targetKind: tab.kind,
|
|
sourceSessionId: tab.sessionId,
|
|
taskId: tab.taskId,
|
|
};
|
|
}
|
|
const task = taskSnapshot?.tasks.find(
|
|
(item) => item.kind === tab.kind && item.id === tab.taskId,
|
|
);
|
|
if (taskSnapshot === null) {
|
|
return {
|
|
id: tab.id,
|
|
kind: 'pending',
|
|
title: tab.title,
|
|
targetKind: tab.kind,
|
|
sourceSessionId: connection.sessionId!,
|
|
taskId: tab.taskId,
|
|
};
|
|
}
|
|
if (!task || task.kind !== tab.kind) return undefined;
|
|
const { taskId: _taskId, ...rest } = tab;
|
|
return {
|
|
...rest,
|
|
title:
|
|
task.kind === 'shell'
|
|
? shellTaskPanelTitle(task.command)
|
|
: tab.title,
|
|
task,
|
|
sessionActions,
|
|
} as ArtifactPanelTab;
|
|
}
|
|
case 'side_task':
|
|
return tab.sessionId ? tab : undefined;
|
|
case 'terminal':
|
|
return webTerminalAvailable
|
|
? { ...tab, initialized: false }
|
|
: undefined;
|
|
case 'trajectory': {
|
|
if (!tab.sessionId) return undefined;
|
|
// A stored tab outlives the host's opt-in, so a host that
|
|
// has since stopped listing the item would get the panel
|
|
// back — and fetching with it — through the browser profile
|
|
// alone.
|
|
if (!trajectoryAvailable) return undefined;
|
|
// The loader is a function, so it cannot survive storage;
|
|
// a restored tab is inert until it is rewired here.
|
|
return {
|
|
...tab,
|
|
loadPage: createTrajectoryPageLoader(
|
|
workspace.client,
|
|
tab.sessionId,
|
|
sessionContextKey(connection.sessionContext),
|
|
),
|
|
};
|
|
}
|
|
case 'context_usage':
|
|
case 'token_usage': {
|
|
if (!tab.sessionId) return undefined;
|
|
const sessionId = tab.sessionId;
|
|
return {
|
|
...tab,
|
|
sessionActions:
|
|
sessionId === connection.sessionId
|
|
? sessionActions
|
|
: tab.kind === 'token_usage'
|
|
? {
|
|
...sessionActions,
|
|
getStats: () =>
|
|
workspace.client.sessionStats(sessionId),
|
|
}
|
|
: {
|
|
...sessionActions,
|
|
getContextUsage: (opts) =>
|
|
workspace.client.sessionContextUsage(
|
|
sessionId,
|
|
opts,
|
|
),
|
|
},
|
|
};
|
|
}
|
|
case 'workflow':
|
|
return !tab.sessionId ||
|
|
(tab.sessionId === connection.sessionId &&
|
|
sessionAgentTraceSupported)
|
|
? tab
|
|
: undefined;
|
|
}
|
|
},
|
|
),
|
|
)
|
|
).filter((tab): tab is ArtifactPanelTab => tab !== undefined);
|
|
const savedRuntimeTabIds = new Set(savedRuntimeTabs.map((tab) => tab.id));
|
|
const mergedRestoredTabs = [
|
|
...restoredTabs.filter((tab) => !savedRuntimeTabIds.has(tab.id)),
|
|
...savedRuntimeTabs,
|
|
];
|
|
if (cancelled) return;
|
|
artifactPanelStateBySessionRef.current.delete(nextSessionId);
|
|
artifactPanelRestoredSessionKeyRef.current = nextSessionId;
|
|
setArtifactPanelRestoring(false);
|
|
const desiredActiveTabId =
|
|
savedState?.activeTabId ?? persisted?.activeTabId ?? null;
|
|
const tabsOpenedDuringRestore = artifactPanelTabsRef.current;
|
|
const openedById = new Map(
|
|
tabsOpenedDuringRestore.map((tab) => [tab.id, tab]),
|
|
);
|
|
const restoredIds = new Set(mergedRestoredTabs.map((tab) => tab.id));
|
|
const candidateTabs = [
|
|
...mergedRestoredTabs.map((tab) => openedById.get(tab.id) ?? tab),
|
|
...tabsOpenedDuringRestore.filter((tab) => !restoredIds.has(tab.id)),
|
|
];
|
|
const mergedTabs = candidateTabs.filter((tab) => {
|
|
if (
|
|
tab.kind === 'side_task' &&
|
|
tab.sessionId &&
|
|
removedSideTaskSessionIdsRef.current.has(tab.sessionId)
|
|
) {
|
|
return false;
|
|
}
|
|
return (
|
|
// Once the split decision is final, drop stale pane-bound tabs before
|
|
// they can be selected active and mount a foreign collection; while
|
|
// the decision is pending, commit and let the settled sweep reclaim.
|
|
!splitViewSettledRef.current ||
|
|
!(
|
|
(tab.kind === 'token_usage' || tab.kind === 'context_usage') &&
|
|
tab.closeWithPane &&
|
|
(mainViewRef.current !== 'split' ||
|
|
tab.sessionId === undefined ||
|
|
!splitSessionIdsRef.current.includes(tab.sessionId))
|
|
)
|
|
);
|
|
});
|
|
const reclaimEmptiedPanel =
|
|
mergedTabs.length === 0 && candidateTabs.length > 0;
|
|
const activeTabId = mergedTabs.some(
|
|
(tab) => tab.id === activeArtifactPanelTabIdRef.current,
|
|
)
|
|
? activeArtifactPanelTabIdRef.current
|
|
: mergedTabs.some((tab) => tab.id === desiredActiveTabId)
|
|
? desiredActiveTabId
|
|
: (mergedTabs[0]?.id ?? null);
|
|
const activatedTabs = mergedTabs.map((tab) =>
|
|
tab.id === activeTabId && tab.kind === 'terminal'
|
|
? { ...tab, initialized: true }
|
|
: tab,
|
|
);
|
|
setArtifactPanelTabs([
|
|
...activatedTabs.filter((tab) => tab.kind === 'turn_calls'),
|
|
...activatedTabs.filter((tab) => tab.kind !== 'turn_calls'),
|
|
]);
|
|
if (reclaimEmptiedPanel) {
|
|
// The reclaim removed every restored tab; apply the canonical empty
|
|
// panel reset so no stale panel state is persisted as open.
|
|
resetEmptyArtifactPanel();
|
|
} else {
|
|
setSuppressArtifactDockOpenAnimation(restoredOpen);
|
|
setArtifactPanelOpen(artifactPanelOpenRef.current);
|
|
setActiveArtifactPanelTabId(activeTabId);
|
|
}
|
|
if (previousSessionId !== nextSessionId) {
|
|
setReviewChanges(restoreInputs.latestReviewChanges);
|
|
setArtifactPanelWidth(
|
|
savedState?.width ?? getDefaultReviewPanelWidth(),
|
|
);
|
|
}
|
|
void hydrateRestoredAttachmentTab(
|
|
activatedTabs.find((tab) => tab.id === activeTabId),
|
|
);
|
|
const activeTab = activatedTabs.find((tab) => tab.id === activeTabId);
|
|
void hydratePendingArtifactPanelTab(
|
|
activeTab?.kind === 'pending' ? activeTab : undefined,
|
|
);
|
|
};
|
|
void restore().catch((error: unknown) => {
|
|
if (cancelled) return;
|
|
artifactPanelRestoredSessionKeyRef.current = nextSessionId;
|
|
setArtifactPanelRestoring(false);
|
|
console.warn('[web-shell] failed to restore right panel:', error);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [
|
|
artifactsLoading,
|
|
connection.sessionContext,
|
|
connection.loadingTranscript,
|
|
connection.sessionId,
|
|
connection.status,
|
|
getDefaultReviewPanelWidth,
|
|
hydratePendingArtifactPanelTab,
|
|
hydrateRestoredAttachmentTab,
|
|
panelStateKey,
|
|
resetEmptyArtifactPanel,
|
|
sessionAgentTraceSupported,
|
|
sessionActions,
|
|
trajectoryAvailable,
|
|
webTerminalAvailable,
|
|
filesAvailable,
|
|
webPreviewAvailable,
|
|
workspace.baseUrl,
|
|
workspace.client,
|
|
]);
|
|
const openShellPanel = useCallback(
|
|
(
|
|
task: DaemonSessionShellTaskStatus,
|
|
sourceSessionId?: string,
|
|
sourceSessionActions?: DaemonSessionActions,
|
|
) => {
|
|
const tab: ArtifactPanelTab = {
|
|
id: sourceSessionId
|
|
? `shell:${sourceSessionId}:${task.id}`
|
|
: `shell:${task.id}`,
|
|
kind: 'shell',
|
|
title: shellTaskPanelTitle(task.command),
|
|
task,
|
|
...(sourceSessionId ? { sessionId: sourceSessionId } : {}),
|
|
...(sourceSessionActions
|
|
? { sessionActions: sourceSessionActions }
|
|
: {}),
|
|
};
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.some((item) => item.id === tab.id)
|
|
? tabs.map((item) => {
|
|
if (item.id !== tab.id || item.kind !== 'shell') return item;
|
|
const mergedTask = mergeShellTaskSnapshot(item.task, task);
|
|
return {
|
|
...tab,
|
|
title: shellTaskPanelTitle(mergedTask.command),
|
|
task: mergedTask,
|
|
};
|
|
})
|
|
: [...tabs, tab],
|
|
);
|
|
setActiveArtifactPanelTabId(tab.id);
|
|
setArtifactPanelWidth((width) =>
|
|
artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
|
|
);
|
|
setArtifactPanelOpen(true);
|
|
},
|
|
[getDefaultReviewPanelWidth],
|
|
);
|
|
const openFilesPanel = useCallback(() => {
|
|
const owner = resolveArtifactWorkspaceOwner(
|
|
workspace.capabilities,
|
|
panelWorkspaceCwd,
|
|
);
|
|
if (!filesAvailable || !owner) return;
|
|
rememberArtifactPanelTrigger();
|
|
const tab: ArtifactPanelTab = {
|
|
id: `files:${owner.id ?? owner.cwd}`,
|
|
kind: 'files',
|
|
title: t('files.title'),
|
|
workspaceCwd: owner.cwd,
|
|
workspaceId: owner.id,
|
|
};
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.some((item) => item.id === tab.id) ? tabs : [...tabs, tab],
|
|
);
|
|
setActiveArtifactPanelTabId(tab.id);
|
|
setArtifactPanelWidth((width) =>
|
|
Math.min(getMaxArtifactPanelWidth(), Math.max(width, 740)),
|
|
);
|
|
setArtifactPanelOpen(true);
|
|
}, [
|
|
panelWorkspaceCwd,
|
|
filesAvailable,
|
|
getMaxArtifactPanelWidth,
|
|
rememberArtifactPanelTrigger,
|
|
t,
|
|
workspace.capabilities,
|
|
]);
|
|
const openSubagentPanelForSession = useCallback(
|
|
(tool: ACPToolCall, sessionId: string, workspaceCwd?: string) => {
|
|
if (getSubagentDetailsUnavailableReason(tool)) return;
|
|
if (!artifactPanelOpenRef.current) {
|
|
setWaitForSubagentPanelAnimation(true);
|
|
}
|
|
rememberArtifactPanelTrigger();
|
|
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);
|
|
const tab: ArtifactPanelTab = {
|
|
id: `subagent:${sessionId}:${tool.callId}`,
|
|
kind: 'subagent',
|
|
title: tool.title || subagentType || t('agent.label'),
|
|
sessionId,
|
|
rootToolCallId: tool.callId,
|
|
rootTool: tool,
|
|
...(workspaceCwd ? { workspaceCwd } : {}),
|
|
};
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.some((item) => item.id === tab.id)
|
|
? tabs.map((item) => (item.id === tab.id ? tab : item))
|
|
: [...tabs, tab],
|
|
);
|
|
setActiveArtifactPanelTabId(tab.id);
|
|
setArtifactPanelWidth((width) =>
|
|
artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
|
|
);
|
|
setArtifactPanelOpen(true);
|
|
},
|
|
[getDefaultReviewPanelWidth, rememberArtifactPanelTrigger, t],
|
|
);
|
|
const openSubagentPanel = useCallback(
|
|
(tool: ACPToolCall, parentSessionId = connection.sessionId) => {
|
|
if (!parentSessionId) return;
|
|
openSubagentPanelForSession(
|
|
tool,
|
|
parentSessionId,
|
|
connection.workspaceCwd,
|
|
);
|
|
},
|
|
[
|
|
connection.sessionId,
|
|
connection.workspaceCwd,
|
|
openSubagentPanelForSession,
|
|
],
|
|
);
|
|
const openEnvironmentAgent = useCallback(
|
|
(task: DaemonSessionAgentTaskStatus) => {
|
|
if (!connection.sessionId) return;
|
|
if (!artifactPanelOpenRef.current) {
|
|
preserveEnvironmentPanelOnArtifactOpenRef.current = true;
|
|
}
|
|
const tool = task.toolUseId
|
|
? findToolCall(messages, task.toolUseId)
|
|
: undefined;
|
|
openSubagentPanelForSession(
|
|
tool ?? agentTaskAsToolCall(task),
|
|
connection.sessionId,
|
|
connection.workspaceCwd,
|
|
);
|
|
},
|
|
[
|
|
connection.sessionId,
|
|
connection.workspaceCwd,
|
|
messages,
|
|
openSubagentPanelForSession,
|
|
],
|
|
);
|
|
const openAgentWorkflow = useCallback(() => {
|
|
if (!connection.sessionId) return;
|
|
if (!artifactPanelOpenRef.current) {
|
|
preserveEnvironmentPanelOnArtifactOpenRef.current = true;
|
|
}
|
|
const tab: ArtifactPanelTab = {
|
|
id: `workflow:${connection.sessionId}`,
|
|
kind: 'workflow',
|
|
title: t('workflow.title'),
|
|
sessionId: connection.sessionId,
|
|
};
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.some((item) => item.id === tab.id) ? tabs : [...tabs, tab],
|
|
);
|
|
setActiveArtifactPanelTabId(tab.id);
|
|
setArtifactPanelWidth((width) =>
|
|
artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
|
|
);
|
|
setArtifactPanelOpen(true);
|
|
}, [connection.sessionId, getDefaultReviewPanelWidth, t]);
|
|
const openTurnCalls = useCallback(
|
|
(
|
|
turnId: string,
|
|
recordId?: string,
|
|
promptId?: string,
|
|
promptLabel?: string,
|
|
) => {
|
|
if (!artifactPanelOpenRef.current) {
|
|
preserveEnvironmentPanelOnArtifactOpenRef.current = true;
|
|
}
|
|
const user = store
|
|
.getSnapshot()
|
|
.blocks.find((block) => block.kind === 'user' && block.id === turnId);
|
|
const tab: ArtifactPanelTab = {
|
|
id: 'turn_calls',
|
|
kind: 'turn_calls',
|
|
sessionId: connection.sessionId,
|
|
title: t('turnCalls.title'),
|
|
turnId,
|
|
recordId: recordId ?? user?.sourceRecordIds?.[0],
|
|
promptId: promptId ?? user?.promptId,
|
|
promptLabel:
|
|
promptLabel ??
|
|
(user?.kind === 'user'
|
|
? user.text.replace(/\s+/g, ' ').trim().slice(0, 160)
|
|
: undefined),
|
|
};
|
|
// One panel follows the turn the reader asked about, so opening another
|
|
// turn's list retargets the existing tab instead of stacking tabs.
|
|
setArtifactPanelTabs((tabs) => [
|
|
tab,
|
|
...tabs.filter((item) => item.kind !== 'turn_calls'),
|
|
]);
|
|
setActiveArtifactPanelTabId(tab.id);
|
|
setArtifactPanelWidth((width) =>
|
|
artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
|
|
);
|
|
setArtifactPanelOpen(true);
|
|
},
|
|
[getDefaultReviewPanelWidth, t, store, connection.sessionId],
|
|
);
|
|
const handleTurnOutputOpen = useCallback(
|
|
(request: TurnOutputOpenRequest) => {
|
|
if (request.kind === 'review' && onFileReviewOpen) {
|
|
onFileReviewOpen(request);
|
|
return;
|
|
}
|
|
if (onRightPanelOpen && onRightPanelOpen(request) !== false) {
|
|
return;
|
|
}
|
|
if (request.kind === 'background_task') {
|
|
if (!request.sourceSessionId) return;
|
|
const turn = request.backgroundTurn;
|
|
// A peer turn handled a message inside this session: what it did is
|
|
// the transcript already on screen, and no task registry entry can
|
|
// hydrate a pending tab for it. A rendering of its own is a
|
|
// follow-up; until then there is nothing to open.
|
|
if (turn.kind === 'peer') return;
|
|
const tab: ArtifactPanelTab =
|
|
turn.kind === 'workflow'
|
|
? {
|
|
id: `workflow:${request.sourceSessionId}`,
|
|
kind: 'workflow',
|
|
title: request.title,
|
|
sessionId: request.sourceSessionId,
|
|
}
|
|
: {
|
|
id: request.id,
|
|
kind: 'pending',
|
|
title: request.title,
|
|
targetKind: turn.kind === 'agent' ? 'subagent' : turn.kind,
|
|
sourceSessionId: request.sourceSessionId,
|
|
rootToolCallId: turn.toolUseId,
|
|
taskId: turn.taskId,
|
|
workspaceCwd: request.workspaceCwd,
|
|
};
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.some((item) => item.id === tab.id) ? tabs : [...tabs, tab],
|
|
);
|
|
setActiveArtifactPanelTabId(tab.id);
|
|
setArtifactPanelWidth((width) =>
|
|
artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
|
|
);
|
|
setArtifactPanelOpen(true);
|
|
if (tab.kind === 'pending') {
|
|
const existing = artifactPanelTabsRef.current.find(
|
|
(item) => item.id === tab.id,
|
|
);
|
|
if (!existing || existing.kind === 'pending')
|
|
void hydratePendingArtifactPanelTab(tab);
|
|
}
|
|
return;
|
|
}
|
|
if (request.kind === 'review') {
|
|
openReviewPanel(
|
|
request.changes,
|
|
request.selectedPath,
|
|
request.workspaceCwd,
|
|
request.workspaceId,
|
|
request.sourceSessionId
|
|
? `review:${request.sourceSessionId}:${request.turnId}`
|
|
: undefined,
|
|
request.turnId,
|
|
request.sourceSessionId,
|
|
);
|
|
return;
|
|
}
|
|
if (request.kind === 'scheduled_task') {
|
|
openScheduledTaskPanel(
|
|
request.task,
|
|
request.workspaceCwd,
|
|
request.workspaceId,
|
|
request.sourceSessionId,
|
|
);
|
|
return;
|
|
}
|
|
if (request.kind === 'image') {
|
|
openImagePanel(
|
|
request.src,
|
|
request.alt,
|
|
request.attachmentId
|
|
? {
|
|
kind: 'attachment',
|
|
attachmentId: request.attachmentId,
|
|
...(request.sourceSessionId
|
|
? { sessionId: request.sourceSessionId }
|
|
: {}),
|
|
}
|
|
: undefined,
|
|
);
|
|
return;
|
|
}
|
|
if (request.kind === 'attachment') {
|
|
openAttachmentPanel(
|
|
{
|
|
name: request.title,
|
|
...(request.mimeType ? { mimeType: request.mimeType } : {}),
|
|
...(request.data ? { data: request.data } : {}),
|
|
...(request.text !== undefined ? { text: request.text } : {}),
|
|
...(request.attachmentId
|
|
? { attachmentId: request.attachmentId }
|
|
: {}),
|
|
...(request.workspacePath
|
|
? { workspacePath: request.workspacePath }
|
|
: {}),
|
|
},
|
|
request.workspaceCwd,
|
|
request.sourceSessionId,
|
|
false,
|
|
request.silentUnavailable,
|
|
);
|
|
return;
|
|
}
|
|
if (request.kind === 'subagent') {
|
|
openSubagentPanelForSession(
|
|
request.tool,
|
|
request.sessionId,
|
|
request.workspaceCwd,
|
|
);
|
|
return;
|
|
}
|
|
const previewUrl =
|
|
webPreviewAvailable &&
|
|
request.artifact.metadata?.['artifactType'] !==
|
|
'web_preview_snapshot' &&
|
|
request.artifact.storage === 'published' &&
|
|
request.artifact.source === 'tool' &&
|
|
request.artifact.toolName?.toLowerCase() === 'artifact' &&
|
|
request.artifact.status === 'available' &&
|
|
(request.artifact.kind === 'html' ||
|
|
request.artifact.kind === 'link') &&
|
|
request.artifact.url
|
|
? parseWebPreviewUrl(
|
|
request.artifact.url,
|
|
window.location.href,
|
|
workspace.baseUrl,
|
|
)
|
|
: undefined;
|
|
if (previewUrl) {
|
|
const tab: ArtifactPanelTab = {
|
|
id: `web-preview:${request.sourceSessionId ?? connection.sessionId}:${request.turnId}:${request.artifactId}`,
|
|
kind: 'web_preview',
|
|
title: request.title,
|
|
url: previewUrl.href,
|
|
viewport: 'desktop',
|
|
};
|
|
rememberArtifactPanelTrigger();
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.some((item) => item.id === tab.id)
|
|
? tabs.map((item) => (item.id === tab.id ? tab : item))
|
|
: [...tabs, tab],
|
|
);
|
|
setActiveArtifactPanelTabId(tab.id);
|
|
setArtifactPanelWidth((width) =>
|
|
artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
|
|
);
|
|
setArtifactPanelOpen(true);
|
|
return;
|
|
}
|
|
// Cache the opened row so the tab keeps rendering through transient
|
|
// gaps in the live artifact lists (an SSE reconnect, or the source
|
|
// pane closing); the snapshot/live-list reconciles drop the copy once
|
|
// a fresher source covers the artifact again.
|
|
setArtifactPanelExtraArtifacts((current) => {
|
|
const index = current.findIndex(
|
|
(artifact) => artifact.id === request.artifact.id,
|
|
);
|
|
if (index < 0) return [...current, request.artifact];
|
|
const next = [...current];
|
|
next[index] = request.artifact;
|
|
return next;
|
|
});
|
|
const tab: ArtifactPanelTab = {
|
|
id: request.sourceSessionId
|
|
? `${request.sourceSessionId}:${request.id}`
|
|
: request.id,
|
|
kind: 'artifact',
|
|
title: request.title,
|
|
artifactId: request.artifactId,
|
|
...(request.workspaceCwd ? { workspaceCwd: request.workspaceCwd } : {}),
|
|
...(request.workspaceId ? { workspaceId: request.workspaceId } : {}),
|
|
...((request.sourceSessionId ?? connection.sessionId)
|
|
? { sourceSessionId: request.sourceSessionId ?? connection.sessionId }
|
|
: {}),
|
|
...(request.previewContent !== undefined
|
|
? { previewContent: request.previewContent }
|
|
: {}),
|
|
};
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.some((item) => item.id === tab.id)
|
|
? tabs.map((item) =>
|
|
item.id === tab.id ? { ...item, ...tab } : item,
|
|
)
|
|
: [...tabs, tab],
|
|
);
|
|
setActiveArtifactPanelTabId(tab.id);
|
|
setArtifactPanelWidth((width) =>
|
|
artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
|
|
);
|
|
setArtifactPanelOpen(true);
|
|
},
|
|
[
|
|
getDefaultReviewPanelWidth,
|
|
hydratePendingArtifactPanelTab,
|
|
onFileReviewOpen,
|
|
onRightPanelOpen,
|
|
openReviewPanel,
|
|
openScheduledTaskPanel,
|
|
openImagePanel,
|
|
openAttachmentPanel,
|
|
openSubagentPanelForSession,
|
|
webPreviewAvailable,
|
|
workspace.baseUrl,
|
|
connection.sessionId,
|
|
rememberArtifactPanelTrigger,
|
|
],
|
|
);
|
|
const openFilePreview = useCallback(
|
|
(
|
|
change: TurnOutputFileChange | string,
|
|
previewWorkspaceCwd?: string,
|
|
previewWorkspaceId?: string,
|
|
) => {
|
|
const path = typeof change === 'string' ? change : change.path;
|
|
const previewContent =
|
|
typeof change === 'string'
|
|
? undefined
|
|
: getFileChangePreviewContent(change);
|
|
const tab: ArtifactPanelTab = {
|
|
id: `file:${previewWorkspaceCwd ?? ''}:${path}`,
|
|
kind: 'file',
|
|
title: displayPath(path, previewWorkspaceCwd),
|
|
workspacePath: path,
|
|
...(previewWorkspaceCwd ? { workspaceCwd: previewWorkspaceCwd } : {}),
|
|
...(previewWorkspaceId ? { workspaceId: previewWorkspaceId } : {}),
|
|
...(previewContent !== undefined ? { previewContent } : {}),
|
|
};
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.some((item) => item.id === tab.id)
|
|
? tabs.map((item) => (item.id === tab.id ? tab : item))
|
|
: [...tabs, tab],
|
|
);
|
|
setActiveArtifactPanelTabId(tab.id);
|
|
},
|
|
[],
|
|
);
|
|
const openMemoryFilePreview = useCallback(
|
|
async (file: DaemonContextMemoryDetail, fileWorkspaceCwd: string) => {
|
|
const absolutePath = file.absolutePath;
|
|
if (!absolutePath) return;
|
|
let result: ContextInstructionFileContent;
|
|
try {
|
|
const client = workspace.client;
|
|
if (!client) {
|
|
throw new Error('The workspace for this file is not available.');
|
|
}
|
|
result = await readContextInstructionFile(
|
|
client,
|
|
workspace.capabilities,
|
|
fileWorkspaceCwd,
|
|
absolutePath,
|
|
);
|
|
} catch (err) {
|
|
pushToast('error', err instanceof Error ? err.message : String(err));
|
|
return;
|
|
}
|
|
if (result.truncated) {
|
|
pushToast('warning', t('contextUsage.previewTruncated'));
|
|
}
|
|
const tab: ArtifactPanelTab = {
|
|
id: `memory-file:${absolutePath}`,
|
|
kind: 'file',
|
|
title: file.path,
|
|
workspacePath: absolutePath,
|
|
previewContent: result.content,
|
|
previewOnly: true,
|
|
...(fileWorkspaceCwd ? { workspaceCwd: fileWorkspaceCwd } : {}),
|
|
};
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.some((item) => item.id === tab.id)
|
|
? tabs.map((item) => (item.id === tab.id ? tab : item))
|
|
: [...tabs, tab],
|
|
);
|
|
setActiveArtifactPanelTabId(tab.id);
|
|
setArtifactPanelOpen(true);
|
|
},
|
|
[workspace, pushToast, t],
|
|
);
|
|
const closeArtifactPanel = useCallback(() => {
|
|
setArtifactPanelOpen(false);
|
|
// Reset fullscreen in the same commit: while it stays true the covered
|
|
// shells keep display:none, and deferring the reset to the management
|
|
// effect would paint one frame of an empty shell after the close.
|
|
setArtifactPanelFullscreen(false);
|
|
setSuppressArtifactDockOpenAnimation(false);
|
|
setSideTaskCatalog((catalog) =>
|
|
catalog.items.length === 0 ? { ...catalog, loaded: false } : catalog,
|
|
);
|
|
}, []);
|
|
useLayoutEffect(() => {
|
|
// Fullscreen hides the chat pane (display:none); measuring it there
|
|
// reports 0 and would clamp the panel down to its minimum width.
|
|
if (!artifactPanelOpen || artifactPanelFullscreen) return;
|
|
const clampWidth = () => {
|
|
setArtifactPanelWidth((width) => {
|
|
const chatPaneWidth =
|
|
chatPaneRef.current?.getBoundingClientRect().width ??
|
|
window.innerWidth - width;
|
|
const maxWidth = Math.max(
|
|
MIN_ARTIFACT_PANEL_WIDTH,
|
|
width + chatPaneWidth - MIN_CHAT_PANE_WIDTH_WITH_ARTIFACT_PANEL,
|
|
);
|
|
return Math.min(width, maxWidth);
|
|
});
|
|
};
|
|
clampWidth();
|
|
window.addEventListener('resize', clampWidth);
|
|
const chatPane = chatPaneRef.current;
|
|
const observer = new ResizeObserver(clampWidth);
|
|
if (chatPane) observer.observe(chatPane);
|
|
return () => {
|
|
window.removeEventListener('resize', clampWidth);
|
|
observer.disconnect();
|
|
};
|
|
}, [artifactPanelOpen, artifactPanelFullscreen]);
|
|
const closeArtifactPanelTabs = useCallback(
|
|
(tabIds: ReadonlySet<string>) => {
|
|
if (tabIds.size === 0) return;
|
|
for (const tab of artifactPanelTabsRef.current) {
|
|
if (tabIds.has(tab.id) && tab.kind === 'terminal') {
|
|
releaseWebTerminal(tab.id);
|
|
}
|
|
}
|
|
setArtifactPanelTabs((tabs) => {
|
|
const nextTabs = tabs.filter((tab) => !tabIds.has(tab.id));
|
|
if (nextTabs.length === tabs.length) return tabs;
|
|
if (nextTabs.length === 0) {
|
|
resetEmptyArtifactPanel();
|
|
return nextTabs;
|
|
}
|
|
if (
|
|
activeArtifactPanelTabIdRef.current &&
|
|
tabIds.has(activeArtifactPanelTabIdRef.current)
|
|
) {
|
|
const closedIndex = tabs.findIndex((tab) => tabIds.has(tab.id));
|
|
const nextActive =
|
|
nextTabs[Math.min(closedIndex, nextTabs.length - 1)] ?? nextTabs[0];
|
|
setActiveArtifactPanelTabId(nextActive.id);
|
|
const activatedTabs =
|
|
nextActive.kind === 'terminal' && !nextActive.initialized
|
|
? nextTabs.map((tab) =>
|
|
tab.id === nextActive.id
|
|
? { ...tab, initialized: true }
|
|
: tab,
|
|
)
|
|
: nextTabs;
|
|
void hydrateRestoredAttachmentTab(nextActive);
|
|
void hydratePendingArtifactPanelTab(
|
|
nextActive.kind === 'pending' ? nextActive : undefined,
|
|
);
|
|
return activatedTabs;
|
|
}
|
|
return nextTabs;
|
|
});
|
|
},
|
|
[
|
|
hydratePendingArtifactPanelTab,
|
|
hydrateRestoredAttachmentTab,
|
|
resetEmptyArtifactPanel,
|
|
],
|
|
);
|
|
const closeArtifactPanelTab = useCallback(
|
|
(tabId: string) => closeArtifactPanelTabs(new Set([tabId])),
|
|
[closeArtifactPanelTabs],
|
|
);
|
|
const removeSideTaskTabs = useCallback(
|
|
(sessionIds: ReadonlySet<string>) => {
|
|
for (const sessionId of sessionIds) {
|
|
removedSideTaskSessionIdsRef.current.add(sessionId);
|
|
}
|
|
const matches = (tab: { kind: string; sessionId?: string }) =>
|
|
tab.kind === 'side_task' &&
|
|
tab.sessionId !== undefined &&
|
|
sessionIds.has(tab.sessionId);
|
|
closeArtifactPanelTabs(
|
|
new Set(
|
|
artifactPanelTabsRef.current.filter(matches).map((tab) => tab.id),
|
|
),
|
|
);
|
|
for (const [
|
|
sessionKey,
|
|
state,
|
|
] of artifactPanelStateBySessionRef.current) {
|
|
const removedIds = new Set(
|
|
state.tabs.filter(matches).map((tab) => tab.id),
|
|
);
|
|
if (removedIds.size === 0) continue;
|
|
const tabs = state.tabs.filter((tab) => !removedIds.has(tab.id));
|
|
artifactPanelStateBySessionRef.current.set(sessionKey, {
|
|
...state,
|
|
open: tabs.length > 0 && state.open,
|
|
tabs,
|
|
activeTabId:
|
|
state.activeTabId && removedIds.has(state.activeTabId)
|
|
? (tabs[0]?.id ?? null)
|
|
: state.activeTabId,
|
|
});
|
|
}
|
|
for (const [
|
|
sessionKey,
|
|
tabs,
|
|
] of artifactPanelDeferredPersistedTabsRef.current) {
|
|
const retained = tabs.filter((tab) => !matches(tab));
|
|
if (retained.length === tabs.length) continue;
|
|
if (retained.length === 0) {
|
|
artifactPanelDeferredPersistedTabsRef.current.delete(sessionKey);
|
|
} else {
|
|
artifactPanelDeferredPersistedTabsRef.current.set(
|
|
sessionKey,
|
|
retained,
|
|
);
|
|
}
|
|
}
|
|
let persistedChanged = false;
|
|
for (const [sessionKey, state] of Object.entries(
|
|
artifactPanelPersistedStatesRef.current,
|
|
)) {
|
|
const removedIds = new Set(
|
|
state.tabs.filter(matches).map((tab) => tab.id),
|
|
);
|
|
if (removedIds.size === 0) continue;
|
|
const tabs = state.tabs.filter((tab) => !removedIds.has(tab.id));
|
|
artifactPanelPersistedStatesRef.current[sessionKey] = {
|
|
open: tabs.length > 0 && state.open,
|
|
tabs,
|
|
activeTabId:
|
|
state.activeTabId && removedIds.has(state.activeTabId)
|
|
? (tabs[0]?.id ?? null)
|
|
: state.activeTabId,
|
|
};
|
|
persistedChanged = true;
|
|
}
|
|
if (persistedChanged) {
|
|
writeStoredArtifactPanelStates(
|
|
RIGHT_PANEL_STATE_STORAGE_KEY,
|
|
artifactPanelPersistedStatesRef.current,
|
|
);
|
|
}
|
|
},
|
|
[closeArtifactPanelTabs],
|
|
);
|
|
const closeUsageTabs = useCallback(
|
|
(sessionIds?: readonly string[], paneOnly = false) => {
|
|
const sessions = sessionIds ? new Set(sessionIds) : undefined;
|
|
closeArtifactPanelTabs(
|
|
new Set(
|
|
artifactPanelTabsRef.current
|
|
.filter(
|
|
(tab) =>
|
|
(tab.kind === 'token_usage' || tab.kind === 'context_usage') &&
|
|
(!paneOnly || tab.closeWithPane) &&
|
|
(!sessions ||
|
|
(tab.sessionId !== undefined && sessions.has(tab.sessionId))),
|
|
)
|
|
.map((tab) => tab.id),
|
|
),
|
|
);
|
|
},
|
|
[closeArtifactPanelTabs],
|
|
);
|
|
const handleArtifactPanelResizeStart = useCallback(
|
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
event.preventDefault();
|
|
const resizeHandle = event.currentTarget;
|
|
resizeHandle.setPointerCapture(event.pointerId);
|
|
const startX = event.clientX;
|
|
const startWidth = artifactPanelWidth;
|
|
const maxWidth = getMaxArtifactPanelWidth();
|
|
const previousCursor = document.body.style.cursor;
|
|
const previousUserSelect = document.body.style.userSelect;
|
|
let pendingWidth = startWidth;
|
|
let animationFrame: number | null = null;
|
|
|
|
document.body.style.cursor = 'col-resize';
|
|
document.body.style.userSelect = 'none';
|
|
|
|
const flushWidth = () => {
|
|
animationFrame = null;
|
|
setArtifactPanelWidth(pendingWidth);
|
|
};
|
|
|
|
const handlePointerMove = (moveEvent: PointerEvent) => {
|
|
pendingWidth = Math.min(
|
|
maxWidth,
|
|
Math.max(
|
|
MIN_ARTIFACT_PANEL_WIDTH,
|
|
startWidth - (moveEvent.clientX - startX),
|
|
),
|
|
);
|
|
if (animationFrame === null) {
|
|
animationFrame = window.requestAnimationFrame(flushWidth);
|
|
}
|
|
};
|
|
let handlePointerUp: () => void = () => {};
|
|
const cleanupResize = (commitWidth: boolean) => {
|
|
artifactPanelResizeCleanupRef.current = null;
|
|
if (animationFrame !== null) {
|
|
window.cancelAnimationFrame(animationFrame);
|
|
animationFrame = null;
|
|
}
|
|
if (commitWidth) setArtifactPanelWidth(pendingWidth);
|
|
if (resizeHandle.hasPointerCapture(event.pointerId)) {
|
|
resizeHandle.releasePointerCapture(event.pointerId);
|
|
}
|
|
document.body.style.cursor = previousCursor;
|
|
document.body.style.userSelect = previousUserSelect;
|
|
window.removeEventListener('pointermove', handlePointerMove);
|
|
window.removeEventListener('pointerup', handlePointerUp);
|
|
window.removeEventListener('pointercancel', handlePointerUp);
|
|
};
|
|
handlePointerUp = () => cleanupResize(true);
|
|
artifactPanelResizeCleanupRef.current = () => cleanupResize(false);
|
|
window.addEventListener('pointermove', handlePointerMove);
|
|
window.addEventListener('pointerup', handlePointerUp);
|
|
window.addEventListener('pointercancel', handlePointerUp);
|
|
},
|
|
[artifactPanelWidth, getMaxArtifactPanelWidth],
|
|
);
|
|
useEffect(() => () => artifactPanelResizeCleanupRef.current?.(), []);
|
|
const rawPendingApproval = useMemo(
|
|
() => extractPendingPermission(blocks),
|
|
[blocks],
|
|
);
|
|
const pendingApproval = useShallowMemo(rawPendingApproval);
|
|
const canActOnPendingApproval = !(
|
|
connection.catchingUp && sidebarSwitchingSessionId !== null
|
|
);
|
|
const pendingAskUserApproval = isAskUserPermission(pendingApproval)
|
|
? canActOnPendingApproval
|
|
? pendingApproval
|
|
: null
|
|
: null;
|
|
const pendingToolApproval =
|
|
pendingApproval && !isAskUserPermission(pendingApproval)
|
|
? canActOnPendingApproval
|
|
? pendingApproval
|
|
: null
|
|
: null;
|
|
const pendingApprovalRef = useRef(pendingApproval);
|
|
pendingApprovalRef.current = canActOnPendingApproval ? pendingApproval : null;
|
|
const [projectHooksApprovalActive, setProjectHooksApprovalActive] =
|
|
useState(false);
|
|
// True exactly when an actionable approval overlay (ToolApproval or
|
|
// AskUserQuestion) is on screen. Single source of truth for the three places
|
|
// that must treat the composer as dormant while an approval owns the keyboard:
|
|
// the panel auto-close, the panel focus-restore guard, and the ChatEditor
|
|
// dialogOpen prop.
|
|
const approvalOverlayActive =
|
|
!managedMode &&
|
|
(pendingToolApproval !== null ||
|
|
pendingAskUserApproval !== null ||
|
|
projectHooksApprovalActive);
|
|
const approvalOverlayActiveRef = useRef(approvalOverlayActive);
|
|
approvalOverlayActiveRef.current =
|
|
approvalOverlayActive ||
|
|
(!managedMode &&
|
|
canActOnPendingApproval &&
|
|
extractPendingPermission(blocks) !== null);
|
|
const floatingTodosState = useMemo(
|
|
() => getFloatingTodos(messages),
|
|
[messages],
|
|
);
|
|
const approvalPlanTodos = useMemo(
|
|
() =>
|
|
isExitPlanApprovalRequest(pendingToolApproval)
|
|
? getActiveTodosForPlanRevision(messages, pendingToolApproval?.todoPlan)
|
|
: [],
|
|
[messages, pendingToolApproval],
|
|
);
|
|
// Keep the timeline Map referentially stable across streaming ticks that
|
|
// don't touch any todo snapshot. The Map is a context value, so a fresh
|
|
// reference would re-render every todo/plan row regardless of memoization;
|
|
// only rebuild when the todo snapshots themselves change.
|
|
const todoTimelineRef = useRef<{
|
|
signature: string;
|
|
timeline: Map<string, TodoSnapshotDiff>;
|
|
} | null>(null);
|
|
const todoTimeline = useMemo(() => {
|
|
const signature = todoTimelineSignature(messages);
|
|
const cached = todoTimelineRef.current;
|
|
if (cached && cached.signature === signature) return cached.timeline;
|
|
const timeline = computeTodoTimeline(messages);
|
|
todoTimelineRef.current = { signature, timeline };
|
|
return timeline;
|
|
}, [messages]);
|
|
// Per-todo detail (start/end + token/API/tool spend) is derived entirely from
|
|
// the transcript: the agent stamps a cumulative-usage snapshot on each todo
|
|
// update and the web-shell diffs consecutive snapshots, so this works live and
|
|
// on resume with no polling. Kept referentially stable like the timeline
|
|
// above (rebuilt only when a relevant snapshot, timestamp, stat, or tool span
|
|
// changes) so an unrelated streaming tick doesn't re-render every expanded
|
|
// todo row that consumes TodoDetailContext.
|
|
const todoDetailRef = useRef<{
|
|
signature: string;
|
|
details: Map<string, TodoDetail>;
|
|
} | null>(null);
|
|
const todoDetails = useMemo(() => {
|
|
const signature = todoDetailSignature(messages);
|
|
const cached = todoDetailRef.current;
|
|
if (cached && cached.signature === signature) return cached.details;
|
|
const details = computeTodoDetails(messages);
|
|
todoDetailRef.current = { signature, details };
|
|
return details;
|
|
}, [messages]);
|
|
const floatingTodos = useStableArray(floatingTodosState.todos, (todo) =>
|
|
JSON.stringify([todo.id, todo.status, todo.content, todo.blockedBy ?? []]),
|
|
);
|
|
const floatingTodosAllCompleted = floatingTodosState.allCompleted;
|
|
const [todoPanelMode, setTodoPanelMode] = useState<'hidden' | 'active'>(
|
|
'hidden',
|
|
);
|
|
const nextTodoPanelMode =
|
|
connection.catchingUp ||
|
|
floatingTodos.length === 0 ||
|
|
floatingTodosAllCompleted
|
|
? 'hidden'
|
|
: 'active';
|
|
if (nextTodoPanelMode !== todoPanelMode) {
|
|
setTodoPanelMode(nextTodoPanelMode);
|
|
}
|
|
const showFloatingTodos = nextTodoPanelMode !== 'hidden';
|
|
const floatingBottomStatusItems =
|
|
bottomStatusItems ?? EMPTY_BOTTOM_STATUS_ITEMS;
|
|
const showBottomPanels =
|
|
showFloatingTodos || floatingBottomStatusItems.length > 0;
|
|
const footerRef = useRef<HTMLDivElement>(null);
|
|
const appRootRef = useRef<HTMLDivElement>(null);
|
|
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
|
|
const portalRootVariableNamesRef = useRef<Set<string>>(new Set());
|
|
const bottomPanelsRef = useRef<HTMLDivElement>(null);
|
|
const [bottomPanelInset, setBottomPanelInset] = useState(0);
|
|
const [bottomPanelHeight, setBottomPanelHeight] = useState(0);
|
|
useLayoutEffect(() => {
|
|
if (!showBottomPanels) {
|
|
setBottomPanelInset(0);
|
|
setBottomPanelHeight(0);
|
|
return;
|
|
}
|
|
const node = bottomPanelsRef.current;
|
|
if (!node) {
|
|
setBottomPanelInset(BOTTOM_PANEL_FALLBACK_INSET_PX);
|
|
setBottomPanelHeight(0);
|
|
return;
|
|
}
|
|
const updateInset = () => {
|
|
const footer = footerRef.current;
|
|
const panelRect = node.getBoundingClientRect();
|
|
const footerRect = footer?.getBoundingClientRect();
|
|
const panelHeight = Math.ceil(panelRect.height);
|
|
const overlapAboveFooter = footerRect
|
|
? Math.max(0, footerRect.top - panelRect.top)
|
|
: panelHeight + BOTTOM_PANEL_GAP_PX;
|
|
setBottomPanelHeight(panelHeight);
|
|
setBottomPanelInset(
|
|
Math.max(BOTTOM_PANEL_FALLBACK_INSET_PX, Math.ceil(overlapAboveFooter)),
|
|
);
|
|
};
|
|
updateInset();
|
|
if (typeof ResizeObserver === 'undefined') return;
|
|
const observer = new ResizeObserver(updateInset);
|
|
observer.observe(node);
|
|
if (footerRef.current) observer.observe(footerRef.current);
|
|
return () => observer.disconnect();
|
|
}, [showBottomPanels]);
|
|
const contentStyle = useMemo(
|
|
() =>
|
|
({
|
|
'--web-shell-bottom-panel-inset': `${bottomPanelInset}px`,
|
|
'--web-shell-bottom-panel-height': `${bottomPanelHeight}px`,
|
|
'--web-shell-bottom-panel-gap': `${BOTTOM_PANEL_GAP_PX}px`,
|
|
}) as CSSProperties,
|
|
[bottomPanelHeight, bottomPanelInset],
|
|
);
|
|
const taskActivityKey = useMemo(
|
|
() => getTaskActivityKey(messages),
|
|
[messages],
|
|
);
|
|
// The activity fact travels beside the key, derived structurally — the
|
|
// key itself is not parseable back (callId is unconstrained text).
|
|
const taskActivityActive = useMemo(
|
|
() => hasActiveTaskActivity(messages, { workflowsEnabled }),
|
|
[messages, workflowsEnabled],
|
|
);
|
|
const [backgroundTasksRefreshTrigger, setBackgroundTasksRefreshTrigger] =
|
|
useState(0);
|
|
const sessionTasks = useBackgroundTasks(
|
|
connection.sessionId,
|
|
taskActivityKey,
|
|
taskActivityActive,
|
|
connection.status === 'connected',
|
|
backgroundTasksRefreshTrigger,
|
|
workflowsEnabled,
|
|
);
|
|
const [sessionAgentInventory, setSessionAgentInventory] = useState<{
|
|
sessionKey: string;
|
|
tasks: DaemonSessionAgentTaskStatus[];
|
|
}>();
|
|
const workflowTabActive =
|
|
artifactPanelOpen &&
|
|
Boolean(
|
|
connection.sessionId &&
|
|
artifactPanelTabs.some(
|
|
(tab) =>
|
|
tab.id === activeArtifactPanelTabId &&
|
|
tab.kind === 'workflow' &&
|
|
tab.sessionId === connection.sessionId,
|
|
),
|
|
);
|
|
const environmentNeedsAgents =
|
|
environmentPanelOpen &&
|
|
environmentPanelReachable &&
|
|
environmentPanelItems.includes('subagents');
|
|
const sessionAgentsSupported =
|
|
connection.capabilities?.features.includes(SESSION_AGENTS_FEATURE) === true;
|
|
useEffect(() => {
|
|
if (
|
|
!daemonAvailable ||
|
|
connection.status !== 'connected' ||
|
|
connection.loadingTranscript ||
|
|
!connection.sessionId ||
|
|
(!environmentNeedsAgents && !workflowTabActive) ||
|
|
!sessionAgentsSupported
|
|
) {
|
|
return;
|
|
}
|
|
const sessionId = connection.sessionId;
|
|
const owner = sessionOwnerGuard.capture();
|
|
const controller = new AbortController();
|
|
let disposed = false;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
let consecutiveFailures = 0;
|
|
const refresh = async () => {
|
|
try {
|
|
const snapshot = await workspace.client.sessionAgents(
|
|
sessionId,
|
|
undefined,
|
|
controller.signal,
|
|
);
|
|
if (disposed || !owner.isCurrent() || snapshot.sessionId !== sessionId)
|
|
return;
|
|
const agents = snapshot.tasks;
|
|
if (logicalSessionKey) {
|
|
setSessionAgentInventory({
|
|
sessionKey: logicalSessionKey,
|
|
tasks: agents,
|
|
});
|
|
}
|
|
consecutiveFailures = 0;
|
|
if (
|
|
agents.some(
|
|
(task) => task.status === 'running' || task.status === 'paused',
|
|
)
|
|
) {
|
|
timer = setTimeout(refresh, SESSION_AGENTS_REFRESH_INTERVAL_MS);
|
|
}
|
|
} catch (error) {
|
|
if (disposed || !owner.isCurrent() || isDaemonUnavailableError(error)) {
|
|
return;
|
|
}
|
|
if (consecutiveFailures === 0) {
|
|
console.warn('[web-shell] failed to refresh session agents:', error);
|
|
}
|
|
consecutiveFailures += 1;
|
|
timer = setTimeout(
|
|
refresh,
|
|
Math.min(
|
|
SESSION_AGENTS_REFRESH_INTERVAL_MS * 2 ** (consecutiveFailures - 1),
|
|
SESSION_AGENTS_MAX_RETRY_INTERVAL_MS,
|
|
),
|
|
);
|
|
}
|
|
};
|
|
void refresh();
|
|
return () => {
|
|
disposed = true;
|
|
controller.abort();
|
|
if (timer) clearTimeout(timer);
|
|
};
|
|
}, [
|
|
backgroundTasksRefreshTrigger,
|
|
connection.loadingTranscript,
|
|
connection.sessionId,
|
|
connection.status,
|
|
environmentNeedsAgents,
|
|
daemonAvailable,
|
|
logicalSessionKey,
|
|
sessionAgentsSupported,
|
|
sessionOwnerGuard,
|
|
taskActivityKey,
|
|
workspace.client,
|
|
workflowTabActive,
|
|
]);
|
|
const terminalBackgroundShellTaskIdsKey = useMemo(
|
|
() =>
|
|
sessionTasks
|
|
.filter((task) => task.kind === 'shell' && task.status !== 'running')
|
|
.map((task) => task.id)
|
|
.join(','),
|
|
[sessionTasks],
|
|
);
|
|
// Preserve Set identity when polling returns an equivalent task snapshot.
|
|
const terminalBackgroundShellTaskIds = useMemo(
|
|
() =>
|
|
new Set(
|
|
terminalBackgroundShellTaskIdsKey
|
|
? terminalBackgroundShellTaskIdsKey.split(',')
|
|
: [],
|
|
),
|
|
[terminalBackgroundShellTaskIdsKey],
|
|
);
|
|
const environmentAgentTasks = useMemo(
|
|
() =>
|
|
getEnvironmentAgentTasks(
|
|
messages,
|
|
sessionAgentsSupported &&
|
|
(environmentNeedsAgents || workflowTabActive) &&
|
|
sessionAgentInventory &&
|
|
sessionAgentInventory.sessionKey === logicalSessionKey
|
|
? sessionAgentInventory.tasks
|
|
: sessionTasks,
|
|
),
|
|
[
|
|
logicalSessionKey,
|
|
messages,
|
|
environmentNeedsAgents,
|
|
sessionAgentInventory,
|
|
sessionAgentsSupported,
|
|
sessionTasks,
|
|
workflowTabActive,
|
|
],
|
|
);
|
|
const [sessionAgentTrace, setSessionAgentTrace] = useState<{
|
|
sessionKey: string;
|
|
trace: DaemonAgentTrace;
|
|
}>();
|
|
const [agentTraceFailedSessionKey, setAgentTraceFailedSessionKey] =
|
|
useState<string>();
|
|
const agentTraceActivityKey = useMemo(
|
|
() =>
|
|
environmentAgentTasks
|
|
.map((task) => `${task.id}:${task.status}`)
|
|
.join(','),
|
|
[environmentAgentTasks],
|
|
);
|
|
useEffect(() => {
|
|
if (
|
|
!daemonAvailable ||
|
|
connection.status !== 'connected' ||
|
|
connection.loadingTranscript ||
|
|
!connection.sessionId ||
|
|
!logicalSessionKey ||
|
|
!workflowTabActive ||
|
|
!sessionAgentTraceSupported
|
|
) {
|
|
return;
|
|
}
|
|
const sessionId = connection.sessionId;
|
|
const sessionKey = logicalSessionKey;
|
|
const owner = sessionOwnerGuard.capture();
|
|
const controller = new AbortController();
|
|
setAgentTraceFailedSessionKey(undefined);
|
|
let retryCount = 0;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const refresh = () => {
|
|
void workspace.client
|
|
.sessionAgentTrace(sessionId, { signal: controller.signal })
|
|
.then((trace) => {
|
|
if (!owner.isCurrent() || trace.sessionId !== sessionId) return;
|
|
const value = { sessionKey, trace };
|
|
setSessionAgentTrace(value);
|
|
})
|
|
.catch(() => {
|
|
if (controller.signal.aborted || !owner.isCurrent()) return;
|
|
if (retryCount < 3) {
|
|
retryCount += 1;
|
|
timer = setTimeout(refresh, SESSION_AGENTS_REFRESH_INTERVAL_MS);
|
|
} else {
|
|
setAgentTraceFailedSessionKey(sessionKey);
|
|
}
|
|
});
|
|
};
|
|
refresh();
|
|
return () => {
|
|
controller.abort();
|
|
if (timer) clearTimeout(timer);
|
|
};
|
|
}, [
|
|
agentTraceActivityKey,
|
|
daemonAvailable,
|
|
connection.loadingTranscript,
|
|
connection.sessionId,
|
|
connection.status,
|
|
logicalSessionKey,
|
|
sessionAgentTraceSupported,
|
|
sessionOwnerGuard,
|
|
workflowTabActive,
|
|
workspace.client,
|
|
]);
|
|
const currentAgentTrace =
|
|
sessionAgentTrace && sessionAgentTrace.sessionKey === logicalSessionKey
|
|
? sessionAgentTrace.trace
|
|
: undefined;
|
|
const agentTraceFailed = agentTraceFailedSessionKey === logicalSessionKey;
|
|
const agentTraceInitialLoading =
|
|
workflowTabActive &&
|
|
sessionAgentTraceSupported &&
|
|
!currentAgentTrace &&
|
|
!agentTraceFailed;
|
|
const tracedEnvironmentAgentTasks = useMemo(
|
|
() => mergeAgentTrace(environmentAgentTasks, currentAgentTrace),
|
|
[currentAgentTrace, environmentAgentTasks],
|
|
);
|
|
const lastReportedAgentTasksRef = useRef<string | undefined>(undefined);
|
|
useEffect(() => {
|
|
if (!onAgentTasksChange) {
|
|
lastReportedAgentTasksRef.current = undefined;
|
|
return;
|
|
}
|
|
const snapshot = JSON.stringify(
|
|
environmentAgentTasks.map(
|
|
({
|
|
runtimeMs: _runtimeMs,
|
|
stats: _stats,
|
|
recentActivities: _recentActivities,
|
|
prompt: _prompt,
|
|
...stable
|
|
}) => stable,
|
|
),
|
|
);
|
|
if (snapshot === lastReportedAgentTasksRef.current) return;
|
|
lastReportedAgentTasksRef.current = snapshot;
|
|
onAgentTasksChange(environmentAgentTasks);
|
|
}, [environmentAgentTasks, onAgentTasksChange]);
|
|
const backgroundTasks = useMemo(
|
|
// Same rule as the status-bar pill: live background state only, never
|
|
// the project's retained workflow history.
|
|
() => sessionTasks.filter(isComposerTask),
|
|
[sessionTasks],
|
|
);
|
|
// The header dot promises only what the panel can show, so it follows the
|
|
// same section gating and the same in-progress rule as the panel's rows.
|
|
const environmentRunning =
|
|
(environmentPanelItems.includes('subagents') &&
|
|
environmentAgentTasks.some((task) => isTaskInProgress(task.status))) ||
|
|
(environmentPanelItems.includes('backgroundTasks') &&
|
|
backgroundTasks.some((task) => isTaskInProgress(task.status)));
|
|
const monitorDetailsSessionIdRef = useRef(connection.sessionId);
|
|
monitorDetailsSessionIdRef.current = connection.sessionId;
|
|
const openMonitorPanelFromTool = useCallback(
|
|
async (tool: ACPToolCall): Promise<boolean> => {
|
|
const sessionId = monitorDetailsSessionIdRef.current;
|
|
if (!sessionId) return false;
|
|
const owner = sessionOwnerGuard.capture();
|
|
try {
|
|
const snapshot = await sessionActions.getTasks();
|
|
if (
|
|
!owner.isCurrent() ||
|
|
monitorDetailsSessionIdRef.current !== sessionId ||
|
|
snapshot.sessionId !== sessionId
|
|
) {
|
|
return false;
|
|
}
|
|
const task = findMonitorTaskForTool(snapshot.tasks, tool);
|
|
if (!task) return false;
|
|
setBackgroundTasksRefreshTrigger((value) => value + 1);
|
|
openMonitorPanel(task);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
[openMonitorPanel, sessionActions, sessionOwnerGuard],
|
|
);
|
|
useEffect(() => {
|
|
const monitors = new Map(
|
|
backgroundTasks
|
|
.filter(
|
|
(task): task is DaemonSessionMonitorTaskStatus =>
|
|
task.kind === 'monitor',
|
|
)
|
|
.map((task) => [task.id, task]),
|
|
);
|
|
if (monitors.size === 0) return;
|
|
setArtifactPanelTabs((tabs) => {
|
|
let changed = false;
|
|
const next = tabs.map((tab) => {
|
|
if (
|
|
tab.kind !== 'monitor' ||
|
|
(tab.sessionId && tab.sessionId !== connection.sessionId)
|
|
) {
|
|
return tab;
|
|
}
|
|
const task = monitors.get(tab.task.id);
|
|
if (!task || task === tab.task) return tab;
|
|
const mergedTask = mergeMonitorTaskSnapshot(tab.task, task);
|
|
if (mergedTask === tab.task) return tab;
|
|
changed = true;
|
|
return {
|
|
...tab,
|
|
title: mergedTask.description,
|
|
task: mergedTask,
|
|
};
|
|
});
|
|
return changed ? next : tabs;
|
|
});
|
|
}, [backgroundTasks, connection.sessionId]);
|
|
useEffect(() => {
|
|
const shellTasks = new Map(
|
|
backgroundTasks
|
|
.filter(
|
|
(task): task is DaemonSessionShellTaskStatus => task.kind === 'shell',
|
|
)
|
|
.map((task) => [task.id, task]),
|
|
);
|
|
if (shellTasks.size === 0) return;
|
|
setArtifactPanelTabs((tabs) => {
|
|
let changed = false;
|
|
const next = tabs.map((tab) => {
|
|
if (
|
|
tab.kind !== 'shell' ||
|
|
(tab.sessionId && tab.sessionId !== connection.sessionId)
|
|
) {
|
|
return tab;
|
|
}
|
|
const task = shellTasks.get(tab.task.id);
|
|
if (!task || task === tab.task) return tab;
|
|
const mergedTask = mergeShellTaskSnapshot(tab.task, task);
|
|
if (mergedTask === tab.task) return tab;
|
|
changed = true;
|
|
return {
|
|
...tab,
|
|
title: shellTaskPanelTitle(mergedTask.command),
|
|
task: mergedTask,
|
|
};
|
|
});
|
|
return changed ? next : tabs;
|
|
});
|
|
}, [backgroundTasks, connection.sessionId]);
|
|
// Host-provided footers render their own task list from composer tasks
|
|
// plus background agents (foreground agents are transcript-only; terminal
|
|
// background agents keep reaching hosts, matching the design doc).
|
|
const footerHostTasks = useMemo(
|
|
() =>
|
|
sessionTasks.filter(
|
|
(task) =>
|
|
isComposerTask(task) ||
|
|
(task.kind === 'agent' && task.isBackgrounded === true),
|
|
),
|
|
[sessionTasks],
|
|
);
|
|
const footerTasks = useMemo(
|
|
() => (renderFooter ? footerHostTasks.map(mapToWebShellTaskInfo) : []),
|
|
[footerHostTasks, renderFooter],
|
|
);
|
|
const statusBarRef = useRef<StatusBarHandle>(null);
|
|
const messageListRef = useRef<MessageListHandle | null>(null);
|
|
const editorRef = useRef<EditorHandle | null>(null);
|
|
const chatFocusBlockedRef = useRef(false);
|
|
const focusChatAfterNavigation = useCallback(() => {
|
|
if (chatFocusBlockedRef.current) return;
|
|
// Keep touch navigation accessible without opening the soft keyboard.
|
|
if (isCoarsePointerDevice()) {
|
|
chatPaneRef.current?.focus({ preventScroll: true });
|
|
} else {
|
|
editorRef.current?.focus();
|
|
}
|
|
}, []);
|
|
const restoreConversationSearchFocus = useCallback(() => {
|
|
editorRef.current?.focus();
|
|
}, []);
|
|
const notifiedComposerReadyRef = useRef<EditorHandle | null>(null);
|
|
const [canScrollMessageListToBottom, setCanScrollMessageListToBottom] =
|
|
useState(false);
|
|
const previousFooterRectRef = useRef<DOMRect | null>(null);
|
|
const previousEmptyStateRef = useRef(false);
|
|
const resumeChatBottomFollow = useCallback(
|
|
(behavior: ScrollBehavior = 'smooth') => {
|
|
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, onComposerReady],
|
|
);
|
|
useEffect(() => {
|
|
assignComposerRef(composerRef, editorRef.current ?? emptyComposerApi);
|
|
}, [composerRef]);
|
|
const [goalSnapshot, setGoalSnapshot] = useState<GoalSnapshotV2 | null>(null);
|
|
const goalSnapshotRef = useRef<GoalSnapshotV2 | null>(null);
|
|
goalSnapshotRef.current = goalSnapshot;
|
|
const [goalControlBusy, setGoalControlBusy] = useState(false);
|
|
// Which control operation owns the busy latch, mirroring ChatPane's twin. A
|
|
// finishing operation must not release the latch under a newer one that is
|
|
// still in flight, or the strip re-enables mid-control and a second dispatch
|
|
// races the first against the same expected revision.
|
|
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);
|
|
useLayoutEffect(() => {
|
|
setGoalSnapshot(null);
|
|
goalControlOwnerRef.current = undefined;
|
|
setGoalControlBusy(false);
|
|
setGoalEditOpen(false);
|
|
setGoalEditError(null);
|
|
}, [logicalSessionKey]);
|
|
const [isCreatingMissingSession, setIsCreatingMissingSession] =
|
|
useState(false);
|
|
const creatingMissingSessionRef = useRef(false);
|
|
const {
|
|
followupState,
|
|
onAcceptFollowup,
|
|
onDismissFollowup,
|
|
clear: clearFollowup,
|
|
} = useDaemonFollowupSuggestion({
|
|
onAccept: (suggestion) => {
|
|
editorRef.current?.insertText(suggestion);
|
|
},
|
|
});
|
|
const composerTextRef = useRef('');
|
|
const [hasComposerAttachments, setHasComposerAttachments] = useState<
|
|
boolean | null
|
|
>(null);
|
|
const [isStartingNewSessionSuggestion, setIsStartingNewSessionSuggestion] =
|
|
useState(false);
|
|
const streamingState = useStreamingState();
|
|
const failedPromptRetryIsCurrent = Boolean(
|
|
failedPromptRetry &&
|
|
retryOwnerMatchesCurrent(
|
|
failedPromptRetry.owner,
|
|
connection.sessionId,
|
|
connection.workspaceCwd,
|
|
composerSourceVersionRef.current,
|
|
) &&
|
|
retryTranscriptIdentityMatches(
|
|
blocks,
|
|
failedPromptRetry.transcriptIdentity,
|
|
),
|
|
);
|
|
useEffect(() => {
|
|
if (
|
|
failedPromptRetry &&
|
|
(!failedPromptRetryIsCurrent ||
|
|
(streamingState === 'idle' && failedPromptRetry.settled))
|
|
) {
|
|
setFailedPromptRetry(null);
|
|
}
|
|
}, [failedPromptRetry, failedPromptRetryIsCurrent, streamingState]);
|
|
const suppressFailedPromptRetryStreaming = Boolean(
|
|
failedPromptRetry &&
|
|
failedPromptRetryIsCurrent &&
|
|
(!failedPromptRetry.admitted || failedPromptRetry.settled),
|
|
);
|
|
const streamingStateRef = useRef<DaemonStreamingState>(streamingState);
|
|
streamingStateRef.current = streamingState;
|
|
// Cleared in three places: the session-switch effect, the drain loop, and
|
|
// handleCancel. Bumping drainGenerationRef at each clear site also cancels
|
|
// any in-flight inline ! command whose ensureSessionForPrompt is resolving.
|
|
// The session-switch effect exempts the lazy-creation transition (into
|
|
// preparingSessionIdRef.current) so a submit's own session creation does
|
|
// not cancel the command.
|
|
const queuedShellCommandsRef = useRef<string[]>([]);
|
|
const drainGenerationRef = useRef(0);
|
|
const shellSubmitInFlightRef = useRef(false);
|
|
const isDrainingRef = useRef(false);
|
|
useEffect(() => {
|
|
if (!connection.runtimeStopped) return;
|
|
const dropped = queuedShellCommandsRef.current.length;
|
|
queuedShellCommandsRef.current = [];
|
|
drainGenerationRef.current++;
|
|
isDrainingRef.current = false;
|
|
if (dropped > 0)
|
|
pushToast('warning', t('queue.shellDropped', { count: dropped }));
|
|
}, [connection.runtimeStopped, pushToast, t]);
|
|
const localStreamingStartedAtRef = useRef(Date.now());
|
|
const previousStreamingStateRef =
|
|
useRef<DaemonStreamingState>(streamingState);
|
|
if (
|
|
previousStreamingStateRef.current === 'idle' &&
|
|
streamingState !== 'idle'
|
|
) {
|
|
localStreamingStartedAtRef.current = Date.now();
|
|
}
|
|
previousStreamingStateRef.current = streamingState;
|
|
const activeTurnStartedAt = useMemo(() => {
|
|
if (connection.backgroundTurn) return connection.backgroundTurn.startedAt;
|
|
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, connection.backgroundTurn]);
|
|
const lastSubmittedPromptRef = useRef<string>('');
|
|
const lastSubmittedImagesRef = useRef<PromptImage[] | undefined>(undefined);
|
|
const lastSubmittedFilesRef = useRef<PromptFile[] | undefined>(undefined);
|
|
const lastSubmittedInputAnnotationsRef = useRef<
|
|
DaemonInputAnnotation[] | undefined
|
|
>(undefined);
|
|
const lastSubmittedSourceVersionRef = useRef(
|
|
composerSourceVersionRef.current,
|
|
);
|
|
// The composer prompt this client still has in flight. Cancelling it before
|
|
// the turn produced anything hands it back instead of leaving it in history.
|
|
const inFlightPromptRef = useRef<InFlightPrompt | null>(null);
|
|
const retryableTurnErrorIdRef = useRef<string | null>(null);
|
|
const lastTurnErrorIdRef = useRef<string | null>(null);
|
|
const retryableTurnErrorIdentityRef = useRef<
|
|
TranscriptTurnErrorIdentity | undefined
|
|
>(undefined);
|
|
const retriedTurnErrorIdRef = useRef<string | null>(null);
|
|
const failedTurnErrorRetryRef = useRef<{
|
|
errorId: string;
|
|
text: string;
|
|
images?: PromptImage[];
|
|
files?: PromptFile[];
|
|
inputAnnotations?: DaemonInputAnnotation[];
|
|
owner: CancelledRetryOwner;
|
|
} | null>(null);
|
|
const [showRetryHint, setShowRetryHint] = useState(false);
|
|
const showRetryHintRef = useRef(showRetryHint);
|
|
showRetryHintRef.current = showRetryHint;
|
|
const disarmSubmittedPromptRetry = useCallback(() => {
|
|
lastSubmittedPromptRef.current = '';
|
|
lastSubmittedImagesRef.current = undefined;
|
|
lastSubmittedFilesRef.current = undefined;
|
|
lastSubmittedInputAnnotationsRef.current = undefined;
|
|
retryableTurnErrorIdRef.current = null;
|
|
retryableTurnErrorIdentityRef.current = undefined;
|
|
retriedTurnErrorIdRef.current = null;
|
|
failedTurnErrorRetryRef.current = null;
|
|
}, []);
|
|
const rearmFailedTurnErrorRetry = useCallback(
|
|
(
|
|
retryableTurnError: DaemonTranscriptBlock,
|
|
currentBlocks: readonly DaemonTranscriptBlock[],
|
|
) => {
|
|
const failedRetry = failedTurnErrorRetryRef.current;
|
|
if (!failedRetry || retryableTurnError.id === failedRetry.errorId) {
|
|
return;
|
|
}
|
|
if (
|
|
!retryOwnerMatchesCurrent(
|
|
failedRetry.owner,
|
|
connectionRef.current.sessionId,
|
|
connectionRef.current.workspaceCwd,
|
|
composerSourceVersionRef.current,
|
|
)
|
|
) {
|
|
failedTurnErrorRetryRef.current = null;
|
|
return;
|
|
}
|
|
if (
|
|
!currentBlocks.some(
|
|
(block) =>
|
|
block.kind === 'error' &&
|
|
block.source === 'turn_error' &&
|
|
block.id === failedRetry.errorId,
|
|
)
|
|
) {
|
|
failedTurnErrorRetryRef.current = null;
|
|
return;
|
|
}
|
|
lastSubmittedPromptRef.current = failedRetry.text;
|
|
lastSubmittedImagesRef.current = failedRetry.images;
|
|
lastSubmittedFilesRef.current = failedRetry.files;
|
|
lastSubmittedInputAnnotationsRef.current = failedRetry.inputAnnotations;
|
|
lastSubmittedSourceVersionRef.current = composerSourceVersionRef.current;
|
|
retryableTurnErrorIdRef.current = retryableTurnError.id;
|
|
retryableTurnErrorIdentityRef.current = { block: retryableTurnError };
|
|
retriedTurnErrorIdRef.current = null;
|
|
failedTurnErrorRetryRef.current = null;
|
|
setShowRetryHint(true);
|
|
},
|
|
[],
|
|
);
|
|
const retryOwnerRef = useRef<CancelledRetryOwner>({
|
|
sessionId: connection.sessionId,
|
|
workspaceCwd: connection.workspaceCwd,
|
|
sessionKey: logicalSessionKey,
|
|
sourceVersion: composerSourceVersionRef.current,
|
|
snapshot: sessionOwnerGuard.capture(),
|
|
});
|
|
useLayoutEffect(() => {
|
|
const previousOwner = retryOwnerRef.current;
|
|
const currentSnapshot = sessionOwnerGuard.capture();
|
|
const workspaceBecameKnown =
|
|
previousOwner.sessionId !== undefined &&
|
|
previousOwner.sessionId === connection.sessionId &&
|
|
previousOwner.workspaceCwd === undefined &&
|
|
connection.workspaceCwd !== undefined &&
|
|
previousOwner.snapshot.isCurrent();
|
|
if (workspaceBecameKnown && logicalSessionKey) {
|
|
const previousSessionKey = previousOwner.sessionKey;
|
|
previousOwner.workspaceCwd = connection.workspaceCwd;
|
|
previousOwner.sessionKey = logicalSessionKey;
|
|
previousOwner.snapshot = currentSnapshot;
|
|
const previousStates =
|
|
previousSessionKey !== undefined
|
|
? cancelledRetryStatesRef.current.get(previousSessionKey)
|
|
: undefined;
|
|
if (
|
|
previousSessionKey &&
|
|
previousStates &&
|
|
previousSessionKey !== logicalSessionKey
|
|
) {
|
|
const currentStates =
|
|
cancelledRetryStatesRef.current.get(logicalSessionKey) ?? [];
|
|
cancelledRetryStatesRef.current.set(
|
|
logicalSessionKey,
|
|
mergeCancelledRetryEntries(
|
|
currentStates,
|
|
previousStates.map((previous) => ({ state: previous.state })),
|
|
),
|
|
);
|
|
cancelledRetryStatesRef.current.delete(previousSessionKey);
|
|
}
|
|
return;
|
|
}
|
|
if (
|
|
retryOwnerMatchesCurrent(
|
|
previousOwner,
|
|
connection.sessionId,
|
|
connection.workspaceCwd,
|
|
composerSourceVersionRef.current,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
if (previousOwner.workspaceCwd === undefined && previousOwner.sessionKey) {
|
|
cancelledRetryStatesRef.current.delete(previousOwner.sessionKey);
|
|
}
|
|
retryOwnerRef.current = {
|
|
sessionId: connection.sessionId,
|
|
workspaceCwd: connection.workspaceCwd,
|
|
sessionKey: logicalSessionKey,
|
|
sourceVersion: composerSourceVersionRef.current,
|
|
snapshot: currentSnapshot,
|
|
};
|
|
lastSubmittedPromptRef.current = '';
|
|
lastSubmittedImagesRef.current = undefined;
|
|
lastSubmittedFilesRef.current = undefined;
|
|
lastSubmittedInputAnnotationsRef.current = undefined;
|
|
lastSubmittedSourceVersionRef.current = -1;
|
|
retryableTurnErrorIdRef.current = null;
|
|
retryableTurnErrorIdentityRef.current = undefined;
|
|
retriedTurnErrorIdRef.current = null;
|
|
failedTurnErrorRetryRef.current = null;
|
|
setShowRetryHint(false);
|
|
}, [
|
|
connection.sessionId,
|
|
connection.workspaceCwd,
|
|
logicalSessionKey,
|
|
sessionOwnerGuard,
|
|
]);
|
|
const applyCancelledRetryState = useCallback(
|
|
(state: CancelledRetryState): CancelledRetryRestoreResult => {
|
|
const currentBlocks = store.getSnapshot().blocks;
|
|
if (state.kind === 'failed-prompt') {
|
|
let failed = state.failed;
|
|
const latestUserMessage = getLatestUserBlock(currentBlocks);
|
|
if (!matchesUserMessageIdentity(latestUserMessage, failed.identity)) {
|
|
if (
|
|
!matchesUserMessageIdentity(
|
|
latestUserMessage,
|
|
failed.previousIdentity,
|
|
)
|
|
) {
|
|
return 'invalid';
|
|
}
|
|
store.appendLocalUserMessage(
|
|
failed.text,
|
|
failed.images?.map((image) => ({
|
|
data: image.data,
|
|
mimeType: image.media_type,
|
|
})),
|
|
failed.inputAnnotations?.length
|
|
? { inputAnnotations: failed.inputAnnotations }
|
|
: undefined,
|
|
failed.files?.map((file) => ({
|
|
name: file.name,
|
|
mimeType: file.media_type,
|
|
})),
|
|
);
|
|
const rehydratedMessage = getLatestUserBlock(
|
|
store.getSnapshot().blocks,
|
|
);
|
|
if (!rehydratedMessage || rehydratedMessage === latestUserMessage) {
|
|
return 'invalid';
|
|
}
|
|
failed = {
|
|
...failed,
|
|
messageId: rehydratedMessage.id,
|
|
identity: { block: rehydratedMessage },
|
|
};
|
|
state.failed = failed;
|
|
} else if (
|
|
latestUserMessage &&
|
|
(latestUserMessage.id !== failed.messageId ||
|
|
latestUserMessage !== failed.identity.block)
|
|
) {
|
|
failed = {
|
|
...failed,
|
|
messageId: latestUserMessage.id,
|
|
identity: { block: latestUserMessage },
|
|
};
|
|
state.failed = failed;
|
|
}
|
|
if (
|
|
!displayMessages.some((message) => message.id === failed.messageId)
|
|
) {
|
|
return 'pending';
|
|
}
|
|
failed = {
|
|
...failed,
|
|
owner: retryOwnerRef.current,
|
|
};
|
|
state.failed = failed;
|
|
updateFailedPrompt(failed);
|
|
setFailedPromptRetry(null);
|
|
return 'restored';
|
|
}
|
|
const currentTurnError = getRetryableTurnError(currentBlocks);
|
|
if (!matchesTurnErrorIdentity(currentTurnError, state.identity)) {
|
|
return 'invalid';
|
|
}
|
|
const renderedTurnError = getRetryableTurnError(blocks);
|
|
if (
|
|
!renderedTurnError ||
|
|
!matchesTurnErrorIdentity(renderedTurnError, state.identity)
|
|
) {
|
|
return 'pending';
|
|
}
|
|
lastSubmittedPromptRef.current = state.text;
|
|
lastSubmittedImagesRef.current = state.images;
|
|
lastSubmittedFilesRef.current = state.files;
|
|
lastSubmittedInputAnnotationsRef.current = state.inputAnnotations;
|
|
lastSubmittedSourceVersionRef.current = composerSourceVersionRef.current;
|
|
retryableTurnErrorIdRef.current = renderedTurnError.id;
|
|
retryableTurnErrorIdentityRef.current = { block: renderedTurnError };
|
|
retriedTurnErrorIdRef.current = state.previousRetriedTurnErrorId;
|
|
setShowRetryHint(state.previousShowRetryHint);
|
|
setFailedPromptRetry(null);
|
|
return 'restored';
|
|
},
|
|
[blocks, displayMessages, store, updateFailedPrompt],
|
|
);
|
|
const restoreOrDeferCancelledRetry = useCallback(
|
|
(owner: CancelledRetryOwner, state: CancelledRetryState) => {
|
|
const resolvedSessionKey = owner.sessionKey;
|
|
if (
|
|
!resolvedSessionKey ||
|
|
(owner.workspaceCwd === undefined && !owner.snapshot.isCurrent())
|
|
) {
|
|
return;
|
|
}
|
|
const states = mergeCancelledRetryEntries(
|
|
cancelledRetryStatesRef.current.get(resolvedSessionKey) ?? [],
|
|
[
|
|
{
|
|
...(owner.workspaceCwd === undefined ? { owner } : {}),
|
|
state,
|
|
},
|
|
],
|
|
);
|
|
cancelledRetryStatesRef.current.set(resolvedSessionKey, states);
|
|
if (appMountedRef.current) {
|
|
setCancelledRetryRevision((current) => current + 1);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
useLayoutEffect(() => {
|
|
if (
|
|
!logicalSessionKey ||
|
|
sessionWriteBlocked ||
|
|
connection.loadingTranscript ||
|
|
connection.catchingUp
|
|
) {
|
|
return;
|
|
}
|
|
const exactStates =
|
|
cancelledRetryStatesRef.current.get(logicalSessionKey) ?? [];
|
|
const pendingExactStates: CancelledRetryEntry[] = [];
|
|
for (const entry of exactStates) {
|
|
if (entry.owner && !entry.owner.snapshot.isCurrent()) {
|
|
continue;
|
|
}
|
|
if (applyCancelledRetryState(entry.state) === 'pending') {
|
|
pendingExactStates.push(entry);
|
|
}
|
|
}
|
|
if (pendingExactStates.length > 0) {
|
|
cancelledRetryStatesRef.current.set(
|
|
logicalSessionKey,
|
|
pendingExactStates,
|
|
);
|
|
} else {
|
|
cancelledRetryStatesRef.current.delete(logicalSessionKey);
|
|
}
|
|
}, [
|
|
applyCancelledRetryState,
|
|
cancelledRetryRevision,
|
|
connection.catchingUp,
|
|
connection.loadingTranscript,
|
|
logicalSessionKey,
|
|
sessionWriteBlocked,
|
|
]);
|
|
const connected = connection.status === 'connected' && daemonAvailable;
|
|
const workspaceEventSignals = useWorkspaceEventSignals();
|
|
const [showHelpDialog, setShowHelpDialog] = useState(false);
|
|
const [composerSkillsOpen, setComposerSkillsOpen] = useState(false);
|
|
const skillsCatalogActive =
|
|
composerSkillsOpen || showHelpDialog || Boolean(renderFooter);
|
|
const skillsCatalogForNewSession = connection.sessionId === undefined;
|
|
const [skillsLoading, setSkillsLoading] = useState(false);
|
|
const [skillsLoadError, setSkillsLoadError] = useState(false);
|
|
const skillsLoadRef = useRef<
|
|
| {
|
|
key: object;
|
|
cwd: string | undefined;
|
|
forNewSession: boolean;
|
|
notifyOnError: boolean;
|
|
request: number;
|
|
promise: Promise<DaemonWorkspaceSkillsStatus | false | undefined>;
|
|
}
|
|
| undefined
|
|
>(undefined);
|
|
const skillsCatalogCacheRef = useRef<
|
|
| {
|
|
key: object;
|
|
promise: Promise<DaemonWorkspaceSkillsStatus | false | undefined>;
|
|
}
|
|
| undefined
|
|
>(undefined);
|
|
const skillsCatalogKey = useMemo(
|
|
() => ({
|
|
cwd: connection.workspaceCwd,
|
|
forNewSession: skillsCatalogForNewSession,
|
|
client: workspace.client,
|
|
settings: workspaceEventSignals?.settingsVersion,
|
|
extensions: workspaceEventSignals?.extensionsVersion,
|
|
skills: workspaceEventSignals?.skillsVersion,
|
|
}),
|
|
[
|
|
connection.workspaceCwd,
|
|
skillsCatalogForNewSession,
|
|
workspace.client,
|
|
workspaceEventSignals?.settingsVersion,
|
|
workspaceEventSignals?.extensionsVersion,
|
|
workspaceEventSignals?.skillsVersion,
|
|
],
|
|
);
|
|
const skillsCatalogKeyRef = useRef(skillsCatalogKey);
|
|
skillsCatalogKeyRef.current = skillsCatalogKey;
|
|
const [loadedSkills, setLoadedSkills] = useState<SkillInfo[]>([]);
|
|
const [loadedSkillsReady, setLoadedSkillsReady] = useState(false);
|
|
const [loadedSkillsFallback, setLoadedSkillsFallback] = useState<{
|
|
sessionId: string;
|
|
workspaceCwd: string | undefined;
|
|
}>();
|
|
const connectionSkillSnapshotRef = useRef({
|
|
sessionId: connection.sessionId,
|
|
skills: connection.skills,
|
|
});
|
|
connectionSkillSnapshotRef.current = {
|
|
sessionId: connection.sessionId,
|
|
skills: connection.skills,
|
|
};
|
|
const loadedSkillsRequestRef = useRef(0);
|
|
const skillsConfigRuntimeSupported = Boolean(
|
|
workspace.capabilities?.features?.includes(
|
|
'workspace_skills_config_runtime',
|
|
),
|
|
);
|
|
const skillsAcpPreheatSupported = Boolean(
|
|
workspace.capabilities?.features?.includes('workspace_acp_preheat'),
|
|
);
|
|
const reloadLoadedSkills = useCallback(
|
|
async (
|
|
workspaceCwd?: string,
|
|
notifyOnError = false,
|
|
forNewSession = false,
|
|
) => {
|
|
const key = skillsCatalogKeyRef.current;
|
|
const pending = skillsLoadRef.current;
|
|
if (
|
|
pending?.key === key &&
|
|
pending.cwd === workspaceCwd &&
|
|
pending.forNewSession === forNewSession &&
|
|
pending.request === loadedSkillsRequestRef.current
|
|
) {
|
|
pending.notifyOnError ||= notifyOnError;
|
|
return pending.promise;
|
|
}
|
|
const request = ++loadedSkillsRequestRef.current;
|
|
const load = async (recovering = false) => {
|
|
if (request === loadedSkillsRequestRef.current) {
|
|
setSkillsLoading(true);
|
|
setSkillsLoadError(false);
|
|
}
|
|
try {
|
|
if (
|
|
forNewSession &&
|
|
workspaceCwd &&
|
|
workspace.client &&
|
|
skillsConfigRuntimeSupported
|
|
) {
|
|
const target = workspace.client.workspaceByCwd(workspaceCwd);
|
|
const status = await target.workspaceConfigSkills();
|
|
if (request !== loadedSkillsRequestRef.current) return;
|
|
if (status.initialized === false)
|
|
throw new Error(
|
|
status.errors?.[0]?.error ??
|
|
'Skills configuration is unavailable',
|
|
);
|
|
setLoadedSkills(availableSkillInfos(status));
|
|
setLoadedSkillsReady(true);
|
|
const runtime = await target.ensureRuntime();
|
|
const runtimeStatus = await loadReadyWorkspaceSkills(
|
|
target,
|
|
runtime,
|
|
() => request !== loadedSkillsRequestRef.current,
|
|
);
|
|
if (request !== loadedSkillsRequestRef.current) return;
|
|
if (!runtimeStatus)
|
|
throw new Error(
|
|
runtime.capabilities?.skills?.error?.message ??
|
|
'Skills runtime is not ready',
|
|
);
|
|
setLoadedSkills(availableSkillInfos(runtimeStatus));
|
|
return status;
|
|
}
|
|
const status =
|
|
workspaceCwd && workspace.client
|
|
? await workspace.client
|
|
.workspaceByCwd(workspaceCwd)
|
|
.workspaceSkills()
|
|
: await workspaceActions.loadSkillsStatus();
|
|
if (request !== loadedSkillsRequestRef.current) return;
|
|
if (status.initialized === false)
|
|
throw new Error(
|
|
status.errors?.[0]?.error ?? 'Skills catalog is unavailable',
|
|
);
|
|
setLoadedSkills(availableSkillInfos(status));
|
|
setLoadedSkillsReady(true);
|
|
if (
|
|
forNewSession &&
|
|
workspaceCwd &&
|
|
workspaceCwd === workspace.capabilities?.workspaceCwd &&
|
|
skillsAcpPreheatSupported
|
|
) {
|
|
const prepared = await workspace.client.workspaceAcpPreheat(5_000);
|
|
if (request !== loadedSkillsRequestRef.current) return;
|
|
if (!prepared.ready) throw new Error('Skills runtime is not ready');
|
|
const refreshed = await workspace.client
|
|
.workspaceByCwd(workspaceCwd)
|
|
.workspaceSkills();
|
|
if (request !== loadedSkillsRequestRef.current) return;
|
|
if (refreshed.initialized === false)
|
|
throw new Error(
|
|
refreshed.errors?.[0]?.error ?? 'Skills catalog is unavailable',
|
|
);
|
|
setLoadedSkills(availableSkillInfos(refreshed));
|
|
return refreshed;
|
|
}
|
|
return status;
|
|
} catch (error) {
|
|
if (request === loadedSkillsRequestRef.current)
|
|
setSkillsLoadError(true);
|
|
if (recovering) throw error;
|
|
if (
|
|
(notifyOnError || skillsLoadRef.current?.notifyOnError) &&
|
|
forNewSession &&
|
|
offerCapacityRecovery(error, {
|
|
requesterCwd: workspaceCwd,
|
|
isCurrent: () =>
|
|
request === loadedSkillsRequestRef.current &&
|
|
key === skillsCatalogKeyRef.current,
|
|
resume: () => load(true),
|
|
})
|
|
)
|
|
return false as const;
|
|
if (
|
|
notifyOnError ||
|
|
(skillsLoadRef.current?.request === request &&
|
|
skillsLoadRef.current.notifyOnError)
|
|
) {
|
|
pushToast(
|
|
'error',
|
|
formatError(error, 'Failed to refresh composer skills'),
|
|
);
|
|
}
|
|
return false as const;
|
|
} finally {
|
|
if (request === loadedSkillsRequestRef.current)
|
|
setSkillsLoading(false);
|
|
}
|
|
};
|
|
const promise = load().finally(() => {
|
|
if (skillsLoadRef.current?.promise === promise)
|
|
skillsLoadRef.current = undefined;
|
|
});
|
|
skillsLoadRef.current = {
|
|
key,
|
|
cwd: workspaceCwd,
|
|
forNewSession,
|
|
notifyOnError,
|
|
request,
|
|
promise,
|
|
};
|
|
return promise;
|
|
},
|
|
[
|
|
offerCapacityRecovery,
|
|
pushToast,
|
|
skillsConfigRuntimeSupported,
|
|
skillsAcpPreheatSupported,
|
|
workspace.capabilities?.workspaceCwd,
|
|
workspace.client,
|
|
workspaceActions,
|
|
],
|
|
);
|
|
useEffect(() => {
|
|
loadedSkillsRequestRef.current += 1;
|
|
skillsCatalogCacheRef.current = undefined;
|
|
setLoadedSkills([]);
|
|
setLoadedSkillsReady(false);
|
|
setSkillsLoading(false);
|
|
setSkillsLoadError(false);
|
|
setLoadedSkillsFallback(undefined);
|
|
}, [connection.workspaceCwd, workspaceContextActive, reloadLoadedSkills]);
|
|
useEffect(() => {
|
|
if (!connected || !workspaceContextActive || !skillsCatalogActive) return;
|
|
// Attached sessions already supply their authoritative Skill commands.
|
|
if (
|
|
connectionSkillSnapshotRef.current.sessionId &&
|
|
connectionSkillSnapshotRef.current.skills !== undefined
|
|
)
|
|
return;
|
|
if (skillsCatalogCacheRef.current?.key === skillsCatalogKey) return;
|
|
const promise = reloadLoadedSkills(
|
|
connection.workspaceCwd,
|
|
false,
|
|
skillsCatalogForNewSession,
|
|
);
|
|
skillsCatalogCacheRef.current = { key: skillsCatalogKey, promise };
|
|
void promise.then((status) => {
|
|
if (
|
|
status === false &&
|
|
skillsCatalogCacheRef.current?.promise === promise
|
|
) {
|
|
skillsCatalogCacheRef.current = undefined;
|
|
}
|
|
});
|
|
}, [
|
|
connected,
|
|
connection.workspaceCwd,
|
|
reloadLoadedSkills,
|
|
skillsCatalogActive,
|
|
skillsCatalogForNewSession,
|
|
skillsCatalogKey,
|
|
workspaceContextActive,
|
|
]);
|
|
const handledSkillMutationKeysRef = useRef(new Set<string>());
|
|
const [, setHandledSkillMutationRevision] = useState(0);
|
|
const pendingSkillTogglesByContextRef = useRef(
|
|
new Map<string, Array<{ name: string; enabled: boolean }>>(),
|
|
);
|
|
useEffect(() => {
|
|
if (!workspaceContextActive) {
|
|
handledSkillMutationKeysRef.current.clear();
|
|
pendingSkillTogglesByContextRef.current.clear();
|
|
return;
|
|
}
|
|
if (!connected) {
|
|
handledSkillMutationKeysRef.current.clear();
|
|
return;
|
|
}
|
|
if (!skillsCatalogActive) return;
|
|
const sessionId = connection.sessionId;
|
|
const workspaceCwd = connection.workspaceCwd;
|
|
const contextKey = `${workspaceCwd ?? ''}\n${sessionId ?? ''}`;
|
|
const mutations = skillMutationsForWorkspace(
|
|
workspaceEventSignals?.lastSkillMutation,
|
|
workspaceEventSignals?.skillMutationsByCwd,
|
|
workspaceCwd,
|
|
);
|
|
const mutationKeys = mutations.map(
|
|
(mutation) => `${contextKey}\n${mutation.id}`,
|
|
);
|
|
const unhandled = mutations.filter(
|
|
(_, index) =>
|
|
!handledSkillMutationKeysRef.current.has(mutationKeys[index]!),
|
|
);
|
|
if (unhandled.length === 0) return;
|
|
const markHandled = () => {
|
|
for (const mutationKey of mutationKeys) {
|
|
handledSkillMutationKeysRef.current.add(mutationKey);
|
|
}
|
|
setHandledSkillMutationRevision((revision) => revision + 1);
|
|
};
|
|
const priorPending =
|
|
pendingSkillTogglesByContextRef.current.get(contextKey) ?? [];
|
|
const pendingToggles = mergeSkillToggles(
|
|
priorPending,
|
|
unhandled.flatMap((mutation) => mutation.skills),
|
|
);
|
|
const sessionSkills = connectionSkillSnapshotRef.current.skills;
|
|
if (
|
|
sessionId &&
|
|
sessionSkills !== undefined &&
|
|
priorPending.length === 0 &&
|
|
unhandled.every(
|
|
(mutation) =>
|
|
mutation.activation === 'applied' &&
|
|
mutation.sessionsFailed === 0 &&
|
|
mutation.sessionsRefreshed > 0,
|
|
)
|
|
) {
|
|
markHandled();
|
|
setLoadedSkillsFallback(undefined);
|
|
return;
|
|
}
|
|
pendingSkillTogglesByContextRef.current.set(contextKey, pendingToggles);
|
|
const owner = sessionOwnerGuard.capture();
|
|
void reloadLoadedSkills(workspaceCwd, true, sessionId === undefined).then(
|
|
(status) => {
|
|
if (!owner.isCurrent() || !status) return;
|
|
markHandled();
|
|
if (!sessionId) {
|
|
pendingSkillTogglesByContextRef.current.delete(contextKey);
|
|
return;
|
|
}
|
|
const availableWorkspaceSkillNames = new Set(
|
|
status.skills
|
|
.filter((skill) => skill.status === 'ok')
|
|
.map((skill) => skill.name.toLowerCase()),
|
|
);
|
|
const pendingForSession = pendingToggles.filter(
|
|
(toggle) =>
|
|
!toggle.enabled ||
|
|
availableWorkspaceSkillNames.has(toggle.name.toLowerCase()),
|
|
);
|
|
if (pendingForSession.length === 0) {
|
|
pendingSkillTogglesByContextRef.current.delete(contextKey);
|
|
setLoadedSkillsFallback(undefined);
|
|
return;
|
|
}
|
|
pendingSkillTogglesByContextRef.current.set(
|
|
contextKey,
|
|
pendingForSession,
|
|
);
|
|
const currentSnapshot = connectionSkillSnapshotRef.current;
|
|
if (
|
|
currentSnapshot.sessionId === sessionId &&
|
|
sessionSkillsReflectToggle(currentSnapshot.skills, pendingForSession)
|
|
) {
|
|
pendingSkillTogglesByContextRef.current.delete(contextKey);
|
|
setLoadedSkillsFallback(undefined);
|
|
return;
|
|
}
|
|
setLoadedSkillsFallback({ sessionId, workspaceCwd });
|
|
},
|
|
);
|
|
}, [
|
|
connected,
|
|
connection.sessionId,
|
|
connection.workspaceCwd,
|
|
reloadLoadedSkills,
|
|
workspaceEventSignals?.lastSkillMutation,
|
|
workspaceEventSignals?.skillMutationsByCwd,
|
|
workspaceContextActive,
|
|
skillsCatalogActive,
|
|
sessionOwnerGuard,
|
|
]);
|
|
useEffect(() => {
|
|
if (!loadedSkillsFallback) return;
|
|
if (
|
|
connection.sessionId !== loadedSkillsFallback.sessionId ||
|
|
connection.workspaceCwd !== loadedSkillsFallback.workspaceCwd
|
|
) {
|
|
const previousContextKey = `${loadedSkillsFallback.workspaceCwd ?? ''}\n${loadedSkillsFallback.sessionId}`;
|
|
for (const mutationKey of handledSkillMutationKeysRef.current) {
|
|
if (mutationKey.startsWith(`${previousContextKey}\n`)) {
|
|
handledSkillMutationKeysRef.current.delete(mutationKey);
|
|
}
|
|
}
|
|
setLoadedSkillsFallback(undefined);
|
|
return;
|
|
}
|
|
const contextKey = `${connection.workspaceCwd ?? ''}\n${connection.sessionId ?? ''}`;
|
|
const pendingToggles =
|
|
pendingSkillTogglesByContextRef.current.get(contextKey) ?? [];
|
|
if (
|
|
pendingToggles.length > 0 &&
|
|
sessionSkillsReflectToggle(connection.skills, pendingToggles)
|
|
) {
|
|
pendingSkillTogglesByContextRef.current.delete(contextKey);
|
|
setLoadedSkillsFallback(undefined);
|
|
}
|
|
}, [
|
|
connection.sessionId,
|
|
connection.skills,
|
|
connection.workspaceCwd,
|
|
loadedSkillsFallback,
|
|
]);
|
|
|
|
const [modelDialogMode, setModelDialogMode] =
|
|
useState<ModelDialogMode | null>(null);
|
|
// Mirror of modelDialogMode (and the fallbacks/auth dialog flags below) for
|
|
// reading the latest values inside the async voice loadProviders callback, so
|
|
// it doesn't open the voice picker on top of a surface opened while loading
|
|
// (see the voiceModel branch in onSubDialog).
|
|
const modelDialogModeRef = useRef<ModelDialogMode | null>(modelDialogMode);
|
|
// Scope a model sub-dialog opened from the Settings panel persists to. Set
|
|
// when opening from the User/Workspace settings tab; reset to 'workspace'
|
|
// whenever the model dialog closes (any path) so command-launched pickers
|
|
// (/model --vision, etc.) always write workspace.
|
|
const [modelSettingScope, setModelSettingScope] = useState<
|
|
'workspace' | 'user'
|
|
>('workspace');
|
|
const settingsDialogKeyRef = useRef<string | undefined>(undefined);
|
|
const settingsPresentationRef = useRef(settingsPresentation);
|
|
settingsPresentationRef.current = settingsPresentation;
|
|
const [showFallbacksDialog, setShowFallbacksDialog] = useState(false);
|
|
const showFallbacksDialogRef = useRef(showFallbacksDialog);
|
|
const [voiceModels, setVoiceModels] = useState<VoiceModelOption[]>([]);
|
|
const [showApprovalModeDialog, setShowApprovalModeDialog] = useState(false);
|
|
useEffect(() => {
|
|
if (managedMode && showApprovalModeDialog) setShowApprovalModeDialog(false);
|
|
}, [managedMode, showApprovalModeDialog]);
|
|
const [showResumeDialog, setShowResumeDialog] = useState(false);
|
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
const [showReleaseDialog, setShowReleaseDialog] = useState(false);
|
|
const [showRewindDialog, setShowRewindDialog] = useState(false);
|
|
const [showThemeDialog, setShowThemeDialog] = useState(false);
|
|
const [showToolsDialog, setShowToolsDialog] = useState(false);
|
|
// The workspace the Git dialog reads. Set by whichever entry point opened
|
|
// it — the composer git chip / `/diff` (current workspace) or a sidebar
|
|
// folder's git chip (that workspace) — so each can target its own repo.
|
|
const [gitDialog, setGitDialog] = useState<
|
|
| {
|
|
workspaceCwd: string;
|
|
gitCwd?: string;
|
|
gitSessionId?: string;
|
|
view: GitDialogView;
|
|
}
|
|
| undefined
|
|
>(undefined);
|
|
const [branchSessionDialog, setBranchSessionDialog] = useState<
|
|
{ sourceSessionId: string; atRecordId?: string } | undefined
|
|
>(undefined);
|
|
const branchSessionDialogRef = useRef(branchSessionDialog);
|
|
branchSessionDialogRef.current = branchSessionDialog;
|
|
const [branchSessionDialogBusy, setBranchSessionDialogBusy] = useState(false);
|
|
// Main content view. The scheduled-tasks page replaces the chat pane inline
|
|
// (not a modal overlay), mirroring the reference design; creating or opening
|
|
// a chat returns to 'chat'. (Daemon Status is no longer a boolean dialog — it
|
|
// is one of the activePanel values below.)
|
|
const [mainView, setMainView] = useState<MainView>('chat');
|
|
const mainViewRef = useRef(mainView);
|
|
const showChat = useCallback(() => {
|
|
if (cockpitViewRequested()) updateCockpitLocation(false);
|
|
setMainView('chat');
|
|
}, []);
|
|
const useFloatingArtifactPanel =
|
|
!canDockArtifactPanel || mainView === 'split';
|
|
// The floating drawer mounts already-open. Only a genuine closed->open may
|
|
// move focus into it: the docked->floating hand-over of an open panel
|
|
// (viewport resize, split view) mounts the drawer mid-session, where
|
|
// autofocus would yank focus from whatever the user is typing. The ref
|
|
// tracks the panel's open state at the last render where the drawer was
|
|
// NOT mounted and stays frozen while it is, so re-renders never flip the
|
|
// prop under the drawer's mount-time autofocus wiring.
|
|
const floatingArtifactPanelVisible =
|
|
artifactPanelOpen && useFloatingArtifactPanel;
|
|
const artifactPanelOpenBeforeDrawerMountRef = useRef(artifactPanelOpen);
|
|
if (!floatingArtifactPanelVisible) {
|
|
artifactPanelOpenBeforeDrawerMountRef.current = artifactPanelOpen;
|
|
}
|
|
const floatingArtifactDrawerAutoFocus =
|
|
floatingArtifactPanelVisible &&
|
|
!artifactPanelOpenBeforeDrawerMountRef.current;
|
|
const deferSubagentMount =
|
|
waitForSubagentPanelAnimation &&
|
|
artifactPanelOpen &&
|
|
!artifactPanelFullscreen &&
|
|
(useFloatingArtifactPanel ||
|
|
(!prefersReducedMotion && !suppressArtifactDockOpenAnimation));
|
|
useLayoutEffect(() => {
|
|
if (waitForSubagentPanelAnimation && !deferSubagentMount) {
|
|
setWaitForSubagentPanelAnimation(false);
|
|
}
|
|
}, [deferSubagentMount, waitForSubagentPanelAnimation]);
|
|
useEffect(() => {
|
|
if (!deferSubagentMount) return;
|
|
const timeout = window.setTimeout(
|
|
() => setWaitForSubagentPanelAnimation(false),
|
|
SUBAGENT_PANEL_ANIMATION_FALLBACK_MS,
|
|
);
|
|
return () => window.clearTimeout(timeout);
|
|
}, [deferSubagentMount]);
|
|
const toggleArtifactPanelFullscreen = useCallback(() => {
|
|
setArtifactPanelFullscreen((value) => !value);
|
|
// Only the docked node replays its open animation on the fullscreen ->
|
|
// docked class swap; the floating drawer has no such replay.
|
|
if (!useFloatingArtifactPanel) setSuppressArtifactDockOpenAnimation(true);
|
|
}, [useFloatingArtifactPanel]);
|
|
useEffect(() => {
|
|
if (!artifactPanelOpen) {
|
|
setArtifactPanelFullscreen(false);
|
|
setSuppressArtifactDockOpenAnimation(false);
|
|
} else if (useFloatingArtifactPanel) {
|
|
// The dock node unmounts while the drawer takes over; a stale flag would
|
|
// suppress the slide-in of the next genuine dock mount. Keep it while
|
|
// fullscreen persists: the next dock mount enters the fullscreen class,
|
|
// and shrinking it back still replays the slide-in without the flag.
|
|
if (!artifactPanelFullscreen) {
|
|
setSuppressArtifactDockOpenAnimation(false);
|
|
}
|
|
} else if (artifactPanelFullscreen) {
|
|
// Floating -> docked hand-over mid-fullscreen: the toggle never set the
|
|
// flag (the panel was floating when it fired), but shrinking the dock
|
|
// back to its docked class would replay the slide-in.
|
|
setSuppressArtifactDockOpenAnimation(true);
|
|
}
|
|
}, [artifactPanelOpen, useFloatingArtifactPanel, artifactPanelFullscreen]);
|
|
const dockedFullscreenActive =
|
|
artifactPanelOpen && !useFloatingArtifactPanel && artifactPanelFullscreen;
|
|
// Fullscreen moves the docked panel's portal slot into the top-level portal
|
|
// root so a transformed/paint-contained/lower-stacking host ancestor can
|
|
// neither bound the fixed surface nor paint over it; shrinking moves it
|
|
// back. The wrapper itself is portaled INTO the slot, so it stays mounted
|
|
// (and React's delegated listeners stay attached to the slot) across the
|
|
// move — a remount would discard panel-local state.
|
|
useLayoutEffect(() => {
|
|
const slot = artifactPanelSlotEl;
|
|
if (!slot || !dockedFullscreenActive) return;
|
|
const host = slot.parentElement;
|
|
if (!host) return;
|
|
const next = slot.nextSibling;
|
|
(portalRoot ?? document.body).appendChild(slot);
|
|
return () => {
|
|
host.insertBefore(slot, next);
|
|
};
|
|
}, [dockedFullscreenActive, portalRoot, artifactPanelSlotEl]);
|
|
// Document-level modal semantics for the fullscreen surface,
|
|
// matching what the floating variant gets from vaul's Radix dialog: hide
|
|
// every outside tree from AT (Tab containment is the surface's own keydown
|
|
// handler) and move stray focus into the surface — the covered chat subtree
|
|
// drops focus to body when it goes display:none.
|
|
useLayoutEffect(() => {
|
|
const surface = artifactPanelFullscreenSurfaceRef.current;
|
|
if (!dockedFullscreenActive || !surface) return;
|
|
const hidden: Array<{ element: Element; previous: string | null }> = [];
|
|
let node: Element | null = surface;
|
|
while (node && node !== document.body) {
|
|
const root = node.getRootNode();
|
|
const parent: Element | null =
|
|
node.parentElement ?? (root instanceof ShadowRoot ? root.host : null);
|
|
if (!parent) break;
|
|
for (const sibling of Array.from(parent.children)) {
|
|
if (sibling === node) continue;
|
|
// The elevated toast host shares this portal root and must stay
|
|
// announced (and dismissible) while the surface is up.
|
|
if (sibling.matches('[data-web-shell-toast-host]')) continue;
|
|
hidden.push({
|
|
element: sibling,
|
|
// A live hideOthers lock (e.g. the floating drawer unmounting in
|
|
// the same commit that mounts the surface) owns this value; its
|
|
// unlock restores the true original itself.
|
|
previous: sibling.hasAttribute('data-aria-hidden')
|
|
? null
|
|
: sibling.getAttribute('aria-hidden'),
|
|
});
|
|
sibling.setAttribute('aria-hidden', 'true');
|
|
}
|
|
node = parent;
|
|
}
|
|
// document.activeElement retargets to the shadow host in shadow-DOM
|
|
// portal mode; read the focused node from the surface's own root.
|
|
const surfaceActive = getShadowAwareActiveElement(surface);
|
|
if (!surface.contains(surfaceActive)) surface.focus();
|
|
// Keydowns inside the sandboxed HTML preview iframe never reach the
|
|
// surface's Tab-wrap handler or the window Escape handler, and a Tab
|
|
// past the preview's last focusable lands focus natively outside the
|
|
// surface. Pull stray focus back like the floating variant's Radix
|
|
// focus trap, so the keyboard stays the surface's escape route.
|
|
const pullStrayFocusIntoSurface = (event: FocusEvent) => {
|
|
// focusin is composed: at this document-level listener the browser
|
|
// retargets it to the shadow host, so resolve the real node.
|
|
const target =
|
|
(event.composedPath()[0] as Element | undefined) ??
|
|
(event.target as Element | null);
|
|
if (
|
|
!target ||
|
|
event.composedPath().includes(surface) ||
|
|
target.contains(surface)
|
|
) {
|
|
return;
|
|
}
|
|
// The elevated toast host and any Radix layer opened from the panel
|
|
// share this portal root and stay actionable beside the surface.
|
|
if (portalRoot?.contains(target)) return;
|
|
surface.focus();
|
|
};
|
|
document.addEventListener('focusin', pullStrayFocusIntoSurface);
|
|
return () => {
|
|
document.removeEventListener('focusin', pullStrayFocusIntoSurface);
|
|
for (const { element, previous } of hidden) {
|
|
const restore = () => {
|
|
if (previous === null) element.removeAttribute('aria-hidden');
|
|
else element.setAttribute('aria-hidden', previous);
|
|
};
|
|
if (!element.hasAttribute('data-aria-hidden')) {
|
|
restore();
|
|
continue;
|
|
}
|
|
// A Radix hideOthers lock (a DialogShell opened over the surface)
|
|
// owns this node now: it recorded the node as already hidden, so its
|
|
// own unlock will not restore it, and restoring now would expose the
|
|
// app behind the open modal. Wait for the lock's marker to drop.
|
|
const observer = new MutationObserver(() => {
|
|
if (element.hasAttribute('data-aria-hidden')) return;
|
|
observer.disconnect();
|
|
restore();
|
|
});
|
|
observer.observe(element, {
|
|
attributes: true,
|
|
attributeFilter: ['data-aria-hidden'],
|
|
});
|
|
}
|
|
};
|
|
}, [dockedFullscreenActive, portalRoot]);
|
|
const handleArtifactPanelSurfaceKeyDown = useCallback(
|
|
(event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
if (!artifactPanelFullscreen && !useFloatingArtifactPanel) return;
|
|
if (
|
|
event.defaultPrevented ||
|
|
event.key !== 'Tab' ||
|
|
event.altKey ||
|
|
event.ctrlKey ||
|
|
event.metaKey
|
|
) {
|
|
return;
|
|
}
|
|
if (!event.nativeEvent.composedPath().includes(event.currentTarget))
|
|
return;
|
|
const candidates = getArtifactPanelTabStops(event.currentTarget);
|
|
const first = candidates[0];
|
|
const last = candidates[candidates.length - 1];
|
|
let focused = getShadowAwareActiveElement(event.currentTarget);
|
|
while (focused?.shadowRoot?.activeElement) {
|
|
focused = focused.shadowRoot.activeElement;
|
|
}
|
|
if (!first || !last) {
|
|
if (focused === event.currentTarget) event.preventDefault();
|
|
return;
|
|
}
|
|
// Radix's drawer loop only sees light DOM, so own Tab before it can
|
|
// wrap from the filter past the file tree's shadow-root rows.
|
|
if (useFloatingArtifactPanel) {
|
|
const index = candidates.indexOf(focused as HTMLElement);
|
|
const next =
|
|
index < 0
|
|
? event.shiftKey
|
|
? last
|
|
: first
|
|
: candidates[
|
|
(index + (event.shiftKey ? -1 : 1) + candidates.length) %
|
|
candidates.length
|
|
];
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
next?.focus();
|
|
return;
|
|
}
|
|
if (!event.shiftKey && focused === last) {
|
|
event.preventDefault();
|
|
first.focus();
|
|
} else if (
|
|
event.shiftKey &&
|
|
(focused === first || focused === event.currentTarget)
|
|
) {
|
|
event.preventDefault();
|
|
last.focus();
|
|
}
|
|
},
|
|
[artifactPanelFullscreen, useFloatingArtifactPanel],
|
|
);
|
|
// The drawer's Radix dismiss layer only checks `event.key === 'Escape'`
|
|
// (no isComposing guard), so while composing in a panel input an Escape
|
|
// that cancels the composition would close the drawer — and its
|
|
// preventDefault would swallow the native cancel. Mask the key for the
|
|
// capture-phase dismiss listener and restore it before the event reaches
|
|
// the focused input, mirroring DialogShell.preserveImeEscape. The docked
|
|
// fullscreen branch is covered by the window handler's IME guard.
|
|
useEffect(() => {
|
|
if (!artifactPanelOpen || !useFloatingArtifactPanel) return;
|
|
const preserveImeEscape = (event: KeyboardEvent) => {
|
|
if (
|
|
event.key !== 'Escape' ||
|
|
(!event.isComposing && event.keyCode !== 229)
|
|
) {
|
|
return;
|
|
}
|
|
Object.defineProperty(event, 'key', {
|
|
configurable: true,
|
|
value: 'Process',
|
|
});
|
|
document.addEventListener(
|
|
'keydown',
|
|
(currentEvent) => {
|
|
if (currentEvent === event) Reflect.deleteProperty(event, 'key');
|
|
},
|
|
{ capture: true, once: true },
|
|
);
|
|
};
|
|
window.addEventListener('keydown', preserveImeEscape, { capture: true });
|
|
return () => {
|
|
window.removeEventListener('keydown', preserveImeEscape, {
|
|
capture: true,
|
|
});
|
|
};
|
|
}, [artifactPanelOpen, useFloatingArtifactPanel]);
|
|
// Sessions to seed the split view with (e.g. the selection from the overview).
|
|
const [splitSessionIds, setSplitSessionIds] = useState<string[]>([]);
|
|
const [outerSplitPanePending, setOuterSplitPanePending] = useState(false);
|
|
const handleSplitPendingPanesChange = useCallback(
|
|
(ids: string[]) =>
|
|
setOuterSplitPanePending(ids.includes(connection.sessionId ?? '')),
|
|
[connection.sessionId],
|
|
);
|
|
// False until the split bootstrap has decided whether a split view is
|
|
// coming (URL deep link, per-tab sessionStorage, or controlled prop). The
|
|
// pane-tab reclaim below must wait for it: at restore-commit time
|
|
// mainView/splitSessionIds are still their useState initials.
|
|
const [splitViewSettled, setSplitViewSettled] = useState(false);
|
|
const splitViewSettledRef = useRef(splitViewSettled);
|
|
splitViewSettledRef.current = splitViewSettled;
|
|
// Latest pane list, readable from the shrink-close effect without making it a
|
|
// dependency (it changes on every pane add/remove).
|
|
const splitSessionIdsRef = useRef<string[]>(splitSessionIds);
|
|
const splitClassificationGenerationRef = useRef(0);
|
|
// Generation recorded when the latest classification started; differs from
|
|
// the live generation only while a superseded classification is in flight.
|
|
const splitClassificationStartGenerationRef = useRef(0);
|
|
// Publish/clear contract: a pending open is published when a classification
|
|
// starts (beginSplitClassification) and cleared when that generation settles
|
|
// (settlePendingSplitOpen). It is honoured only while its generation equals
|
|
// the live splitClassificationGenerationRef — navigation bumps that ref to
|
|
// cancel a pending selection. Do not clear it on the controlled effect's
|
|
// early returns: merging into a not-yet-classified controlled request is
|
|
// intended.
|
|
const pendingSplitOpenRef = useRef<{
|
|
generation: number;
|
|
sessionIds: string[];
|
|
} | null>(null);
|
|
// State mirror of the live pending open's ids, so the sidebar can render
|
|
// disabled/title during the opening window, when mainView is not 'split' yet
|
|
// and the real splitSessionIds is not handed over. Written and cleared
|
|
// alongside pendingSplitOpenRef; surfaced only while its generation is the
|
|
// live one (navigation invalidates it the same way it supersedes the ref).
|
|
const [pendingSplitPaneIds, setPendingSplitPaneIds] = useState<{
|
|
generation: number;
|
|
sessionIds: string[];
|
|
} | null>(null);
|
|
splitSessionIdsRef.current = splitSessionIds;
|
|
const previousSplitSessionIdsRef = useRef<string[]>(splitSessionIds);
|
|
useEffect(() => {
|
|
const nextIds = new Set(splitSessionIds);
|
|
closeUsageTabs(
|
|
previousSplitSessionIdsRef.current.filter((id) => !nextIds.has(id)),
|
|
true,
|
|
);
|
|
previousSplitSessionIdsRef.current = splitSessionIds;
|
|
}, [closeUsageTabs, splitSessionIds]);
|
|
// Signature of the pane-bound usage tabs, so the sweep below re-runs when
|
|
// restoration lands them even if no other dependency moves.
|
|
const paneBoundUsageTabsSignature = useMemo(
|
|
() =>
|
|
artifactPanelTabs
|
|
.filter(
|
|
(tab) =>
|
|
(tab.kind === 'token_usage' || tab.kind === 'context_usage') &&
|
|
tab.closeWithPane,
|
|
)
|
|
.map((tab) => tab.id)
|
|
.join('\0'),
|
|
[artifactPanelTabs],
|
|
);
|
|
// Pane-bound usage tabs restored from storage are reclaimed only once the
|
|
// split bootstrap has settled: deciding at restore-commit time reads
|
|
// mainView/splitSessionIds before the split lands, dropping tabs whose
|
|
// panes arrive a moment later — and the drop is written back to storage.
|
|
useEffect(() => {
|
|
if (!splitViewSettled || artifactPanelRestoring) return;
|
|
if (mainView === 'split') {
|
|
const live = new Set(splitSessionIds);
|
|
const orphans = Array.from(
|
|
new Set(
|
|
artifactPanelTabsRef.current.flatMap((tab) =>
|
|
(tab.kind === 'token_usage' || tab.kind === 'context_usage') &&
|
|
tab.closeWithPane &&
|
|
tab.sessionId !== undefined &&
|
|
!live.has(tab.sessionId)
|
|
? [tab.sessionId]
|
|
: [],
|
|
),
|
|
),
|
|
);
|
|
if (orphans.length > 0) closeUsageTabs(orphans, true);
|
|
} else {
|
|
closeUsageTabs(undefined, true);
|
|
}
|
|
}, [
|
|
artifactPanelRestoring,
|
|
closeUsageTabs,
|
|
mainView,
|
|
paneBoundUsageTabsSignature,
|
|
splitSessionIds,
|
|
splitViewSettled,
|
|
]);
|
|
const [mcpDialogMessage, setMcpDialogMessage] =
|
|
useState<SerializedMcpStatusMessage | null>(null);
|
|
const [initialConnectionsSettingsCategory] = useState(() =>
|
|
standalone ? getInitialConnectionsSettingsCategory() : undefined,
|
|
);
|
|
// Settings and Daemon Status are shown as an in-place panel that replaces the
|
|
// chat view (message list + composer), not as a modal overlay. Only one may be
|
|
// active at a time; null means the normal chat view is shown.
|
|
const [activePanel, setActivePanel] = useState<
|
|
| 'settings'
|
|
| 'status'
|
|
| 'sessions'
|
|
| 'extensions'
|
|
| 'mcp'
|
|
| 'skills'
|
|
| 'plugins'
|
|
| 'agents'
|
|
| 'channels'
|
|
| 'channel-events'
|
|
| 'live'
|
|
| 'workspaces'
|
|
| null
|
|
>(() => (initialConnectionsSettingsCategory ? 'settings' : null));
|
|
const [managedSessionId, setManagedSessionId] = useState<string | undefined>(
|
|
() => managedSelectionFromUrl().sessionId,
|
|
);
|
|
const [managedHistoryRevision, setManagedHistoryRevision] = useState(
|
|
navigation?.historyRevision,
|
|
);
|
|
if (managedHistoryRevision !== navigation?.historyRevision) {
|
|
setManagedHistoryRevision(navigation?.historyRevision);
|
|
if (managedAgentProvider) {
|
|
const selection = managedSelectionFromUrl(
|
|
navigation?.route.page !== 'chat' || !navigation.route.sessionId,
|
|
);
|
|
setManagedHome(selection.open);
|
|
if (managedSessionId !== selection.sessionId) {
|
|
setManagedWorkspaceId(undefined);
|
|
}
|
|
setManagedSessionId(selection.sessionId);
|
|
}
|
|
}
|
|
useEffect(() => {
|
|
if (managedAgentProvider)
|
|
saveManagedSelection(managedMode, managedSessionId);
|
|
}, [managedAgentProvider, managedMode, managedSessionId]);
|
|
const chatActive =
|
|
!managedMode &&
|
|
!activePanel &&
|
|
mainView === 'chat' &&
|
|
!artifactPanelFullscreen;
|
|
const managedChatVisible =
|
|
managedMode &&
|
|
!activePanel &&
|
|
mainView === 'chat' &&
|
|
!artifactPanelFullscreen;
|
|
const navigateToMessage = useMessageNavigation(messageListRef, chatActive);
|
|
|
|
const activePanelRef = useRef(activePanel);
|
|
const [agentsNav, setAgentsNav] = useState<{
|
|
view: 'agents' | 'squads' | 'runtime' | 'new-agent';
|
|
request: number;
|
|
}>({ view: 'agents', request: 0 });
|
|
// Deep-link target for the Settings panel (e.g. 'Daemon' from the Local
|
|
// Control QR popover). Cleared on any panel close/switch, not just
|
|
// closePanel — several paths call setActivePanel directly (approval
|
|
// overlay auto-close, openScheduledTasks, openSplitView, ...).
|
|
const [settingsInitialCategory, setSettingsInitialCategory] = useState<
|
|
string | undefined
|
|
>(initialConnectionsSettingsCategory);
|
|
useEffect(() => {
|
|
if (initialConnectionsSettingsCategory) {
|
|
clearInitialConnectionsSettingsCategory();
|
|
}
|
|
}, [initialConnectionsSettingsCategory]);
|
|
useEffect(() => {
|
|
if (activePanel !== 'settings') {
|
|
setSettingsInitialCategory(undefined);
|
|
}
|
|
if (activePanel !== 'plugins') setPluginInitialTab('extensions');
|
|
}, [activePanel]);
|
|
const closePanel = useCallback(() => {
|
|
splitClassificationGenerationRef.current += 1;
|
|
setActivePanel(null);
|
|
}, []);
|
|
useEffect(() => {
|
|
setModelDialogMode((mode) => (mode === 'main' ? mode : null));
|
|
setShowFallbacksDialog(false);
|
|
}, [workspaceContextActive]);
|
|
useEffect(() => {
|
|
if (workspaceContextActive) return;
|
|
// A first open started from the sidebar owns the live generation until
|
|
// navigation supersedes it; bumping here would silently kill the in-flight
|
|
// classification the click just began, so cancellation (the bump) is
|
|
// skipped while a pending open is live. The teardown below still runs.
|
|
if (
|
|
pendingSplitOpenRef.current?.generation !==
|
|
splitClassificationGenerationRef.current
|
|
) {
|
|
splitClassificationGenerationRef.current += 1;
|
|
}
|
|
if (!projectFeaturesAvailable) setSplitSessionIds([]);
|
|
if (
|
|
!projectFeaturesAvailable &&
|
|
activePanel !== 'status' &&
|
|
!(
|
|
activePanel === 'settings' &&
|
|
(settingsAvailable || initialConnectionsSettingsCategory)
|
|
)
|
|
) {
|
|
setActivePanel(null);
|
|
}
|
|
setShowResumeDialog(false);
|
|
setShowDeleteDialog(false);
|
|
setShowReleaseDialog(false);
|
|
if (!projectFeaturesAvailable) setShowMemoryDialog(false);
|
|
if (!workspaceBrowseActiveRef.current) {
|
|
setShowAddWorkspaceDialog(false);
|
|
}
|
|
setGitDialog(undefined);
|
|
if (
|
|
!projectFeaturesAvailable &&
|
|
modelDialogMode &&
|
|
modelDialogMode !== 'main'
|
|
) {
|
|
setModelDialogMode(null);
|
|
}
|
|
if (!projectFeaturesAvailable) setShowFallbacksDialog(false);
|
|
if (
|
|
mainView === 'cockpit' ||
|
|
(!projectFeaturesAvailable &&
|
|
(mainView === 'scheduledTasks' ||
|
|
mainView === 'prWatches' ||
|
|
mainView === 'goals' ||
|
|
mainView === 'split'))
|
|
) {
|
|
setMainView('chat');
|
|
}
|
|
}, [
|
|
activePanel,
|
|
externalManagedAgentAvailable,
|
|
mainView,
|
|
modelDialogMode,
|
|
projectFeaturesAvailable,
|
|
initialConnectionsSettingsCategory,
|
|
settingsAvailable,
|
|
workspaceContextActive,
|
|
]);
|
|
const handleUseSkill = useCallback(
|
|
(name: string) => {
|
|
closePanel();
|
|
window.setTimeout(() => {
|
|
editorRef.current?.setText(`/${name} `);
|
|
editorRef.current?.focus();
|
|
}, 0);
|
|
},
|
|
[closePanel],
|
|
);
|
|
// The Settings/Status panel (activePanel) and the Scheduled Tasks page
|
|
// (mainView) are mutually-exclusive full-pane views — the latter is a
|
|
// position:absolute overlay that would otherwise cover the former — so opening
|
|
// one closes the other. Without this, opening Scheduled Tasks then Daemon
|
|
// Status left the panel rendered behind the Scheduled Tasks overlay, looking
|
|
// like the button did nothing.
|
|
const openPanel = useCallback(
|
|
(
|
|
panel:
|
|
| 'settings'
|
|
| 'status'
|
|
| 'sessions'
|
|
| 'extensions'
|
|
| 'mcp'
|
|
| 'skills'
|
|
| 'plugins'
|
|
| 'agents'
|
|
| 'channels'
|
|
| 'channel-events'
|
|
| 'live'
|
|
| 'workspaces',
|
|
) => {
|
|
splitClassificationGenerationRef.current += 1;
|
|
if (navigationRef.current && isWebShellPage(panel)) setMainView('chat');
|
|
else showChat();
|
|
setActivePanel(panel);
|
|
if (isWebShellPage(panel)) navigationRef.current?.openPage(panel);
|
|
},
|
|
[showChat],
|
|
);
|
|
const openScheduledTasks = useCallback(() => {
|
|
splitClassificationGenerationRef.current += 1;
|
|
setActivePanel(null);
|
|
// Route through showChat so leaving the cockpit strips its ?view=cockpit
|
|
// deep link — the URL-sync removal branch only fires when the feature is
|
|
// disabled, and a stale deep link would reopen the cockpit on reload or
|
|
// Forward navigation instead of the view actually on screen.
|
|
if (!navigationRef.current) showChat();
|
|
setMainView('scheduledTasks');
|
|
navigationRef.current?.openPage('scheduled-tasks');
|
|
}, [showChat]);
|
|
const openWorkflows = useCallback(() => {
|
|
if (!projectFeaturesAvailable || !workflowsEnabled) return;
|
|
splitClassificationGenerationRef.current += 1;
|
|
setActivePanel(null);
|
|
showChat();
|
|
setMainView('workflows');
|
|
}, [projectFeaturesAvailable, showChat, workflowsEnabled]);
|
|
useEffect(() => {
|
|
if (
|
|
mainView === 'workflows' &&
|
|
(!projectFeaturesAvailable || !workflowsEnabled)
|
|
) {
|
|
showChat();
|
|
}
|
|
}, [mainView, projectFeaturesAvailable, showChat, workflowsEnabled]);
|
|
const openGoals = useCallback(() => {
|
|
splitClassificationGenerationRef.current += 1;
|
|
setActivePanel(null);
|
|
// See openScheduledTasks: leaving the cockpit must strip its deep link.
|
|
if (!navigationRef.current) showChat();
|
|
setMainView('goals');
|
|
navigationRef.current?.openPage('goals');
|
|
}, [showChat]);
|
|
const openPrWatches = useCallback(() => {
|
|
splitClassificationGenerationRef.current += 1;
|
|
setActivePanel(null);
|
|
// See openScheduledTasks: leaving the cockpit must strip its deep link.
|
|
if (!navigationRef.current) showChat();
|
|
setMainView('prWatches');
|
|
navigationRef.current?.openPage('pr-watches');
|
|
}, [showChat]);
|
|
// A daemon that stops listing watches (another daemon behind the same
|
|
// origin) leaves no page to show.
|
|
useEffect(() => {
|
|
if (
|
|
mainView === 'prWatches' &&
|
|
(!projectFeaturesAvailable || !prWatchesAvailable)
|
|
) {
|
|
showChat();
|
|
}
|
|
}, [mainView, prWatchesAvailable, projectFeaturesAvailable, showChat]);
|
|
const returnToChat = useCallback(() => {
|
|
if (navigationRef.current && navigationRef.current.route.page !== 'chat') {
|
|
navigationRef.current.returnToChat();
|
|
} else {
|
|
closePanel();
|
|
showChat();
|
|
}
|
|
}, [closePanel, showChat]);
|
|
const sidebarRailEnabled =
|
|
sidebarOptions.enabled &&
|
|
Boolean(
|
|
sidebarOptions.primaryNav?.items?.some((item) => {
|
|
if (item === 'newTask') return false;
|
|
if (item === 'managed') return externalManagedAgentAvailable;
|
|
if (!projectFeaturesAvailable) return false;
|
|
if (item === 'live') return sidebarOptions.showLive;
|
|
if (item === 'workflows') return workflowsEnabled;
|
|
if (item === 'prWatches') return prWatchesAvailable;
|
|
return true;
|
|
}) ||
|
|
(sidebarOptions.footer !== false &&
|
|
sidebarOptions.footer?.items?.some((item) => {
|
|
if (item === 'collapse') return false;
|
|
if (item === 'settings') return settingsAvailable;
|
|
if (item === 'sessionsOverview' || item === 'splitView') {
|
|
return isSidebarFooterItemAvailable(
|
|
item,
|
|
projectFeaturesAvailable,
|
|
isLargeScreen,
|
|
);
|
|
}
|
|
if (item === 'workspacesOverview') return !lockedWorkspaceCwd;
|
|
if (item === 'localFiles' || item === 'desktopRelay')
|
|
return isPageOriginDaemon(workspace.baseUrl);
|
|
if (item === 'update') {
|
|
return (
|
|
!isDesktopShell() &&
|
|
Boolean(
|
|
connection.capabilities?.features?.includes('daemon_update'),
|
|
)
|
|
);
|
|
}
|
|
return true;
|
|
})),
|
|
);
|
|
const navigationRailVisible =
|
|
sidebarOptions.enabled &&
|
|
sidebarRailEnabled &&
|
|
(sidebarLayoutWidth ?? window.innerWidth) > 760;
|
|
// In the split-view fold band the auto-fold, not the user, owns the
|
|
// rendered state. Toggles issued there must rewrite the unchanged
|
|
// preference rather than toggle it, or a visually no-op keypress flips the
|
|
// stored value (and a stored `true` can never be reached again).
|
|
const splitFoldedSidebar =
|
|
mainView === 'split' && !splitSidebarHasRoom && !mobileDrawerOpen;
|
|
splitFoldedSidebarRef.current = splitFoldedSidebar;
|
|
// The collapsed value the sidebar actually renders: the split-view
|
|
// auto-fold and the mobile drawer override the persisted preference.
|
|
// Keyboard toggles and dock breakpoints must read this expression, not the
|
|
// raw state, or they silently invert the stored preference while the
|
|
// auto-fold owns the screen.
|
|
const sidebarCollapsedEffective =
|
|
(sidebarCollapsed || splitFoldedSidebar) && !mobileDrawerOpen;
|
|
// One compact-chrome signal for the whole shell: the sidebar drawer chrome
|
|
// and the empty-chat welcome layout both key off it, so they cannot split
|
|
// when an embedded container disagrees with the viewport.
|
|
const compactShell =
|
|
sidebarLayoutWidth !== undefined && sidebarLayoutWidth <= 760;
|
|
const channelSidebarEnabled =
|
|
sidebarOptions.enabled &&
|
|
projectFeaturesAvailable &&
|
|
sidebarRailEnabled &&
|
|
sidebarOptions.showSessionSourceSwitch &&
|
|
sidebarOptions.primaryNav?.items?.includes('channels') !== false &&
|
|
Boolean(
|
|
connection.capabilities?.features?.includes('session_source_metadata'),
|
|
);
|
|
const liveSidebarEnabled =
|
|
sidebarOptions.enabled &&
|
|
projectFeaturesAvailable &&
|
|
sidebarRailEnabled &&
|
|
sidebarOptions.showLive &&
|
|
sidebarOptions.primaryNav?.items?.includes('live') !== false;
|
|
const channelNavigationEnabled =
|
|
projectFeaturesAvailable &&
|
|
(sidebarOptions.primaryNav?.items?.includes('channelEvents') ?? true);
|
|
useEffect(() => {
|
|
setSidebarSection((current) =>
|
|
liveSidebarEnabled && connection.sessionContext?.kind === 'live'
|
|
? 'live'
|
|
: current === 'live'
|
|
? 'home'
|
|
: current,
|
|
);
|
|
}, [
|
|
connection.sessionId,
|
|
connection.sessionContext?.kind,
|
|
liveSidebarEnabled,
|
|
]);
|
|
useEffect(() => {
|
|
if (activePanel === 'channels' || activePanel === 'live') {
|
|
setSidebarSection(activePanel);
|
|
return;
|
|
}
|
|
// Closing a panel returns to the chat. Keep a still-valid current
|
|
// section — an explicit Home choice or the Channels column the opened
|
|
// conversation came from — and only drop sections whose feature is gone.
|
|
if (activePanel === null) {
|
|
setSidebarSection((current) => {
|
|
if (current === 'channels') {
|
|
return channelSidebarEnabled ? 'channels' : 'home';
|
|
}
|
|
if (current === 'live') {
|
|
return liveSidebarEnabled &&
|
|
connection.sessionContext?.kind === 'live'
|
|
? 'live'
|
|
: 'home';
|
|
}
|
|
return current;
|
|
});
|
|
}
|
|
}, [
|
|
activePanel,
|
|
channelSidebarEnabled,
|
|
connection.sessionContext?.kind,
|
|
liveSidebarEnabled,
|
|
]);
|
|
const appliedHistoryRevision = useRef<number | undefined>(undefined);
|
|
useEffect(() => {
|
|
if (navigation?.historyRevision === appliedHistoryRevision.current) return;
|
|
appliedHistoryRevision.current = navigation?.historyRevision;
|
|
if (navigation?.route.page === 'chat') {
|
|
setSidebarSection(
|
|
navigation.route.context === 'live' && liveSidebarEnabled
|
|
? 'live'
|
|
: 'home',
|
|
);
|
|
}
|
|
}, [navigation, liveSidebarEnabled]);
|
|
const sidebarPage =
|
|
(activePanel === 'mcp' ||
|
|
activePanel === 'skills' ||
|
|
activePanel === 'agents' ||
|
|
activePanel === 'extensions'
|
|
? 'home'
|
|
: activePanel) ??
|
|
(mainView === 'chat' || mainView === 'cockpit' || mainView === 'split'
|
|
? (sidebarSection === 'channels' && channelSidebarEnabled) ||
|
|
(sidebarSection === 'live' && liveSidebarEnabled)
|
|
? sidebarSection
|
|
: 'home'
|
|
: mainView);
|
|
// #5074: Cmd+B / Ctrl+B toggles the session sidebar, matching the editor
|
|
// convention (VS Code et al.). It works while any element is focused —
|
|
// the composer has no bold formatting, so nothing competes for the
|
|
// binding. Phone-width layouts render the sidebar as a drawer, so the
|
|
// shortcut toggles that instead of the collapsed rail.
|
|
useEffect(() => {
|
|
if (!sidebarOptions.enabled) return undefined;
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (!isSidebarToggleShortcut(e)) return;
|
|
if (isWebTerminalTarget(e)) return;
|
|
// The composer keeps the editor-convention behavior (VS Code toggles
|
|
// the sidebar while the editor is focused), but other editable targets
|
|
// — sidebar search, session rename, settings inputs — must not have
|
|
// the sidebar yanked around while the user is typing, matching the
|
|
// codebase's isEditableTarget convention. Shadow-DOM portal hosts
|
|
// retarget the event to the host element, so resolve the real target.
|
|
const target = (e.composedPath()[0] ?? e.target) as HTMLElement | null;
|
|
if (
|
|
isEditableTarget(target) &&
|
|
!target?.closest('[data-web-shell-composer-editor]')
|
|
) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
// All state updates are dispatched sequentially outside the updater
|
|
// functions (React purity contract — mirrors the hamburger handler),
|
|
// which is why this effect re-binds on state changes: the listener
|
|
// closure must stay fresh.
|
|
if (
|
|
forceMobileDrawer ||
|
|
(sidebarLayoutWidth ?? window.innerWidth) <= 760
|
|
) {
|
|
// A forced drawer on a wide viewport still belongs to the drawer
|
|
// path: collapsing the rail underneath the overlay would look like
|
|
// a no-op to the user.
|
|
if (mobileDrawerOpen) {
|
|
setMobileDrawerOpen(false);
|
|
setForceMobileDrawer(false);
|
|
} else {
|
|
setMobileDrawerOpen(true);
|
|
}
|
|
return;
|
|
}
|
|
handleSidebarCollapsedChange(!sidebarCollapsedEffective);
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, [
|
|
sidebarOptions.enabled,
|
|
mobileDrawerOpen,
|
|
forceMobileDrawer,
|
|
sidebarCollapsedEffective,
|
|
sidebarLayoutWidth,
|
|
handleSidebarCollapsedChange,
|
|
]);
|
|
const navigationAppliedRef = useRef<number | undefined>(undefined);
|
|
const navigationExpectedPageRef = useRef<WebShellPage | undefined>(undefined);
|
|
const navigationSettledRef = useRef(false);
|
|
const visibleNavigationPage: WebShellPage | undefined =
|
|
activePanel && isWebShellPage(activePanel)
|
|
? activePanel
|
|
: mainView === 'scheduledTasks'
|
|
? 'scheduled-tasks'
|
|
: mainView === 'prWatches'
|
|
? 'pr-watches'
|
|
: mainView === 'goals'
|
|
? 'goals'
|
|
: undefined;
|
|
useEffect(() => {
|
|
if (!navigation || !workspaceCapabilitiesReady) return;
|
|
const page = navigation.route.page;
|
|
if (page === 'chat' && navigation.revision === 0) {
|
|
navigationAppliedRef.current = navigation.revision;
|
|
navigationSettledRef.current = true;
|
|
return;
|
|
}
|
|
const primaryItem =
|
|
page === 'scheduled-tasks'
|
|
? 'scheduledTasks'
|
|
: page === 'pr-watches'
|
|
? 'prWatches'
|
|
: page === 'channel-events'
|
|
? 'channelEvents'
|
|
: page;
|
|
const allowed =
|
|
page === 'chat' ||
|
|
(page === 'settings'
|
|
? settingsAvailable &&
|
|
hostSidebar?.footer !== false &&
|
|
(hostSidebar?.footer?.items?.includes('settings') ?? true)
|
|
: projectFeaturesAvailable &&
|
|
(page !== 'live' || liveSidebarEnabled) &&
|
|
(page !== 'pr-watches' || prWatchesAvailable) &&
|
|
(page !== 'channel-events' || channelNavigationEnabled) &&
|
|
(hostSidebar?.primaryNav?.items?.some(
|
|
(item) => item === primaryItem,
|
|
) ??
|
|
true));
|
|
if (navigationAppliedRef.current === navigation.revision && allowed) return;
|
|
navigationAppliedRef.current = navigation.revision;
|
|
navigationSettledRef.current = false;
|
|
const nextPage = allowed && page !== 'chat' ? page : undefined;
|
|
navigationExpectedPageRef.current = nextPage;
|
|
splitClassificationGenerationRef.current += 1;
|
|
setActivePanel(
|
|
nextPage === 'plugins' ||
|
|
nextPage === 'channels' ||
|
|
nextPage === 'channel-events' ||
|
|
nextPage === 'live' ||
|
|
nextPage === 'settings'
|
|
? nextPage
|
|
: null,
|
|
);
|
|
setMainView(
|
|
nextPage === 'scheduled-tasks'
|
|
? 'scheduledTasks'
|
|
: nextPage === 'pr-watches'
|
|
? 'prWatches'
|
|
: nextPage === 'goals'
|
|
? 'goals'
|
|
: 'chat',
|
|
);
|
|
if (nextPage === 'settings') setSettingsInitialCategory(undefined);
|
|
if (!allowed) navigation.reconcilePage(undefined);
|
|
}, [
|
|
navigation,
|
|
projectFeaturesAvailable,
|
|
settingsAvailable,
|
|
hostSidebar?.footer,
|
|
hostSidebar?.primaryNav?.items,
|
|
liveSidebarEnabled,
|
|
prWatchesAvailable,
|
|
channelNavigationEnabled,
|
|
workspaceCapabilitiesReady,
|
|
]);
|
|
useEffect(() => {
|
|
if (
|
|
!navigation ||
|
|
!workspaceCapabilitiesReady ||
|
|
navigationAppliedRef.current !== navigation.revision
|
|
)
|
|
return;
|
|
if (!navigationSettledRef.current) {
|
|
if (visibleNavigationPage !== navigationExpectedPageRef.current) return;
|
|
navigationSettledRef.current = true;
|
|
return;
|
|
}
|
|
navigation.reconcilePage(visibleNavigationPage);
|
|
}, [navigation, visibleNavigationPage, workspaceCapabilitiesReady]);
|
|
const openSessionDrawer = useCallback(() => {
|
|
if (!sidebarOptions.enabled) return;
|
|
splitClassificationGenerationRef.current += 1;
|
|
setActivePanel(null);
|
|
showChat();
|
|
setForceMobileDrawer(true);
|
|
setMobileDrawerOpen(true);
|
|
}, [showChat, sidebarOptions.enabled]);
|
|
// Memoized per-id split classification. A merged re-open during a pending
|
|
// window must not re-fetch ids already classified (in flight or settled);
|
|
// transient failures are dropped so a retry re-classifies them. The memo is
|
|
// cleared on the same identities sanitizeSplitSessionIds is rebuilt on, so a
|
|
// daemon switch never reuses the previous target's answers. Entries that
|
|
// are still in flight when their open is cancelled by navigation are
|
|
// dropped at the next begin (see beginSplitClassification), so a re-open
|
|
// never waits on the cancelled open's classification.
|
|
const splitSessionClassificationRef = useRef<
|
|
Map<string, { promise: Promise<SplitSessionOutcome>; settled: boolean }>
|
|
>(new Map());
|
|
useEffect(() => {
|
|
splitSessionClassificationRef.current.clear();
|
|
}, [workspace.client, workspace.capabilities]);
|
|
const isSplitEligibleSession = useCallback(
|
|
(sessionId: string): Promise<SplitSessionOutcome> => {
|
|
const memo = splitSessionClassificationRef.current;
|
|
const cached = memo.get(sessionId);
|
|
if (cached) return cached.promise;
|
|
if (
|
|
!workspace.capabilities?.features?.includes(
|
|
STANDALONE_SESSIONS_CAPABILITY,
|
|
)
|
|
) {
|
|
// Capability-absent fast path: every id is eligible, nothing to cache.
|
|
return Promise.resolve('workspace');
|
|
}
|
|
const outcome = workspace.client.getStandaloneSession(sessionId).then(
|
|
() => 'standalone' as const,
|
|
(error) =>
|
|
(isStandaloneSessionNotFoundError(error)
|
|
? 'workspace'
|
|
: 'failed') as SplitSessionOutcome,
|
|
);
|
|
const entry = { promise: outcome, settled: false };
|
|
outcome.then((result) => {
|
|
entry.settled = true;
|
|
if (result === 'failed') memo.delete(sessionId);
|
|
});
|
|
memo.set(sessionId, entry);
|
|
return outcome;
|
|
},
|
|
[workspace.capabilities, workspace.client],
|
|
);
|
|
// Publishes a pending open when a split classification starts: bumps the
|
|
// generation refs, records the requested ids as the live pending open, and
|
|
// un-settles the split view so pane-bound reclaim waits for the decision.
|
|
// A pending open already superseded by navigation drops its still-in-flight
|
|
// classifications (see below). Returns the new generation.
|
|
const beginSplitClassification = useCallback(
|
|
(requested: readonly string[]) => {
|
|
// A pending open that is no longer on the live generation was
|
|
// superseded by navigation before it settled: its in-flight
|
|
// classifications are orphaned (the daemon call may never resolve),
|
|
// and a new open reusing them would stall, so drop the still-in-flight
|
|
// entries. Settled outcomes stay reusable; a live pending open (merged
|
|
// sidebar re-open) keeps its in-flight promises so the merged list
|
|
// doesn't re-classify.
|
|
const supersededPending = pendingSplitOpenRef.current;
|
|
if (
|
|
supersededPending &&
|
|
supersededPending.generation !==
|
|
splitClassificationGenerationRef.current
|
|
) {
|
|
const memo = splitSessionClassificationRef.current;
|
|
for (const [id, entry] of memo) {
|
|
if (!entry.settled) memo.delete(id);
|
|
}
|
|
}
|
|
const generation = splitClassificationGenerationRef.current + 1;
|
|
splitClassificationGenerationRef.current = generation;
|
|
splitClassificationStartGenerationRef.current = generation;
|
|
const pending =
|
|
requested.length > 0
|
|
? { generation, sessionIds: [...requested] }
|
|
: null;
|
|
pendingSplitOpenRef.current = pending;
|
|
setPendingSplitPaneIds(pending);
|
|
// A new decision is pending: un-settle so reclaim waits for it. The
|
|
// empty-request branch settles without classifying.
|
|
if (requested.length > 0) setSplitViewSettled(false);
|
|
return generation;
|
|
},
|
|
[],
|
|
);
|
|
// Clears the pending open when its generation settles; no-op once a newer
|
|
// generation (or navigation) has superseded it.
|
|
const settlePendingSplitOpen = useCallback((generation: number) => {
|
|
if (pendingSplitOpenRef.current?.generation === generation) {
|
|
pendingSplitOpenRef.current = null;
|
|
}
|
|
setPendingSplitPaneIds((current) =>
|
|
current?.generation === generation ? null : current,
|
|
);
|
|
}, []);
|
|
const sanitizeSplitSessionIds = useCallback(
|
|
async (
|
|
sessionIds: readonly string[],
|
|
): Promise<SplitSessionClassification> => {
|
|
const requested = Array.from(new Set(sessionIds.filter(Boolean))).slice(
|
|
0,
|
|
MAX_SPLIT_PANES,
|
|
);
|
|
if (requested.length === 0) {
|
|
return { kept: [], transientFailures: [] };
|
|
}
|
|
return keepWorkspaceSplitSessionIds(
|
|
workspace.client,
|
|
workspace.capabilities,
|
|
requested,
|
|
isSplitEligibleSession,
|
|
);
|
|
},
|
|
[isSplitEligibleSession, workspace.capabilities, workspace.client],
|
|
);
|
|
// Open the in-window split view showing 2+ sessions side by side. `splitSessionIds`
|
|
// is the live pane set — SplitView mirrors add/remove back into it via
|
|
// onPanesChange — so it must be preserved across entries, not blindly reset.
|
|
const openSplitView = useCallback(
|
|
(sessionIds?: readonly string[]) => {
|
|
if (!projectFeaturesAvailable) {
|
|
// No classification can start past this guard, so the split decision
|
|
// is final here: settle so pane-bound reclaim is not stranded.
|
|
setSplitViewSettled(true);
|
|
return;
|
|
}
|
|
setActivePanel(null);
|
|
// See openScheduledTasks: leaving the cockpit must strip its deep link.
|
|
showChat();
|
|
const requested = Array.from(
|
|
new Set((sessionIds ?? []).filter(Boolean)),
|
|
).slice(0, MAX_SPLIT_PANES);
|
|
const generation = beginSplitClassification(requested);
|
|
if (requested.length === 0) {
|
|
const currentWorkspaceSessionId =
|
|
connection.sessionId !== undefined &&
|
|
(connection.sessionContext?.kind === 'workspace' ||
|
|
(connection.sessionContext === undefined &&
|
|
connection.workspaceCwd !== undefined))
|
|
? connection.sessionId
|
|
: undefined;
|
|
setSplitSessionIds((previous) =>
|
|
previous.length > 0
|
|
? previous
|
|
: currentWorkspaceSessionId
|
|
? [currentWorkspaceSessionId]
|
|
: [],
|
|
);
|
|
setMainView('split');
|
|
setSplitViewSettled(true);
|
|
return;
|
|
}
|
|
void sanitizeSplitSessionIds(requested).then((result) => {
|
|
settlePendingSplitOpen(generation);
|
|
if (splitClassificationGenerationRef.current !== generation) {
|
|
// Superseded: settle only when no newer classification started,
|
|
// otherwise the newer one owns the decision.
|
|
if (
|
|
splitClassificationStartGenerationRef.current !==
|
|
splitClassificationGenerationRef.current
|
|
) {
|
|
setSplitViewSettled(true);
|
|
}
|
|
return;
|
|
}
|
|
setSplitViewSettled(true);
|
|
if (result.transientFailures.length > 0) {
|
|
// A named session was lost to a transient (non-404) classification
|
|
// error: say so instead of opening a partial split silently.
|
|
pushToast('info', t('session.splitAddFailed'));
|
|
}
|
|
if (result.kept.length === 0) {
|
|
return;
|
|
}
|
|
setSplitSessionIds(result.kept);
|
|
setMainView('split');
|
|
});
|
|
},
|
|
[
|
|
beginSplitClassification,
|
|
connection.sessionContext,
|
|
connection.sessionId,
|
|
connection.workspaceCwd,
|
|
pushToast,
|
|
sanitizeSplitSessionIds,
|
|
settlePendingSplitOpen,
|
|
showChat,
|
|
t,
|
|
projectFeaturesAvailable,
|
|
],
|
|
);
|
|
const externalSplitSignature = useMemo(() => {
|
|
const requested = Array.from(
|
|
new Set((externalSplitSessionIds ?? []).filter(Boolean)),
|
|
).slice(0, MAX_SPLIT_PANES);
|
|
return requested.join('\0');
|
|
}, [externalSplitSessionIds]);
|
|
const externalSplitControlled = externalSplitSessionIds !== undefined;
|
|
const onSplitSessionIdsChangeRef = useRef(onSplitSessionIdsChange);
|
|
onSplitSessionIdsChangeRef.current = onSplitSessionIdsChange;
|
|
// The pane list a fresh split seeds with when the user picks no explicit
|
|
// sessions: the current session, but only in a workspace context — a
|
|
// standalone/live session is its own pane and must not be duplicated.
|
|
const splitSeedIds = useCallback(
|
|
() =>
|
|
workspaceContextActive && connection.sessionId
|
|
? [connection.sessionId]
|
|
: [],
|
|
[connection.sessionId, workspaceContextActive],
|
|
);
|
|
const requestSplitWithIds = useCallback(
|
|
(ids: string[]) => {
|
|
if (externalSplitControlled) {
|
|
onSplitSessionIdsChangeRef.current?.(ids);
|
|
} else {
|
|
openSplitView(ids);
|
|
}
|
|
},
|
|
[externalSplitControlled, openSplitView],
|
|
);
|
|
const requestOpenSplitView = useCallback(() => {
|
|
if (!projectFeaturesAvailable) return;
|
|
if (!externalSplitControlled) {
|
|
openSplitView();
|
|
return;
|
|
}
|
|
if (!workspaceContextActive && splitSessionIds.length === 0) return;
|
|
const requested =
|
|
splitSessionIds.length > 0 ? splitSessionIds : splitSeedIds();
|
|
onSplitSessionIdsChangeRef.current?.(requested);
|
|
}, [
|
|
externalSplitControlled,
|
|
openSplitView,
|
|
splitSeedIds,
|
|
splitSessionIds,
|
|
projectFeaturesAvailable,
|
|
workspaceContextActive,
|
|
]);
|
|
useEffect(() => {
|
|
if (!externalSplitControlled) return;
|
|
const requested = externalSplitSignature
|
|
? externalSplitSignature.split('\0')
|
|
: [];
|
|
const generation = beginSplitClassification(requested);
|
|
if (requested.length > 0 && cockpitViewRequested()) {
|
|
updateCockpitLocation(false, true);
|
|
}
|
|
if (requested.length === 0) {
|
|
setSplitSessionIds([]);
|
|
setMainView((previous) => (previous === 'split' ? 'chat' : previous));
|
|
setSplitViewSettled(true);
|
|
return;
|
|
}
|
|
if (!workspaceCapabilitiesReady) {
|
|
// A terminal capabilities error means no classification can start, so
|
|
// the split decision is final here; a merely pending load must not
|
|
// settle, or the latch would license the premature drop it prevents.
|
|
if (workspace.status === 'error') setSplitViewSettled(true);
|
|
return;
|
|
}
|
|
if (!projectFeaturesAvailable) {
|
|
setSplitSessionIds([]);
|
|
setMainView((previous) => (previous === 'split' ? 'chat' : previous));
|
|
onSplitSessionIdsChangeRef.current?.([]);
|
|
setSplitViewSettled(true);
|
|
return;
|
|
}
|
|
void sanitizeSplitSessionIds(requested).then((result) => {
|
|
settlePendingSplitOpen(generation);
|
|
if (splitClassificationGenerationRef.current !== generation) {
|
|
if (
|
|
splitClassificationStartGenerationRef.current !==
|
|
splitClassificationGenerationRef.current
|
|
) {
|
|
setSplitViewSettled(true);
|
|
}
|
|
return;
|
|
}
|
|
setSplitViewSettled(true);
|
|
if (result.transientFailures.length > 0) {
|
|
// A named session was lost to a transient (non-404) classification
|
|
// error: say so instead of opening a partial split silently.
|
|
pushToast('info', t('session.splitAddFailed'));
|
|
}
|
|
const kept = result.kept;
|
|
setSplitSessionIds((previous) =>
|
|
areSessionIdsEqual(previous, kept) ? previous : kept,
|
|
);
|
|
if (!areSessionIdsEqual(requested, kept)) {
|
|
onSplitSessionIdsChangeRef.current?.(kept);
|
|
}
|
|
if (kept.length > 0) {
|
|
setActivePanel((previous) => (previous === null ? previous : null));
|
|
setMainView((previous) => (previous === 'split' ? previous : 'split'));
|
|
} else {
|
|
setMainView((previous) => (previous === 'split' ? 'chat' : previous));
|
|
}
|
|
});
|
|
}, [
|
|
beginSplitClassification,
|
|
externalSplitControlled,
|
|
externalSplitSignature,
|
|
effectiveSessionContext,
|
|
projectFeaturesAvailable,
|
|
pushToast,
|
|
sanitizeSplitSessionIds,
|
|
settlePendingSplitOpen,
|
|
t,
|
|
workspace.status,
|
|
workspaceCapabilitiesReady,
|
|
]);
|
|
const handleSplitPanesChange = useCallback(
|
|
(sessionIds: string[]) => {
|
|
const nextIds = new Set(sessionIds);
|
|
closeUsageTabs(
|
|
splitSessionIdsRef.current.filter((id) => !nextIds.has(id)),
|
|
true,
|
|
);
|
|
if (!externalSplitControlled) {
|
|
setSplitSessionIds(sessionIds);
|
|
}
|
|
onSplitSessionIdsChangeRef.current?.(sessionIds);
|
|
},
|
|
[closeUsageTabs, externalSplitControlled],
|
|
);
|
|
const addSessionToSplitView = useCallback(
|
|
(sessionId: string) => {
|
|
if (!isLargeScreen || !projectFeaturesAvailable) return;
|
|
const pending = pendingSplitOpenRef.current;
|
|
// Only merge an opening request that navigation has not superseded.
|
|
const previous =
|
|
pending?.generation === splitClassificationGenerationRef.current
|
|
? pending.sessionIds
|
|
: mainView === 'split'
|
|
? splitSessionIds
|
|
: undefined;
|
|
if (
|
|
previous &&
|
|
(previous.includes(sessionId) || previous.length >= MAX_SPLIT_PANES)
|
|
) {
|
|
return;
|
|
}
|
|
closeMobileDrawer();
|
|
if (mainView === 'split' && previous) {
|
|
handleSplitPanesChange([...previous, sessionId]);
|
|
return;
|
|
}
|
|
const requested = Array.from(
|
|
new Set([...(previous ?? splitSeedIds()), sessionId]),
|
|
);
|
|
requestSplitWithIds(requested);
|
|
},
|
|
[
|
|
closeMobileDrawer,
|
|
handleSplitPanesChange,
|
|
isLargeScreen,
|
|
mainView,
|
|
projectFeaturesAvailable,
|
|
requestSplitWithIds,
|
|
splitSeedIds,
|
|
splitSessionIds,
|
|
],
|
|
);
|
|
const notifyControlledSplitClose = useCallback(() => {
|
|
if (externalSplitControlled) {
|
|
onSplitSessionIdsChangeRef.current?.([]);
|
|
}
|
|
}, [externalSplitControlled]);
|
|
// Stable so SplitView's onExit-dependent effect (auto-exit on last pane
|
|
// close) doesn't re-fire on every App re-render. Back from the split returns
|
|
// to the Session Overview — the hub the split is launched from.
|
|
const handleSplitExit = useCallback(() => {
|
|
splitClassificationGenerationRef.current += 1;
|
|
notifyControlledSplitClose();
|
|
// The user left the split of their own accord, so a refresh must not bring
|
|
// it back. (A shrink-fold is transient and deliberately doesn't clear it.)
|
|
clearSplitSessions();
|
|
// Explicit exit also cancels any pending shrink-fold restore.
|
|
splitFoldedByShrinkRef.current = false;
|
|
openPanel('sessions');
|
|
}, [notifyControlledSplitClose, openPanel]);
|
|
const handleOpenLocalControlSettings = useCallback(() => {
|
|
setSettingsInitialCategory('Daemon');
|
|
openPanel('settings');
|
|
}, [openPanel]);
|
|
// Built-in pane actions follow the same opt-ins as the chat header.
|
|
// Hosts can override via `renderPaneHeaderActions` to replace or extend it.
|
|
const defaultPaneHeaderActions = useCallback<PaneHeaderActionsRenderer>(
|
|
({ sessionId, sessionActions }) => (
|
|
<>
|
|
{showMobileAccess && (
|
|
<LocalControlQrButton
|
|
onOpenSettings={handleOpenLocalControlSettings}
|
|
/>
|
|
)}
|
|
{contextUsageHeaderItemVisible && (
|
|
<button
|
|
type="button"
|
|
className={styles.tokenUsageHeaderButton}
|
|
aria-label={t('contextUsage.title')}
|
|
title={t('contextUsage.title')}
|
|
disabled={!sessionActions}
|
|
onClick={() =>
|
|
sessionActions &&
|
|
openContextUsagePanel(sessionId, sessionActions, true)
|
|
}
|
|
>
|
|
<LayersIcon size={16} aria-hidden="true" />
|
|
</button>
|
|
)}
|
|
{tokenUsageHeaderItemVisible && (
|
|
<button
|
|
type="button"
|
|
className={styles.tokenUsageHeaderButton}
|
|
aria-label={t('tokenUsage.open')}
|
|
title={t('tokenUsage.open')}
|
|
disabled={!sessionActions}
|
|
onClick={() =>
|
|
sessionActions &&
|
|
openTokenUsagePanel(sessionId, sessionActions, true)
|
|
}
|
|
>
|
|
<GaugeIcon size={16} aria-hidden="true" />
|
|
</button>
|
|
)}
|
|
</>
|
|
),
|
|
[
|
|
handleOpenLocalControlSettings,
|
|
showMobileAccess,
|
|
openTokenUsagePanel,
|
|
openContextUsagePanel,
|
|
t,
|
|
tokenUsageHeaderItemVisible,
|
|
contextUsageHeaderItemVisible,
|
|
],
|
|
);
|
|
const resolvedPaneHeaderActions =
|
|
renderPaneHeaderActions ?? defaultPaneHeaderActions;
|
|
const splitBootstrapHandledRef = useRef(false);
|
|
// A `?split=a,b` URL (opened in a new tab from the overview) enters the split
|
|
// view with those sessions on load. Consume the param once so a later reload
|
|
// or exit doesn't force the split back on.
|
|
useEffect(() => {
|
|
if (!workspace.capabilities || splitBootstrapHandledRef.current) return;
|
|
splitBootstrapHandledRef.current = true;
|
|
const ids = parseSplitSessionIds(window.location.search);
|
|
if (ids.length > 0) {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.delete('split');
|
|
window.history.replaceState(window.history.state, '', url);
|
|
if (!externalSplitControlled) {
|
|
openSplitView(ids);
|
|
}
|
|
return;
|
|
}
|
|
// No `?split=` deep link: restore the in-window split the user had before a
|
|
// refresh, when one was persisted for this tab. sessionStorage is per-tab,
|
|
// so a fresh tab (or a controlled host, which owns its own lifecycle)
|
|
// restores nothing.
|
|
if (externalSplitControlled) return;
|
|
const saved = loadSplitSessions();
|
|
if (saved.length > 0) {
|
|
openSplitView(saved);
|
|
return;
|
|
}
|
|
setSplitViewSettled(true);
|
|
}, [externalSplitControlled, openSplitView, workspace.capabilities]);
|
|
// Mirror the live split session set to per-tab storage while the split is the
|
|
// active view, so a refresh restores exactly these panes. Not written when the
|
|
// split is merely folded by a shrink (mainView flips to 'chat' transiently) —
|
|
// the saved set is kept so growing back, or refreshing mid-fold, still restores.
|
|
useEffect(() => {
|
|
if (externalSplitControlled) return;
|
|
if (mainView === 'split' && splitSessionIds.length > 0) {
|
|
saveSplitSessions(splitSessionIds);
|
|
}
|
|
}, [mainView, splitSessionIds, externalSplitControlled]);
|
|
// If the viewport shrinks below the large-screen breakpoint, fold away the
|
|
// split view. It is only folded, not discarded: growing back past the
|
|
// breakpoint restores it, so a transient resize is lossless. When a shrink
|
|
// folds the split, its panes unmount and take keyboard focus with them; flag
|
|
// the composer to be refocused once the chat is shown again.
|
|
const focusComposerAfterSplitCloseRef = useRef(false);
|
|
// True while the split view is only *temporarily* folded away because the
|
|
// container is narrower than the large-screen breakpoint. Growing back past the
|
|
// breakpoint restores it, so a transient resize doesn't drop the user's panes.
|
|
const splitFoldedByShrinkRef = useRef(false);
|
|
// The manual title an armed `/clear` carries into the next created session
|
|
// (consumed by the deferred creation in ensureSessionForPrompt). Session
|
|
// binding outside the carry flow — sidebar navigation, workspace switches,
|
|
// the shrink-fold landing, workspace-resolver creation — must discard it,
|
|
// or a cleared title resurfaces on an unrelated session's successor.
|
|
const pendingManualTitleRef = useRef<{ displayName: string } | undefined>(
|
|
undefined,
|
|
);
|
|
useEffect(() => {
|
|
if (isLargeScreen) {
|
|
// Grew back above the breakpoint: restore a split that a shrink folded
|
|
// away. Standalone/uncontrolled only — a controlled host owns its split
|
|
// lifecycle and re-opens it itself.
|
|
if (splitFoldedByShrinkRef.current && !activePanel) {
|
|
splitFoldedByShrinkRef.current = false;
|
|
if (!externalSplitControlled && splitSessionIdsRef.current.length > 0) {
|
|
setMainView((prev) => (prev === 'chat' ? 'split' : prev));
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (mainView === 'split') {
|
|
notifyControlledSplitClose();
|
|
setMainView('chat');
|
|
focusComposerAfterSplitCloseRef.current = true;
|
|
// Fold, don't discard: remember to restore the same split once the screen
|
|
// grows back, so a transient shrink is lossless. The chat's own connection
|
|
// (its session, git branch, URL, …) is left untouched — restoring the
|
|
// split, or dropping back to that chat, is exactly what it was before.
|
|
if (!externalSplitControlled) {
|
|
splitFoldedByShrinkRef.current = true;
|
|
// …except when the chat has no session of its own — the common case
|
|
// when the split was entered from the Session Overview or a `?split=a,b`
|
|
// link. A bare fold would then strand the user on an empty "new chat",
|
|
// so land on the split's first (leftmost) pane instead. Guarded on the
|
|
// *empty* chat so it never re-points a chat that already has a session
|
|
// (which would wipe its git branch / change the session+URL it drops
|
|
// back to). Best-effort: a load failure (e.g. a non-primary-workspace
|
|
// pane the single connection can't own) just leaves the empty chat.
|
|
const firstPane = splitSessionIdsRef.current[0];
|
|
if (firstPane && !currentSessionIdRef.current) {
|
|
// Landing on a pane is session navigation: discard an armed
|
|
// `/clear` carry so the pane session's lineage never inherits the
|
|
// cleared session's title.
|
|
pendingManualTitleRef.current = undefined;
|
|
void sessionActions.loadSession(firstPane).catch(() => undefined);
|
|
}
|
|
}
|
|
}
|
|
}, [
|
|
activePanel,
|
|
isLargeScreen,
|
|
mainView,
|
|
notifyControlledSplitClose,
|
|
externalSplitControlled,
|
|
sessionActions,
|
|
]);
|
|
// Land focus in the chat after a shrink-driven split close so keyboard
|
|
// users aren't dropped onto <body> — but not when the chat now shows an
|
|
// approval overlay (it owns the keyboard) or a panel (it manages focus).
|
|
useEffect(() => {
|
|
if (mainView !== 'chat' || !focusComposerAfterSplitCloseRef.current) return;
|
|
focusComposerAfterSplitCloseRef.current = false;
|
|
if (!activePanel && !approvalOverlayActive) focusChatAfterNavigation();
|
|
}, [mainView, activePanel, approvalOverlayActive, focusChatAfterNavigation]);
|
|
// The Settings / Daemon Status panel is a view, not a modal, so it lacks
|
|
// DialogShell's focus trap/restore. Move focus into a panel when it opens (or
|
|
// when switching directly between panels) and back to the chat when it
|
|
// closes, so keyboard users aren't stranded on an element that is hidden.
|
|
const panelBackRef = useRef<HTMLButtonElement | null>(null);
|
|
const panelHeadingRef = useRef<HTMLHeadingElement | null>(null);
|
|
const pluginTabRef = useRef<HTMLButtonElement | null>(null);
|
|
const prevActivePanelRef = useRef(activePanel);
|
|
const prevApprovalOverlayRef = useRef(approvalOverlayActive);
|
|
useEffect(() => {
|
|
const prev = prevActivePanelRef.current;
|
|
const wasApprovalActive = prevApprovalOverlayRef.current;
|
|
prevActivePanelRef.current = activePanel;
|
|
prevApprovalOverlayRef.current = approvalOverlayActive;
|
|
if (activePanel) {
|
|
if (
|
|
activePanel === 'extensions' ||
|
|
activePanel === 'channels' ||
|
|
activePanel === 'live' ||
|
|
// The Workspaces panel renders its own header; the generic Back
|
|
// button (panelBackRef) is excluded for it, so the fallback below
|
|
// would focus nothing.
|
|
activePanel === 'workspaces'
|
|
) {
|
|
panelHeadingRef.current?.focus();
|
|
return;
|
|
}
|
|
if (activePanel === 'plugins') {
|
|
pluginTabRef.current?.focus();
|
|
return;
|
|
}
|
|
// Covers null→panel and panel→panel: the Back button lives outside the
|
|
// keyed panel body so it survives a switch, but refocus explicitly rather
|
|
// than depending on that DOM coincidence.
|
|
panelBackRef.current?.focus();
|
|
} else if (prev) {
|
|
// Panel just closed. Return focus to the chat — unless an approval
|
|
// overlay is what forced it closed (see the effect below): that overlay
|
|
// drives its own keyboard handling and ToolApproval ignores keys from
|
|
// editable targets, so focusing the composer here would swallow its
|
|
// shortcuts and leave the user unable to respond by keyboard.
|
|
if (!approvalOverlayActive) {
|
|
focusChatAfterNavigation();
|
|
}
|
|
} else if (wasApprovalActive && !approvalOverlayActive) {
|
|
// The panel was auto-closed for an approval (prev was consumed to null on
|
|
// that render, editor focus skipped). Now the approval has resolved with
|
|
// no panel to return to, so restore chat focus here. (useComposerCore's
|
|
// dialogOpen effect also refocuses on this transition; this keeps the
|
|
// panel focus effect self-contained instead of relying on that.)
|
|
focusChatAfterNavigation();
|
|
}
|
|
}, [activePanel, approvalOverlayActive, focusChatAfterNavigation]);
|
|
// A pending approval (a gated tool call or an AskUserQuestion) renders its
|
|
// overlay in the chat footer, which is hidden (display:none) while a panel is
|
|
// shown. Left alone, the turn would hang behind Settings/Status with no
|
|
// visible prompt. Close the panel so the approval surfaces. Only actionable
|
|
// approvals count — pendingToolApproval/pendingAskUserApproval already gate on
|
|
// canActOnPendingApproval, so a non-owner in a shared session isn't yanked out
|
|
// of Settings by someone else's prompt.
|
|
useEffect(() => {
|
|
if (!approvalOverlayActive) return;
|
|
// The approval overlay renders in the chat footer; dismiss anything layered
|
|
// over it so it's visible and actionable instead of trapped behind a
|
|
// backdrop — the panel itself and any DialogShell sub-dialog opened from it
|
|
// (model picker, approval-mode picker). Leaving the approval-mode picker up
|
|
// is also a security hole: the user could pick "yolo" and silently
|
|
// auto-approve a tool call they never saw (handleSetMode auto-approves
|
|
// pendingApprovalRef.current).
|
|
if (activePanel) setActivePanel(null);
|
|
if (modelDialogMode) setModelDialogMode(null);
|
|
if (showApprovalModeDialog) setShowApprovalModeDialog(false);
|
|
// The fullscreen artifact panel hides the chat (display:none +
|
|
// aria-hidden), and the approval overlay renders inside the chat footer —
|
|
// shrink the panel back to its dock/drawer so the approval surfaces.
|
|
// Split-view pane approvals deliberately do not shrink the panel: each
|
|
// pane renders its own approval banner, and surfacing them here would
|
|
// need a pending-approval signal plumbed up through SplitView. Escape or
|
|
// the toolbar exits fullscreen and reveals them.
|
|
if (artifactPanelFullscreen) setArtifactPanelFullscreen(false);
|
|
// Workflow, Scheduled Tasks, and Goals are full-pane overlays
|
|
// (position:absolute) that cover the chat footer too, so dismiss them for
|
|
// the same reason. The split view is deliberately NOT dismissed: each pane
|
|
// owns and renders its own session's approval, so an approval on the (outer)
|
|
// main session must not yank the user out of the panes they are working in.
|
|
if (cockpitViewRequested()) updateCockpitLocation(false, true);
|
|
if (
|
|
mainView === 'cockpit' ||
|
|
mainView === 'scheduledTasks' ||
|
|
mainView === 'prWatches' ||
|
|
mainView === 'workflows' ||
|
|
mainView === 'goals'
|
|
) {
|
|
setMainView('chat');
|
|
}
|
|
}, [
|
|
approvalOverlayActive,
|
|
activePanel,
|
|
modelDialogMode,
|
|
showApprovalModeDialog,
|
|
mainView,
|
|
artifactPanelFullscreen,
|
|
]);
|
|
// Whether each approval overlay is the topmost (visible, uncovered) one. The
|
|
// overlay components consume this as `keyboardActive`: when it flips true — on
|
|
// appearance, or once a panel/dialog that was covering it closes — they pull
|
|
// keyboard focus to their own safe-default option. Focus handling now lives in
|
|
// ToolApproval/AskUserQuestion (their keyboard handling is focus-scoped), so
|
|
// the app no longer focuses the wrapper element directly.
|
|
const toolApprovalOverlayVisible =
|
|
!managedMode &&
|
|
pendingToolApproval !== null &&
|
|
!activePanel &&
|
|
modelDialogMode === null &&
|
|
!showApprovalModeDialog &&
|
|
mainView === 'chat' &&
|
|
!artifactPanelFullscreen;
|
|
const askUserOverlayVisible =
|
|
!managedMode &&
|
|
pendingAskUserApproval !== null &&
|
|
!activePanel &&
|
|
modelDialogMode === null &&
|
|
!showApprovalModeDialog &&
|
|
mainView === 'chat' &&
|
|
!artifactPanelFullscreen;
|
|
const [showMemoryDialog, setShowMemoryDialog] = useState(false);
|
|
const [showAuthDialog, setShowAuthDialog] = useState(false);
|
|
const showAuthDialogRef = useRef(showAuthDialog);
|
|
useEffect(() => {
|
|
if (!modelManagementPolicy.allowAdd) setShowAuthDialog(false);
|
|
}, [modelManagementPolicy.allowAdd, showAuthDialog]);
|
|
const [memoryRefreshSignal, setMemoryRefreshSignal] = useState(0);
|
|
const [memoryAddSignal, setMemoryAddSignal] = useState(0);
|
|
const [externalInteractionBlockCount, setExternalInteractionBlockCount] =
|
|
useState(0);
|
|
const registerInteractionBlocker = useCallback(() => {
|
|
let released = false;
|
|
setExternalInteractionBlockCount((count) => count + 1);
|
|
return () => {
|
|
if (released) return;
|
|
released = true;
|
|
setExternalInteractionBlockCount((count) => Math.max(0, count - 1));
|
|
};
|
|
}, []);
|
|
|
|
// Extension changes invalidate command suggestions until their next use.
|
|
const commandsRefreshSessionRef = useRef<string | undefined>(undefined);
|
|
const commandsRefreshRequestRef = useRef(0);
|
|
const [commandsRefreshErrorOwner, setCommandsRefreshErrorOwner] =
|
|
useState<DaemonSessionOwnerSnapshot>();
|
|
const [commandsRefreshingSession, setCommandsRefreshingSession] =
|
|
useState<string>();
|
|
const extensionsVersionRef = useRef(
|
|
workspaceEventSignals?.extensionsVersion ?? 0,
|
|
);
|
|
useEffect(() => {
|
|
const current = workspaceEventSignals?.extensionsVersion ?? 0;
|
|
if (current !== extensionsVersionRef.current) {
|
|
extensionsVersionRef.current = current;
|
|
const change = workspaceEventSignals?.lastExtensionChange;
|
|
if (change?.status === 'failed') {
|
|
store.dispatch([
|
|
{
|
|
type: 'error',
|
|
text: t('extensions.action.failed', {
|
|
name: change.name ?? '',
|
|
source: change.source ?? '',
|
|
error: change.error ?? t('error.unknown'),
|
|
}),
|
|
},
|
|
]);
|
|
return;
|
|
}
|
|
if (change?.status === 'installed') {
|
|
const name = change.name ?? change.source ?? t('extensions.label');
|
|
store.dispatch([
|
|
{
|
|
type: 'status',
|
|
promptId: `local-command:${crypto.randomUUID()}`,
|
|
text: change.version
|
|
? t('extensions.install.installedWithVersion', {
|
|
name,
|
|
version: change.version,
|
|
})
|
|
: t('extensions.install.installed', { name }),
|
|
},
|
|
]);
|
|
} else if (change?.status) {
|
|
const name = change.name ?? change.source ?? t('extensions.label');
|
|
const key =
|
|
change.status === 'updated' && change.version
|
|
? 'extensions.manage.updatedWithVersion'
|
|
: `extensions.manage.${change.status}`;
|
|
store.dispatch([
|
|
{
|
|
type: 'status',
|
|
promptId: `local-command:${crypto.randomUUID()}`,
|
|
text: t(key, { name, version: change.version ?? '' }),
|
|
},
|
|
]);
|
|
}
|
|
commandsRefreshSessionRef.current = connection.sessionId;
|
|
}
|
|
if (
|
|
!skillsCatalogActive ||
|
|
!connection.sessionId ||
|
|
commandsRefreshSessionRef.current !== connection.sessionId
|
|
)
|
|
return;
|
|
const sessionId = connection.sessionId;
|
|
const owner = sessionOwnerGuard.capture();
|
|
commandsRefreshSessionRef.current = undefined;
|
|
const request = ++commandsRefreshRequestRef.current;
|
|
setCommandsRefreshingSession(sessionId);
|
|
setCommandsRefreshErrorOwner(undefined);
|
|
sessionActions
|
|
.refreshCommands()
|
|
.catch(() => {
|
|
if (!owner.isCurrent() || commandsRefreshRequestRef.current !== request)
|
|
return;
|
|
commandsRefreshSessionRef.current = sessionId;
|
|
setCommandsRefreshErrorOwner(owner);
|
|
store.dispatch([
|
|
{ type: 'error', text: t('extensions.commands.refreshFailed') },
|
|
]);
|
|
})
|
|
.finally(() => {
|
|
if (commandsRefreshRequestRef.current === request) {
|
|
setCommandsRefreshingSession(undefined);
|
|
}
|
|
});
|
|
}, [
|
|
connection.sessionId,
|
|
skillsCatalogActive,
|
|
workspaceEventSignals?.extensionsVersion,
|
|
workspaceEventSignals?.lastExtensionChange,
|
|
sessionActions,
|
|
sessionOwnerGuard,
|
|
store,
|
|
t,
|
|
]);
|
|
const sessionCatalogPending =
|
|
Boolean(connection.sessionId) &&
|
|
(extensionsVersionRef.current !==
|
|
(workspaceEventSignals?.extensionsVersion ?? 0) ||
|
|
commandsRefreshSessionRef.current === connection.sessionId ||
|
|
commandsRefreshingSession === connection.sessionId ||
|
|
skillMutationsForWorkspace(
|
|
workspaceEventSignals?.lastSkillMutation,
|
|
workspaceEventSignals?.skillMutationsByCwd,
|
|
connection.workspaceCwd,
|
|
).some(
|
|
(mutation) =>
|
|
!handledSkillMutationKeysRef.current.has(
|
|
`${connection.workspaceCwd ?? ''}\n${connection.sessionId ?? ''}\n${mutation.id}`,
|
|
),
|
|
));
|
|
const [memoryAddScope, setMemoryAddScope] = useState<'workspace' | 'global'>(
|
|
'workspace',
|
|
);
|
|
const [agentsCreateScope, setAgentsCreateScope] = useState<
|
|
'workspace' | 'global' | null
|
|
>(null);
|
|
const [escapeHintVisible, setEscapeHintVisible] = useState(false);
|
|
// Whether the first Esc has armed a stream cancellation; the composer's send
|
|
// button shows an "Esc again to stop" affordance while true.
|
|
const [cancelArmed, setCancelArmed] = useState(false);
|
|
// Which action the pending second Esc would perform, or null when idle.
|
|
const escArmedActionRef = useRef<'cancel' | 'clear' | null>(null);
|
|
const escapeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const [tasksDialogMessage, setTasksDialogMessage] =
|
|
useState<SerializedTasksMessage | null>(null);
|
|
const handleOpenMonitorDetails = useCallback(
|
|
(task: DaemonSessionMonitorTaskStatus) => {
|
|
setTasksDialogMessage(null);
|
|
setBackgroundTasksRefreshTrigger((value) => value + 1);
|
|
openMonitorPanel(task);
|
|
},
|
|
[openMonitorPanel],
|
|
);
|
|
const handleOpenShellDetails = useCallback(
|
|
(task: DaemonSessionShellTaskStatus) => {
|
|
setTasksDialogMessage(null);
|
|
setBackgroundTasksRefreshTrigger((value) => value + 1);
|
|
openShellPanel(task);
|
|
},
|
|
[openShellPanel],
|
|
);
|
|
const [selectedTheme, setSelectedTheme] = useState<WebShellTheme>(
|
|
providedTheme ?? WebShellThemeId.Dark,
|
|
);
|
|
const [currentModel, setCurrentModel] = useState('');
|
|
const currentModelRef = useRef(currentModel);
|
|
currentModelRef.current = currentModel;
|
|
const setPendingModel = useCallback((modelId: string) => {
|
|
currentModelRef.current = modelId;
|
|
setCurrentModel(modelId);
|
|
}, []);
|
|
const [pendingReasoningIntent, setPendingReasoningIntentState] = useState<
|
|
PendingReasoningIntent | undefined
|
|
>(undefined);
|
|
const pendingReasoningIntentRef = useRef(pendingReasoningIntent);
|
|
const setPendingReasoningIntent = useCallback(
|
|
(intent: PendingReasoningIntent | undefined) => {
|
|
pendingReasoningIntentRef.current = intent;
|
|
setPendingReasoningIntentState(intent);
|
|
},
|
|
[],
|
|
);
|
|
const connectionRef = useRef(connection);
|
|
connectionRef.current = connection;
|
|
const selectWelcomeModel = useCallback(
|
|
(modelId: string) => {
|
|
const models = connectionRef.current.models;
|
|
const reasoningIntent = pendingReasoningIntentRef.current;
|
|
const sourceReasoningIntent =
|
|
reasoningIntent?.modelId === currentModelRef.current
|
|
? reasoningIntent
|
|
: undefined;
|
|
const reasoningPreview = models?.find(
|
|
(model) => model.id === modelId,
|
|
)?.reasoningPreview;
|
|
const keepReasoningIntent =
|
|
sourceReasoningIntent &&
|
|
reasoningPreview &&
|
|
reasoningPreviewSupports(reasoningPreview, sourceReasoningIntent.value);
|
|
setPendingReasoningIntent(
|
|
sourceReasoningIntent?.fromToggle && modelId !== currentModelRef.current
|
|
? undefined
|
|
: sourceReasoningIntent && keepReasoningIntent
|
|
? { ...sourceReasoningIntent, modelId }
|
|
: undefined,
|
|
);
|
|
setPendingModel(modelId);
|
|
},
|
|
[setPendingModel, setPendingReasoningIntent],
|
|
);
|
|
/**
|
|
* Whether a local action must be held back because a Goal owns the session.
|
|
* Reads the latest connection through the ref so callers get the gate as of
|
|
* call time. Commands and run guards fail closed during Goal hydration;
|
|
* ordinary chat submissions do not consult this gate.
|
|
*/
|
|
const isGoalGateBlocked = useCallback(
|
|
() => isGoalGateBlockedFor(connectionRef.current),
|
|
[],
|
|
);
|
|
const refreshActiveSessionDisplayName = useCallback(async () => {
|
|
const activeConnection = connectionRef.current;
|
|
if (
|
|
activeConnection.sessionId &&
|
|
activeConnection.sessionContext?.kind === 'standalone'
|
|
) {
|
|
const owner = sessionOwnerGuard.capture();
|
|
try {
|
|
const summary = await workspace.client.getStandaloneSession(
|
|
activeConnection.sessionId,
|
|
);
|
|
if (
|
|
owner.isCurrent() &&
|
|
connectionRef.current.sessionId === activeConnection.sessionId &&
|
|
!('state' in summary) &&
|
|
summary.displayName?.trim()
|
|
) {
|
|
setSessionStatusDisplayName(summary.displayName);
|
|
}
|
|
} catch {
|
|
// A title refresh must not disrupt the completed conversation.
|
|
}
|
|
return;
|
|
}
|
|
if (
|
|
!activeConnection.sessionId ||
|
|
!activeConnection.workspaceCwd ||
|
|
(activeConnection.sessionContext &&
|
|
activeConnection.sessionContext.kind !== 'workspace')
|
|
) {
|
|
return;
|
|
}
|
|
const owner = sessionOwnerGuard.capture();
|
|
try {
|
|
const page = await loadSessionCatalogOnce(
|
|
workspace.client,
|
|
{
|
|
routeKind: 'legacy',
|
|
workspaceCwd: activeConnection.workspaceCwd,
|
|
options: { pageSize: 200 },
|
|
},
|
|
{ fresh: true },
|
|
);
|
|
if (
|
|
!owner.isCurrent() ||
|
|
connectionRef.current.sessionId !== activeConnection.sessionId ||
|
|
connectionRef.current.workspaceCwd !== activeConnection.workspaceCwd ||
|
|
connectionRef.current.displayName
|
|
) {
|
|
return;
|
|
}
|
|
const displayName = page.sessions.find(
|
|
(session) => session.sessionId === activeConnection.sessionId,
|
|
)?.displayName;
|
|
if (displayName?.trim()) setSessionStatusDisplayName(displayName);
|
|
} catch {
|
|
// The live session_metadata_updated event remains the primary path.
|
|
}
|
|
}, [sessionOwnerGuard, workspace.client]);
|
|
const refreshActiveSessionDisplayNameRef = useRef(
|
|
refreshActiveSessionDisplayName,
|
|
);
|
|
refreshActiveSessionDisplayNameRef.current = refreshActiveSessionDisplayName;
|
|
const delayedDisplayNameRefreshTimerRef = useRef<ReturnType<
|
|
typeof setTimeout
|
|
> | null>(null);
|
|
const pendingDisplayNameRefreshRef = useRef<
|
|
{ sessionId: string; workspaceCwd: string } | undefined
|
|
>(undefined);
|
|
const refreshDisplayNameIfCurrent = useCallback(
|
|
(sessionId: string, workspaceCwd: string) => {
|
|
const activeConnection = connectionRef.current;
|
|
if (
|
|
activeConnection.sessionId !== sessionId ||
|
|
activeConnection.workspaceCwd !== workspaceCwd ||
|
|
activeConnection.displayName
|
|
) {
|
|
return;
|
|
}
|
|
void refreshActiveSessionDisplayNameRef.current();
|
|
},
|
|
[],
|
|
);
|
|
const scheduleDelayedActiveSessionDisplayNameRefresh = useCallback(
|
|
(sessionId: string, workspaceCwd: string) => {
|
|
pendingDisplayNameRefreshRef.current = undefined;
|
|
if (delayedDisplayNameRefreshTimerRef.current !== null) {
|
|
clearTimeout(delayedDisplayNameRefreshTimerRef.current);
|
|
}
|
|
delayedDisplayNameRefreshTimerRef.current = setTimeout(() => {
|
|
delayedDisplayNameRefreshTimerRef.current = null;
|
|
if (typeof document !== 'undefined' && document.hidden) {
|
|
pendingDisplayNameRefreshRef.current = { sessionId, workspaceCwd };
|
|
return;
|
|
}
|
|
refreshDisplayNameIfCurrent(sessionId, workspaceCwd);
|
|
}, SESSION_CATALOG_TRAILING_REFRESH_MS);
|
|
},
|
|
[refreshDisplayNameIfCurrent],
|
|
);
|
|
useEffect(() => {
|
|
const onVisibilityChange = () => {
|
|
if (document.hidden) return;
|
|
const pending = pendingDisplayNameRefreshRef.current;
|
|
if (!pending) return;
|
|
pendingDisplayNameRefreshRef.current = undefined;
|
|
refreshDisplayNameIfCurrent(pending.sessionId, pending.workspaceCwd);
|
|
};
|
|
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
return () => {
|
|
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
if (delayedDisplayNameRefreshTimerRef.current !== null) {
|
|
clearTimeout(delayedDisplayNameRefreshTimerRef.current);
|
|
}
|
|
};
|
|
}, [refreshDisplayNameIfCurrent]);
|
|
const requireActiveSessionForLocalCommand = useCallback((): boolean => {
|
|
if (connectionRef.current.sessionId) return true;
|
|
pushToast('info', t('localCommand.noSession'));
|
|
return false;
|
|
}, [pushToast, t]);
|
|
const sessionDisplayName =
|
|
connection.sessionContext?.kind === 'standalone'
|
|
? (sessionStatusDisplayName ?? connection.displayName)
|
|
: (connection.displayName ?? sessionStatusDisplayName);
|
|
useEffect(() => {
|
|
onSessionInfoChange?.({
|
|
sessionId: connection.sessionId,
|
|
sessionName: sessionDisplayName,
|
|
});
|
|
}, [connection.sessionId, onSessionInfoChange, sessionDisplayName]);
|
|
const currentModelAuthType = connection.context?.state._meta?.['authType'];
|
|
useEffect(() => {
|
|
onModelChange?.(
|
|
connection.currentModel,
|
|
typeof currentModelAuthType === 'string'
|
|
? currentModelAuthType
|
|
: undefined,
|
|
);
|
|
}, [connection.currentModel, currentModelAuthType, onModelChange]);
|
|
const [currentMode, setCurrentMode] = useState('default');
|
|
const [planExecutionMode, setPlanExecutionMode] = useState('default');
|
|
const executionMode =
|
|
currentMode === 'plan' ? planExecutionMode : currentMode;
|
|
const executionModeRef = useRef(executionMode);
|
|
executionModeRef.current = executionMode;
|
|
const currentModeRef = useRef(currentMode);
|
|
currentModeRef.current = currentMode;
|
|
const sessionSourceTypeRef = useRef(sessionSourceType);
|
|
sessionSourceTypeRef.current = sessionSourceType;
|
|
const pendingModeSelectionRef = useRef(false);
|
|
const setPendingMode = useCallback((modeId: string) => {
|
|
pendingModeSelectionRef.current = true;
|
|
currentModeRef.current = modeId;
|
|
setCurrentMode(modeId);
|
|
}, []);
|
|
const [isPreparingPrompt, setIsPreparingPrompt] = useState(false);
|
|
const promptPreparationOwnerRef = useRef<symbol | null>(null);
|
|
const beginPromptPreparation = useCallback(() => {
|
|
const owner = Symbol('prompt-preparation');
|
|
promptPreparationOwnerRef.current = owner;
|
|
setIsPreparingPrompt(true);
|
|
return owner;
|
|
}, []);
|
|
const finishPromptPreparation = useCallback((owner: symbol | undefined) => {
|
|
if (!owner || promptPreparationOwnerRef.current !== owner) return;
|
|
promptPreparationOwnerRef.current = null;
|
|
setIsPreparingPrompt(false);
|
|
}, []);
|
|
const planPreparationTokenRef = useRef(0);
|
|
useLayoutEffect(() => {
|
|
planPreparationTokenRef.current += 1;
|
|
promptPreparationOwnerRef.current = null;
|
|
setIsPreparingPrompt(false);
|
|
}, [logicalSessionKey]);
|
|
const createSessionPromiseRef = useRef<Promise<string | undefined> | null>(
|
|
null,
|
|
);
|
|
const preparingSessionIdRef = useRef<string | null>(null);
|
|
useEffect(() => {
|
|
if (
|
|
connection.sessionId &&
|
|
connection.sessionId !== preparingSessionIdRef.current
|
|
) {
|
|
setPendingReasoningIntent(undefined);
|
|
}
|
|
}, [connection.sessionId, setPendingReasoningIntent]);
|
|
const allocatedSessionCatalogOwnerRef = useRef<
|
|
| {
|
|
sessionId: string;
|
|
workspaceCwd: string;
|
|
}
|
|
| undefined
|
|
>(undefined);
|
|
const newSessionSuggestionSubmitTokenRef = useRef(0);
|
|
const pendingNewSessionSuggestionSubmitRef = useRef<{
|
|
token: number;
|
|
sourceSessionId: string | undefined;
|
|
mode: 'cleared' | 'live';
|
|
newSessionReady: boolean;
|
|
submitScheduled: boolean;
|
|
} | null>(null);
|
|
const onSessionCreatedRef = useRef(onSessionCreated);
|
|
onSessionCreatedRef.current = onSessionCreated;
|
|
/**
|
|
* The session a failed Goal creation left behind.
|
|
*
|
|
* Creating a Goal from the Goals page allocates a fresh session and then
|
|
* installs the Goal in it; the daemon session is created lazily, so an
|
|
* attempt that fails leaves a session that exists but never got its Goal.
|
|
*
|
|
* The form keeps the condition and lets the user retry. Without this ref
|
|
* every retry would abandon that session and create another, piling up blank
|
|
* chats in the sidebar. Remembering it lets the retry reuse it — no session
|
|
* is ever deleted.
|
|
*
|
|
* Only valid while the Goals page stays mounted. The moment the user leaves,
|
|
* that session is reachable from the composer and may stop being a scratch
|
|
* session, so the effect below forgets it: a later Goal then starts a fresh
|
|
* session rather than landing on top of a conversation.
|
|
*/
|
|
const strandedGoalSessionRef = useRef<string | undefined>(undefined);
|
|
useEffect(() => {
|
|
if (mainView !== 'goals') strandedGoalSessionRef.current = undefined;
|
|
}, [mainView]);
|
|
const getComposerWorkspaceCwd = useCallback(() => {
|
|
const productContext =
|
|
pendingSessionContextRef.current ?? connectionRef.current.sessionContext;
|
|
if (productContext && productContext.kind !== 'workspace') {
|
|
return undefined;
|
|
}
|
|
if (connectionRef.current.sessionId) {
|
|
return productContext?.kind === 'workspace'
|
|
? productContext.cwd
|
|
: connectionRef.current.workspaceCwd;
|
|
}
|
|
return (
|
|
workspacesRef.current.find(
|
|
(entry) => entry.cwd === lockedWorkspaceCwdRef.current,
|
|
)?.cwd ??
|
|
selectedWorkspaceCwdRef.current ??
|
|
workspacesRef.current.find((entry) => entry.primary)?.cwd
|
|
);
|
|
}, []);
|
|
const ensureSessionForPrompt = useCallback(
|
|
(notify?: (id: string) => void, initialTitle?: string) => {
|
|
const currentSessionId = connectionRef.current.sessionId;
|
|
if (createSessionPromiseRef.current) {
|
|
if (
|
|
!currentSessionId ||
|
|
currentSessionId === preparingSessionIdRef.current
|
|
) {
|
|
return createSessionPromiseRef.current;
|
|
}
|
|
return Promise.resolve(undefined);
|
|
}
|
|
if (currentSessionId) return Promise.resolve(undefined);
|
|
const pendingManualTitle = pendingManualTitleRef.current;
|
|
const promise = (async () => {
|
|
let allocatedSessionId: string | undefined;
|
|
const modelId =
|
|
currentModelRef.current || connectionRef.current.currentModel;
|
|
const reasoningIntent = pendingReasoningIntentRef.current;
|
|
const reasoningPreview = connectionRef.current.models?.find(
|
|
(model) => model.id === modelId,
|
|
)?.reasoningPreview;
|
|
const reasoningEffort =
|
|
reasoningIntent &&
|
|
reasoningIntent.modelId === modelId &&
|
|
reasoningPreview &&
|
|
reasoningPreviewSupports(reasoningPreview, reasoningIntent.value)
|
|
? reasoningIntent.value
|
|
: undefined;
|
|
const modeId = executionModeRef.current;
|
|
const planMode = currentModeRef.current === 'plan';
|
|
const requestedSessionContext =
|
|
pendingSessionContextRef.current ??
|
|
connectionRef.current.sessionContext;
|
|
const availableWorkspaces = workspacesRef.current;
|
|
const primaryWorkspaceCwd = availableWorkspaces.find(
|
|
(entry) => entry.primary && entry.trusted !== false,
|
|
)?.cwd;
|
|
const requestedWorkspaceCwd = selectedWorkspaceCwdRef.current;
|
|
const acceptedWorkspaceCwd = requestedWorkspaceCwd
|
|
? availableWorkspaces.find(
|
|
(entry) =>
|
|
entry.cwd === requestedWorkspaceCwd && entry.trusted !== false,
|
|
)?.cwd
|
|
: undefined;
|
|
const targetWorkspaceCwd =
|
|
requestedSessionContext?.kind === 'workspace'
|
|
? (availableWorkspaces.find(
|
|
(entry) =>
|
|
entry.cwd === requestedSessionContext.cwd &&
|
|
entry.trusted !== false,
|
|
)?.cwd ??
|
|
(requestedSessionContext.cwd ===
|
|
connectionRef.current.workspaceCwd &&
|
|
availableWorkspaces.find(
|
|
(entry) => entry.cwd === requestedSessionContext.cwd,
|
|
)?.trusted !== false
|
|
? requestedSessionContext.cwd
|
|
: undefined))
|
|
: requestedSessionContext?.kind === 'standalone'
|
|
? undefined
|
|
: (availableWorkspaces.find(
|
|
(entry) => entry.cwd === lockedWorkspaceCwd,
|
|
)?.cwd ??
|
|
acceptedWorkspaceCwd ??
|
|
primaryWorkspaceCwd);
|
|
if (
|
|
requestedSessionContext?.kind === 'workspace' &&
|
|
!targetWorkspaceCwd
|
|
) {
|
|
throw new Error('The selected workspace is unavailable or untrusted');
|
|
}
|
|
const creationSessionContext: DaemonProductSessionContext | undefined =
|
|
requestedSessionContext ??
|
|
(targetWorkspaceCwd
|
|
? { kind: 'workspace', cwd: targetWorkspaceCwd }
|
|
: undefined);
|
|
const catalogWorkspaceCwd =
|
|
creationSessionContext?.kind === 'workspace'
|
|
? creationSessionContext.cwd
|
|
: undefined;
|
|
const draftProviders = connectionRef.current.providers;
|
|
// Reapplying the effective workspace default would save it again,
|
|
// potentially overwriting a different default in user settings.
|
|
const useWorkspaceDefault =
|
|
!reasoningEffort &&
|
|
draftProviders?.workspaceCwd === catalogWorkspaceCwd &&
|
|
draftProviders?.current?.modelId === modelId;
|
|
try {
|
|
await createAndAttachSessionForPrompt({
|
|
sessionActions: sessionActions as typeof sessionActions &
|
|
SessionActionsWithCreate,
|
|
modelId: useWorkspaceDefault ? undefined : modelId,
|
|
reasoningEffort,
|
|
modeId,
|
|
planMode,
|
|
workspaceCwd: targetWorkspaceCwd,
|
|
sessionContext: creationSessionContext,
|
|
worktree:
|
|
creationSessionContext?.kind === 'workspace' &&
|
|
gitModeIntentRef.current.mode === 'worktree'
|
|
? { slug: gitModeIntentRef.current.slug }
|
|
: undefined,
|
|
branch:
|
|
creationSessionContext?.kind === 'workspace' &&
|
|
gitModeIntentRef.current.mode === 'branch'
|
|
? { name: gitModeIntentRef.current.name }
|
|
: undefined,
|
|
sessionSourceType: sessionSourceTypeRef.current,
|
|
onSessionCreated: async (sessionId) => {
|
|
if (
|
|
pendingManualTitle &&
|
|
pendingManualTitleRef.current === pendingManualTitle
|
|
) {
|
|
try {
|
|
await sessionActions.renameSession(
|
|
pendingManualTitle.displayName,
|
|
);
|
|
if (catalogWorkspaceCwd) {
|
|
sessionCatalogController.renamed(
|
|
catalogWorkspaceCwd,
|
|
sessionId,
|
|
pendingManualTitle.displayName,
|
|
);
|
|
}
|
|
} catch {
|
|
pendingManualTitleRef.current = undefined;
|
|
}
|
|
} else if (initialTitle) {
|
|
// Name the new session from its first prompt before the row
|
|
// is first rendered, so it never shows the session-id
|
|
// fallback. Best-effort: on failure the daemon's first-prompt
|
|
// label still names it.
|
|
try {
|
|
await sessionActions.renameSession(initialTitle);
|
|
if (catalogWorkspaceCwd) {
|
|
sessionCatalogController.renamed(
|
|
catalogWorkspaceCwd,
|
|
sessionId,
|
|
initialTitle,
|
|
);
|
|
}
|
|
} catch {
|
|
// Fall back to the first-prompt label.
|
|
}
|
|
}
|
|
// Start the first catalog read only after the rename (or the
|
|
// carry) settled, so the row's first publish already carries
|
|
// the name instead of the session-id prefix.
|
|
if (catalogWorkspaceCwd) {
|
|
sessionCatalogController.sessionCreated(
|
|
catalogWorkspaceCwd,
|
|
sessionId,
|
|
);
|
|
}
|
|
await onSessionCreatedRef.current?.(sessionId);
|
|
},
|
|
onSessionAllocated: (sessionId) => {
|
|
draftMaterializedSessionIdRef.current = sessionId;
|
|
// A still-armed /clear carry wins over the fresh prompt title;
|
|
// do not register a provisional title it would have to undo.
|
|
const carryWillApply =
|
|
pendingManualTitle !== undefined &&
|
|
pendingManualTitleRef.current === pendingManualTitle;
|
|
if (catalogWorkspaceCwd) {
|
|
allocatedSessionCatalogOwnerRef.current = {
|
|
sessionId,
|
|
workspaceCwd: catalogWorkspaceCwd,
|
|
};
|
|
if (carryWillApply && pendingManualTitle) {
|
|
// The carry title is authoritative for this successor; use
|
|
// it as the provisional name too so no read can publish the
|
|
// id before the rename lands.
|
|
sessionCatalogController.setPendingSessionTitle(
|
|
catalogWorkspaceCwd,
|
|
sessionId,
|
|
pendingManualTitle.displayName,
|
|
);
|
|
} else if (initialTitle) {
|
|
// Register the title before notify() so a render triggered
|
|
// by the callback cannot publish the row first.
|
|
sessionCatalogController.setPendingSessionTitle(
|
|
catalogWorkspaceCwd,
|
|
sessionId,
|
|
initialTitle,
|
|
);
|
|
}
|
|
}
|
|
notify?.(sessionId);
|
|
preparingSessionIdRef.current = sessionId;
|
|
allocatedSessionId = sessionId;
|
|
},
|
|
getCurrentSessionId: () => connectionRef.current.sessionId,
|
|
getCurrentWorkspaceCwd: getComposerWorkspaceCwd,
|
|
}).then((result) => {
|
|
if (pendingManualTitleRef.current === pendingManualTitle) {
|
|
pendingManualTitleRef.current = undefined;
|
|
}
|
|
if (result.worktree) {
|
|
setSessionWorktree(result.worktree);
|
|
}
|
|
if (result.branch) {
|
|
setSessionBranch(result.branch);
|
|
}
|
|
// Clear the pending intent only on success. On failure the
|
|
// composer chip stays in the selected mode so the user knows
|
|
// the intent was not fulfilled and can retry.
|
|
setGitModeIntent({ mode: 'current' });
|
|
if (pendingReasoningIntentRef.current === reasoningIntent) {
|
|
setPendingReasoningIntent(undefined);
|
|
}
|
|
if (pendingSessionContextRef.current === requestedSessionContext) {
|
|
setPendingSessionContext(undefined);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
if (allocatedSessionId && catalogWorkspaceCwd) {
|
|
sessionCatalogController.invalidateWorkspace(catalogWorkspaceCwd);
|
|
}
|
|
throw error;
|
|
}
|
|
// One-shot: the picker targets only the *next* new session, so clear
|
|
// it after creation. The next new chat defaults back to the primary
|
|
// workspace unless the user picks one again.
|
|
if (creationSessionContext?.kind === 'workspace') {
|
|
setSelectedWorkspaceCwd(undefined);
|
|
}
|
|
return allocatedSessionId;
|
|
})();
|
|
createSessionPromiseRef.current = promise;
|
|
const clearPreparation = () => {
|
|
if (createSessionPromiseRef.current === promise) {
|
|
createSessionPromiseRef.current = null;
|
|
preparingSessionIdRef.current = null;
|
|
}
|
|
};
|
|
void promise.then(clearPreparation, clearPreparation);
|
|
return promise;
|
|
},
|
|
[
|
|
getComposerWorkspaceCwd,
|
|
lockedWorkspaceCwd,
|
|
sessionActions,
|
|
sessionCatalogController,
|
|
setPendingSessionContext,
|
|
setPendingReasoningIntent,
|
|
],
|
|
);
|
|
const onSubmitBeforeRef = useRef(onSubmitBefore);
|
|
onSubmitBeforeRef.current = onSubmitBefore;
|
|
const prepareSubmitRef = useRef(prepareSubmit);
|
|
prepareSubmitRef.current = prepareSubmit;
|
|
const onSlashCommandRef = useRef(onSlashCommand);
|
|
onSlashCommandRef.current = onSlashCommand;
|
|
const retryOwnerIsCurrent = useCallback(
|
|
(owner: CancelledRetryOwner) =>
|
|
retryOwnerMatchesCurrent(
|
|
owner,
|
|
connectionRef.current.sessionId,
|
|
getComposerWorkspaceCwd(),
|
|
composerSourceVersionRef.current,
|
|
),
|
|
[getComposerWorkspaceCwd],
|
|
);
|
|
const dispatchSessionChange = useCallback(
|
|
(event: SessionChangeEvent) => {
|
|
onSessionChange?.(event);
|
|
},
|
|
[onSessionChange],
|
|
);
|
|
// Ref-stable handle so that useCallback hooks (sendPrompt, enqueuePrompt,
|
|
// turn_complete effect) don't need dispatchSessionChange in their dep arrays.
|
|
// Without this, an unstable onSessionChange prop would cause those callbacks
|
|
// to be recreated on every render, cascading into downstream effect chains.
|
|
const dispatchSessionChangeRef = useRef(dispatchSessionChange);
|
|
dispatchSessionChangeRef.current = dispatchSessionChange;
|
|
// Single error-to-toast helper: suppresses aborts, daemon-turn errors and
|
|
// already-dispatched notices before surfacing anything. Declared above
|
|
// sendPrompt / enqueuePrompt so their dep arrays can reference it (a
|
|
// reference below those callbacks would be a TDZ error).
|
|
const reportError = useCallback(
|
|
(error: unknown, fallback: string) => {
|
|
if (isAbortError(error)) return;
|
|
if (isDaemonTurnError(error)) {
|
|
return;
|
|
}
|
|
if (isAlreadyDispatched(error)) {
|
|
return;
|
|
}
|
|
const message = isAcpChildCapacityError(error)
|
|
? t('daemon.capacity.exhausted')
|
|
: formatError(error, fallback);
|
|
console.error('[web-shell]', message, error);
|
|
pushToast('error', message);
|
|
},
|
|
[pushToast, t],
|
|
);
|
|
const archiveSideTasks = useCallback(
|
|
async (sideTasks: readonly SideTaskListItem[]) => {
|
|
if (!sideTaskArchiveAvailable) return;
|
|
const eligibleTasks = sideTasks.filter(
|
|
(sideTask) => sideTask.archivable === true,
|
|
);
|
|
if (eligibleTasks.length === 0) return;
|
|
// Archive through each task's owning workspace runtime: the unscoped
|
|
// route resolves ids against the legacy-primary catalog, where a
|
|
// secondary-Workspace task answers notFound without migrating.
|
|
const idsByWorkspace = new Map<string | undefined, string[]>();
|
|
for (const sideTask of eligibleTasks) {
|
|
const key = sideTask.workspaceCwd?.trim()
|
|
? sideTask.workspaceCwd
|
|
: undefined;
|
|
const ids = idsByWorkspace.get(key);
|
|
if (ids === undefined) idsByWorkspace.set(key, [sideTask.sessionId]);
|
|
else ids.push(sideTask.sessionId);
|
|
}
|
|
try {
|
|
const batches = [...idsByWorkspace.entries()].flatMap(([cwd, ids]) =>
|
|
Array.from(
|
|
{ length: Math.ceil(ids.length / SESSION_ARCHIVE_BATCH_SIZE) },
|
|
(_, index) => ({
|
|
cwd,
|
|
ids: ids.slice(
|
|
index * SESSION_ARCHIVE_BATCH_SIZE,
|
|
(index + 1) * SESSION_ARCHIVE_BATCH_SIZE,
|
|
),
|
|
}),
|
|
),
|
|
);
|
|
const settledResults = await Promise.allSettled(
|
|
batches.map((batch) =>
|
|
batch.cwd === undefined
|
|
? workspace.client.archiveSessionsData(batch.ids, {
|
|
requireIdle: true,
|
|
})
|
|
: workspace.client
|
|
.workspaceByCwd(batch.cwd)
|
|
.archiveSessionsData(batch.ids, { requireIdle: true }),
|
|
),
|
|
);
|
|
const results = settledResults.flatMap((result) =>
|
|
result.status === 'fulfilled' ? [result.value] : [],
|
|
);
|
|
const archived = new Set([
|
|
...results.flatMap((result) => result.archived),
|
|
...results.flatMap((result) => result.alreadyArchived),
|
|
]);
|
|
if (archived.size > 0) {
|
|
sideTaskCatalogRequestGenerationRef.current += 1;
|
|
for (const sessionId of archived) {
|
|
optimisticSideTaskIdsRef.current.delete(sessionId);
|
|
}
|
|
setSideTaskCatalog((catalog) => ({
|
|
...catalog,
|
|
items: catalog.items.filter(
|
|
(sideTask) => !archived.has(sideTask.sessionId),
|
|
),
|
|
}));
|
|
removeSideTaskTabs(archived);
|
|
setSideTaskCatalogRefreshNonce((nonce) => nonce + 1);
|
|
}
|
|
const errorMessages = [
|
|
...results.flatMap((result) =>
|
|
result.notFound.map(
|
|
(sessionId) => `${sessionId}: not found in its workspace catalog`,
|
|
),
|
|
),
|
|
...results.flatMap((result) =>
|
|
result.errors.map((entry) => `${entry.sessionId}: ${entry.error}`),
|
|
),
|
|
...settledResults.flatMap((result, index) =>
|
|
result.status === 'rejected'
|
|
? batches[index]!.ids.map(
|
|
(sessionId) =>
|
|
`${sessionId}: ${formatError(result.reason, t('sidebar.archiveFailed'))}`,
|
|
)
|
|
: [],
|
|
),
|
|
];
|
|
if (errorMessages.length > 0) {
|
|
throw new Error(errorMessages.join('; '));
|
|
}
|
|
} catch (error) {
|
|
reportError(error, t('sidebar.archiveFailed'));
|
|
}
|
|
},
|
|
[
|
|
reportError,
|
|
removeSideTaskTabs,
|
|
sideTaskArchiveAvailable,
|
|
t,
|
|
workspace.client,
|
|
],
|
|
);
|
|
const unarchiveSideTasks = useCallback(
|
|
async (sideTasks: readonly SideTaskListItem[]) => {
|
|
if (!sideTaskRestoreAvailable) return;
|
|
if (sideTasks.length === 0) return;
|
|
const idsByWorkspace = new Map<string | undefined, string[]>();
|
|
for (const sideTask of sideTasks) {
|
|
const key = sideTask.workspaceCwd?.trim()
|
|
? sideTask.workspaceCwd
|
|
: undefined;
|
|
const ids = idsByWorkspace.get(key);
|
|
if (ids === undefined) idsByWorkspace.set(key, [sideTask.sessionId]);
|
|
else ids.push(sideTask.sessionId);
|
|
}
|
|
try {
|
|
const groups = [...idsByWorkspace.entries()];
|
|
const settledResults = await Promise.allSettled(
|
|
groups.map(([cwd, ids]) =>
|
|
cwd === undefined
|
|
? workspace.client.unarchiveSessionsData(ids)
|
|
: workspace.client.workspaceByCwd(cwd).unarchiveSessionsData(ids),
|
|
),
|
|
);
|
|
const results = settledResults.flatMap((result) =>
|
|
result.status === 'fulfilled' ? [result.value] : [],
|
|
);
|
|
const restored = new Set([
|
|
...results.flatMap((result) => result.unarchived),
|
|
...results.flatMap((result) => result.alreadyActive),
|
|
]);
|
|
if (restored.size > 0) {
|
|
sideTaskCatalogRequestGenerationRef.current += 1;
|
|
setArchivedSideTaskCatalog((catalog) => ({
|
|
...catalog,
|
|
items: catalog.items.filter(
|
|
(sideTask) => !restored.has(sideTask.sessionId),
|
|
),
|
|
}));
|
|
setSideTaskCatalogRefreshNonce((nonce) => nonce + 1);
|
|
}
|
|
const errorMessages = [
|
|
...results.flatMap((result) =>
|
|
result.notFound.map(
|
|
(sessionId) => `${sessionId}: not found in its workspace catalog`,
|
|
),
|
|
),
|
|
...results.flatMap((result) =>
|
|
result.errors.map((entry) => `${entry.sessionId}: ${entry.error}`),
|
|
),
|
|
...settledResults.flatMap((result, index) =>
|
|
result.status === 'rejected'
|
|
? groups[index]![1].map(
|
|
(sessionId) =>
|
|
`${sessionId}: ${formatError(result.reason, t('sidebar.unarchiveFailed'))}`,
|
|
)
|
|
: [],
|
|
),
|
|
];
|
|
if (errorMessages.length > 0) {
|
|
throw new Error(errorMessages.join('; '));
|
|
}
|
|
} catch (error) {
|
|
reportError(error, t('sidebar.unarchiveFailed'));
|
|
}
|
|
},
|
|
[reportError, sideTaskRestoreAvailable, t, workspace.client],
|
|
);
|
|
const refuseModelSetup = useCallback(
|
|
(text: string) => {
|
|
if (
|
|
modelManagementRef.current.allowAdd ||
|
|
!isModelSetupCommand(text, connectionRef.current.commands)
|
|
)
|
|
return false;
|
|
pushToast('info', t('settings.models.addDisabled'));
|
|
return true;
|
|
},
|
|
[pushToast, t],
|
|
);
|
|
const sendPrompt = useCallback(
|
|
async (
|
|
text: string,
|
|
images?: PromptImage[],
|
|
files?: PromptFile[],
|
|
opts?: {
|
|
optimisticUserMessage?: boolean;
|
|
bindBlockId?: string;
|
|
retry?: boolean;
|
|
// Skips prepareSubmit without daemon retry semantics; used
|
|
// by the failed-prompt retry, whose user message was never
|
|
// recorded.
|
|
skipPrepareSubmit?: boolean;
|
|
skipSubmitBefore?: boolean;
|
|
submittedPrompt?: string;
|
|
inputAnnotations?: DaemonInputAnnotation[];
|
|
clearComposerOnPromptStart?: boolean;
|
|
commitComposerAccepted?: ComposerSubmitCommit;
|
|
source?: 'mcp-app';
|
|
isCurrent?: () => boolean;
|
|
beforeDispatch?: (clientId: string | undefined) => void;
|
|
onAdmissionStarted?: (sessionId: string | undefined) => void;
|
|
onAdmitted?: (admission: { promptId: string }) => void;
|
|
onCancelledBeforeAdmission?: () => void;
|
|
onOptimisticUserMessage?: (message: OptimisticUserMessage) => void;
|
|
onPreparedSubmit?: (prepared: WebShellPreparedSubmit) => void;
|
|
onSessionAllocated?: (sessionId: string) => void;
|
|
beforeAdmission?: () => Promise<void>;
|
|
/**
|
|
* Internal commands (settings language switches) reuse the prompt
|
|
* path but must not name the session they create.
|
|
*/
|
|
suppressSessionTitle?: boolean;
|
|
ownerRef?: { current: DaemonSessionOwnerSnapshot };
|
|
},
|
|
) => {
|
|
if (sessionWriteBlockedRef.current) {
|
|
throw new DOMException(
|
|
'Session switch is still preparing',
|
|
'InvalidStateError',
|
|
);
|
|
}
|
|
const preserveComposer = opts?.source === 'mcp-app';
|
|
let preparedPrompt = text;
|
|
let preparedInputAnnotations = opts?.inputAnnotations
|
|
? [...opts.inputAnnotations]
|
|
: [];
|
|
let promptPreparationOwner: symbol | undefined;
|
|
const startPreparing = () => {
|
|
promptPreparationOwner ??= beginPromptPreparation();
|
|
};
|
|
const finishPreparing = () => {
|
|
finishPromptPreparation(promptPreparationOwner);
|
|
};
|
|
const restoreCancelledSubmitState = () => {
|
|
finishPreparing();
|
|
opts?.onCancelledBeforeAdmission?.();
|
|
};
|
|
const shouldShowPreparing = !connectionRef.current.sessionId;
|
|
const prepare =
|
|
opts?.retry || opts?.skipPrepareSubmit
|
|
? undefined
|
|
: prepareSubmitRef.current;
|
|
const submitBefore = opts?.skipSubmitBefore
|
|
? undefined
|
|
: onSubmitBeforeRef.current;
|
|
const hasAsyncPreflight = Boolean(
|
|
prepare || submitBefore || opts?.beforeAdmission,
|
|
);
|
|
const admissionSource = {
|
|
owner: sessionOwnerGuard.capture(),
|
|
sessionId: connectionRef.current.sessionId,
|
|
workspaceCwd: getComposerWorkspaceCwd(),
|
|
sourceVersion: composerSourceVersionRef.current,
|
|
writeBlockGeneration: sessionWriteBlockGenerationRef.current,
|
|
};
|
|
const admissionOwnerIsCurrent = (allocatedSessionId?: string) => {
|
|
if (!appMountedRef.current) return false;
|
|
const currentSessionId = connectionRef.current.sessionId;
|
|
const sessionMatches =
|
|
currentSessionId === admissionSource.sessionId ||
|
|
(admissionSource.sessionId === undefined &&
|
|
(currentSessionId === undefined ||
|
|
(allocatedSessionId !== undefined &&
|
|
currentSessionId === allocatedSessionId)));
|
|
const ownAllocationSucceeded =
|
|
admissionSource.sessionId === undefined &&
|
|
allocatedSessionId !== undefined &&
|
|
(currentSessionId === undefined ||
|
|
currentSessionId === allocatedSessionId);
|
|
return (
|
|
(admissionSource.owner.isCurrent() || ownAllocationSucceeded) &&
|
|
sessionMatches &&
|
|
(ownAllocationSucceeded ||
|
|
getComposerWorkspaceCwd() === admissionSource.workspaceCwd) &&
|
|
composerSourceVersionRef.current === admissionSource.sourceVersion
|
|
);
|
|
};
|
|
const admissionSourceIsCurrent = (allocatedSessionId?: string) =>
|
|
admissionOwnerIsCurrent(allocatedSessionId) &&
|
|
(opts?.isCurrent?.() ?? true) &&
|
|
!sessionWriteBlockedRef.current &&
|
|
sessionWriteBlockGenerationRef.current ===
|
|
admissionSource.writeBlockGeneration;
|
|
if (hasAsyncPreflight) {
|
|
startPreparing();
|
|
try {
|
|
if (prepare) {
|
|
const prepared = await prepare({
|
|
sessionId: admissionSource.sessionId,
|
|
prompt: preparedPrompt,
|
|
inputAnnotations: preparedInputAnnotations,
|
|
...(opts?.source ? { source: opts.source } : {}),
|
|
});
|
|
const resolved = resolvePreparedSubmit(
|
|
preparedPrompt,
|
|
preparedInputAnnotations,
|
|
prepared,
|
|
);
|
|
preparedPrompt = resolved.prompt;
|
|
preparedInputAnnotations = [...(resolved.inputAnnotations ?? [])];
|
|
}
|
|
if (!admissionSourceIsCurrent()) {
|
|
restoreCancelledSubmitState();
|
|
return;
|
|
}
|
|
if (submitBefore) {
|
|
await submitBefore({
|
|
sessionId: admissionSource.sessionId,
|
|
prompt: preparedPrompt,
|
|
...(opts?.source ? { source: opts.source } : {}),
|
|
});
|
|
}
|
|
} catch (err) {
|
|
if (!appMountedRef.current) return;
|
|
// Say so. Hosts put user-facing text in these errors — the VS Code
|
|
// companion's message-edit rewind throws localized failures here —
|
|
// and cancelling silently leaves the user in front of a composer
|
|
// that appeared to do nothing (#9911). Only when the user is still
|
|
// on the session this submission belonged to; a toast for a session
|
|
// they have left would be noise. reportError suppresses aborts and
|
|
// logs the error itself, so this catch no longer warns (that would
|
|
// double-log a real failure).
|
|
if (admissionOwnerIsCurrent()) {
|
|
reportError(err, 'Message could not be submitted');
|
|
}
|
|
// Restore retry-critical refs so Ctrl+Y doesn't resend the
|
|
// cancelled prompt.
|
|
restoreCancelledSubmitState();
|
|
if (preserveComposer && !isAbortError(err)) throw err;
|
|
return;
|
|
}
|
|
if (!appMountedRef.current) return;
|
|
if (!admissionSourceIsCurrent()) {
|
|
restoreCancelledSubmitState();
|
|
return;
|
|
}
|
|
}
|
|
if (
|
|
!preparedPrompt.trim() &&
|
|
(images?.length ?? 0) === 0 &&
|
|
(files?.length ?? 0) === 0
|
|
) {
|
|
restoreCancelledSubmitState();
|
|
return;
|
|
}
|
|
if (refuseModelSetup(preparedPrompt)) {
|
|
restoreCancelledSubmitState();
|
|
return;
|
|
}
|
|
opts?.onPreparedSubmit?.({
|
|
prompt: preparedPrompt,
|
|
inputAnnotations: preparedInputAnnotations,
|
|
});
|
|
if (!hasAsyncPreflight && shouldShowPreparing) {
|
|
startPreparing();
|
|
}
|
|
const existingSessionWorkspaceCwd = getComposerWorkspaceCwd();
|
|
let allocatedSessionId: string | undefined;
|
|
try {
|
|
allocatedSessionId = await ensureSessionForPrompt(
|
|
opts?.onSessionAllocated,
|
|
opts?.suppressSessionTitle
|
|
? undefined
|
|
: titleFromPrompt(preparedPrompt),
|
|
);
|
|
if (!admissionSourceIsCurrent(allocatedSessionId)) {
|
|
restoreCancelledSubmitState();
|
|
return;
|
|
}
|
|
if (opts?.beforeAdmission) await opts.beforeAdmission();
|
|
} finally {
|
|
if (
|
|
appMountedRef.current &&
|
|
(shouldShowPreparing || opts?.beforeAdmission)
|
|
) {
|
|
finishPreparing();
|
|
}
|
|
}
|
|
if (!appMountedRef.current) return;
|
|
if (!admissionSourceIsCurrent(allocatedSessionId)) {
|
|
restoreCancelledSubmitState();
|
|
return;
|
|
}
|
|
if (refuseModelSetup(preparedPrompt)) {
|
|
restoreCancelledSubmitState();
|
|
return;
|
|
}
|
|
const sessionIdAfterEnsure =
|
|
connectionRef.current.sessionId ?? allocatedSessionId;
|
|
const allocatedOwner = allocatedSessionCatalogOwnerRef.current;
|
|
const promptWorkspaceCwd = allocatedSessionId
|
|
? allocatedOwner?.sessionId === allocatedSessionId
|
|
? allocatedOwner.workspaceCwd
|
|
: undefined
|
|
: existingSessionWorkspaceCwd;
|
|
const isSlashPreparedSubmit = preparedPrompt.trimStart().startsWith('/');
|
|
if (
|
|
!preserveComposer &&
|
|
!opts?.retry &&
|
|
opts?.optimisticUserMessage !== false &&
|
|
!isSlashPreparedSubmit
|
|
) {
|
|
lastSubmittedPromptRef.current = preparedPrompt;
|
|
lastSubmittedImagesRef.current = images;
|
|
lastSubmittedFilesRef.current = files;
|
|
lastSubmittedInputAnnotationsRef.current =
|
|
preparedInputAnnotations.length > 0
|
|
? preparedInputAnnotations
|
|
: undefined;
|
|
lastSubmittedSourceVersionRef.current =
|
|
composerSourceVersionRef.current;
|
|
retryableTurnErrorIdRef.current = null;
|
|
retryableTurnErrorIdentityRef.current = undefined;
|
|
retriedTurnErrorIdRef.current = null;
|
|
failedTurnErrorRetryRef.current = null;
|
|
retryOwnerRef.current = {
|
|
sessionId: sessionIdAfterEnsure,
|
|
workspaceCwd: promptWorkspaceCwd,
|
|
sessionKey: getLogicalSessionKey(
|
|
sessionIdAfterEnsure,
|
|
promptWorkspaceCwd,
|
|
),
|
|
sourceVersion: composerSourceVersionRef.current,
|
|
snapshot: sessionOwnerGuard.capture(),
|
|
};
|
|
} else if (
|
|
!preserveComposer &&
|
|
!opts?.retry &&
|
|
opts?.optimisticUserMessage !== false &&
|
|
isSlashPreparedSubmit
|
|
) {
|
|
// A slash submit is never retried; disarm the previous submit's
|
|
// recorded retry state so a turn error on the slash turn cannot
|
|
// resend the already-succeeded earlier prompt.
|
|
disarmSubmittedPromptRetry();
|
|
}
|
|
if (!preserveComposer) setShowRetryHint(false);
|
|
finishPreparing();
|
|
if (opts?.ownerRef) opts.ownerRef.current = sessionOwnerGuard.capture();
|
|
if (!preserveComposer) {
|
|
clearFollowup();
|
|
if (opts?.commitComposerAccepted) {
|
|
opts.commitComposerAccepted();
|
|
} else if (opts?.clearComposerOnPromptStart) {
|
|
editorRef.current?.clear();
|
|
}
|
|
}
|
|
let admissionStarted = false;
|
|
let admitted = false;
|
|
const previousUserMessage = getLatestUserBlock(
|
|
store.getSnapshot().blocks,
|
|
);
|
|
let inFlightPrompt: InFlightPrompt | undefined;
|
|
// A text prompt gets its transcript block as it is sent, one with
|
|
// attachments only once it is admitted, so both points try.
|
|
const trackInFlightPrompt = () => {
|
|
if (
|
|
preserveComposer ||
|
|
inFlightPrompt ||
|
|
opts?.retry ||
|
|
opts?.optimisticUserMessage === false ||
|
|
isSlashPreparedSubmit
|
|
) {
|
|
return;
|
|
}
|
|
const block = getLatestUserBlock(store.getSnapshot().blocks);
|
|
if (!block || block === previousUserMessage) return;
|
|
inFlightPrompt = {
|
|
block,
|
|
text: preparedPrompt,
|
|
images,
|
|
files,
|
|
inputAnnotations: preparedInputAnnotations,
|
|
};
|
|
inFlightPromptRef.current = inFlightPrompt;
|
|
};
|
|
const promptOptions: SendPromptOptionsWithRetry = {
|
|
...(opts?.submittedPrompt !== undefined
|
|
? { submittedPrompt: opts.submittedPrompt }
|
|
: {}),
|
|
...(opts?.source ? { source: opts.source } : {}),
|
|
images,
|
|
files,
|
|
inputAnnotations:
|
|
preparedInputAnnotations.length > 0
|
|
? preparedInputAnnotations
|
|
: undefined,
|
|
optimisticUserMessage: opts?.optimisticUserMessage,
|
|
...(opts?.bindBlockId ? { bindBlockId: opts.bindBlockId } : {}),
|
|
retry: opts?.retry,
|
|
...(opts?.beforeDispatch
|
|
? { beforeDispatch: opts.beforeDispatch }
|
|
: {}),
|
|
onAdmissionStarted: () => {
|
|
opts?.onAdmissionStarted?.(
|
|
connectionRef.current.sessionId ?? allocatedSessionId,
|
|
);
|
|
admissionStarted = true;
|
|
},
|
|
onAdmitted: (admission) => {
|
|
admitted = true;
|
|
if (preserveComposer && admissionOwnerIsCurrent()) {
|
|
disarmSubmittedPromptRetry();
|
|
setShowRetryHint(false);
|
|
}
|
|
if (sessionIdAfterEnsure && promptWorkspaceCwd) {
|
|
sessionCatalogController.promptAdmitted(
|
|
promptWorkspaceCwd,
|
|
sessionIdAfterEnsure,
|
|
);
|
|
}
|
|
trackInFlightPrompt();
|
|
if (inFlightPrompt && admission?.promptId) {
|
|
inFlightPrompt.promptId = admission.promptId;
|
|
}
|
|
opts?.onAdmitted?.(admission);
|
|
},
|
|
};
|
|
if (
|
|
sessionIdAfterEnsure &&
|
|
(preparedPrompt.trim() ||
|
|
(images?.length ?? 0) > 0 ||
|
|
(files?.length ?? 0) > 0)
|
|
) {
|
|
dispatchSessionChangeRef.current?.({
|
|
type: 'submit',
|
|
sessionId: sessionIdAfterEnsure,
|
|
prompt: preparedPrompt,
|
|
queued: false,
|
|
});
|
|
}
|
|
const resultPromise = (
|
|
sessionActions.sendPrompt as (
|
|
promptText: string,
|
|
options?: SendPromptOptionsWithRetry,
|
|
) => ReturnType<typeof sessionActions.sendPrompt>
|
|
)(preparedPrompt, promptOptions);
|
|
trackInFlightPrompt();
|
|
if (
|
|
sessionIdAfterEnsure &&
|
|
opts?.optimisticUserMessage !== false &&
|
|
opts?.onOptimisticUserMessage
|
|
) {
|
|
const message = getLatestUserBlock(store.getSnapshot().blocks);
|
|
if (message && message !== previousUserMessage) {
|
|
opts.onOptimisticUserMessage({
|
|
sessionId: sessionIdAfterEnsure,
|
|
messageId: message.id,
|
|
identity: { block: message },
|
|
owner: retryOwnerRef.current,
|
|
...(previousUserMessage
|
|
? { previousIdentity: { block: previousUserMessage } }
|
|
: {}),
|
|
});
|
|
}
|
|
}
|
|
try {
|
|
return await resultPromise;
|
|
} catch (error) {
|
|
if (
|
|
admissionStarted &&
|
|
!admitted &&
|
|
!(preserveComposer
|
|
? isMcpAppPromptAdmissionRejected(error)
|
|
: isDefinitelyRejectedPromptAdmission(error)) &&
|
|
promptWorkspaceCwd
|
|
) {
|
|
sessionCatalogController.promptAdmissionUncertain(promptWorkspaceCwd);
|
|
}
|
|
throw error;
|
|
} finally {
|
|
if (inFlightPrompt && inFlightPromptRef.current === inFlightPrompt) {
|
|
inFlightPromptRef.current = null;
|
|
}
|
|
}
|
|
},
|
|
[
|
|
beginPromptPreparation,
|
|
clearFollowup,
|
|
disarmSubmittedPromptRetry,
|
|
ensureSessionForPrompt,
|
|
finishPromptPreparation,
|
|
getComposerWorkspaceCwd,
|
|
reportError,
|
|
refuseModelSetup,
|
|
sessionCatalogController,
|
|
sessionActions,
|
|
sessionOwnerGuard,
|
|
store,
|
|
],
|
|
);
|
|
|
|
const resolveSessionForWorkspace = useCallback(
|
|
async (cwd: string, forceCreate?: boolean): Promise<string | undefined> => {
|
|
try {
|
|
if (!forceCreate) {
|
|
// Reuse the connected session only when it owns the target checkout.
|
|
// For a linked-worktree session the owned checkout is the worktree
|
|
// path, not the base workspace (activeWorkspaceCwd), so a Commit
|
|
// opened on the base workspace must not borrow the worktree session.
|
|
const sessionOwnerCwd = sessionWorktree?.path ?? activeWorkspaceCwd;
|
|
if (connection.sessionId && sessionOwnerCwd === cwd) {
|
|
return connection.sessionId;
|
|
}
|
|
// Fetch the most recent session for this workspace.
|
|
const page = await loadSessionCatalogOnce(
|
|
workspace.client,
|
|
{
|
|
routeKind: 'qualified',
|
|
workspaceCwd: cwd,
|
|
options: { pageSize: 1, archiveState: 'active' },
|
|
},
|
|
{ fresh: true },
|
|
);
|
|
if (page.sessions.length > 0) return page.sessions[0].sessionId;
|
|
}
|
|
// No session exists or forced: create one. Creating binds the chat
|
|
// connection — session navigation that must discard an armed
|
|
// `/clear` carry, mirroring loadSidebarSession.
|
|
pendingManualTitleRef.current = undefined;
|
|
const result = await (
|
|
sessionActions as typeof sessionActions & SessionActionsWithCreate
|
|
).createSession({
|
|
workspaceCwd: cwd,
|
|
getCurrentWorkspaceCwd: getComposerWorkspaceCwd,
|
|
});
|
|
sessionCatalogController.sessionCreated(cwd, result.sessionId);
|
|
return result.sessionId;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
},
|
|
[
|
|
getComposerWorkspaceCwd,
|
|
connection.sessionId,
|
|
activeWorkspaceCwd,
|
|
sessionCatalogController,
|
|
workspace.client,
|
|
sessionActions,
|
|
sessionWorktree,
|
|
],
|
|
);
|
|
|
|
const availableModels = useMemo(
|
|
() =>
|
|
(connection.models ?? []).filter(isVisibleComposerModel).map((m) => ({
|
|
id: m.id,
|
|
label: getModelDisplayName(m.label || m.id),
|
|
})),
|
|
[connection.models],
|
|
);
|
|
const hasAuthoritativeReasoningContext = Boolean(
|
|
connection.sessionId &&
|
|
connection.context?.sessionId === connection.sessionId,
|
|
);
|
|
const welcomeReasoningPreview =
|
|
!connection.sessionId && !connection.context
|
|
? connection.models?.find((model) => model.id === currentModel)
|
|
?.reasoningPreview
|
|
: undefined;
|
|
useEffect(() => {
|
|
if (
|
|
pendingReasoningIntent?.modelId === currentModel &&
|
|
pendingReasoningIntent.value === 'none' &&
|
|
welcomeReasoningPreview?.canDisable === false
|
|
) {
|
|
setPendingReasoningIntent({
|
|
modelId: currentModel,
|
|
value: 'default',
|
|
});
|
|
}
|
|
}, [
|
|
currentModel,
|
|
pendingReasoningIntent,
|
|
setPendingReasoningIntent,
|
|
welcomeReasoningPreview,
|
|
]);
|
|
const validPendingReasoningIntent =
|
|
pendingReasoningIntent?.modelId === currentModel &&
|
|
welcomeReasoningPreview &&
|
|
reasoningPreviewSupports(
|
|
welcomeReasoningPreview,
|
|
pendingReasoningIntent.value,
|
|
)
|
|
? pendingReasoningIntent
|
|
: undefined;
|
|
const displayedReasoning = hasAuthoritativeReasoningContext
|
|
? connection.reasoning
|
|
: welcomeReasoningPreview
|
|
? {
|
|
...welcomeReasoningPreview,
|
|
enabled: validPendingReasoningIntent
|
|
? validPendingReasoningIntent.value !== 'none'
|
|
: welcomeReasoningPreview.enabled,
|
|
effort:
|
|
validPendingReasoningIntent?.value === 'none' ||
|
|
validPendingReasoningIntent?.value === 'default'
|
|
? (welcomeReasoningPreview.defaultEffort ?? 'default')
|
|
: (validPendingReasoningIntent?.value ??
|
|
welcomeReasoningPreview.effort),
|
|
}
|
|
: undefined;
|
|
// The workspace the Changes dialog reads — the same active workspace the
|
|
// git-status effect targets, so the chip and the
|
|
// dialog always target the same repo.
|
|
const gitDiffWorkspaceCwd = isKnownLiveWorkspaceCwd(activeWorkspaceCwd)
|
|
? undefined
|
|
: activeWorkspaceCwd;
|
|
const activeWorkspaceTrusted = ordinaryWorkspaces.find(
|
|
(entry) => entry.cwd === activeWorkspaceCwd,
|
|
)?.trusted;
|
|
const gitModeEligible = Boolean(
|
|
!connection.sessionId &&
|
|
activeWorkspaceTrusted &&
|
|
selectedWorkspaceGitStatus?.branch,
|
|
);
|
|
const branchWorktreeEligible = Boolean(
|
|
connection.sessionId &&
|
|
workspace.capabilities?.features?.includes('session_branch_worktree') ===
|
|
true &&
|
|
workspaces.some(
|
|
(entry) =>
|
|
entry.cwd === connection.workspaceCwd &&
|
|
entry.primary &&
|
|
entry.trusted,
|
|
),
|
|
);
|
|
// An armed branch/worktree intent survives a transient status gap (a
|
|
// failed poll round, a refetch still in flight); only a definitive answer
|
|
// clears it. The chip stays hidden meanwhile because it keys on
|
|
// `gitModeEligible`.
|
|
// The status is keyed by workspace: right after a draft re-target the
|
|
// state still holds the previous workspace's answer (the git-status effect
|
|
// clears it only after this commit), and a definitive no-branch answer for
|
|
// the workspace being left must not clear an intent armed for the new one.
|
|
// Same cwd-keying as the `git_status_changed` mirror effect above.
|
|
const gitModeIntentInvalid = gitModeIntentMustReset({
|
|
sessionId: connection.sessionId,
|
|
workspaceTrusted: activeWorkspaceTrusted,
|
|
gitStatus:
|
|
selectedWorkspaceGitStatus?.workspaceCwd === activeWorkspaceCwd
|
|
? selectedWorkspaceGitStatus
|
|
: undefined,
|
|
});
|
|
// Re-run on intent changes as well: an intent set while a session already
|
|
// exists (or the workspace is untrusted / not a repo) has nothing to apply
|
|
// to and must not survive into the next draft.
|
|
useEffect(() => {
|
|
if (gitModeIntentInvalid && gitModeIntent.mode !== 'current') {
|
|
setGitModeIntent({ mode: 'current' });
|
|
}
|
|
}, [gitModeIntentInvalid, gitModeIntent]);
|
|
const handleOpenGitDiff = useCallback(() => {
|
|
if (!gitDiffWorkspaceCwd) return;
|
|
setGitDialog({
|
|
workspaceCwd: gitDiffWorkspaceCwd,
|
|
gitCwd: sessionWorktree?.path,
|
|
gitSessionId: sessionWorktree ? connection.sessionId : undefined,
|
|
view: 'diff',
|
|
});
|
|
}, [connection.sessionId, gitDiffWorkspaceCwd, sessionWorktree]);
|
|
const handleOpenCommit = useCallback(() => {
|
|
if (!gitDiffWorkspaceCwd) return;
|
|
setGitDialog({
|
|
workspaceCwd: gitDiffWorkspaceCwd,
|
|
gitCwd: sessionWorktree?.path,
|
|
gitSessionId: sessionWorktree ? connection.sessionId : undefined,
|
|
view: 'commit',
|
|
});
|
|
}, [connection.sessionId, gitDiffWorkspaceCwd, sessionWorktree]);
|
|
const handleOpenWorktrees = useCallback(() => {
|
|
if (!gitDiffWorkspaceCwd) return;
|
|
// The dialog's tab bar reaches Changes and History from here, and both
|
|
// read `gitCwd`. Omitting it would answer a session running in a worktree
|
|
// with the workspace root's diff and log.
|
|
setGitDialog({
|
|
workspaceCwd: gitDiffWorkspaceCwd,
|
|
gitCwd: sessionWorktree?.path,
|
|
view: 'worktrees',
|
|
});
|
|
}, [gitDiffWorkspaceCwd, sessionWorktree?.path]);
|
|
const handleOpenLog = useCallback(() => {
|
|
if (!gitDiffWorkspaceCwd) return;
|
|
setGitDialog({
|
|
workspaceCwd: gitDiffWorkspaceCwd,
|
|
gitCwd: sessionWorktree?.path,
|
|
gitSessionId: sessionWorktree ? connection.sessionId : undefined,
|
|
view: 'log',
|
|
});
|
|
}, [connection.sessionId, gitDiffWorkspaceCwd, sessionWorktree]);
|
|
// Every modal the shell itself tracks, plus host-registered interaction
|
|
// blocks. View navigation hotkeys gate on this rather than dialogOpen,
|
|
// because dialogOpen also counts activePanel and the Session Overview is
|
|
// itself a panel.
|
|
const modalDialogOpen =
|
|
capacityRecovery !== undefined ||
|
|
showResumeDialog ||
|
|
showDeleteDialog ||
|
|
showReleaseDialog ||
|
|
showRewindDialog ||
|
|
showHelpDialog ||
|
|
showThemeDialog ||
|
|
showToolsDialog ||
|
|
gitDialog !== undefined ||
|
|
branchSessionDialog !== undefined ||
|
|
modelDialogMode !== null ||
|
|
showApprovalModeDialog ||
|
|
tasksDialogMessage !== null ||
|
|
// mcpDialogMessage survives closing the Plugins panel; MCP surfaces are
|
|
// already blocked by activePanel below, so including it would lock chat.
|
|
showMemoryDialog ||
|
|
(modelManagementPolicy.allowAdd && showAuthDialog) ||
|
|
showAddWorkspaceDialog ||
|
|
managedWorkspaceCreationOpen ||
|
|
scratchOutcomeUnknown !== 'clear' ||
|
|
externalInteractionBlockCount > 0;
|
|
const dialogOpen =
|
|
modalDialogOpen ||
|
|
// The Settings / Daemon Status panel replaces the chat surface, so — like a
|
|
// modal — it must suppress chat-only global shortcuts (Ctrl+L/O/Y, the
|
|
// Shift+Tab mode cycle, the btw hotkey). Escape is intercepted earlier and
|
|
// returns to the chat instead of falling through to those handlers.
|
|
activePanel !== null;
|
|
// Block chat interaction (composer, chat keyboard shortcuts) both when a modal
|
|
// is open (dialogOpen, which already includes the Settings/Status panel) and
|
|
// while a covering surface hides the chat — a full-pane view (the Scheduled
|
|
// Tasks page) or the fullscreen artifact panel — so keystrokes/Escape can't
|
|
// reach the hidden composer underneath. The fullscreen gate also keeps the
|
|
// btw capture-phase Escape handler from dismissing hidden content and
|
|
// swallowing the Escape that shrinks the panel. Managed home also hides
|
|
// this daemon composer; its shortcuts must not reach the Local session.
|
|
const interactionBlocked =
|
|
managedMode || dialogOpen || mainView !== 'chat' || artifactPanelFullscreen;
|
|
chatFocusBlockedRef.current = interactionBlocked;
|
|
const mainVoiceTarget = useMemo(
|
|
() =>
|
|
workspaceContextActive
|
|
? resolveVoiceWorkspaceTarget({
|
|
capabilities: workspace.capabilities,
|
|
intendedCwd: activeWorkspaceCwd,
|
|
sessionId: connection.sessionId,
|
|
workspaces:
|
|
workspace.capabilities?.workspaces || lockedWorkspaceCapability
|
|
? ordinaryWorkspaces
|
|
: undefined,
|
|
})
|
|
: undefined,
|
|
[
|
|
activeWorkspaceCwd,
|
|
connection.sessionId,
|
|
lockedWorkspaceCapability,
|
|
workspace.capabilities,
|
|
ordinaryWorkspaces,
|
|
workspaceContextActive,
|
|
],
|
|
);
|
|
const [voiceUserRevision, setVoiceUserRevision] = useState(0);
|
|
const [voiceWorkspaceRevisions, setVoiceWorkspaceRevisions] = useState<
|
|
Record<string, number>
|
|
>({});
|
|
const mainVoiceStatusRevision: VoiceStatusRevision = useMemo(
|
|
() => ({
|
|
user: voiceUserRevision,
|
|
workspace: mainVoiceTarget
|
|
? (voiceWorkspaceRevisions[mainVoiceTarget.workspaceKey] ?? 0)
|
|
: 0,
|
|
}),
|
|
[mainVoiceTarget, voiceUserRevision, voiceWorkspaceRevisions],
|
|
);
|
|
const voiceModelSettingsSupported = supportsVoiceModelSettings(
|
|
mainVoiceTarget,
|
|
workspace.capabilities?.features ?? [],
|
|
);
|
|
const matchingVoiceSettingsVersion =
|
|
mainVoiceTarget?.sessionId === connection.sessionId &&
|
|
(!mainVoiceTarget?.cwd || mainVoiceTarget.cwd === connection.workspaceCwd)
|
|
? workspaceEventSignals?.settingsVersion
|
|
: undefined;
|
|
const {
|
|
descriptor: qualifiedVoiceSetting,
|
|
reload: reloadQualifiedVoiceSettings,
|
|
} = useVoiceWorkspaceSettings(
|
|
workspace.client,
|
|
mainVoiceTarget,
|
|
voiceModelSettingsSupported && mainView !== 'split',
|
|
JSON.stringify([
|
|
voiceUserRevision,
|
|
mainVoiceStatusRevision.workspace,
|
|
matchingVoiceSettingsVersion ?? null,
|
|
]),
|
|
);
|
|
|
|
const handleFailedPromptRetry = useCallback(() => {
|
|
if (
|
|
sessionWriteBlockedRef.current ||
|
|
promptPreparationOwnerRef.current ||
|
|
streamingStateRef.current !== 'idle' ||
|
|
sessionHasActivePromptRef.current
|
|
) {
|
|
return;
|
|
}
|
|
let failed = failedPromptRef.current;
|
|
if (!failed || failed.sessionId !== connectionRef.current.sessionId) {
|
|
updateFailedPrompt(null);
|
|
return;
|
|
}
|
|
if (refuseModelSetup(failed.text)) {
|
|
updateFailedPrompt(null);
|
|
return;
|
|
}
|
|
const retryOwner = failed.owner;
|
|
if (!retryOwnerIsCurrent(retryOwner)) {
|
|
updateFailedPrompt(null);
|
|
return;
|
|
}
|
|
const currentFailedBlock = getLatestUserBlock(store.getSnapshot().blocks);
|
|
if (
|
|
!currentFailedBlock ||
|
|
!matchesUserMessageIdentity(
|
|
currentFailedBlock,
|
|
failed.identity,
|
|
retryOwner.snapshot.isCurrent(),
|
|
)
|
|
) {
|
|
updateFailedPrompt(null);
|
|
return;
|
|
}
|
|
if (
|
|
currentFailedBlock !== failed.identity.block ||
|
|
currentFailedBlock.id !== failed.messageId
|
|
) {
|
|
failed = {
|
|
...failed,
|
|
messageId: currentFailedBlock.id,
|
|
identity: { block: currentFailedBlock },
|
|
};
|
|
}
|
|
updateFailedPrompt(null);
|
|
const retryStartedAt = Date.now();
|
|
const retryAttemptId = ++cancelledRetryAttemptRef.current;
|
|
const retryTranscriptIdentity: FailedPromptRetry['transcriptIdentity'] = {
|
|
kind: 'failed-prompt',
|
|
identity: failed.identity,
|
|
};
|
|
const retryTranscriptIsCurrent = () =>
|
|
retryTranscriptIdentityMatches(
|
|
store.getSnapshot().blocks,
|
|
retryTranscriptIdentity,
|
|
retryOwner.snapshot.isCurrent(),
|
|
);
|
|
setFailedPromptRetry({
|
|
sessionId: failed.sessionId,
|
|
messageId: failed.messageId,
|
|
startedAt: retryStartedAt,
|
|
admitted: false,
|
|
settled: false,
|
|
owner: retryOwner,
|
|
transcriptIdentity: retryTranscriptIdentity,
|
|
});
|
|
let admitted = false;
|
|
let admissionStarted = false;
|
|
sendPrompt(failed.text, failed.images, failed.files, {
|
|
optimisticUserMessage: false,
|
|
// The failed attempt appended this block but never got it bound.
|
|
bindBlockId: failed.messageId,
|
|
skipPrepareSubmit: true,
|
|
inputAnnotations: failed.inputAnnotations,
|
|
onAdmissionStarted: () => {
|
|
admissionStarted = true;
|
|
},
|
|
onAdmitted: () => {
|
|
admitted = true;
|
|
if (!retryOwnerIsCurrent(retryOwner)) return;
|
|
setFailedPromptRetry((current) =>
|
|
current?.transcriptIdentity === retryTranscriptIdentity
|
|
? retryTranscriptIsCurrent()
|
|
? { ...current, admitted: true }
|
|
: null
|
|
: current,
|
|
);
|
|
},
|
|
onCancelledBeforeAdmission: () => {
|
|
restoreOrDeferCancelledRetry(retryOwner, {
|
|
kind: 'failed-prompt',
|
|
attemptId: retryAttemptId,
|
|
failed,
|
|
});
|
|
},
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!retryOwnerIsCurrent(retryOwner)) return;
|
|
const definitelyRejected = isDefinitelyRejectedPromptAdmission(error);
|
|
if (admissionStarted && !admitted && !definitelyRejected) {
|
|
if (!retryTranscriptIsCurrent()) return;
|
|
updateUnknownPromptAdmission({
|
|
sessionId: failed.sessionId,
|
|
messageId: failed.messageId,
|
|
text: failed.text,
|
|
images: failed.images ? [...failed.images] : undefined,
|
|
files: failed.files ? [...failed.files] : undefined,
|
|
inputAnnotations: failed.inputAnnotations,
|
|
payloadAvailable: true,
|
|
});
|
|
pushToast('warning', t('queue.admissionUnknown'));
|
|
console.warn(
|
|
'[WebShell] prompt retry admission outcome is unknown',
|
|
error,
|
|
);
|
|
return;
|
|
}
|
|
if (!admitted) {
|
|
restoreOrDeferCancelledRetry(retryOwner, {
|
|
kind: 'failed-prompt',
|
|
attemptId: retryAttemptId,
|
|
failed,
|
|
});
|
|
}
|
|
if (retryTranscriptIsCurrent()) {
|
|
reportError(error, 'Failed to resend message');
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!retryOwnerIsCurrent(retryOwner)) return;
|
|
setFailedPromptRetry((current) =>
|
|
current?.transcriptIdentity === retryTranscriptIdentity
|
|
? retryTranscriptIsCurrent()
|
|
? { ...current, settled: true }
|
|
: null
|
|
: current,
|
|
);
|
|
});
|
|
}, [
|
|
pushToast,
|
|
reportError,
|
|
restoreOrDeferCancelledRetry,
|
|
retryOwnerIsCurrent,
|
|
refuseModelSetup,
|
|
sendPrompt,
|
|
store,
|
|
t,
|
|
updateFailedPrompt,
|
|
updateUnknownPromptAdmission,
|
|
]);
|
|
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 composerAttachmentsEnabled =
|
|
workspaceContextActive ||
|
|
(effectiveSessionContext?.kind === 'standalone' && canInjectMidTurnMedia);
|
|
const {
|
|
queuedPrompts,
|
|
queuedTexts,
|
|
enqueuePrompt: rawEnqueuePrompt,
|
|
removeQueuedPrompt,
|
|
insertQueuedPrompt,
|
|
sendQueuedPromptNow,
|
|
editQueuedPrompt,
|
|
editLastQueuedPrompt,
|
|
clearQueuedPrompts,
|
|
} = useQueuedPrompts({
|
|
getPromptDispatchError: (text) =>
|
|
!modelManagementRef.current.allowAdd &&
|
|
isModelSetupCommand(text, connectionRef.current.commands)
|
|
? t('settings.models.addDisabled')
|
|
: undefined,
|
|
connected,
|
|
writeBlocked:
|
|
!daemonAvailable ||
|
|
sessionWriteBlocked ||
|
|
standaloneWriterBlocked ||
|
|
connection.runtimeStopped,
|
|
runtimeStopped: connection.runtimeStopped,
|
|
sessionId: connection.sessionId,
|
|
workspaceCwd: connection.workspaceCwd,
|
|
clientId: connection.clientId,
|
|
canMutateMidTurn,
|
|
canQueryMidTurn,
|
|
canInjectMidTurnMedia,
|
|
workspaceFileActions: artifactWorkspaceActions,
|
|
streamingState,
|
|
sessionHasActivePrompt,
|
|
sessionActions,
|
|
store,
|
|
editorRef,
|
|
reportError,
|
|
t,
|
|
});
|
|
|
|
const mcpAppQueueBusyRef = useRef(queuedPrompts.length > 0);
|
|
mcpAppQueueBusyRef.current = queuedPrompts.length > 0;
|
|
const submitMcpAppMessage = useCallback(
|
|
async (text: string, submission: McpAppPromptSubmission) => {
|
|
const sessionId = connectionRef.current.sessionId;
|
|
const clientId = connectionRef.current.clientId;
|
|
const workspaceCwd = getComposerWorkspaceCwd();
|
|
const sourceVersion = composerSourceVersionRef.current;
|
|
const writeBlockGeneration = sessionWriteBlockGenerationRef.current;
|
|
const isCurrent = () => {
|
|
const current = connectionRef.current;
|
|
return (
|
|
appMountedRef.current &&
|
|
!submission.signal.aborted &&
|
|
submission.isCurrent() &&
|
|
current.status === 'connected' &&
|
|
!current.runtimeStopped &&
|
|
Boolean(sessionId && clientId) &&
|
|
current.sessionId === sessionId &&
|
|
current.clientId === clientId &&
|
|
getComposerWorkspaceCwd() === workspaceCwd &&
|
|
composerSourceVersionRef.current === sourceVersion &&
|
|
!sessionWriteBlockedRef.current &&
|
|
sessionWriteBlockGenerationRef.current === writeBlockGeneration &&
|
|
!current.standaloneSession?.creationRecovery &&
|
|
!isSessionWriterBlockedCode(current.standaloneSession?.errorCode) &&
|
|
current.standaloneSession?.errorCode !==
|
|
'working_directory_missing' &&
|
|
current.standaloneSession?.errorCode !==
|
|
'working_directory_compromised' &&
|
|
!(
|
|
unknownPromptAdmissionRef.current?.payloadAvailable &&
|
|
unknownPromptAdmissionRef.current.sessionId === sessionId
|
|
)
|
|
);
|
|
};
|
|
const assertCurrent = () => {
|
|
if (!isCurrent()) throw new DOMException('Cancelled', 'AbortError');
|
|
};
|
|
const assertIdle = () => {
|
|
if (
|
|
streamingStateRef.current !== 'idle' ||
|
|
sessionHasActivePromptRef.current ||
|
|
isGoalGateBlocked() ||
|
|
mcpAppQueueBusyRef.current ||
|
|
queuedShellCommandsRef.current.length > 0 ||
|
|
isDrainingRef.current ||
|
|
shellSubmitInFlightRef.current
|
|
) {
|
|
throw new Error(t('mcpApp.message.busy'));
|
|
}
|
|
};
|
|
assertCurrent();
|
|
assertIdle();
|
|
if (promptPreparationOwnerRef.current) {
|
|
throw new Error(t('mcpApp.message.busy'));
|
|
}
|
|
return sendPrompt(text, undefined, undefined, {
|
|
source: 'mcp-app',
|
|
...(submission.inputAnnotations
|
|
? { inputAnnotations: submission.inputAnnotations }
|
|
: {}),
|
|
isCurrent,
|
|
beforeAdmission: async () => {
|
|
assertCurrent();
|
|
assertIdle();
|
|
},
|
|
beforeDispatch: (dispatchClientId) => {
|
|
try {
|
|
assertCurrent();
|
|
if (dispatchClientId !== clientId) {
|
|
throw new DOMException('Session client changed', 'AbortError');
|
|
}
|
|
submission.onDispatched();
|
|
// A card's values attached to the composer are now sent with
|
|
// this message; keep the next message from repeating them.
|
|
for (const annotation of submission.inputAnnotations ?? []) {
|
|
if (annotation.reference.kind === GENERATED_UI_CONTEXT_TAG_KIND)
|
|
editorRef.current?.removeTag(annotation.reference.id);
|
|
}
|
|
} catch (error) {
|
|
throw new PromptDispatchCancelledError('Cancelled before sending', {
|
|
cause: error,
|
|
});
|
|
}
|
|
},
|
|
onAdmissionStarted: assertCurrent,
|
|
onAdmitted: submission.onAdmitted,
|
|
});
|
|
},
|
|
[getComposerWorkspaceCwd, isGoalGateBlocked, sendPrompt, t],
|
|
);
|
|
const mcpAppModelContext = useMemo<McpAppModelContext | undefined>(
|
|
() =>
|
|
connection.sessionId
|
|
? {
|
|
sessionId: connection.sessionId,
|
|
attach: ({ id, title, text }) => {
|
|
if (connectionRef.current.sessionId !== connection.sessionId)
|
|
return;
|
|
// One removable tag per card, replaced as its values change and
|
|
// sent with whatever the user types next.
|
|
editorRef.current?.addTags(
|
|
[
|
|
{
|
|
id,
|
|
kind: GENERATED_UI_CONTEXT_TAG_KIND,
|
|
// Tags with a kind display their value.
|
|
value: t('mcpApp.generatedUi.contextTag', { title }),
|
|
serialized: text,
|
|
removable: true,
|
|
},
|
|
],
|
|
{ placement: 'top' },
|
|
);
|
|
},
|
|
}
|
|
: undefined,
|
|
[connection.sessionId, t],
|
|
);
|
|
const mcpAppMessages = useMcpAppMessages({
|
|
enabled: connection.status === 'connected' && !connection.runtimeStopped,
|
|
sessionId: connection.sessionId,
|
|
clientId: connection.clientId,
|
|
captureOwner: () => sessionOwnerGuard.capture({ includeRecovery: true }),
|
|
submit: submitMcpAppMessage,
|
|
translate: t,
|
|
});
|
|
|
|
const enqueuePrompt = useCallback(
|
|
(
|
|
text: string,
|
|
images?: PromptImage[],
|
|
files?: PromptFile[],
|
|
onComplete?: () => void,
|
|
commitComposerAccepted?: ComposerSubmitCommit,
|
|
inputAnnotations?: DaemonInputAnnotation[],
|
|
submittedPrompt = text,
|
|
) => {
|
|
const normalizedInputAnnotations = inputAnnotations
|
|
? [...inputAnnotations]
|
|
: [];
|
|
const enqueuePreparedPrompt = (
|
|
prepared: WebShellPreparedSubmit,
|
|
sessionId: string | undefined,
|
|
workspaceCwd: string | undefined,
|
|
) => {
|
|
if (
|
|
!prepared.prompt.trim() &&
|
|
(images?.length ?? 0) === 0 &&
|
|
(files?.length ?? 0) === 0
|
|
) {
|
|
return false;
|
|
}
|
|
const annotations =
|
|
prepared.inputAnnotations && prepared.inputAnnotations.length > 0
|
|
? [...prepared.inputAnnotations]
|
|
: undefined;
|
|
const result = rawEnqueuePrompt(
|
|
prepared.prompt,
|
|
images,
|
|
files,
|
|
onComplete,
|
|
annotations,
|
|
undefined,
|
|
submittedPrompt,
|
|
);
|
|
if (result !== false) {
|
|
if (commitComposerAccepted) {
|
|
commitComposerAccepted();
|
|
} else {
|
|
editorRef.current?.clear();
|
|
}
|
|
}
|
|
if (
|
|
sessionId &&
|
|
(prepared.prompt.trim() ||
|
|
(images?.length ?? 0) > 0 ||
|
|
(files?.length ?? 0) > 0)
|
|
) {
|
|
dispatchSessionChangeRef.current?.({
|
|
type: 'submit',
|
|
sessionId,
|
|
prompt: prepared.prompt,
|
|
queued: true,
|
|
});
|
|
if (workspaceCwd) {
|
|
sessionCatalogController.invalidateWorkspace(workspaceCwd);
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
const prepare = prepareSubmitRef.current;
|
|
const submitBefore = onSubmitBeforeRef.current;
|
|
if (prepare || submitBefore) {
|
|
const sourceOwner = sessionOwnerGuard.capture();
|
|
const sourceSessionId = connectionRef.current.sessionId;
|
|
const sourceWorkspaceCwd = getComposerWorkspaceCwd();
|
|
const sourceVersion = composerSourceVersionRef.current;
|
|
const writeBlockGeneration = sessionWriteBlockGenerationRef.current;
|
|
// Narrower than submissionOwnerIsCurrent below: it answers "is the user
|
|
// still looking at the session this submission belonged to", which is
|
|
// what decides whether a failure is worth telling them about. The full
|
|
// guard also tracks composer identity, and submitting is itself what
|
|
// moves that — so reusing it here would suppress the very message the
|
|
// user needs (#9911). The full guard is composed on this base rather
|
|
// than restating the same four conjuncts, so the two cannot drift.
|
|
const submissionSessionIsCurrent = () =>
|
|
appMountedRef.current &&
|
|
sourceOwner.isCurrent() &&
|
|
connectionRef.current.sessionId === sourceSessionId &&
|
|
getComposerWorkspaceCwd() === sourceWorkspaceCwd;
|
|
const submissionOwnerIsCurrent = () =>
|
|
submissionSessionIsCurrent() &&
|
|
!sessionWriteBlockedRef.current &&
|
|
sessionWriteBlockGenerationRef.current === writeBlockGeneration &&
|
|
composerSourceVersionRef.current === sourceVersion;
|
|
void (async () => {
|
|
let preparedPrompt = text;
|
|
let preparedInputAnnotations = normalizedInputAnnotations;
|
|
try {
|
|
if (prepare) {
|
|
const prepared = await prepare({
|
|
sessionId: sourceSessionId,
|
|
prompt: preparedPrompt,
|
|
inputAnnotations: preparedInputAnnotations,
|
|
});
|
|
const resolved = resolvePreparedSubmit(
|
|
preparedPrompt,
|
|
preparedInputAnnotations,
|
|
prepared,
|
|
);
|
|
preparedPrompt = resolved.prompt;
|
|
preparedInputAnnotations = [...(resolved.inputAnnotations ?? [])];
|
|
}
|
|
if (!submissionOwnerIsCurrent()) return;
|
|
if (submitBefore) {
|
|
await submitBefore({
|
|
sessionId: sourceSessionId,
|
|
prompt: preparedPrompt,
|
|
});
|
|
}
|
|
if (!submissionOwnerIsCurrent()) return;
|
|
enqueuePreparedPrompt(
|
|
{
|
|
prompt: preparedPrompt,
|
|
inputAnnotations: preparedInputAnnotations,
|
|
},
|
|
sourceSessionId,
|
|
sourceWorkspaceCwd,
|
|
);
|
|
} catch (err) {
|
|
// A rejected preflight cancels the submission, so it has to say so.
|
|
// Hosts put user-facing text in these errors — the VS Code
|
|
// companion's rewind failures are localized strings — and a silent
|
|
// cancel leaves the user in front of a composer that did nothing
|
|
// (#9911). Stay quiet only when this submission is no longer the
|
|
// current one, where the toast would belong to a session the user
|
|
// has already left. reportError suppresses aborts and logs the
|
|
// error itself, so this catch no longer warns (that would
|
|
// double-log a real failure).
|
|
if (submissionSessionIsCurrent()) {
|
|
reportError(err, 'Message could not be submitted');
|
|
}
|
|
}
|
|
})();
|
|
return false;
|
|
}
|
|
return enqueuePreparedPrompt(
|
|
{
|
|
prompt: text,
|
|
inputAnnotations: normalizedInputAnnotations,
|
|
},
|
|
connectionRef.current.sessionId,
|
|
getComposerWorkspaceCwd(),
|
|
);
|
|
},
|
|
[
|
|
getComposerWorkspaceCwd,
|
|
reportError,
|
|
rawEnqueuePrompt,
|
|
sessionCatalogController,
|
|
sessionOwnerGuard,
|
|
],
|
|
);
|
|
|
|
useEffect(() => {
|
|
for (const notice of notices) {
|
|
if (notice.sourceRetry) {
|
|
const retry = notice.sourceRetry;
|
|
setSourceRegistrationRetries((previous) =>
|
|
previous.includes(retry) ? previous : [...previous, retry],
|
|
);
|
|
}
|
|
if (
|
|
notice.category === 'connection' &&
|
|
notice.operation === 'stream' &&
|
|
!daemonAvailable
|
|
) {
|
|
dismissNotice(notice.id);
|
|
continue;
|
|
}
|
|
if (shouldToastNotice(notice)) {
|
|
pushToast(
|
|
toastToneFromNotice(notice),
|
|
notice.code === 'acp_child_capacity_exhausted'
|
|
? t('daemon.capacity.exhausted')
|
|
: notice.message,
|
|
);
|
|
} else if (notice.category !== 'lifecycle') {
|
|
console.warn('[web-shell] daemon notice', notice);
|
|
}
|
|
dismissNotice(notice.id);
|
|
}
|
|
}, [dismissNotice, notices, pushToast, t, daemonAvailable]);
|
|
|
|
const onBugReportRef = useRef(onBugReport);
|
|
onBugReportRef.current = onBugReport;
|
|
|
|
useEffect(() => {
|
|
currentSessionIdRef.current = connection.sessionId;
|
|
btwAbortControllerRef.current?.abort();
|
|
btwAbortControllerRef.current = null;
|
|
setRecapMessage(null);
|
|
setBtwMessage(null);
|
|
setTasksDialogMessage(null);
|
|
lastRecapBlockCountRef.current = 0;
|
|
}, [connection.sessionId, connection.workspaceCwd]);
|
|
|
|
// A recap is one local annotation shared by both triggers: the latest request
|
|
// owns it, so a newer recap replaces the previous one instead of accumulating
|
|
// in the transcript. Each request writes under the generation it was issued
|
|
// with, so a slow answer cannot replace a newer recap.
|
|
const recapRequestRef = useRef(0);
|
|
|
|
const showRecapMessage = useCallback(
|
|
(
|
|
requestId: number,
|
|
content: string,
|
|
anchor?: {
|
|
messageId: string;
|
|
anchorAfterId?: string;
|
|
anchorIndex: number;
|
|
},
|
|
) => {
|
|
// A superseded request must not write, but it must still drop the row it
|
|
// owns: an answer that never lands would otherwise strand the manual
|
|
// path's "Generating recap" placeholder in the transcript.
|
|
const ownMessageId = anchor?.messageId;
|
|
if (requestId !== recapRequestRef.current) {
|
|
if (ownMessageId) {
|
|
setRecapMessage((previous) =>
|
|
previous?.message.id === ownMessageId ? null : previous,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
const currentMessages = messagesRef.current;
|
|
setRecapMessage({
|
|
anchorAfterId: anchor
|
|
? anchor.anchorAfterId
|
|
: currentMessages.at(-1)?.id,
|
|
anchorIndex: anchor ? anchor.anchorIndex : currentMessages.length,
|
|
message: {
|
|
id: ownMessageId ?? `local-recap-${nextRecapMessageIdRef.current++}`,
|
|
role: 'system',
|
|
content,
|
|
variant: 'info',
|
|
source: 'recap',
|
|
},
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const runVisibleRecap = useCallback(() => {
|
|
if (sessionWriteBlocked) return;
|
|
if (!requireActiveSessionForLocalCommand()) return;
|
|
recapRequestRef.current += 1;
|
|
const requestId = recapRequestRef.current;
|
|
const messageId = `local-recap-${nextRecapMessageIdRef.current++}`;
|
|
const currentMessages = messagesRef.current;
|
|
const anchor = {
|
|
messageId,
|
|
anchorIndex: currentMessages.length,
|
|
anchorAfterId: currentMessages.at(-1)?.id,
|
|
};
|
|
const sessionId = connection.sessionId;
|
|
const workspaceCwd = connection.workspaceCwd;
|
|
showRecapMessage(
|
|
requestId,
|
|
`※ ${t('recap.label')}: ${t('recap.loading')}`,
|
|
anchor,
|
|
);
|
|
sessionActions.recapSession().then(
|
|
(result) => {
|
|
if (
|
|
currentSessionIdRef.current !== sessionId ||
|
|
connectionRef.current.workspaceCwd !== workspaceCwd
|
|
)
|
|
return;
|
|
showRecapMessage(
|
|
requestId,
|
|
result.recap
|
|
? `※ ${t('recap.label')}: ${result.recap}`
|
|
: t('recap.empty'),
|
|
anchor,
|
|
);
|
|
},
|
|
(error: unknown) => {
|
|
if (
|
|
currentSessionIdRef.current !== sessionId ||
|
|
connectionRef.current.workspaceCwd !== workspaceCwd
|
|
)
|
|
return;
|
|
// A stale failure must not clear a newer recap.
|
|
if (requestId === recapRequestRef.current) setRecapMessage(null);
|
|
if (!isAbortError(error) && !isAlreadyDispatched(error)) {
|
|
console.warn('[web-shell] unhandled recap failure', error);
|
|
}
|
|
},
|
|
);
|
|
}, [
|
|
connection.sessionId,
|
|
connection.workspaceCwd,
|
|
requireActiveSessionForLocalCommand,
|
|
sessionWriteBlocked,
|
|
sessionActions,
|
|
showRecapMessage,
|
|
t,
|
|
]);
|
|
|
|
const runVisibleBtw = useCallback(
|
|
(rawQuestion: string) => {
|
|
if (sessionWriteBlocked) return;
|
|
const question = rawQuestion.trim();
|
|
if (!question) {
|
|
pushToast('error', t('btw.empty'));
|
|
return;
|
|
}
|
|
if (!requireActiveSessionForLocalCommand()) return;
|
|
|
|
const messageId = `local-btw-${nextBtwMessageIdRef.current++}`;
|
|
const sessionId = connection.sessionId;
|
|
const workspaceCwd = connection.workspaceCwd;
|
|
btwAbortControllerRef.current?.abort();
|
|
const abortController = new AbortController();
|
|
btwAbortControllerRef.current = abortController;
|
|
setBtwMessage({
|
|
id: messageId,
|
|
role: 'btw',
|
|
question,
|
|
answer: '',
|
|
isPending: true,
|
|
});
|
|
|
|
void (async () => {
|
|
try {
|
|
for await (const event of sessionActions.btwSessionStream(question, {
|
|
signal: abortController.signal,
|
|
})) {
|
|
if (
|
|
currentSessionIdRef.current !== sessionId ||
|
|
connectionRef.current.workspaceCwd !== workspaceCwd ||
|
|
btwAbortControllerRef.current !== abortController
|
|
)
|
|
return;
|
|
const done = event.type === 'done';
|
|
setBtwMessage({
|
|
id: messageId,
|
|
role: 'btw',
|
|
question,
|
|
answer: event.answer || (done ? t('btw.emptyAnswer') : ''),
|
|
isPending: !done,
|
|
});
|
|
if (done) btwAbortControllerRef.current = null;
|
|
}
|
|
} catch (error) {
|
|
if (
|
|
currentSessionIdRef.current !== sessionId ||
|
|
connectionRef.current.workspaceCwd !== workspaceCwd ||
|
|
btwAbortControllerRef.current !== abortController
|
|
)
|
|
return;
|
|
btwAbortControllerRef.current = null;
|
|
setBtwMessage((current) =>
|
|
current?.id === messageId &&
|
|
current.role === 'btw' &&
|
|
current.answer
|
|
? { ...current, isPending: false }
|
|
: null,
|
|
);
|
|
if (!isAbortError(error) && !isAlreadyDispatched(error)) {
|
|
console.warn('[web-shell] unhandled btw failure', error);
|
|
}
|
|
}
|
|
})();
|
|
},
|
|
[
|
|
connection.sessionId,
|
|
connection.workspaceCwd,
|
|
pushToast,
|
|
requireActiveSessionForLocalCommand,
|
|
sessionWriteBlocked,
|
|
sessionActions,
|
|
t,
|
|
],
|
|
);
|
|
|
|
const dismissBtwMessage = useCallback(() => {
|
|
btwAbortControllerRef.current?.abort();
|
|
btwAbortControllerRef.current = null;
|
|
setBtwMessage(null);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const onBtwShortcut = (e: KeyboardEvent) => {
|
|
if (interactionBlocked || pendingApproval || isWebTerminalTarget(e))
|
|
return;
|
|
const target = e.composedPath()[0] ?? e.target;
|
|
if (
|
|
target instanceof Element &&
|
|
target.closest('[data-web-shell-context-popover]')
|
|
)
|
|
return;
|
|
const message = btwMessage;
|
|
if (!message || message.role !== 'btw') return;
|
|
|
|
const key = e.key.toLowerCase();
|
|
const isPlainEscape =
|
|
e.key === 'Escape' &&
|
|
!e.ctrlKey &&
|
|
!e.metaKey &&
|
|
!e.altKey &&
|
|
!e.shiftKey;
|
|
const isCtrlCancel =
|
|
e.ctrlKey &&
|
|
!e.metaKey &&
|
|
!e.altKey &&
|
|
!e.shiftKey &&
|
|
(key === 'c' || key === 'd');
|
|
|
|
if (message.isPending) {
|
|
if (!isPlainEscape && !isCtrlCancel) return;
|
|
} else {
|
|
const editorHasText = editorRef.current?.hasInput() ?? false;
|
|
const isPlainDismiss =
|
|
!e.ctrlKey &&
|
|
!e.metaKey &&
|
|
!e.altKey &&
|
|
!e.shiftKey &&
|
|
(e.key === 'Escape' ||
|
|
(!editorHasText && (e.key === 'Enter' || e.key === ' ')));
|
|
if (!isPlainDismiss) return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
dismissBtwMessage();
|
|
};
|
|
|
|
window.addEventListener('keydown', onBtwShortcut, true);
|
|
return () => window.removeEventListener('keydown', onBtwShortcut, true);
|
|
}, [interactionBlocked, btwMessage, dismissBtwMessage, pendingApproval]);
|
|
|
|
// Echo a local command into the transcript, or suppress it while a turn is
|
|
// streaming so the injected user row can't split the active turn (see
|
|
// appendOrDeferLocalUserMessage). Returns true when suppressed — callers must
|
|
// then stop and not run the command's inline side effects. Exception:
|
|
// read-only display commands wrap this in echoLocalCommandIfIdle and
|
|
// intentionally ignore the suppression signal — their status-block output
|
|
// does not split the active turn.
|
|
useEffect(() => {
|
|
clearPendingLocalCommands(store);
|
|
}, [connection.sessionId, store]);
|
|
const echoOrDeferLocalCommand = useCallback(
|
|
(
|
|
text: string,
|
|
images?: PromptImage[],
|
|
localCommandId = `local-command:echo:${crypto.randomUUID()}`,
|
|
): boolean =>
|
|
appendOrDeferLocalUserMessage(
|
|
streamingStateRef.current !== 'idle',
|
|
text,
|
|
images,
|
|
{
|
|
append: (value: string) => {
|
|
store.appendLocalUserMessage(value, undefined, {
|
|
source: 'local_command',
|
|
localCommandId,
|
|
});
|
|
},
|
|
},
|
|
),
|
|
[store],
|
|
);
|
|
|
|
// Echo a local command when idle, but never block it. Read-only display
|
|
// commands (/stats, /about, /context) render their result as a status
|
|
// block, which does not split the active turn, so they run immediately
|
|
// mid-turn with only the echo skipped.
|
|
const echoLocalCommandIfIdle = useCallback(
|
|
(text: string): string => {
|
|
const localCommandId = `local-command:echo:${crypto.randomUUID()}`;
|
|
beginLocalCommand(store, localCommandId);
|
|
if (streamingStateRef.current === 'idle') {
|
|
store.appendLocalUserMessage(text, undefined, {
|
|
source: 'local_command',
|
|
localCommandId,
|
|
});
|
|
}
|
|
return localCommandId;
|
|
},
|
|
[store],
|
|
);
|
|
|
|
// Shared result dispatch for those read-only commands: `clearActiveText:
|
|
// false` keeps the status block from finalizing the in-flight assistant
|
|
// block mid-stream, which would split the streaming answer and orphan its
|
|
// usage frames.
|
|
const dispatchReadOnlyStatus = useCallback(
|
|
(text: string, data?: unknown, localCommandId?: string) => {
|
|
finishLocalCommand(store, localCommandId);
|
|
store.dispatch([
|
|
{
|
|
type: 'status',
|
|
promptId: localCommandId ?? `local-command:${crypto.randomUUID()}`,
|
|
text,
|
|
...(data !== undefined ? { data } : {}),
|
|
clearActiveText: false,
|
|
},
|
|
]);
|
|
resumeChatBottomFollow('smooth');
|
|
},
|
|
[store, resumeChatBottomFollow],
|
|
);
|
|
|
|
const blockCommand = useCallback((): false => {
|
|
pushToast(
|
|
'error',
|
|
t(
|
|
isGoalGateBlocked()
|
|
? 'queue.commandGoalBlocked'
|
|
: 'queue.commandBlocked',
|
|
),
|
|
);
|
|
return false;
|
|
}, [isGoalGateBlocked, pushToast, t]);
|
|
|
|
const handleThemeChange = useCallback(
|
|
(nextTheme: WebShellTheme) => {
|
|
setSelectedTheme(nextTheme);
|
|
onThemeChange?.(nextTheme);
|
|
},
|
|
[onThemeChange],
|
|
);
|
|
|
|
const handleLanguageChange = useCallback(
|
|
(nextLanguage: WebShellLanguage) => {
|
|
setSelectedLanguage(nextLanguage);
|
|
onLanguageChange?.(nextLanguage);
|
|
},
|
|
[onLanguageChange],
|
|
);
|
|
|
|
const handleToggleShortcuts = useCallback(() => {
|
|
setShowHelpDialog(true);
|
|
}, []);
|
|
|
|
const workspaceSettingsState = useSettings({
|
|
autoLoad: projectFeaturesAvailable,
|
|
enabled: projectFeaturesAvailable,
|
|
});
|
|
const providersEnabled =
|
|
projectFeaturesAvailable &&
|
|
(activePanel === 'settings' || modelDialogMode === 'fast');
|
|
const providersState = useProviders({
|
|
autoLoad: providersEnabled,
|
|
enabled: providersEnabled,
|
|
});
|
|
const modelConfigurations = useModelConfigurations(projectFeaturesAvailable);
|
|
const reloadModelConfigurations = modelConfigurations.reload;
|
|
// useProviders returns a fresh object each render, but its `reload` identity is
|
|
// stable — pull it out so callbacks can depend on the function alone without
|
|
// re-creating on every render (and without an exhaustive-deps warning).
|
|
const reloadProviders = providersState.reload;
|
|
const {
|
|
available: subscriptionAvailable,
|
|
handleCommand: handleSubscriptionLogout,
|
|
} = useSubscriptionLogout({
|
|
t,
|
|
onCommand: (text) =>
|
|
invokeSlashCommandHandler(text, onSlashCommandRef.current, reportError),
|
|
workspaceCwd: workspaceContextActive
|
|
? getComposerWorkspaceCwd()
|
|
: undefined,
|
|
isBlocked: () =>
|
|
streamingStateRef.current !== 'idle' ||
|
|
sessionHasActivePromptRef.current ||
|
|
isGoalGateBlocked(),
|
|
onBlocked: blockCommand,
|
|
onNotice: pushToast,
|
|
onError: reportError,
|
|
});
|
|
const [modelActionBusy, setModelActionBusy] = useState(false);
|
|
const modelActionTokenRef = useRef(0);
|
|
useLayoutEffect(() => {
|
|
modelActionTokenRef.current += 1;
|
|
setModelActionBusy(false);
|
|
}, [logicalSessionKey]);
|
|
const {
|
|
settings: loadedWorkspaceSettings,
|
|
setValue: setWorkspaceSetting,
|
|
reload: reloadWorkspaceSettings,
|
|
} = workspaceSettingsState;
|
|
const workspaceSettings = useMemo(
|
|
() => (projectFeaturesAvailable ? loadedWorkspaceSettings : []),
|
|
[loadedWorkspaceSettings, projectFeaturesAvailable],
|
|
);
|
|
const availableApprovalModes = useApprovalModes(
|
|
activeWorkspaceCwd,
|
|
allowedApprovalModes,
|
|
);
|
|
const showResponseTokensPerSecond =
|
|
workspaceSettings.find(
|
|
(setting) => setting.key === 'ui.showResponseTokensPerSecond',
|
|
)?.values.effective === true;
|
|
const enableLoadingPhrases =
|
|
workspaceSettings.find(
|
|
(setting) => setting.key === 'ui.accessibility.enableLoadingPhrases',
|
|
)?.values.effective !== false;
|
|
const liveSetup = useLiveVoiceSetup(
|
|
workspaceSettings.some(
|
|
(setting) => setting.key === 'experimental.liveVoice.enabled',
|
|
),
|
|
activePanel === 'live' ||
|
|
(activePanel === 'settings' && !liveSidebarEnabled),
|
|
t,
|
|
);
|
|
// Do not expose workflow surfaces until settings have loaded successfully.
|
|
// The resource keeps stale data when a reload fails, so the error check is
|
|
// required to fail closed in that case too. `loading` is deliberately NOT
|
|
// part of the gate: a background revalidation (any client of this workspace
|
|
// saving any setting bumps settingsVersion) sets loading:true while keeping
|
|
// the last-known-good data, and gating on it would evict the user from an
|
|
// established Workflow view and delete its deep link. The initial load still
|
|
// fails closed via `status === undefined`.
|
|
const sessionWorkflowEnabled =
|
|
!workspaceSettingsState.error &&
|
|
workspaceSettingsState.status !== undefined &&
|
|
workspaceSettings.find(
|
|
(setting) => setting.key === 'experimental.sessionWorkflow',
|
|
)?.values.effective === true;
|
|
const sessionWorkflowSettingsResolved =
|
|
workspaceSettingsState.status !== undefined ||
|
|
workspaceSettingsState.error !== undefined;
|
|
const sessionWorkflowTodosState = useMemo(
|
|
() =>
|
|
sessionWorkflowEnabled
|
|
? getSessionWorkflowTodos(messages)
|
|
: EMPTY_SESSION_WORKFLOW_TODOS,
|
|
[messages, sessionWorkflowEnabled],
|
|
);
|
|
const sessionWorkflowTodos = useStableArray(
|
|
sessionWorkflowTodosState.todos,
|
|
(todo) =>
|
|
JSON.stringify([
|
|
todo.id,
|
|
todo.status,
|
|
todo.content,
|
|
todo.blockedBy ?? [],
|
|
]),
|
|
);
|
|
const floatingTodosUseSessionWorkflow =
|
|
sessionWorkflowEnabled &&
|
|
floatingTodosState.sourceMessageId !== null &&
|
|
floatingTodosState.sourceMessageId ===
|
|
sessionWorkflowTodosState.sourceMessageId;
|
|
const [selectedWorkflowTodoId, setSelectedWorkflowTodoId] = useState<
|
|
string | undefined
|
|
>();
|
|
useEffect(() => {
|
|
setSelectedWorkflowTodoId(undefined);
|
|
}, [logicalSessionKey]);
|
|
useEffect(() => {
|
|
if (
|
|
approvalOverlayActive ||
|
|
sessionWorkflowEnabled ||
|
|
!sessionWorkflowSettingsResolved ||
|
|
!artifactPanelTabs.some((tab) => tab.kind === 'workflow')
|
|
) {
|
|
return;
|
|
}
|
|
closeArtifactPanelTab('workflow');
|
|
}, [
|
|
artifactPanelTabs,
|
|
approvalOverlayActive,
|
|
closeArtifactPanelTab,
|
|
sessionWorkflowEnabled,
|
|
sessionWorkflowSettingsResolved,
|
|
]);
|
|
const planAgentTools = useMemo(() => {
|
|
if (
|
|
!sessionWorkflowEnabled ||
|
|
(mainView !== 'cockpit' &&
|
|
!artifactPanelTabs.some((tab) => tab.kind === 'workflow'))
|
|
) {
|
|
return [];
|
|
}
|
|
return getAgentToolsForPlan(messages, sessionWorkflowTodosState);
|
|
}, [
|
|
artifactPanelTabs,
|
|
mainView,
|
|
messages,
|
|
sessionWorkflowEnabled,
|
|
sessionWorkflowTodosState,
|
|
]);
|
|
// The tasks dialog renders the floating (latest) plan, so its agent tools
|
|
// anchor to floatingTodosState. That anchoring must stay out of
|
|
// planAgentTools: the cockpit and workflow inspector stay mounted behind
|
|
// the dialog and would lose (or re-anchor) the session-workflow plan's
|
|
// linked tools whenever the dialog opens after a user message emptied the
|
|
// floating plan.
|
|
const tasksDialogAgentTools = useMemo(
|
|
() =>
|
|
tasksDialogMessage
|
|
? getAgentToolsForPlan(messages, floatingTodosState)
|
|
: [],
|
|
[floatingTodosState, messages, tasksDialogMessage],
|
|
);
|
|
// One projection per render for every session-workflow surface. The
|
|
// cockpit, the artifact-panel inspector and the graph embedded in the
|
|
// cockpit each used to derive their own copy of the same projection; they
|
|
// now share this one, which also carries the single task-execution index
|
|
// they all read from.
|
|
const sessionWorkflowProjection = useMemo(
|
|
() =>
|
|
sessionWorkflowEnabled
|
|
? buildSessionWorkflowProjection(
|
|
sessionWorkflowTodos,
|
|
planAgentTools,
|
|
environmentAgentTasks,
|
|
)
|
|
: undefined,
|
|
[
|
|
environmentAgentTasks,
|
|
planAgentTools,
|
|
sessionWorkflowEnabled,
|
|
sessionWorkflowTodos,
|
|
],
|
|
);
|
|
const reloadTargetedWorkspaceSettings = useCallback(async () => {
|
|
const status = await reloadWorkspaceSettings();
|
|
if (mainVoiceTarget?.route === 'workspace-qualified') {
|
|
await reloadQualifiedVoiceSettings();
|
|
}
|
|
return status;
|
|
}, [
|
|
mainVoiceTarget?.route,
|
|
reloadQualifiedVoiceSettings,
|
|
reloadWorkspaceSettings,
|
|
]);
|
|
const targetedWorkspaceSettings = useMemo(() => {
|
|
const withoutVoice = workspaceSettings.filter(
|
|
(setting) => setting.key !== 'voiceModel',
|
|
);
|
|
if (mainView === 'split') return withoutVoice;
|
|
if (mainVoiceTarget?.route === 'legacy-primary') return workspaceSettings;
|
|
if (!voiceModelSettingsSupported || !qualifiedVoiceSetting) {
|
|
return withoutVoice;
|
|
}
|
|
const voiceIndex = workspaceSettings.findIndex(
|
|
(setting) => setting.key === 'voiceModel',
|
|
);
|
|
if (voiceIndex < 0) {
|
|
return [...withoutVoice, qualifiedVoiceSetting];
|
|
}
|
|
return workspaceSettings.map((setting) =>
|
|
setting.key === 'voiceModel' ? qualifiedVoiceSetting : setting,
|
|
);
|
|
}, [
|
|
mainView,
|
|
mainVoiceTarget?.route,
|
|
qualifiedVoiceSetting,
|
|
voiceModelSettingsSupported,
|
|
workspaceSettings,
|
|
]);
|
|
const targetedWorkspaceSettingsState = {
|
|
...workspaceSettingsState,
|
|
settings: targetedWorkspaceSettings,
|
|
reload: reloadTargetedWorkspaceSettings,
|
|
liveSetup: liveSidebarEnabled ? undefined : liveSetup,
|
|
};
|
|
const themeSetting = workspaceSettings.find(
|
|
(setting) => setting.key === THEME_SETTING_KEY,
|
|
);
|
|
const hideTipsSetting = workspaceSettings.find(
|
|
(setting) => setting.key === HIDE_TIPS_SETTING_KEY,
|
|
);
|
|
const languageSetting = workspaceSettings.find(
|
|
(setting) => setting.key === LANGUAGE_SETTING_KEY,
|
|
);
|
|
const currentVoiceModel = (() => {
|
|
const value = readScopedModelSetting(
|
|
targetedWorkspaceSettings,
|
|
modelSettingScope,
|
|
'voiceModel',
|
|
);
|
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
})();
|
|
const currentVisionModel = (() => {
|
|
const value = readScopedModelSetting(
|
|
workspaceSettings,
|
|
modelSettingScope,
|
|
'visionModel',
|
|
);
|
|
if (typeof value !== 'string' || !value.trim()) return undefined;
|
|
return decodeVisionModelForPicker(value.trim());
|
|
})();
|
|
const currentFastModel = (() => {
|
|
const value = readScopedModelSetting(
|
|
workspaceSettings,
|
|
modelSettingScope,
|
|
'fastModel',
|
|
);
|
|
if (typeof value !== 'string' || !value.trim()) return undefined;
|
|
const models = providersState.providers.flatMap((provider) =>
|
|
provider.models.map((model) => ({
|
|
id: model.modelId,
|
|
baseModelId: model.baseModelId,
|
|
authType: provider.authType,
|
|
baseUrl: model.baseUrl,
|
|
})),
|
|
);
|
|
return resolveFastModelForPicker(
|
|
value.trim(),
|
|
models.length ? models : (connection.models ?? []),
|
|
);
|
|
})();
|
|
const currentAdvisorModel = readScopedModelSetting(
|
|
workspaceSettings,
|
|
modelSettingScope,
|
|
'advisorModel',
|
|
);
|
|
const currentImageModel = readScopedModelSetting(
|
|
workspaceSettings,
|
|
modelSettingScope,
|
|
'imageModel',
|
|
);
|
|
const roleModelsReady =
|
|
!modelConfigurations.loading &&
|
|
!modelConfigurations.error &&
|
|
modelConfigurations.data !== undefined;
|
|
const advisorModels: ModelDialogModel[] =
|
|
roleModelsReady && !providersState.loading && !providersState.error
|
|
? [
|
|
{ id: '', label: t('model.useMain') },
|
|
...providersState.providers.flatMap((provider) =>
|
|
provider.models.flatMap((model) => {
|
|
if (
|
|
model.isRuntime ||
|
|
!isVisibleComposerModel({ id: model.modelId })
|
|
)
|
|
return [];
|
|
const configuration = modelConfigurations.models.find(
|
|
(entry) => entry.key === model.configurationKey,
|
|
);
|
|
const id =
|
|
configuration?.advisorModel ??
|
|
(provider.authType === 'qwen-oauth'
|
|
? `${provider.authType}:${model.baseModelId}`
|
|
: undefined);
|
|
return id
|
|
? [
|
|
{
|
|
id,
|
|
baseModelId: model.baseModelId,
|
|
label: model.name,
|
|
authType: provider.authType,
|
|
baseUrl: model.baseUrl,
|
|
envKey: model.envKey,
|
|
contextWindow: model.contextLimit,
|
|
modalities: model.modalities,
|
|
},
|
|
]
|
|
: [];
|
|
}),
|
|
),
|
|
]
|
|
: [];
|
|
const imageModels = modelConfigurations.models.flatMap((model) =>
|
|
model.imageModel
|
|
? [
|
|
{
|
|
id: model.imageModel,
|
|
baseModelId: model.modelId,
|
|
label: model.name ?? model.modelId,
|
|
authType: model.authType,
|
|
baseUrl: model.baseUrl,
|
|
envKey: model.envKey,
|
|
contextWindow: model.contextWindowSize,
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
const currentModelFallbacks = useMemo(() => {
|
|
const value = readScopedModelSetting(
|
|
workspaceSettings,
|
|
modelSettingScope,
|
|
'modelFallbacks',
|
|
);
|
|
return typeof value === 'string'
|
|
? value
|
|
.split(',')
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean)
|
|
: [];
|
|
}, [workspaceSettings, modelSettingScope]);
|
|
const bumpVoiceRevision = useCallback(
|
|
(target: typeof mainVoiceTarget, scope: 'workspace' | 'user') => {
|
|
if (!target) return;
|
|
if (scope === 'user') {
|
|
setVoiceUserRevision((revision) => revision + 1);
|
|
return;
|
|
}
|
|
setVoiceWorkspaceRevisions((revisions) => ({
|
|
...revisions,
|
|
[target.workspaceKey]: (revisions[target.workspaceKey] ?? 0) + 1,
|
|
}));
|
|
},
|
|
[],
|
|
);
|
|
const writeVoiceModelForTarget = useCallback(
|
|
async (
|
|
value: string,
|
|
scope: 'workspace' | 'user',
|
|
target = mainVoiceTarget,
|
|
) => {
|
|
if (
|
|
!target ||
|
|
!supportsVoiceModelSettings(
|
|
target,
|
|
workspace.capabilities?.features ?? [],
|
|
)
|
|
) {
|
|
throw new Error(
|
|
'Voice model settings are unavailable for this workspace.',
|
|
);
|
|
}
|
|
try {
|
|
if (target.route === 'legacy-primary' || scope === 'user') {
|
|
await setWorkspaceSetting(scope, 'voiceModel', value);
|
|
} else {
|
|
await setVoiceModelSetting(workspace.client, target, scope, value);
|
|
}
|
|
} catch (error) {
|
|
bumpVoiceRevision(target, scope);
|
|
if (target.route === 'legacy-primary') {
|
|
await reloadWorkspaceSettings().catch(() => undefined);
|
|
} else {
|
|
await reloadQualifiedVoiceSettings().catch(() => undefined);
|
|
}
|
|
throw error;
|
|
}
|
|
bumpVoiceRevision(target, scope);
|
|
if (target.route === 'legacy-primary') {
|
|
await reloadWorkspaceSettings().catch(() => undefined);
|
|
} else {
|
|
await reloadQualifiedVoiceSettings().catch(() => undefined);
|
|
}
|
|
},
|
|
[
|
|
bumpVoiceRevision,
|
|
mainVoiceTarget,
|
|
reloadQualifiedVoiceSettings,
|
|
reloadWorkspaceSettings,
|
|
setWorkspaceSetting,
|
|
workspace.capabilities?.features,
|
|
workspace.client,
|
|
],
|
|
);
|
|
const mainVoiceTargetRef = useRef(mainVoiceTarget);
|
|
const voiceFeaturesRef = useRef(workspace.capabilities?.features ?? []);
|
|
useLayoutEffect(() => {
|
|
modelDialogModeRef.current = modelDialogMode;
|
|
showFallbacksDialogRef.current = showFallbacksDialog;
|
|
mainViewRef.current = mainView;
|
|
activePanelRef.current = activePanel;
|
|
showAuthDialogRef.current = showAuthDialog;
|
|
mainVoiceTargetRef.current = mainVoiceTarget;
|
|
voiceFeaturesRef.current = workspace.capabilities?.features ?? [];
|
|
}, [
|
|
activePanel,
|
|
mainView,
|
|
mainVoiceTarget,
|
|
modelDialogMode,
|
|
showAuthDialog,
|
|
showFallbacksDialog,
|
|
workspace.capabilities?.features,
|
|
]);
|
|
const voicePickerRequestRef = useRef(0);
|
|
const pendingVoicePickerSourceRef = useRef<
|
|
'command' | 'settings' | undefined
|
|
>(undefined);
|
|
const pendingVoicePickerOwnerRef = useRef<string | undefined>(undefined);
|
|
const voicePickerTargetRef = useRef(mainVoiceTarget);
|
|
useEffect(
|
|
() => () => {
|
|
voicePickerRequestRef.current++;
|
|
pendingVoicePickerSourceRef.current = undefined;
|
|
},
|
|
[],
|
|
);
|
|
const openVoiceModelPicker = useCallback(
|
|
async (scope: 'workspace' | 'user', source: 'command' | 'settings') => {
|
|
const target = mainVoiceTargetRef.current;
|
|
if (
|
|
!target ||
|
|
!supportsVoiceModelSettings(
|
|
target,
|
|
workspace.capabilities?.features ?? [],
|
|
)
|
|
) {
|
|
reportError(
|
|
new Error('Voice model settings are unavailable for this workspace.'),
|
|
t('model.setVoice'),
|
|
);
|
|
return;
|
|
}
|
|
const request = ++voicePickerRequestRef.current;
|
|
pendingVoicePickerSourceRef.current = source;
|
|
pendingVoicePickerOwnerRef.current = target.ownerKey;
|
|
const intentIsCurrent = () =>
|
|
request === voicePickerRequestRef.current &&
|
|
mainVoiceTargetRef.current?.ownerKey === target.ownerKey &&
|
|
supportsVoiceModelSettings(
|
|
mainVoiceTargetRef.current,
|
|
voiceFeaturesRef.current,
|
|
) &&
|
|
(source === 'settings'
|
|
? activePanelRef.current === 'settings' &&
|
|
isSettingVisible('voiceModel', settingsPresentationRef.current)
|
|
: activePanelRef.current === null) &&
|
|
mainViewRef.current === 'chat' &&
|
|
modelDialogModeRef.current === null &&
|
|
!showFallbacksDialogRef.current &&
|
|
!showAuthDialogRef.current;
|
|
try {
|
|
const status = await loadVoiceStatus(workspace.client, target);
|
|
if (!intentIsCurrent()) {
|
|
if (request === voicePickerRequestRef.current) {
|
|
pendingVoicePickerSourceRef.current = undefined;
|
|
}
|
|
return;
|
|
}
|
|
pendingVoicePickerSourceRef.current = undefined;
|
|
voicePickerTargetRef.current = target;
|
|
setVoiceModels(
|
|
status.availableVoiceModels.map((model) => ({
|
|
id: model.id,
|
|
label: model.name,
|
|
baseUrl: model.baseUrl,
|
|
contextWindow: model.contextWindow,
|
|
authType: 'openai',
|
|
modalities: { audio: true },
|
|
})),
|
|
);
|
|
setModelSettingScope(scope);
|
|
setModelDialogMode('voice');
|
|
} catch (error) {
|
|
if (!intentIsCurrent()) {
|
|
if (request === voicePickerRequestRef.current) {
|
|
pendingVoicePickerSourceRef.current = undefined;
|
|
}
|
|
return;
|
|
}
|
|
pendingVoicePickerSourceRef.current = undefined;
|
|
reportError(error, t('model.setVoice'));
|
|
}
|
|
},
|
|
[reportError, t, workspace.capabilities?.features, workspace.client],
|
|
);
|
|
useEffect(() => {
|
|
if (
|
|
pendingVoicePickerSourceRef.current &&
|
|
(pendingVoicePickerOwnerRef.current !== mainVoiceTarget?.ownerKey ||
|
|
!voiceModelSettingsSupported)
|
|
) {
|
|
voicePickerRequestRef.current++;
|
|
pendingVoicePickerSourceRef.current = undefined;
|
|
}
|
|
}, [mainVoiceTarget?.ownerKey, voiceModelSettingsSupported]);
|
|
useEffect(() => {
|
|
const source = pendingVoicePickerSourceRef.current;
|
|
const sourceSurfaceIsCurrent =
|
|
source === 'settings' ? activePanel === 'settings' : activePanel === null;
|
|
if (source && !sourceSurfaceIsCurrent) {
|
|
voicePickerRequestRef.current++;
|
|
pendingVoicePickerSourceRef.current = undefined;
|
|
}
|
|
}, [activePanel]);
|
|
useEffect(() => {
|
|
if (
|
|
pendingVoicePickerSourceRef.current &&
|
|
(mainView !== 'chat' ||
|
|
modelDialogMode !== null ||
|
|
showFallbacksDialog ||
|
|
showAuthDialog)
|
|
) {
|
|
voicePickerRequestRef.current++;
|
|
pendingVoicePickerSourceRef.current = undefined;
|
|
}
|
|
}, [mainView, modelDialogMode, showAuthDialog, showFallbacksDialog]);
|
|
useEffect(() => {
|
|
const pickerTarget = voicePickerTargetRef.current;
|
|
if (
|
|
modelDialogMode === 'voice' &&
|
|
(pickerTarget?.ownerKey !== mainVoiceTarget?.ownerKey ||
|
|
!voiceModelSettingsSupported ||
|
|
mainView !== 'chat')
|
|
) {
|
|
setModelDialogMode(null);
|
|
}
|
|
}, [
|
|
mainView,
|
|
mainVoiceTarget?.ownerKey,
|
|
modelDialogMode,
|
|
voiceModelSettingsSupported,
|
|
]);
|
|
// Fallback candidates are the selectable (non-runtime) models, keyed by their
|
|
// base id — the same value shape the modelFallbacks setting stores.
|
|
const fallbackModelOptions = useMemo(() => {
|
|
// modelFallbacks stores bare ids and the dialog keys rows by baseId, so
|
|
// dedupe here — multiple endpoints can expose the same base model id.
|
|
const seen = new Set<string>();
|
|
const options: Array<{ baseId: string; label: string }> = [];
|
|
for (const m of (connection.models ?? [])
|
|
.filter(isVisibleComposerModel)
|
|
.filter((m) => !m.isRuntime)) {
|
|
const baseId = m.baseModelId ?? extractBareModelId(m.id);
|
|
if (seen.has(baseId)) continue;
|
|
seen.add(baseId);
|
|
options.push({
|
|
baseId,
|
|
label: getModelDisplayName(m.label || m.baseModelId || m.id),
|
|
});
|
|
}
|
|
return options;
|
|
}, [connection.models]);
|
|
|
|
// Settings-resolved values are reported to the host through refs, keyed on
|
|
// the setting value itself — an inline host handler must not re-fire the
|
|
// effect on every render (same loop hazard as onBrandResolved below).
|
|
const onThemeResolvedRef = useRef(onThemeResolved);
|
|
onThemeResolvedRef.current = onThemeResolved;
|
|
const onLanguageResolvedRef = useRef(onLanguageResolved);
|
|
onLanguageResolvedRef.current = onLanguageResolved;
|
|
|
|
useEffect(() => {
|
|
if (providedTheme) {
|
|
setSelectedTheme(providedTheme);
|
|
return;
|
|
}
|
|
const settingTheme = themeSettingToWebShellTheme(
|
|
themeSetting?.values.effective,
|
|
);
|
|
if (settingTheme) {
|
|
setSelectedTheme(settingTheme);
|
|
// Let the host steer document chrome (html class, theme-color meta)
|
|
// without handing it a new opinion — the value stays settings-owned.
|
|
onThemeResolvedRef.current?.(settingTheme);
|
|
}
|
|
}, [providedTheme, themeSetting?.values.effective]);
|
|
|
|
useEffect(() => {
|
|
if (providedLanguage !== undefined) {
|
|
setSelectedLanguage(normalizeLanguage(providedLanguage));
|
|
return;
|
|
}
|
|
const settingLanguage = languageSettingToWebShellLanguage(
|
|
languageSetting?.values.effective,
|
|
);
|
|
if (settingLanguage) {
|
|
setSelectedLanguage(settingLanguage);
|
|
onLanguageResolvedRef.current?.(settingLanguage);
|
|
}
|
|
}, [providedLanguage, languageSetting?.values.effective]);
|
|
|
|
// A host that passes `brand` owns the name and the logo outright, mirroring
|
|
// how the `theme` and `language` props win above. The daemon-resolved brand
|
|
// arrives asynchronously and stays undefined on a daemon without `GET /brand`,
|
|
// in which case every consumer falls back to its built-in literal.
|
|
const resolvedBrand = providedBrand ?? workspace.brand ?? EMPTY_BRAND;
|
|
|
|
// `workspace.brand` is undefined both while the fetch is in flight and when a
|
|
// daemon has no brand route, so rendering can treat it as "built-in" but the
|
|
// resolution callback must not fire on the in-flight state — that would make
|
|
// the standalone entry reset the tab title and drop the pre-paint cache on
|
|
// every load. `brandSettled` is the distinction: it flips once the fetch
|
|
// reaches a definitive outcome (an answer, or a 404 from a route-less
|
|
// daemon), so a settled-with-no-brand result (older daemon, withdrawn host
|
|
// prop) is reported as an empty brand and clears stale cached chrome, while
|
|
// a retryable failure clears nothing. The prop check is `!= null`, matching
|
|
// the `??` above: an untyped host passing `null` must not open the gate
|
|
// during the in-flight state either.
|
|
const brandResolved =
|
|
providedBrand != null || workspace.brandSettled === true;
|
|
|
|
// Keyed on the two primitive fields with the callback behind a ref, so a host
|
|
// passing an inline `brand` object and an inline handler — the shape the
|
|
// README shows — does not re-fire on every render. Keying on identity loops
|
|
// forever against a handler that stores the value: each call hands it a fresh
|
|
// object, React never bails out, and the host re-renders into the next call.
|
|
// `logo` is left out because a document can only act on the title and the
|
|
// favicon, and a React node has no stable identity by construction.
|
|
const onBrandResolvedRef = useRef(onBrandResolved);
|
|
onBrandResolvedRef.current = onBrandResolved;
|
|
// Empty means unset on the settings surface; the payload must not hand a host
|
|
// an `''` it would write into a tab title. Truthiness matches every in-shell
|
|
// reader (`useBrandName`, `webShellDocumentTitle`).
|
|
const brandNameValue = resolvedBrand.name || undefined;
|
|
const brandLogoUri = resolvedBrand.logoDataUri || undefined;
|
|
|
|
useEffect(() => {
|
|
if (!brandResolved) return;
|
|
onBrandResolvedRef.current?.({
|
|
...(brandNameValue === undefined ? {} : { name: brandNameValue }),
|
|
...(brandLogoUri === undefined ? {} : { logoDataUri: brandLogoUri }),
|
|
});
|
|
}, [brandResolved, brandNameValue, brandLogoUri]);
|
|
|
|
const uiLanguageWorkspaceCwd =
|
|
effectiveSessionContext?.kind === 'live'
|
|
? (workspace.capabilities?.workspaceCwd ?? workspace.workspaceCwd)
|
|
: getComposerWorkspaceCwd();
|
|
const handleSettingsLanguageChange = useCallback(
|
|
(nextLanguage: WebShellLanguage, scope: 'user' | 'workspace' = 'user') => {
|
|
if (sessionWriteBlocked) return;
|
|
const owner = { current: sessionOwnerGuard.capture() };
|
|
const previousLanguage = selectedLanguage;
|
|
// A standalone session refuses every workspace write, so its only
|
|
// writable language scope is the user one. The settings panel hides the
|
|
// row on the Workspace tab there; keep this guard for a scope that was
|
|
// picked before the session context settled.
|
|
const effectiveScope = sessionIsStandalone ? 'user' : scope;
|
|
const refreshSettings = async () => {
|
|
if (!owner.current.isCurrent()) return;
|
|
await Promise.all([
|
|
...(connectionRef.current.sessionId
|
|
? [sessionActions.refreshCommands()]
|
|
: []),
|
|
reloadWorkspaceSettings(),
|
|
]);
|
|
};
|
|
if (
|
|
streamingStateRef.current !== 'idle' ||
|
|
sessionHasActivePromptRef.current ||
|
|
isGoalGateBlocked()
|
|
) {
|
|
blockCommand();
|
|
return;
|
|
}
|
|
// Switch optimistically only when settings own the language. An explicit
|
|
// host prop remains authoritative while a workspace value is persisted.
|
|
// onLanguageChange persists an accepted user-scoped choice as a new host
|
|
// opinion; the observe-only channel keeps settings-derived values from
|
|
// shadowing later settings.json edits (#11955).
|
|
const hostControlsLanguage = providedLanguage !== undefined;
|
|
if (!hostControlsLanguage) {
|
|
setSelectedLanguage(nextLanguage);
|
|
onLanguageResolvedRef.current?.(nextLanguage);
|
|
}
|
|
workspaceActions
|
|
.setUiLanguage(
|
|
nextLanguage === 'zh-CN' ? 'zh' : nextLanguage,
|
|
effectiveScope,
|
|
uiLanguageWorkspaceCwd,
|
|
)
|
|
.then((result) => {
|
|
if (!owner.current.isCurrent()) return;
|
|
const appliedLanguage =
|
|
languageSettingToWebShellLanguage(result.language) ??
|
|
normalizeLanguage(result.language);
|
|
if (!hostControlsLanguage && appliedLanguage !== nextLanguage) {
|
|
setSelectedLanguage(appliedLanguage);
|
|
onLanguageResolvedRef.current?.(appliedLanguage);
|
|
}
|
|
if (effectiveScope === 'user') {
|
|
// A user-scoped choice becomes the host's persisted opinion. A
|
|
// workspace choice remains settings-owned for that workspace.
|
|
handleLanguageChange(appliedLanguage);
|
|
}
|
|
if (result.refresh.failed > 0) {
|
|
pushToast('warning', t('language.refreshFailed'));
|
|
}
|
|
return refreshSettings().catch((error: unknown) => {
|
|
if (!owner.current.isCurrent()) return;
|
|
reportError(error, 'Failed to refresh settings after /language');
|
|
});
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!owner.current.isCurrent()) return;
|
|
if (!hostControlsLanguage) {
|
|
setSelectedLanguage(previousLanguage);
|
|
onLanguageResolvedRef.current?.(previousLanguage);
|
|
}
|
|
reportError(error, 'Failed to sync /language command');
|
|
});
|
|
},
|
|
[
|
|
blockCommand,
|
|
handleLanguageChange,
|
|
reloadWorkspaceSettings,
|
|
reportError,
|
|
providedLanguage,
|
|
sessionWriteBlocked,
|
|
workspaceActions,
|
|
uiLanguageWorkspaceCwd,
|
|
selectedLanguage,
|
|
sessionActions,
|
|
sessionOwnerGuard,
|
|
sessionIsStandalone,
|
|
isGoalGateBlocked,
|
|
pushToast,
|
|
t,
|
|
],
|
|
);
|
|
|
|
const handleSettingsOutputLanguageChange = useCallback(
|
|
async (language: string): Promise<boolean> => {
|
|
if (sessionWriteBlocked) return false;
|
|
const owner = { current: sessionOwnerGuard.capture() };
|
|
if (
|
|
streamingStateRef.current !== 'idle' ||
|
|
sessionHasActivePromptRef.current ||
|
|
isGoalGateBlocked()
|
|
) {
|
|
blockCommand();
|
|
return false;
|
|
}
|
|
// Only the language command rewrites output-language.md and refreshes
|
|
// running sessions; a plain settings write would leave both stale.
|
|
const command = `/language output ${language.trim() || 'auto'}`;
|
|
try {
|
|
await sendPrompt(command, undefined, undefined, {
|
|
ownerRef: owner,
|
|
suppressSessionTitle: true,
|
|
});
|
|
} catch (error) {
|
|
if (owner.current.isCurrent()) {
|
|
reportError(error, 'Failed to sync /language output command');
|
|
}
|
|
return false;
|
|
}
|
|
if (!owner.current.isCurrent()) return true;
|
|
try {
|
|
await reloadWorkspaceSettings();
|
|
} catch (error) {
|
|
if (owner.current.isCurrent()) {
|
|
reportError(
|
|
error,
|
|
'Failed to refresh settings after /language output',
|
|
);
|
|
}
|
|
}
|
|
return true;
|
|
},
|
|
[
|
|
blockCommand,
|
|
reloadWorkspaceSettings,
|
|
reportError,
|
|
sessionWriteBlocked,
|
|
sendPrompt,
|
|
sessionOwnerGuard,
|
|
isGoalGateBlocked,
|
|
],
|
|
);
|
|
|
|
const handleClearScreen = useCallback(() => {
|
|
if (streamingStateRef.current !== 'idle') {
|
|
store.dispatch([
|
|
{
|
|
type: 'status',
|
|
promptId: `local-command:${crypto.randomUUID()}`,
|
|
text: t('clear.blocked'),
|
|
},
|
|
]);
|
|
return;
|
|
}
|
|
autoRecapVersionRef.current += 1;
|
|
// Clearing the screen wipes the transcript, so it also drops every recap:
|
|
// the one on screen and any request still in flight.
|
|
recapRequestRef.current += 1;
|
|
setRecapMessage(null);
|
|
lastRecapBlockCountRef.current = 0;
|
|
store.reset();
|
|
}, [store, t]);
|
|
|
|
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, planMode: boolean): Promise<boolean> => {
|
|
if (managedModeRef.current) return false;
|
|
if (modeTransitionRef.current?.owner.isCurrent()) return false;
|
|
if (sessionWriteBlocked) return false;
|
|
if (!isDaemonApprovalMode(modeId) || modeId === 'plan') {
|
|
reportError(
|
|
new Error(`Unsupported execution approval mode: ${modeId}`),
|
|
t('local.approvalMode'),
|
|
);
|
|
return false;
|
|
}
|
|
if (!connectionRef.current.sessionId) {
|
|
executionModeRef.current = modeId;
|
|
setPlanExecutionMode(modeId);
|
|
setPendingMode(planMode ? 'plan' : modeId);
|
|
return true;
|
|
}
|
|
const owner = sessionOwnerGuard.capture();
|
|
const transition = { owner };
|
|
modeTransitionRef.current = transition;
|
|
setModeControlsBusy(true);
|
|
try {
|
|
const result = await sessionActions.setApprovalMode(modeId, {
|
|
planMode,
|
|
});
|
|
if (!owner.isCurrent()) return false;
|
|
setPendingMode(result.mode || (planMode ? 'plan' : modeId));
|
|
executionModeRef.current = result.planExecutionMode || modeId;
|
|
setPlanExecutionMode(executionModeRef.current);
|
|
const approval = pendingApprovalRef.current;
|
|
if (
|
|
!managedModeRef.current &&
|
|
!planMode &&
|
|
approval &&
|
|
!isExitPlanApprovalRequest(approval) &&
|
|
(modeId === 'yolo' ||
|
|
(modeId === 'auto-edit' && isEditToolPermission(approval)))
|
|
) {
|
|
const allowOnce = approval.options.find(
|
|
(option) => option.kind === 'allow_once',
|
|
);
|
|
if (allowOnce) {
|
|
store.dispatch([
|
|
{
|
|
type: 'status',
|
|
promptId: `local-command:${crypto.randomUUID()}`,
|
|
text: t('mode.autoApproved', { tool: approval.title || '' }),
|
|
},
|
|
]);
|
|
sessionActions
|
|
.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, t('local.approvalMode'));
|
|
return false;
|
|
} finally {
|
|
releaseModeTransition(transition);
|
|
}
|
|
},
|
|
[
|
|
sessionWriteBlocked,
|
|
reportError,
|
|
sessionActions,
|
|
sessionOwnerGuard,
|
|
releaseModeTransition,
|
|
setPendingMode,
|
|
store,
|
|
t,
|
|
],
|
|
);
|
|
const handleSetMode = useCallback(
|
|
(modeId: string) => {
|
|
if (
|
|
isDaemonApprovalMode(modeId) &&
|
|
modeId !== 'plan' &&
|
|
!availableApprovalModes.some((mode) => mode === modeId)
|
|
) {
|
|
reportError(
|
|
new Error(`Approval mode not offered here: ${modeId}`),
|
|
t('local.approvalMode'),
|
|
);
|
|
return;
|
|
}
|
|
void setComposerMode(modeId, currentModeRef.current === 'plan');
|
|
},
|
|
[availableApprovalModes, setComposerMode, reportError, t],
|
|
);
|
|
const handleTogglePlan = useCallback(() => {
|
|
void setComposerMode(
|
|
executionModeRef.current,
|
|
currentModeRef.current !== 'plan',
|
|
);
|
|
}, [setComposerMode]);
|
|
|
|
// Drop queued commands on a session switch so the drain never runs a
|
|
// command against a different workspace's daemon (mirrors useQueuedPrompts).
|
|
const prevQueueSessionIdRef = useRef(logicalSessionKey);
|
|
useEffect(() => {
|
|
if (prevQueueSessionIdRef.current === logicalSessionKey) return;
|
|
prevQueueSessionIdRef.current = logicalSessionKey;
|
|
const dropped = queuedShellCommandsRef.current.length;
|
|
queuedShellCommandsRef.current = [];
|
|
// Skip the bump when the transition is into the session that
|
|
// ensureSessionForPrompt is preparing — that is the submit's own lazy
|
|
// creation, not a user-initiated switch.
|
|
if (connection.sessionId !== preparingSessionIdRef.current) {
|
|
drainGenerationRef.current++;
|
|
isDrainingRef.current = false;
|
|
}
|
|
if (dropped > 0) {
|
|
pushToast('warning', t('queue.shellDropped', { count: dropped }));
|
|
}
|
|
}, [connection.sessionId, logicalSessionKey, pushToast, t]);
|
|
|
|
// Declared after the session-switch wipe effect above: React runs effects in
|
|
// declaration order, so the queue is already cleared before this drain sees it.
|
|
const prevShellDrainStreamingStateRef = useRef(streamingState);
|
|
useEffect(() => {
|
|
const prev = prevShellDrainStreamingStateRef.current;
|
|
prevShellDrainStreamingStateRef.current = streamingState;
|
|
// Only start a drain on the transition into idle. sendShellCommand drives
|
|
// streamingState non-idle while each command runs, so cancelling the drain
|
|
// on every streamingState change would drop every command after the first.
|
|
if (prev === 'idle' || streamingState !== 'idle') return;
|
|
// A running drain re-reads the queue after each batch, so commands queued
|
|
// mid-drain are picked up by it. Starting a second drain here would bump the
|
|
// generation and cancel the in-flight batch, silently dropping commands.
|
|
if (isDrainingRef.current) return;
|
|
const cmds = queuedShellCommandsRef.current;
|
|
if (cmds.length === 0) return;
|
|
queuedShellCommandsRef.current = [];
|
|
isDrainingRef.current = true;
|
|
const generation = ++drainGenerationRef.current;
|
|
const drainSessionId = connectionRef.current.sessionId;
|
|
const drainWorkspaceCwd = getComposerWorkspaceCwd();
|
|
const drainOwner = sessionOwnerGuard.capture();
|
|
void (async () => {
|
|
try {
|
|
let batch = cmds;
|
|
while (batch.length > 0) {
|
|
for (let i = 0; i < batch.length; i++) {
|
|
const generationChanged = drainGenerationRef.current !== generation;
|
|
if (
|
|
generationChanged ||
|
|
!drainOwner.isCurrent() ||
|
|
connectionRef.current.sessionId !== drainSessionId ||
|
|
connectionRef.current.status !== 'connected'
|
|
) {
|
|
let dropped = batch.length - i;
|
|
if (!generationChanged) {
|
|
// The two generation-bump sites (session switch, cancel) wipe
|
|
// the queue themselves, so anything parked here was queued
|
|
// after the drop and is fresh user intent. A disconnect has no
|
|
// such wipe — clear it so a reconnect cannot resurrect a newer
|
|
// command behind an already-dropped older one.
|
|
dropped += queuedShellCommandsRef.current.length;
|
|
queuedShellCommandsRef.current = [];
|
|
}
|
|
console.warn(
|
|
'[web-shell] dropping %d queued shell command(s)',
|
|
dropped,
|
|
);
|
|
pushToast('warning', t('queue.shellDropped', { count: dropped }));
|
|
return;
|
|
}
|
|
try {
|
|
await sessionActions.sendShellCommand(batch[i]);
|
|
} catch (error: unknown) {
|
|
reportError(
|
|
error,
|
|
`Failed to execute shell command: !${batch[i]}`,
|
|
);
|
|
} finally {
|
|
if (drainWorkspaceCwd) {
|
|
sessionCatalogController.invalidateWorkspace(drainWorkspaceCwd);
|
|
}
|
|
}
|
|
}
|
|
batch = queuedShellCommandsRef.current;
|
|
queuedShellCommandsRef.current = [];
|
|
}
|
|
} finally {
|
|
// Release the lock only if this IIFE is still the active drainer; a
|
|
// cancel or session switch may have bumped the generation and handed
|
|
// the lock to a newer drain that must not be unblocked prematurely.
|
|
if (drainGenerationRef.current === generation) {
|
|
isDrainingRef.current = false;
|
|
}
|
|
}
|
|
})();
|
|
}, [
|
|
getComposerWorkspaceCwd,
|
|
pushToast,
|
|
reportError,
|
|
sessionActions,
|
|
sessionCatalogController,
|
|
sessionOwnerGuard,
|
|
streamingState,
|
|
t,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const lastTurnError = getRetryableTurnError(blocks);
|
|
// Loop-detected turn errors still surface through turn_complete below,
|
|
// but resubmitting a prompt the daemon stopped for loop protection
|
|
// tends to re-loop, so no retry affordance is offered for them.
|
|
const retryableTurnError =
|
|
lastTurnError &&
|
|
lastTurnError.kind === 'error' &&
|
|
isRetryableTurnErrorKind(lastTurnError.errorKind)
|
|
? lastTurnError
|
|
: undefined;
|
|
if (retryableTurnError) {
|
|
rearmFailedTurnErrorRetry(retryableTurnError, blocks);
|
|
}
|
|
const previousIdentity = retryableTurnErrorIdentityRef.current;
|
|
const identityMatches =
|
|
previousIdentity === undefined ||
|
|
(retryableTurnError !== undefined &&
|
|
matchesTurnErrorIdentity(retryableTurnError, previousIdentity));
|
|
if (
|
|
retryableTurnError &&
|
|
previousIdentity &&
|
|
identityMatches &&
|
|
retriedTurnErrorIdRef.current !== null
|
|
) {
|
|
retriedTurnErrorIdRef.current = retryableTurnError.id;
|
|
}
|
|
// Same walk as the retry decision above, so turn_complete and the
|
|
// retry affordance never disagree about whether the current turn has
|
|
// a turn error (e.g. across a trailing background notification). An
|
|
// error the user already retried stays suppressed, mirroring the
|
|
// retry affordance; loop-detected errors are never retried, so they
|
|
// always surface.
|
|
lastTurnErrorIdRef.current =
|
|
lastTurnError && lastTurnError.id !== retriedTurnErrorIdRef.current
|
|
? lastTurnError.id
|
|
: null;
|
|
const canRetry =
|
|
connected &&
|
|
retryableTurnError !== undefined &&
|
|
identityMatches &&
|
|
retryableTurnError.id !== retriedTurnErrorIdRef.current &&
|
|
failedPromptRetry === null &&
|
|
lastSubmittedSourceVersionRef.current ===
|
|
composerSourceVersionRef.current &&
|
|
(lastSubmittedPromptRef.current.length > 0 ||
|
|
(lastSubmittedImagesRef.current?.length ?? 0) > 0 ||
|
|
(lastSubmittedFilesRef.current?.length ?? 0) > 0);
|
|
if (retryableTurnError && previousIdentity && !identityMatches) {
|
|
lastSubmittedPromptRef.current = '';
|
|
lastSubmittedImagesRef.current = undefined;
|
|
lastSubmittedFilesRef.current = undefined;
|
|
lastSubmittedInputAnnotationsRef.current = undefined;
|
|
lastSubmittedSourceVersionRef.current = -1;
|
|
retryableTurnErrorIdentityRef.current = undefined;
|
|
retriedTurnErrorIdRef.current = null;
|
|
failedTurnErrorRetryRef.current = null;
|
|
} else if (canRetry) {
|
|
retryableTurnErrorIdentityRef.current = { block: retryableTurnError };
|
|
}
|
|
retryableTurnErrorIdRef.current = canRetry ? retryableTurnError.id : null;
|
|
setShowRetryHint(canRetry);
|
|
}, [blocks, connected, failedPromptRetry, rearmFailedTurnErrorRetry]);
|
|
|
|
useEffect(() => {
|
|
onStreamingStateChange?.(streamingState);
|
|
}, [streamingState, onStreamingStateChange]);
|
|
|
|
// Reads lastTurnErrorIdRef which is set by the blocks effect above.
|
|
// Declaration order matters: this effect must run after the blocks effect
|
|
// so that within the same render, the ref is already updated before we read it.
|
|
const prevStreamingForTurnCompleteRef = useRef(streamingState);
|
|
const streamingSessionIdRef = useRef<string | undefined>(undefined);
|
|
const streamingWorkspaceCwdRef = useRef<string | undefined>(undefined);
|
|
useEffect(() => {
|
|
const prev = prevStreamingForTurnCompleteRef.current;
|
|
prevStreamingForTurnCompleteRef.current = streamingState;
|
|
if (streamingState !== 'idle') {
|
|
if (prev === 'idle' || streamingSessionIdRef.current === undefined) {
|
|
streamingSessionIdRef.current = connection.sessionId;
|
|
streamingWorkspaceCwdRef.current = connection.workspaceCwd;
|
|
} else if (
|
|
connection.sessionId === streamingSessionIdRef.current &&
|
|
streamingWorkspaceCwdRef.current === undefined
|
|
) {
|
|
streamingWorkspaceCwdRef.current = connection.workspaceCwd;
|
|
}
|
|
}
|
|
if (prev !== 'idle' && streamingState === 'idle') {
|
|
const sessionId = connectionRef.current.sessionId;
|
|
const workspaceCwd = connectionRef.current.workspaceCwd;
|
|
// Only fire if the session that was streaming is still the active one.
|
|
// Session switches reset streamingState to idle, which must not produce
|
|
// a spurious turn_complete for the new session.
|
|
if (
|
|
!sessionId ||
|
|
sessionId !== streamingSessionIdRef.current ||
|
|
workspaceCwd !== streamingWorkspaceCwdRef.current
|
|
) {
|
|
return;
|
|
}
|
|
const turnError =
|
|
lastTurnErrorIdRef.current != null
|
|
? new Error(`Turn error (block ${lastTurnErrorIdRef.current})`)
|
|
: undefined;
|
|
const productContext = connectionRef.current.sessionContext;
|
|
if (
|
|
workspaceCwd &&
|
|
(!productContext || productContext.kind === 'workspace')
|
|
) {
|
|
sessionCatalogController.turnCompleted(workspaceCwd, sessionId);
|
|
if (!sessionDisplayName) {
|
|
scheduleDelayedActiveSessionDisplayNameRefresh(
|
|
sessionId,
|
|
workspaceCwd,
|
|
);
|
|
if (typeof document !== 'undefined' && document.hidden) {
|
|
pendingDisplayNameRefreshRef.current = {
|
|
sessionId,
|
|
workspaceCwd,
|
|
};
|
|
} else {
|
|
void refreshActiveSessionDisplayNameRef.current();
|
|
}
|
|
}
|
|
}
|
|
if (productContext?.kind === 'standalone' && !sessionDisplayName) {
|
|
void refreshActiveSessionDisplayNameRef.current();
|
|
}
|
|
dispatchSessionChangeRef.current?.({
|
|
type: 'turn_complete',
|
|
sessionId,
|
|
error: turnError,
|
|
});
|
|
}
|
|
}, [
|
|
connection.sessionId,
|
|
connection.workspaceCwd,
|
|
scheduleDelayedActiveSessionDisplayNameRefresh,
|
|
sessionCatalogController,
|
|
sessionDisplayName,
|
|
streamingState,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
onConnectionChange?.(connection.status);
|
|
}, [connection.status, onConnectionChange]);
|
|
|
|
// Report each distinct connection.error value only once. Hosts may pass an
|
|
// inline onError (or one whose identity changes) and update their own state
|
|
// when it fires; the resulting re-render then hands this effect a fresh
|
|
// callback identity, which would re-notify the same persistent error
|
|
// forever (#10406).
|
|
const lastReportedConnectionErrorRef = useRef<string | undefined>(undefined);
|
|
useEffect(() => {
|
|
if (!connection.error) {
|
|
lastReportedConnectionErrorRef.current = undefined;
|
|
return;
|
|
}
|
|
if (standaloneWriterBlocked) {
|
|
lastReportedConnectionErrorRef.current = connection.error;
|
|
return;
|
|
}
|
|
if (lastReportedConnectionErrorRef.current === connection.error) return;
|
|
if (!onError) return;
|
|
lastReportedConnectionErrorRef.current = connection.error;
|
|
onError(new Error(connection.error));
|
|
}, [connection.error, onError, standaloneWriterBlocked]);
|
|
|
|
const prevConnectionModelRef = useRef(connection.currentModel);
|
|
useLayoutEffect(() => {
|
|
const next = connection.currentModel;
|
|
// A late standalone options publish lands as an undefined→value change;
|
|
// never let it overwrite a model the user already picked while waiting.
|
|
const wasLateHydration =
|
|
prevConnectionModelRef.current === undefined && next !== undefined;
|
|
prevConnectionModelRef.current = next;
|
|
setCurrentModel((prev) => (wasLateHydration && prev ? prev : (next ?? '')));
|
|
}, [connection.currentModel, logicalSessionKey]);
|
|
|
|
const prevModeSessionKeyRef = useRef(logicalSessionKey);
|
|
useLayoutEffect(() => {
|
|
const next = connection.currentMode;
|
|
const sameSession = prevModeSessionKeyRef.current === logicalSessionKey;
|
|
prevModeSessionKeyRef.current = logicalSessionKey;
|
|
const preservePendingSelection =
|
|
sameSession && !connection.sessionId && pendingModeSelectionRef.current;
|
|
if (!sameSession || connection.sessionId)
|
|
pendingModeSelectionRef.current = false;
|
|
if (preservePendingSelection) return;
|
|
setPlanExecutionMode((previous) =>
|
|
next === 'plan'
|
|
? (connection.planExecutionMode ?? (sameSession ? previous : 'default'))
|
|
: (next ?? 'default'),
|
|
);
|
|
setCurrentMode(next ?? 'default');
|
|
}, [
|
|
connection.currentMode,
|
|
connection.planExecutionMode,
|
|
connection.sessionId,
|
|
logicalSessionKey,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (connection.loadingTranscript) return;
|
|
if (connection.standaloneSession?.creationRecovery) {
|
|
return;
|
|
}
|
|
if (!connection.sessionId && connection.missingSession) {
|
|
// Keep the dead-session route visible until the user explicitly starts a
|
|
// new chat; clearing it here would immediately hide the recovery state.
|
|
lastNotifiedSessionIdRef.current = connection.sessionId;
|
|
lastNotifiedWorkspaceIdRef.current = undefined;
|
|
lastNotifiedWorkspaceCwdRef.current = undefined;
|
|
lastNotifiedNonWorkspaceContextRef.current = undefined;
|
|
return;
|
|
}
|
|
const reportedWorkspaceCwd = connection.sessionId
|
|
? connection.workspaceCwd
|
|
: activeWorkspaceCwd;
|
|
const activeWorkspace = workspaces.find(
|
|
(entry) => entry.cwd === reportedWorkspaceCwd,
|
|
);
|
|
if (connection.sessionId && !workspace.capabilities) return;
|
|
const workspaceId =
|
|
activeWorkspace && !activeWorkspace.primary
|
|
? activeWorkspace.id
|
|
: undefined;
|
|
const reportedSessionContext = connection.sessionId
|
|
? connection.sessionContext
|
|
: (pendingSessionContext ?? connection.sessionContext);
|
|
const nonWorkspaceContext =
|
|
reportedSessionContext?.kind === 'standalone' ||
|
|
reportedSessionContext?.kind === 'live'
|
|
? reportedSessionContext.kind
|
|
: undefined;
|
|
if (
|
|
lastNotifiedSessionIdRef.current === connection.sessionId &&
|
|
lastNotifiedWorkspaceIdRef.current === workspaceId &&
|
|
lastNotifiedWorkspaceCwdRef.current === reportedWorkspaceCwd &&
|
|
lastNotifiedNonWorkspaceContextRef.current === nonWorkspaceContext
|
|
) {
|
|
return;
|
|
}
|
|
lastNotifiedSessionIdRef.current = connection.sessionId;
|
|
lastNotifiedWorkspaceIdRef.current = workspaceId;
|
|
lastNotifiedWorkspaceCwdRef.current = reportedWorkspaceCwd;
|
|
lastNotifiedNonWorkspaceContextRef.current = nonWorkspaceContext;
|
|
if (nonWorkspaceContext) {
|
|
onSessionIdChange?.(connection.sessionId, undefined, undefined, {
|
|
kind: nonWorkspaceContext,
|
|
});
|
|
} else {
|
|
onSessionIdChange?.(
|
|
connection.sessionId,
|
|
workspaceId,
|
|
reportedWorkspaceCwd,
|
|
);
|
|
}
|
|
}, [
|
|
connection.missingSession,
|
|
connection.loadingTranscript,
|
|
connection.sessionId,
|
|
connection.sessionContext,
|
|
connection.standaloneSession?.creationRecovery,
|
|
connection.workspaceCwd,
|
|
onSessionIdChange,
|
|
activeWorkspaceCwd,
|
|
pendingSessionContext,
|
|
workspace.capabilities,
|
|
workspaces,
|
|
]);
|
|
|
|
const lastRenameSessionRef = useRef<string | undefined>(undefined);
|
|
const lastRenameNameRef = useRef<string | undefined>(undefined);
|
|
const lastReconciledRenameRef = useRef<
|
|
| {
|
|
workspaceCwd?: string;
|
|
sessionId: string;
|
|
displayName: string;
|
|
}
|
|
| undefined
|
|
>(undefined);
|
|
const reconcileCatalogRename = useCallback(
|
|
(
|
|
workspaceCwd: string | undefined,
|
|
sessionId: string,
|
|
displayName: string,
|
|
) => {
|
|
lastReconciledRenameRef.current = {
|
|
workspaceCwd,
|
|
sessionId,
|
|
displayName,
|
|
};
|
|
if (workspaceCwd) {
|
|
sessionCatalogController.renamed(workspaceCwd, sessionId, displayName);
|
|
} else if (
|
|
connectionRef.current.sessionId === sessionId &&
|
|
connectionRef.current.sessionContext?.kind === 'standalone'
|
|
) {
|
|
setSessionStatusDisplayName(displayName);
|
|
}
|
|
},
|
|
[sessionCatalogController],
|
|
);
|
|
useEffect(() => {
|
|
const sessionId = connection.sessionId;
|
|
const displayName = connection.displayName;
|
|
if (!sessionId || !displayName) return;
|
|
if (logicalSessionKey !== lastRenameSessionRef.current) {
|
|
lastRenameSessionRef.current = logicalSessionKey;
|
|
lastRenameNameRef.current = displayName;
|
|
lastReconciledRenameRef.current = undefined;
|
|
return;
|
|
}
|
|
if (displayName === lastRenameNameRef.current) return;
|
|
lastRenameNameRef.current = displayName;
|
|
const reconciled = lastReconciledRenameRef.current;
|
|
lastReconciledRenameRef.current = undefined;
|
|
const alreadyReconciled =
|
|
reconciled !== undefined &&
|
|
reconciled.workspaceCwd === connection.workspaceCwd &&
|
|
reconciled.sessionId === sessionId &&
|
|
reconciled.displayName === displayName;
|
|
if (!alreadyReconciled) {
|
|
if (
|
|
connection.workspaceCwd &&
|
|
(!connection.sessionContext ||
|
|
connection.sessionContext.kind === 'workspace')
|
|
) {
|
|
sessionCatalogController.renamed(
|
|
connection.workspaceCwd,
|
|
sessionId,
|
|
displayName,
|
|
);
|
|
}
|
|
}
|
|
dispatchSessionChangeRef.current?.({
|
|
type: 'rename',
|
|
sessionId,
|
|
newName: displayName,
|
|
});
|
|
}, [
|
|
connection.displayName,
|
|
connection.sessionId,
|
|
connection.sessionContext,
|
|
connection.workspaceCwd,
|
|
logicalSessionKey,
|
|
sessionCatalogController,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
setGoalSnapshot(connection.goalState ?? null);
|
|
}, [connection.goalState, connection.sessionId, logicalSessionKey]);
|
|
|
|
const connectionGoalComplete =
|
|
connection.goalState?.goal?.status === 'complete';
|
|
useEffect(() => {
|
|
setGoalEditOpen(false);
|
|
setGoalEditError(null);
|
|
}, [
|
|
connection.goalState?.goal?.goalId,
|
|
connection.sessionId,
|
|
connectionGoalComplete,
|
|
]);
|
|
|
|
const activeGoal =
|
|
goalSnapshot?.goal && goalSnapshot.goal.status !== 'complete'
|
|
? {
|
|
condition: goalSnapshot.goal.objective,
|
|
setAt: goalSnapshot.goal.createdAt,
|
|
}
|
|
: null;
|
|
const liveGoalSnapshot =
|
|
goalSnapshot?.goal?.status === 'complete' ? null : goalSnapshot;
|
|
|
|
// Auto-recap: fire when the user returns after being away ≥ 3 minutes
|
|
const hiddenAtRef = useRef<number | null>(null);
|
|
const lastRecapBlockCountRef = useRef(0);
|
|
const autoRecapVersionRef = useRef(0);
|
|
useEffect(() => {
|
|
lastRecapBlockCountRef.current = 0;
|
|
autoRecapVersionRef.current += 1;
|
|
}, [logicalSessionKey]);
|
|
useEffect(() => {
|
|
const AWAY_THRESHOLD_MS = 3 * 60 * 1000;
|
|
const MIN_NEW_BLOCKS = 4;
|
|
function onVisibilityChange() {
|
|
if (document.hidden) {
|
|
if (hiddenAtRef.current === null) hiddenAtRef.current = Date.now();
|
|
return;
|
|
}
|
|
const hiddenAt = hiddenAtRef.current;
|
|
hiddenAtRef.current = null;
|
|
if (hiddenAt === null) return;
|
|
if (Date.now() - hiddenAt < AWAY_THRESHOLD_MS) return;
|
|
if (sessionWriteBlocked) return;
|
|
if (streamingStateRef.current !== 'idle') return;
|
|
if (!connection.sessionId) return;
|
|
const currentCount = store.getSnapshot().blocks.length;
|
|
if (currentCount - lastRecapBlockCountRef.current < MIN_NEW_BLOCKS)
|
|
return;
|
|
lastRecapBlockCountRef.current = currentCount;
|
|
const sessionId = connection.sessionId;
|
|
const version = autoRecapVersionRef.current;
|
|
const owner = sessionOwnerGuard.capture();
|
|
// Local-only commands also append user blocks. Treat any new visible user
|
|
// activity as invalidating the recap rather than risk placing it too late.
|
|
const userBlockId = getLatestUserBlockId(store.getSnapshot().blocks);
|
|
recapRequestRef.current += 1;
|
|
const recapRequestId = recapRequestRef.current;
|
|
sessionActions.recapSession().then(
|
|
(result) => {
|
|
const currentUserBlockId = getLatestUserBlockId(
|
|
store.getSnapshot().blocks,
|
|
);
|
|
// result.sessionId only pins the daemon wire contract (the daemon
|
|
// echoes the id back), not a real race; the epoch/connection checks
|
|
// catch those. Kept so it is not simplified away as redundant.
|
|
if (
|
|
autoRecapVersionRef.current !== version ||
|
|
!owner.isCurrent() ||
|
|
connectionRef.current.sessionId !== sessionId ||
|
|
result.sessionId !== sessionId ||
|
|
currentUserBlockId !== userBlockId ||
|
|
streamingStateRef.current !== 'idle'
|
|
) {
|
|
console.warn('[auto-recap] discarding stale recap', {
|
|
captured: { sessionId, version, userBlockId },
|
|
current: {
|
|
sessionId: connectionRef.current.sessionId,
|
|
version: autoRecapVersionRef.current,
|
|
userBlockId: currentUserBlockId,
|
|
streamingState: streamingStateRef.current,
|
|
},
|
|
result: result.sessionId,
|
|
});
|
|
return;
|
|
}
|
|
if (result.recap) {
|
|
showRecapMessage(
|
|
recapRequestId,
|
|
`※ ${t('recap.label')}: ${result.recap}`,
|
|
);
|
|
}
|
|
},
|
|
(error: unknown) => {
|
|
console.error('[auto-recap] failed:', error);
|
|
},
|
|
);
|
|
}
|
|
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
return () =>
|
|
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
}, [
|
|
connection.sessionId,
|
|
sessionActions,
|
|
sessionOwnerGuard,
|
|
sessionWriteBlocked,
|
|
showRecapMessage,
|
|
store,
|
|
t,
|
|
]);
|
|
|
|
const handleCycleMode = useCallback(() => {
|
|
const next = nextApprovalMode(availableApprovalModes, executionMode);
|
|
if (next) handleSetMode(next);
|
|
}, [availableApprovalModes, executionMode, handleSetMode]);
|
|
|
|
// Shared by the /context slash command and the status-bar context
|
|
// indicator. Echoes the command when idle — that also makes the transcript
|
|
// follow the tail (MessageList Rule 4). Mid-turn the echo is skipped and
|
|
// dispatchReadOnlyStatus's resumeChatBottomFollow resumes bottom-follow,
|
|
// so the panel is revealed even when the click comes while scrolled up.
|
|
const showContextUsage = useCallback(
|
|
(commandText: string, detail: boolean) => {
|
|
// Read-only: every entry point (keyboard, status-bar button) runs
|
|
// immediately, even mid-turn — only the echo is skipped while streaming
|
|
// so the active turn is not split.
|
|
if (!requireActiveSessionForLocalCommand()) return;
|
|
const owner = sessionOwnerGuard.capture();
|
|
const localCommandId = echoLocalCommandIfIdle(commandText);
|
|
sessionActions
|
|
.getContextUsage({ detail })
|
|
.then((result) => {
|
|
if (!owner.isCurrent()) return;
|
|
dispatchReadOnlyStatus(
|
|
t('contextUsage.title'),
|
|
createContextUsageMessageData(result),
|
|
localCommandId,
|
|
);
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!owner.isCurrent()) return;
|
|
dispatchReadOnlyStatus(
|
|
t('messageDelete.commandFailed'),
|
|
undefined,
|
|
localCommandId,
|
|
);
|
|
reportError(error, 'Failed to load context usage');
|
|
})
|
|
.finally(() => finishLocalCommand(store, localCommandId));
|
|
},
|
|
[
|
|
echoLocalCommandIfIdle,
|
|
dispatchReadOnlyStatus,
|
|
requireActiveSessionForLocalCommand,
|
|
sessionActions,
|
|
store,
|
|
sessionOwnerGuard,
|
|
reportError,
|
|
t,
|
|
],
|
|
);
|
|
const contextUsageAvailable = !shouldBlockComposerSubmit({
|
|
connectionStatus: connection.status,
|
|
hasSession: Boolean(connection.sessionId),
|
|
});
|
|
|
|
const pendingBranchRequestsRef = useRef(new Map<string, Promise<void>>());
|
|
const branchCurrentSession = useCallback(
|
|
(options: {
|
|
name?: string;
|
|
atRecordId?: string;
|
|
worktree?: { slug?: string };
|
|
}) => {
|
|
if (!workspaceContextActive) {
|
|
pushToast('info', t('session.workspaceActionUnavailable'));
|
|
return;
|
|
}
|
|
if (sessionWriteBlocked) return;
|
|
if (!requireActiveSessionForLocalCommand()) return;
|
|
const sourceSessionId = connectionRef.current.sessionId;
|
|
const sourceWorkspaceCwd = connectionRef.current.workspaceCwd;
|
|
const requestKey = JSON.stringify([
|
|
sourceSessionId,
|
|
options.name ?? null,
|
|
options.atRecordId ?? null,
|
|
options.worktree !== undefined,
|
|
]);
|
|
const pending = pendingBranchRequestsRef.current.get(requestKey);
|
|
if (pending) return pending;
|
|
|
|
const branchRequest =
|
|
options.worktree !== undefined
|
|
? sessionActions.branchSession({
|
|
name: options.name,
|
|
...(options.atRecordId !== undefined
|
|
? { atRecordId: options.atRecordId }
|
|
: {}),
|
|
worktree: options.worktree,
|
|
})
|
|
: options.atRecordId !== undefined
|
|
? sessionActions.branchSession({
|
|
name: options.name,
|
|
atRecordId: options.atRecordId,
|
|
})
|
|
: sessionActions.branchSession({ name: options.name });
|
|
const request = branchRequest
|
|
.then((result) => {
|
|
if (!result.switchStarted) return;
|
|
if (result.sourceWarnings?.length)
|
|
pushToast('warning', result.sourceWarnings.join(' '));
|
|
store.dispatch([
|
|
{
|
|
type: 'status',
|
|
promptId: `local-command:${crypto.randomUUID()}`,
|
|
text: t('branch.success', {
|
|
name: result.displayName,
|
|
}),
|
|
},
|
|
]);
|
|
})
|
|
.catch(async (error: unknown) => {
|
|
if (
|
|
error instanceof DOMException &&
|
|
error.name === 'InvalidStateError' &&
|
|
error.message === 'A branch request is already in progress'
|
|
) {
|
|
return;
|
|
}
|
|
if (isStaleBranchPointError(error)) {
|
|
if (!transcriptReloadSupported) {
|
|
pushToast('error', t('branch.staleUnsupported'));
|
|
return;
|
|
}
|
|
// The recovery reload targets whatever session is selected when
|
|
// the branch call returns. If the user switched away in flight,
|
|
// report the failure without refreshing the unrelated session.
|
|
if (connectionRef.current.sessionId !== sourceSessionId) {
|
|
pushToast('error', t('branch.failed'));
|
|
return;
|
|
}
|
|
let refreshed = false;
|
|
try {
|
|
await sessionActions.reloadSession(new AbortController().signal);
|
|
refreshed = true;
|
|
} catch (reloadError) {
|
|
refreshed = isAbortError(reloadError);
|
|
}
|
|
// A switch landing while the recovery reload is in flight
|
|
// supersedes it; the outcome toast belongs to the source session.
|
|
if (connectionRef.current.sessionId !== sourceSessionId) return;
|
|
pushToast(
|
|
'error',
|
|
t(refreshed ? 'branch.stale' : 'branch.staleRefreshFailed'),
|
|
);
|
|
return;
|
|
}
|
|
if (error instanceof DaemonHttpError) {
|
|
const body =
|
|
typeof error.body === 'object' && error.body !== null
|
|
? (error.body as Record<string, unknown>)
|
|
: undefined;
|
|
const code = body?.['code'];
|
|
if (
|
|
code === 'branch_worktree_activation_failed' ||
|
|
code === 'branch_worktree_outcome_unknown'
|
|
) {
|
|
if (sourceWorkspaceCwd) {
|
|
sessionCatalogController.invalidateWorkspace(
|
|
sourceWorkspaceCwd,
|
|
);
|
|
}
|
|
}
|
|
if (code === 'branch_worktree_activation_failed') {
|
|
pushToast('error', t('branch.worktreeActivationFailed'));
|
|
return;
|
|
}
|
|
}
|
|
reportError(error, t('branch.failed'));
|
|
})
|
|
.finally(() => {
|
|
if (pendingBranchRequestsRef.current.get(requestKey) === request) {
|
|
pendingBranchRequestsRef.current.delete(requestKey);
|
|
}
|
|
});
|
|
pendingBranchRequestsRef.current.set(requestKey, request);
|
|
return request;
|
|
},
|
|
[
|
|
reportError,
|
|
pushToast,
|
|
requireActiveSessionForLocalCommand,
|
|
sessionWriteBlocked,
|
|
sessionActions,
|
|
sessionCatalogController,
|
|
store,
|
|
t,
|
|
transcriptReloadSupported,
|
|
workspaceContextActive,
|
|
],
|
|
);
|
|
const handleBranchCurrentSession = useCallback(
|
|
(atRecordId?: string) => {
|
|
const sourceSessionId = connectionRef.current.sessionId;
|
|
const sourceWorkspaceCwd = connectionRef.current.workspaceCwd;
|
|
if (!branchWorktreeEligible || !sourceSessionId || !sourceWorkspaceCwd) {
|
|
return branchCurrentSession({ atRecordId });
|
|
}
|
|
return (async () => {
|
|
try {
|
|
const status = await workspace.client
|
|
.workspaceByCwd(sourceWorkspaceCwd)
|
|
.workspaceGit({
|
|
cwd: sessionWorktree?.path,
|
|
sessionId: sourceSessionId,
|
|
});
|
|
if (connectionRef.current.sessionId !== sourceSessionId) return;
|
|
setSelectedWorkspaceGitStatus(status);
|
|
if (status.worktreeSupported !== true) {
|
|
return branchCurrentSession({ atRecordId });
|
|
}
|
|
} catch {
|
|
if (connectionRef.current.sessionId !== sourceSessionId) return;
|
|
return branchCurrentSession({ atRecordId });
|
|
}
|
|
setBranchSessionDialog({ sourceSessionId, atRecordId });
|
|
setBranchSessionDialogBusy(false);
|
|
})();
|
|
},
|
|
[
|
|
branchCurrentSession,
|
|
branchWorktreeEligible,
|
|
sessionWorktree?.path,
|
|
workspace.client,
|
|
],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
branchSessionDialog &&
|
|
(connection.sessionId !== branchSessionDialog.sourceSessionId ||
|
|
!branchWorktreeEligible)
|
|
) {
|
|
setBranchSessionDialog(undefined);
|
|
setBranchSessionDialogBusy(false);
|
|
}
|
|
}, [branchSessionDialog, branchWorktreeEligible, connection.sessionId]);
|
|
|
|
const confirmBranchSession = useCallback(
|
|
(isolation: BranchSessionIsolation) => {
|
|
if (!branchSessionDialog || branchSessionDialogBusy) return;
|
|
if (
|
|
connectionRef.current.sessionId !== branchSessionDialog.sourceSessionId
|
|
) {
|
|
setBranchSessionDialog(undefined);
|
|
setBranchSessionDialogBusy(false);
|
|
return;
|
|
}
|
|
const confirmedDialog = branchSessionDialog;
|
|
setBranchSessionDialogBusy(true);
|
|
const result = branchCurrentSession({
|
|
atRecordId: confirmedDialog.atRecordId,
|
|
...(isolation === 'worktree' ? { worktree: {} } : {}),
|
|
});
|
|
Promise.resolve(result).finally(() => {
|
|
if (branchSessionDialogRef.current !== confirmedDialog) return;
|
|
setBranchSessionDialogBusy(false);
|
|
setBranchSessionDialog(undefined);
|
|
});
|
|
},
|
|
[branchCurrentSession, branchSessionDialog, branchSessionDialogBusy],
|
|
);
|
|
|
|
const composerFocusRequestRef = useRef(0);
|
|
const sessionOpenInvocationRef = useRef(0);
|
|
const scheduleChatFocus = useCallback(
|
|
(focus: () => void, sessionId?: string) => {
|
|
const request = ++composerFocusRequestRef.current;
|
|
window.setTimeout(() => {
|
|
if (
|
|
request !== composerFocusRequestRef.current ||
|
|
approvalOverlayActiveRef.current ||
|
|
chatFocusBlockedRef.current ||
|
|
(sessionId !== undefined &&
|
|
connectionRef.current.sessionId !== sessionId)
|
|
) {
|
|
return;
|
|
}
|
|
focus();
|
|
}, 0);
|
|
return request;
|
|
},
|
|
[],
|
|
);
|
|
const createNewSession = useCallback(
|
|
async (
|
|
intent: NewSessionIntent,
|
|
/** Preserve the current full-page view and/or active panel while clearing. */
|
|
opts?: {
|
|
keepView?: boolean;
|
|
keepPanel?: boolean;
|
|
allowRecoveryReset?: boolean;
|
|
/**
|
|
* Git mode the new workspace draft starts with. Set here, in the same
|
|
* synchronous step that resets the previous intent, so the first
|
|
* prompt sees it even while the clear is still in flight.
|
|
*/
|
|
gitIntent?: SessionGitIntent;
|
|
carryManualTitle?: string;
|
|
},
|
|
) => {
|
|
if (
|
|
connectionRef.current.standaloneSession?.creationRecovery &&
|
|
!opts?.allowRecoveryReset
|
|
) {
|
|
pushToast('warning', t('session.recoveryBlocksAction'));
|
|
return false;
|
|
}
|
|
setManagedHome(false);
|
|
pendingManualTitleRef.current = opts?.carryManualTitle
|
|
? { displayName: opts.carryManualTitle }
|
|
: undefined;
|
|
splitClassificationGenerationRef.current += 1;
|
|
const invocation = ++sessionOpenInvocationRef.current;
|
|
let nextContext: DaemonProductSessionContext | undefined;
|
|
let availableWorkspaces = workspacesRef.current;
|
|
if (intent.kind === 'workspace') {
|
|
nextContext = { kind: 'workspace', cwd: intent.cwd };
|
|
} else if (lockedWorkspaceCwd) {
|
|
nextContext = { kind: 'workspace', cwd: lockedWorkspaceCwd };
|
|
} else if (intent.kind === 'inherit') {
|
|
nextContext =
|
|
pendingSessionContextRef.current ??
|
|
connectionRef.current.sessionContext ??
|
|
(connectionRef.current.workspaceCwd
|
|
? {
|
|
kind: 'workspace',
|
|
cwd: connectionRef.current.workspaceCwd,
|
|
}
|
|
: undefined);
|
|
if (nextContext?.kind === 'live' && intent.leaveLive) {
|
|
// Without a trusted primary, leave for a standalone draft when the
|
|
// daemon offers one; otherwise fall back to a plain cwd-less draft,
|
|
// the same as a cold draft in that setup, instead of keeping the
|
|
// Live path — attempting startLive('new') here throws when Live
|
|
// Voice is unavailable and the New task click is discarded
|
|
// (#12620). The undefined case only means "plain draft" because the
|
|
// clear below is told to drop the connection's live context: an
|
|
// undefined pending context means "inherit from the connection", so
|
|
// leaving it in place would send the first prompt back to Live.
|
|
const primaryCwd = workspacesRef.current.find(
|
|
(entry) => entry.primary && entry.trusted !== false,
|
|
)?.cwd;
|
|
if (primaryCwd) {
|
|
nextContext = { kind: 'workspace', cwd: primaryCwd };
|
|
} else if (
|
|
workspaceCapabilitiesRef.current?.features?.includes(
|
|
STANDALONE_SESSIONS_CAPABILITY,
|
|
)
|
|
) {
|
|
nextContext = { kind: 'standalone' };
|
|
} else {
|
|
nextContext = undefined;
|
|
}
|
|
}
|
|
if (nextContext?.kind === 'live') {
|
|
pendingManualTitleRef.current = undefined;
|
|
gitModeIntentRef.current = { mode: 'current' };
|
|
setGitModeIntent({ mode: 'current' });
|
|
try {
|
|
await workspace.client.startLive('new');
|
|
return sessionOpenInvocationRef.current === invocation;
|
|
} catch (error) {
|
|
if (sessionOpenInvocationRef.current !== invocation) return false;
|
|
reportError(error, 'Failed to start a new Live chat');
|
|
return false;
|
|
}
|
|
}
|
|
} else {
|
|
let capabilities = workspaceCapabilitiesRef.current;
|
|
if (workspace.status === 'error') {
|
|
if (!refreshWorkspaceCapabilities) {
|
|
pushToast('info', t('session.capabilitiesFailed'));
|
|
return false;
|
|
}
|
|
try {
|
|
capabilities = await refreshWorkspaceCapabilities();
|
|
// The brand fetch fails independently of capabilities and is
|
|
// never retried on its own; the recovery path is the one place
|
|
// that can re-ask. Gated inside the provider to the genuinely-
|
|
// missing state, so an already-branded shell is unaffected.
|
|
refreshWorkspaceBrand?.();
|
|
} catch (error) {
|
|
reportError(error, t('session.capabilitiesFailed'));
|
|
return false;
|
|
}
|
|
} else if (!workspaceCapabilitiesReady) {
|
|
pushToast('info', t('common.loading'));
|
|
return false;
|
|
}
|
|
if (!capabilities) {
|
|
pushToast('info', t('session.capabilitiesFailed'));
|
|
return false;
|
|
}
|
|
availableWorkspaces = (capabilities.workspaces ?? []).filter(
|
|
(entry) => entry.kind !== 'live',
|
|
);
|
|
if (
|
|
capabilities.features?.includes(STANDALONE_SESSIONS_CAPABILITY) ===
|
|
true
|
|
) {
|
|
nextContext = { kind: 'standalone' };
|
|
} else {
|
|
const primaryCwd = availableWorkspaces.find(
|
|
(entry) => entry.primary && entry.trusted !== false,
|
|
)?.cwd;
|
|
nextContext = primaryCwd
|
|
? { kind: 'workspace', cwd: primaryCwd }
|
|
: undefined;
|
|
}
|
|
}
|
|
if (sessionOpenInvocationRef.current !== invocation) return false;
|
|
const targetWorkspaceCwd =
|
|
nextContext?.kind === 'workspace' ? nextContext.cwd : undefined;
|
|
const previousPendingContext = pendingSessionContextRef.current;
|
|
if (!opts?.keepView && !opts?.keepPanel)
|
|
navigationRef.current?.beginSessionNavigation();
|
|
setPendingSessionContext(nextContext);
|
|
setSidebarSwitchingSessionId(null);
|
|
composerSourceVersionRef.current += 1;
|
|
selectedWorkspaceCwdRef.current = targetWorkspaceCwd;
|
|
setSelectedWorkspaceCwd(targetWorkspaceCwd);
|
|
// Starting a fresh chat drops any pending git mode intent so it never
|
|
// leaks into the next created session; a caller-supplied intent takes
|
|
// its place. The ref is written too so a prompt admitted before the
|
|
// next render reads the same answer.
|
|
const nextGitIntent: SessionGitIntent =
|
|
nextContext?.kind === 'workspace'
|
|
? (opts?.gitIntent ?? { mode: 'current' })
|
|
: { mode: 'current' };
|
|
gitModeIntentRef.current = nextGitIntent;
|
|
setGitModeIntent(nextGitIntent);
|
|
// Close the drawer before awaiting so a failed createSession() doesn't leave
|
|
// it stuck open with the page scroll still locked, matching loadSidebarSession.
|
|
closeMobileDrawer();
|
|
// Starting a new chat means the user wants to see it — leave any open
|
|
// Settings/Status panel so the fresh chat is visible (no-op when closed).
|
|
if (!opts?.keepPanel) {
|
|
// Explicit navigation cancels any pending shrink-fold split restore.
|
|
splitFoldedByShrinkRef.current = false;
|
|
closePanel();
|
|
}
|
|
if (!opts?.keepView) showChat();
|
|
// Leaving Live has to reach the connection: `undefined` is the
|
|
// downstream sentinel for "inherit from the connection", so clearing
|
|
// alone would hand the first prompt the live context we just left, and
|
|
// createSession rejects a live context (#12620). Only a context this
|
|
// click chose to leave is dropped — a workspace connection still
|
|
// inherits its cwd on the next prompt.
|
|
const dropSessionContextOnClear =
|
|
nextContext === undefined &&
|
|
connectionRef.current.sessionContext?.kind === 'live';
|
|
let focusRequest: number | undefined;
|
|
try {
|
|
autoRecapVersionRef.current += 1;
|
|
const clearPromise = (
|
|
sessionActions as typeof sessionActions & SessionActionsWithCreate
|
|
).clearSession(
|
|
dropSessionContextOnClear ? { dropSessionContext: true } : undefined,
|
|
);
|
|
focusRequest = scheduleChatFocus(() => editorRef.current?.focus());
|
|
await clearPromise;
|
|
if (
|
|
sessionOpenInvocationRef.current === invocation &&
|
|
!opts?.keepView &&
|
|
!opts?.keepPanel
|
|
) {
|
|
navigationRef.current?.finishNewChat(nextContext);
|
|
}
|
|
// Clear after successful clearSession — if it rejects, the old
|
|
// session's worktree/branch state is preserved.
|
|
setSessionWorktree(undefined);
|
|
setSessionBranch(undefined);
|
|
return true;
|
|
} catch (error) {
|
|
if (
|
|
sessionOpenInvocationRef.current === invocation &&
|
|
pendingSessionContextRef.current === nextContext
|
|
) {
|
|
navigationRef.current?.cancelSessionNavigation();
|
|
setPendingSessionContext(previousPendingContext);
|
|
}
|
|
if (composerFocusRequestRef.current === focusRequest) {
|
|
composerFocusRequestRef.current += 1;
|
|
}
|
|
reportError(error, 'Failed to start a new chat');
|
|
return false;
|
|
}
|
|
},
|
|
[
|
|
closeMobileDrawer,
|
|
closePanel,
|
|
lockedWorkspaceCwd,
|
|
pushToast,
|
|
reportError,
|
|
refreshWorkspaceBrand,
|
|
refreshWorkspaceCapabilities,
|
|
scheduleChatFocus,
|
|
setPendingSessionContext,
|
|
sessionActions,
|
|
showChat,
|
|
t,
|
|
workspaceCapabilitiesReady,
|
|
workspace.client,
|
|
workspace.status,
|
|
],
|
|
);
|
|
/**
|
|
* Serializes workspace intent. An active session keeps its owner and is
|
|
* replaced by a fresh chat; a draft only changes its next-session target.
|
|
*/
|
|
// The Workspaces panel's rows always name a workspace; a stable identity
|
|
// matters because the panel keys its column definitions on this handler.
|
|
const handlePanelNewSession = useCallback(
|
|
(workspaceCwd: string) =>
|
|
createNewSession({ kind: 'workspace', cwd: workspaceCwd }),
|
|
[createNewSession],
|
|
);
|
|
|
|
/**
|
|
* Post-delete landing for the session the client is currently attached to:
|
|
* leave the deleted conversation and open a fresh draft in the same
|
|
* workspace context. Shared by the Session Overview panel, the sidebar row
|
|
* delete, and the delete-session picker (issue #12619).
|
|
*/
|
|
const handleCurrentSessionRemoved = useCallback(
|
|
async (removed: { sessionId: string; workspaceCwd: string }) => {
|
|
const current = connectionRef.current;
|
|
// A current session in the no-workspace area (standalone) carries no
|
|
// cwd of its own; the call sites substitute the primary workspace cwd
|
|
// for a missing one, which must not pull the post-delete landing into
|
|
// that workspace (#12619).
|
|
const removedWorkspaceCwd =
|
|
current.sessionContext?.kind === 'standalone'
|
|
? ''
|
|
: removed.workspaceCwd;
|
|
// The daemon publishes the terminal `session_closed` frame before it
|
|
// answers the delete request, so the attachment is usually already
|
|
// cleared by the time this runs. Only a *different* attached session
|
|
// means the client moved on mid-delete (#12619).
|
|
if (
|
|
current.sessionId !== undefined &&
|
|
current.sessionId !== removed.sessionId
|
|
)
|
|
return;
|
|
if (removedWorkspaceCwd) {
|
|
const currentWorkspaceCwd =
|
|
current.workspaceCwd ||
|
|
lockedWorkspaceCwd ||
|
|
workspacesRef.current.find((entry) => entry.primary)?.cwd;
|
|
if (currentWorkspaceCwd && currentWorkspaceCwd !== removedWorkspaceCwd)
|
|
return;
|
|
} else if (current.workspaceCwd || lockedWorkspaceCwd) {
|
|
// The client moved into a workspace after the delete started.
|
|
return;
|
|
}
|
|
const cleared = await createNewSession(
|
|
removedWorkspaceCwd
|
|
? {
|
|
kind: 'workspace',
|
|
cwd: removedWorkspaceCwd,
|
|
}
|
|
: // No-workspace landing: a fresh standalone draft where the daemon
|
|
// supports one, mirroring the Session Overview delete (#12619).
|
|
{ kind: 'global' },
|
|
{
|
|
keepView: true,
|
|
keepPanel: true,
|
|
},
|
|
);
|
|
const latest = connectionRef.current;
|
|
const latestWorkspaceCwd =
|
|
latest.workspaceCwd ||
|
|
lockedWorkspaceCwd ||
|
|
workspacesRef.current.find((entry) => entry.primary)?.cwd;
|
|
const landingMatches = removedWorkspaceCwd
|
|
? !latestWorkspaceCwd || latestWorkspaceCwd === removedWorkspaceCwd
|
|
: // The no-workspace landing only counts while the client stays
|
|
// cwd-less; the primary fallback is a display default, not where
|
|
// the deleted session lived.
|
|
!(latest.workspaceCwd || lockedWorkspaceCwd);
|
|
if (
|
|
cleared &&
|
|
landingMatches &&
|
|
(latest.sessionId === removed.sessionId ||
|
|
latest.sessionId === undefined)
|
|
) {
|
|
onSessionIdChange?.(undefined);
|
|
}
|
|
return cleared;
|
|
},
|
|
[createNewSession, lockedWorkspaceCwd, onSessionIdChange],
|
|
);
|
|
|
|
const switchWorkspace = useCallback(
|
|
async (
|
|
workspaceCwd: string | undefined,
|
|
acceptedWorkspaces: readonly DaemonWorkspaceCapability[] = workspacesRef.current,
|
|
) => {
|
|
if (workspaceSwitchTokenRef.current) return;
|
|
const token = Symbol('workspace-switch');
|
|
workspaceSwitchTokenRef.current = token;
|
|
try {
|
|
const primaryCwd = acceptedWorkspaces.find(
|
|
(entry) => entry.primary,
|
|
)?.cwd;
|
|
const targetCwd = workspaceCwd ?? primaryCwd;
|
|
if (workspaceCwd) {
|
|
const target = acceptedWorkspaces.find(
|
|
(entry) => entry.cwd === workspaceCwd,
|
|
);
|
|
if (!target?.trusted) return;
|
|
}
|
|
splitClassificationGenerationRef.current += 1;
|
|
if (connectionRef.current.sessionId) {
|
|
if (connectionRef.current.workspaceCwd === targetCwd) return;
|
|
if (!targetCwd) return;
|
|
await createNewSession({ kind: 'workspace', cwd: targetCwd });
|
|
return;
|
|
}
|
|
// The composer picker fires for its checked row too; re-selecting
|
|
// the draft's own workspace changes nothing and must keep an armed
|
|
// intent. Compared before the ref is overwritten below. `undefined`
|
|
// is the primary selection on both sides.
|
|
const sameTarget = workspaceCwd === selectedWorkspaceCwdRef.current;
|
|
if (!sameTarget) pendingManualTitleRef.current = undefined;
|
|
composerSourceVersionRef.current += 1;
|
|
selectedWorkspaceCwdRef.current = workspaceCwd;
|
|
setSelectedWorkspaceCwd(workspaceCwd);
|
|
setPendingSessionContext(
|
|
targetCwd ? { kind: 'workspace', cwd: targetCwd } : undefined,
|
|
);
|
|
// The intent was armed for the previous draft workspace; a switch
|
|
// must not carry a branch/worktree request over to another repo.
|
|
if (!sameTarget) {
|
|
gitModeIntentRef.current = { mode: 'current' };
|
|
setGitModeIntent({ mode: 'current' });
|
|
}
|
|
} finally {
|
|
if (workspaceSwitchTokenRef.current === token) {
|
|
workspaceSwitchTokenRef.current = null;
|
|
}
|
|
}
|
|
},
|
|
[createNewSession, setPendingSessionContext],
|
|
);
|
|
|
|
/** Refreshes once and switches only from the accepted capability snapshot. */
|
|
const reconcileAddedWorkspace = useCallback(
|
|
async (canonicalCwd: string): Promise<boolean> => {
|
|
try {
|
|
const capabilities = await refreshWorkspaceCapabilities?.();
|
|
const acceptedWorkspaces = capabilities?.workspaces ?? [];
|
|
const added = acceptedWorkspaces.find(
|
|
(entry) => entry.cwd === canonicalCwd,
|
|
);
|
|
if (added?.trusted) {
|
|
await switchWorkspace(
|
|
added.primary ? undefined : added.cwd,
|
|
acceptedWorkspaces,
|
|
);
|
|
}
|
|
return capabilities !== undefined;
|
|
} catch (error) {
|
|
reportError(error, 'Failed to refresh the workspace list');
|
|
return false;
|
|
}
|
|
},
|
|
[refreshWorkspaceCapabilities, reportError, switchWorkspace],
|
|
);
|
|
|
|
/** Registers an existing directory through the shared mutation lane. */
|
|
const handleAddWorkspace = useCallback(
|
|
async (cwd: string, persist: boolean, displayName?: string) => {
|
|
if (workspaceMutationTokenRef.current) {
|
|
throw new Error(t('sidebar.addWorkspaceBusyError'));
|
|
}
|
|
const token = Symbol('workspace-mutation');
|
|
workspaceMutationTokenRef.current = token;
|
|
setWorkspaceMutationBusy(true);
|
|
try {
|
|
const effectivePersist =
|
|
persistentWorkspaceRegistrationSupported === true && persist;
|
|
const effectiveDisplayName = workspaceDisplayNameSupported
|
|
? displayName
|
|
: undefined;
|
|
const result = await workspaceActions.addWorkspace(cwd, {
|
|
persist: effectivePersist,
|
|
...(effectiveDisplayName
|
|
? { displayName: effectiveDisplayName }
|
|
: {}),
|
|
});
|
|
if (effectivePersist && result.persisted !== true) {
|
|
throw new Error(t('sidebar.addWorkspacePersistenceError'));
|
|
}
|
|
const reconciled = await reconcileAddedWorkspace(result.cwd);
|
|
if (!reconciled) {
|
|
throw new Error(t('sidebar.addWorkspaceRefreshError'));
|
|
}
|
|
setManagedHome(false);
|
|
} finally {
|
|
if (workspaceMutationTokenRef.current === token) {
|
|
workspaceMutationTokenRef.current = null;
|
|
setWorkspaceMutationBusy(false);
|
|
}
|
|
}
|
|
},
|
|
[
|
|
persistentWorkspaceRegistrationSupported,
|
|
reconcileAddedWorkspace,
|
|
t,
|
|
workspaceDisplayNameSupported,
|
|
workspaceActions,
|
|
],
|
|
);
|
|
const [workspaceAddLocation, setWorkspaceAddLocation] = useState<
|
|
string | undefined
|
|
>(undefined);
|
|
const workspaceAddSelectedLocation =
|
|
workspaceAddLocation || workspace.baseUrl || window.location.origin;
|
|
const workspaceAddLocations = standalone
|
|
? [
|
|
{
|
|
origin: window.location.origin,
|
|
label: t('workspaceHost.thisComputer'),
|
|
remote: false,
|
|
},
|
|
...listRemoteComputers().map((origin) => ({
|
|
origin,
|
|
label: formatOriginHost(origin),
|
|
remote: true,
|
|
})),
|
|
]
|
|
: undefined;
|
|
const hasRemoteWorkspaceLocation = (workspaceAddLocations?.length ?? 0) > 1;
|
|
// Whether the location the dialog is browsing is the daemon this shell is
|
|
// actually connected to — the same comparison `handleAddRemoteWorkspace`
|
|
// uses to choose between a local add and the remote proxy.
|
|
//
|
|
// Every capability-driven affordance below reads `workspace.capabilities`,
|
|
// which describes the CONNECTED daemon only. While a different location is
|
|
// selected in place the target's answers are unknowable without querying it,
|
|
// so the honest UI withholds the affordance rather than assuming the
|
|
// connected daemon's: a native picker would open an OS dialog on the wrong
|
|
// machine and register its result on the target, a Persist switch would
|
|
// either surface the target's raw 501 or silently register a workspace that
|
|
// dies on its next restart, and a cwd from this filesystem seeds a browse
|
|
// the target answers with an empty (not failed) list. Before in-place browse
|
|
// the location switch navigated first, so all of these re-resolved against
|
|
// the target.
|
|
const workspaceAddLocationIsConnected =
|
|
workspaceAddSelectedLocation ===
|
|
(workspace.baseUrl || window.location.origin);
|
|
const workspacePickerTarget = useMemo(
|
|
() => ({
|
|
client: workspace.client,
|
|
baseUrl: workspace.baseUrl,
|
|
status: workspace.status,
|
|
}),
|
|
[workspace.client, workspace.baseUrl, workspace.status],
|
|
);
|
|
let workspaceDirectoryPicker: WebShellWorkspaceDirectoryPicker | undefined;
|
|
if (workspaceAddLocationIsConnected) {
|
|
if (onPickWorkspaceDirectory) {
|
|
if (workspace.status === 'connected') {
|
|
workspaceDirectoryPicker = onPickWorkspaceDirectory;
|
|
}
|
|
} else if (
|
|
nativeDirectoryPickerSupported &&
|
|
(!workspace.baseUrl ||
|
|
new URL(workspace.baseUrl, window.location.origin).origin ===
|
|
window.location.origin)
|
|
) {
|
|
workspaceDirectoryPicker = async () => {
|
|
const result = await workspaceActions.pickWorkspaceDirectory();
|
|
return result.selected ? result.path : undefined;
|
|
};
|
|
}
|
|
}
|
|
const changeWorkspaceAddLocation = useCallback((origin: string) => {
|
|
setWorkspaceAddLocation(origin);
|
|
return true;
|
|
}, []);
|
|
|
|
// Suggest paths from the selected location's daemon, not necessarily the
|
|
// currently connected one. This lets the user browse a remote daemon's
|
|
// folders without navigating the page.
|
|
const suggestWorkspacePathsForLocation = useMemo(() => {
|
|
if (!hasRemoteWorkspaceLocation) {
|
|
return workspaceActions.suggestWorkspacePaths;
|
|
}
|
|
return async (prefix: string) => {
|
|
const location = workspaceAddSelectedLocation;
|
|
const currentOrigin = workspace.baseUrl || window.location.origin;
|
|
if (location === currentOrigin) {
|
|
return workspaceActions.suggestWorkspacePaths(prefix);
|
|
}
|
|
return fetchRemotePathSuggestions(location, prefix);
|
|
};
|
|
}, [
|
|
hasRemoteWorkspaceLocation,
|
|
workspaceAddSelectedLocation,
|
|
workspace.baseUrl,
|
|
workspaceActions,
|
|
]);
|
|
|
|
const gitCloneControls = useMemo(() => {
|
|
if (!gitWorkspaceCloneSupported || !workspaceAddLocationIsConnected) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
start: (input: { url: string; target: string }) =>
|
|
workspaceActions.startGitWorkspaceClone(input),
|
|
poll: (jobId: string) => workspaceActions.getGitWorkspaceCloneJob(jobId),
|
|
cancel: async (jobId: string) => {
|
|
await workspaceActions.cancelGitWorkspaceClone(jobId);
|
|
},
|
|
};
|
|
}, [
|
|
gitWorkspaceCloneSupported,
|
|
workspaceAddLocationIsConnected,
|
|
workspaceActions,
|
|
]);
|
|
|
|
const handleAddRemoteWorkspace = useCallback(
|
|
async (cwd: string, persist: boolean, displayName?: string) => {
|
|
const currentOrigin = workspace.baseUrl || window.location.origin;
|
|
if (
|
|
workspaceAddSelectedLocation &&
|
|
workspaceAddSelectedLocation !== currentOrigin
|
|
) {
|
|
// The user browsed a different daemon's folders in place. Register
|
|
// the workspace there via REST, then navigate to that daemon.
|
|
await addWorkspaceToDaemon(
|
|
workspaceAddSelectedLocation,
|
|
cwd,
|
|
persist,
|
|
displayName,
|
|
);
|
|
workspaceBrowseActiveRef.current = false;
|
|
completeRemoteWorkspaceAdd();
|
|
const targetOrigin = workspaceAddSelectedLocation;
|
|
if (daemonTarget?.coversOrigin(targetOrigin)) {
|
|
// Multi-daemon (#13727): an already-connected host needs no
|
|
// navigation — focus it in-app and hand the new workspace's
|
|
// preselection to the remounted picker state.
|
|
daemonTarget.focusHost({ origin: targetOrigin });
|
|
refreshAllHosts();
|
|
return;
|
|
}
|
|
// Navigate to the target daemon so the user lands on the new
|
|
// workspace. Deliberately a plain switch, NOT
|
|
// selectRemoteWorkspaceLocation(): that helper arms the
|
|
// `addRemoteWorkspace=browse` continuation on the target URL, so the
|
|
// daemon we just registered on would boot into a fresh, empty Add
|
|
// Workspace dialog on top of the workspace the user already added.
|
|
// The add is finished — there is no flow left to continue.
|
|
navigateToDaemon(targetOrigin, getDaemonToken(targetOrigin));
|
|
return;
|
|
}
|
|
await handleAddWorkspace(cwd, persist, displayName);
|
|
workspaceBrowseActiveRef.current = false;
|
|
completeRemoteWorkspaceAdd();
|
|
},
|
|
[
|
|
handleAddWorkspace,
|
|
workspace.baseUrl,
|
|
workspaceAddSelectedLocation,
|
|
daemonTarget,
|
|
refreshAllHosts,
|
|
],
|
|
);
|
|
|
|
const closeAddWorkspaceDialog = useCallback(() => {
|
|
setShowAddWorkspaceDialog(false);
|
|
setWorkspaceAddLocation(undefined);
|
|
if (!workspaceBrowseActiveRef.current) return;
|
|
workspaceBrowseActiveRef.current = false;
|
|
leaveRemoteWorkspaceAdd();
|
|
}, []);
|
|
|
|
// Which computer the folder step is reading, for loading and error states.
|
|
const workspaceAddRemoteHost = isPageOriginDaemon(
|
|
workspaceAddSelectedLocation,
|
|
)
|
|
? undefined
|
|
: formatOriginHost(workspaceAddSelectedLocation);
|
|
const workspaceAddLocationSubtitle = workspaceAddRemoteHost
|
|
? t('workspaceHost.folderOn', { address: workspaceAddRemoteHost })
|
|
: t('workspaceHost.folderOnThisComputer');
|
|
|
|
/**
|
|
* Reconciles either a known committed cwd or an unknown POST outcome. Known
|
|
* commits may switch; unknown outcomes require explicit user acknowledgement.
|
|
*/
|
|
const refreshScratchOutcome = useCallback(async () => {
|
|
setScratchOutcome('refreshing');
|
|
try {
|
|
const capabilities = await refreshWorkspaceCapabilities?.();
|
|
if (!capabilities) return;
|
|
const acceptedWorkspaces = capabilities.workspaces ?? [];
|
|
const committedCwd = committedScratchCwdRef.current;
|
|
if (committedCwd) {
|
|
const added = acceptedWorkspaces.find(
|
|
(entry) => entry.cwd === committedCwd,
|
|
);
|
|
if (added?.trusted) {
|
|
await switchWorkspace(
|
|
added.primary ? undefined : added.cwd,
|
|
acceptedWorkspaces,
|
|
);
|
|
}
|
|
committedScratchCwdRef.current = undefined;
|
|
setScratchOutcome('clear');
|
|
} else {
|
|
setScratchOutcome('awaiting-ack');
|
|
}
|
|
} catch (error) {
|
|
reportError(error, 'Failed to refresh the workspace list');
|
|
}
|
|
}, [
|
|
reportError,
|
|
setScratchOutcome,
|
|
switchWorkspace,
|
|
refreshWorkspaceCapabilities,
|
|
]);
|
|
|
|
/**
|
|
* Creates at most one scratch directory per intent and locks further POSTs
|
|
* whenever timeout, transport failure, or refresh leaves the result unclear.
|
|
*/
|
|
const handleCreateScratchWorkspace = useCallback(async () => {
|
|
if (
|
|
scratchOutcomeUnknownRef.current !== 'clear' ||
|
|
workspaceMutationTokenRef.current
|
|
) {
|
|
return;
|
|
}
|
|
const token = Symbol('workspace-mutation');
|
|
workspaceMutationTokenRef.current = token;
|
|
setWorkspaceMutationBusy(true);
|
|
try {
|
|
const result = await workspaceActions.addScratchWorkspace();
|
|
const reconciled = await reconcileAddedWorkspace(result.cwd);
|
|
if (!reconciled) {
|
|
committedScratchCwdRef.current = result.cwd;
|
|
setScratchOutcome('refreshing');
|
|
}
|
|
} catch (error) {
|
|
const definitelyRejected =
|
|
error instanceof DaemonHttpError &&
|
|
(error.status < 500 || error.status === 501);
|
|
if (definitelyRejected) {
|
|
reportError(error, t('sidebar.addWorkspaceError'));
|
|
} else {
|
|
committedScratchCwdRef.current = undefined;
|
|
setScratchOutcome('refreshing');
|
|
await refreshScratchOutcome();
|
|
}
|
|
} finally {
|
|
if (workspaceMutationTokenRef.current === token) {
|
|
workspaceMutationTokenRef.current = null;
|
|
setWorkspaceMutationBusy(false);
|
|
}
|
|
}
|
|
}, [
|
|
reconcileAddedWorkspace,
|
|
refreshScratchOutcome,
|
|
reportError,
|
|
setScratchOutcome,
|
|
t,
|
|
workspaceActions,
|
|
]);
|
|
const handleSelectComposerWorkspace = useCallback(
|
|
(cwd: string | undefined) => {
|
|
void switchWorkspace(cwd);
|
|
},
|
|
[switchWorkspace],
|
|
);
|
|
// A workspace picked on another connected host (#13727): focus that host
|
|
// in-app (no document reload), carrying the target cwd and any typed draft
|
|
// text so the remounted composer lands ready to submit there. Attachments
|
|
// cannot cross the provider remount and are dropped.
|
|
const handleSelectHostedWorkspace = useCallback(
|
|
(hostOrigin: string, cwd: string | undefined) => {
|
|
if (!daemonTarget || hostOrigin === focusedHostOrigin) {
|
|
handleSelectComposerWorkspace(cwd);
|
|
return;
|
|
}
|
|
daemonTarget.focusHostWithHandoff(
|
|
{ origin: hostOrigin },
|
|
{
|
|
kind: 'create',
|
|
origin: hostOrigin,
|
|
workspaceCwd: cwd,
|
|
draftText: composerTextRef.current.trim() || undefined,
|
|
},
|
|
);
|
|
},
|
|
[daemonTarget, focusedHostOrigin, handleSelectComposerWorkspace],
|
|
);
|
|
const handleSelectDaemonWorkspace = useCallback(
|
|
(workspace: WorkspaceSelectorOption) => {
|
|
const target = composerWorkspaces?.find(
|
|
(item) =>
|
|
item.id === workspace.id &&
|
|
item.cwd === workspace.cwd &&
|
|
item.hostOrigin === workspace.hostOrigin,
|
|
);
|
|
if (!target?.trusted) return;
|
|
if (target.hostOrigin && target.hostOrigin !== focusedHostOrigin) {
|
|
if (!daemonTarget) return;
|
|
setManagedHome(false);
|
|
handleSelectHostedWorkspace(target.hostOrigin, target.cwd);
|
|
} else if (connectionRef.current.sessionId) {
|
|
void createNewSession({ kind: 'workspace', cwd: target.cwd });
|
|
} else {
|
|
setManagedHome(false);
|
|
void switchWorkspace(target.cwd);
|
|
returnToChat();
|
|
}
|
|
},
|
|
[
|
|
composerWorkspaces,
|
|
createNewSession,
|
|
daemonTarget,
|
|
focusedHostOrigin,
|
|
handleSelectHostedWorkspace,
|
|
returnToChat,
|
|
switchWorkspace,
|
|
],
|
|
);
|
|
const handleSelectManagedWorkspace = useCallback(
|
|
(workspaceId: string) => {
|
|
setManagedWorkspaceId(workspaceId);
|
|
setManagedSessionId(undefined);
|
|
setManagedHome(true);
|
|
closeMobileDrawer();
|
|
returnToChat();
|
|
},
|
|
[closeMobileDrawer, returnToChat],
|
|
);
|
|
const handleSelectComposerStandalone = useCallback(() => {
|
|
if (connectionRef.current.sessionId) {
|
|
void createNewSession({ kind: 'global' });
|
|
return;
|
|
}
|
|
const sameTarget = pendingSessionContextRef.current?.kind === 'standalone';
|
|
if (!sameTarget) pendingManualTitleRef.current = undefined;
|
|
composerSourceVersionRef.current += 1;
|
|
selectedWorkspaceCwdRef.current = undefined;
|
|
setSelectedWorkspaceCwd(undefined);
|
|
setPendingSessionContext({ kind: 'standalone' });
|
|
if (!sameTarget) {
|
|
gitModeIntentRef.current = { mode: 'current' };
|
|
setGitModeIntent({ mode: 'current' });
|
|
}
|
|
}, [createNewSession, setPendingSessionContext]);
|
|
const handleCreateComposerScratchWorkspace = useCallback(() => {
|
|
void handleCreateScratchWorkspace();
|
|
}, [handleCreateScratchWorkspace]);
|
|
const handleOpenLocalWorkspace = useCallback(() => {
|
|
workspaceBrowseActiveRef.current = standalone;
|
|
setShowAddWorkspaceDialog(true);
|
|
}, [standalone]);
|
|
const handleOpenManagedWorkspace = useCallback(() => {
|
|
setShowAddWorkspaceDialog(false);
|
|
setWorkspaceAddLocation(undefined);
|
|
workspaceBrowseActiveRef.current = false;
|
|
completeRemoteWorkspaceAdd();
|
|
setManagedWorkspaceCreationOpen(true);
|
|
closeMobileDrawer();
|
|
}, [closeMobileDrawer]);
|
|
const handleOpenExistingWorkspace = useCallback(() => {
|
|
if (
|
|
managedMode &&
|
|
managedAgentProvider?.workspaceBinding?.createWorkspace
|
|
) {
|
|
handleOpenManagedWorkspace();
|
|
} else {
|
|
handleOpenLocalWorkspace();
|
|
}
|
|
}, [
|
|
managedMode,
|
|
managedAgentProvider,
|
|
handleOpenManagedWorkspace,
|
|
handleOpenLocalWorkspace,
|
|
]);
|
|
const workspaceExecutionField = managedAgentProvider?.workspaceBinding
|
|
?.createWorkspace ? (
|
|
<WorkspaceExecutionSelect
|
|
value="daemon"
|
|
onValueChange={(mode) => {
|
|
if (mode === 'managed') handleOpenManagedWorkspace();
|
|
}}
|
|
/>
|
|
) : undefined;
|
|
|
|
const handleComposerAttachmentsChange = useCallback(
|
|
(hasAttachments: boolean) => {
|
|
setHasComposerAttachments(hasAttachments);
|
|
},
|
|
[],
|
|
);
|
|
const generateSuggestionContent = useCallback(
|
|
(prompt: string, options?: { signal?: AbortSignal }) => {
|
|
void logicalSessionKey;
|
|
return sessionActions.generateSessionContent(prompt, options);
|
|
},
|
|
[logicalSessionKey, sessionActions],
|
|
);
|
|
|
|
const {
|
|
suggestion: newSessionSuggestion,
|
|
updateInput: updateNewSessionSuggestionInput,
|
|
dismiss: dismissNewSessionSuggestion,
|
|
suppress: suppressNewSessionSuggestion,
|
|
} = useNewSessionSuggestion({
|
|
enabled:
|
|
connection.capabilities?.features.includes('session_generation') === true,
|
|
messages,
|
|
sessionId: connection.sessionId,
|
|
contextUsageRatio:
|
|
(connection.contextWindow ?? 0) > 0
|
|
? (connection.tokenCount ?? 0) / (connection.contextWindow ?? 0)
|
|
: 0,
|
|
isRunning: streamingState !== 'idle',
|
|
dialogOpen: interactionBlocked || approvalOverlayActive,
|
|
hasAttachments: hasComposerAttachments,
|
|
generateContent: generateSuggestionContent,
|
|
});
|
|
|
|
const handleComposerTextChange = useCallback(
|
|
(text: string) => {
|
|
composerTextRef.current = text;
|
|
updateNewSessionSuggestionInput(text);
|
|
},
|
|
[updateNewSessionSuggestionInput],
|
|
);
|
|
|
|
const flushPendingNewSessionSuggestionSubmit = useCallback(
|
|
(expectedToken?: number) => {
|
|
const pending = pendingNewSessionSuggestionSubmitRef.current;
|
|
if (!pending) return;
|
|
if (expectedToken !== undefined && pending.token !== expectedToken)
|
|
return;
|
|
|
|
const activeSessionId = connectionRef.current.sessionId;
|
|
if (
|
|
(pending.mode === 'cleared' &&
|
|
pending.sourceSessionId !== undefined &&
|
|
activeSessionId !== undefined &&
|
|
activeSessionId !== pending.sourceSessionId) ||
|
|
(pending.mode === 'live' &&
|
|
activeSessionId !== undefined &&
|
|
activeSessionId !== pending.sourceSessionId &&
|
|
connectionRef.current.sessionContext?.kind !== 'live')
|
|
) {
|
|
pendingNewSessionSuggestionSubmitRef.current = null;
|
|
setIsStartingNewSessionSuggestion(false);
|
|
return;
|
|
}
|
|
|
|
if (!pending.newSessionReady) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
pending.mode === 'live'
|
|
? activeSessionId === undefined ||
|
|
activeSessionId === pending.sourceSessionId ||
|
|
connectionRef.current.sessionContext?.kind !== 'live'
|
|
: activeSessionId !== undefined
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (pending.submitScheduled) {
|
|
return;
|
|
}
|
|
|
|
pendingNewSessionSuggestionSubmitRef.current = {
|
|
...pending,
|
|
submitScheduled: true,
|
|
};
|
|
window.setTimeout(() => {
|
|
const latestPending = pendingNewSessionSuggestionSubmitRef.current;
|
|
if (!latestPending || latestPending.token !== pending.token) {
|
|
return;
|
|
}
|
|
const latestSessionId = connectionRef.current.sessionId;
|
|
if (
|
|
latestPending.mode === 'live'
|
|
? latestSessionId === undefined ||
|
|
latestSessionId === latestPending.sourceSessionId ||
|
|
connectionRef.current.sessionContext?.kind !== 'live'
|
|
: latestSessionId !== undefined
|
|
) {
|
|
pendingNewSessionSuggestionSubmitRef.current = null;
|
|
setIsStartingNewSessionSuggestion(false);
|
|
return;
|
|
}
|
|
pendingNewSessionSuggestionSubmitRef.current = null;
|
|
editorRef.current?.submit();
|
|
editorRef.current?.focus();
|
|
setIsStartingNewSessionSuggestion(false);
|
|
}, 0);
|
|
},
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
flushPendingNewSessionSuggestionSubmit();
|
|
}, [connection.sessionId, flushPendingNewSessionSuggestionSubmit]);
|
|
|
|
const handleAcceptNewSessionSuggestion = useCallback(() => {
|
|
const draft = composerTextRef.current.trim();
|
|
if (!draft || isStartingNewSessionSuggestion) return;
|
|
if (
|
|
newSessionSuggestion?.suggestion !== 'new_session' ||
|
|
newSessionSuggestion.classifiedInput !== draft ||
|
|
newSessionSuggestion.sourceSessionId !== connectionRef.current.sessionId
|
|
) {
|
|
dismissNewSessionSuggestion();
|
|
return;
|
|
}
|
|
suppressNewSessionSuggestion();
|
|
setIsStartingNewSessionSuggestion(true);
|
|
const token = newSessionSuggestionSubmitTokenRef.current + 1;
|
|
newSessionSuggestionSubmitTokenRef.current = token;
|
|
const mode =
|
|
!lockedWorkspaceCwd &&
|
|
(pendingSessionContextRef.current ?? connectionRef.current.sessionContext)
|
|
?.kind === 'live'
|
|
? 'live'
|
|
: 'cleared';
|
|
pendingNewSessionSuggestionSubmitRef.current = {
|
|
token,
|
|
sourceSessionId: connectionRef.current.sessionId,
|
|
mode,
|
|
newSessionReady: false,
|
|
submitScheduled: false,
|
|
};
|
|
void createNewSession({ kind: 'inherit' }).then((created) => {
|
|
const pending = pendingNewSessionSuggestionSubmitRef.current;
|
|
if (!created || pending?.token !== token) {
|
|
if (pending?.token === token) {
|
|
pendingNewSessionSuggestionSubmitRef.current = null;
|
|
}
|
|
setIsStartingNewSessionSuggestion(false);
|
|
return;
|
|
}
|
|
pendingNewSessionSuggestionSubmitRef.current = {
|
|
...pending,
|
|
newSessionReady: true,
|
|
};
|
|
if (mode === 'cleared') {
|
|
const nextContext = pendingSessionContextRef.current;
|
|
if (nextContext?.kind === 'standalone') {
|
|
onSessionIdChange?.(undefined, undefined, undefined, nextContext);
|
|
} else {
|
|
onSessionIdChange?.(undefined);
|
|
}
|
|
}
|
|
flushPendingNewSessionSuggestionSubmit(token);
|
|
});
|
|
}, [
|
|
createNewSession,
|
|
dismissNewSessionSuggestion,
|
|
flushPendingNewSessionSuggestionSubmit,
|
|
isStartingNewSessionSuggestion,
|
|
lockedWorkspaceCwd,
|
|
newSessionSuggestion,
|
|
onSessionIdChange,
|
|
suppressNewSessionSuggestion,
|
|
]);
|
|
|
|
const handleAcceptBtwSuggestion = useCallback(() => {
|
|
const draft = composerTextRef.current.trim();
|
|
if (
|
|
!draft ||
|
|
newSessionSuggestion?.suggestion !== 'btw' ||
|
|
newSessionSuggestion.classifiedInput !== draft ||
|
|
newSessionSuggestion.sourceSessionId !==
|
|
connectionRef.current.sessionId ||
|
|
editorRef.current?.hasAttachments() !== false
|
|
) {
|
|
dismissNewSessionSuggestion();
|
|
return;
|
|
}
|
|
dismissNewSessionSuggestion();
|
|
editorRef.current?.submit({ text: `/btw ${draft}` });
|
|
editorRef.current?.focus();
|
|
}, [dismissNewSessionSuggestion, newSessionSuggestion]);
|
|
|
|
const handleConfirm = useCallback(
|
|
async (
|
|
id: string,
|
|
selectedOption: string,
|
|
answers?: Record<string, string>,
|
|
) => {
|
|
const request = pendingApprovalRef.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 sessionActions.respondToPermission(id, {
|
|
outcome: { outcome: 'selected', optionId: selectedOption },
|
|
expectedPlanExecutionMode: connection.planExecutionMode,
|
|
});
|
|
} else {
|
|
await sessionActions.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;
|
|
}
|
|
},
|
|
[
|
|
sessionActions,
|
|
reportError,
|
|
sessionOwnerGuard,
|
|
releaseModeTransition,
|
|
connection.planExecutionMode,
|
|
],
|
|
);
|
|
|
|
const respondToPendingPermission = useCallback(
|
|
async (
|
|
requestIdOrDecision: string,
|
|
exactDecision?: 'allow' | 'reject',
|
|
): Promise<boolean> => {
|
|
const request = pendingApprovalRef.current;
|
|
const decision = exactDecision ?? requestIdOrDecision;
|
|
if (
|
|
!request ||
|
|
isAskUserPermission(request) ||
|
|
(decision !== 'allow' && decision !== 'reject')
|
|
) {
|
|
return false;
|
|
}
|
|
const requestId = exactDecision ? requestIdOrDecision : undefined;
|
|
if (
|
|
requestId !== undefined &&
|
|
(!requestId ||
|
|
request.id !== requestId ||
|
|
!hostOwnsEditDiffPreview ||
|
|
request.hasDiffPreview !== true)
|
|
) {
|
|
return false;
|
|
}
|
|
const preferredKind = decision === 'allow' ? 'allow_once' : 'reject_once';
|
|
const fallbackKind =
|
|
decision === 'allow' ? 'allow_always' : 'reject_always';
|
|
const option = requestId
|
|
? request.options.find((candidate) => candidate.kind === preferredKind)
|
|
: (request.options.find(
|
|
(candidate) => candidate.kind === preferredKind,
|
|
) ??
|
|
request.options.find(
|
|
(candidate) => candidate.kind === fallbackKind,
|
|
) ??
|
|
request.options.find((candidate) => {
|
|
const id = candidate.id.toLowerCase();
|
|
return decision === 'allow'
|
|
? id.includes('allow') || id.includes('proceed')
|
|
: id.includes('reject') || id.includes('cancel');
|
|
}));
|
|
if (!option) {
|
|
return false;
|
|
}
|
|
await handleConfirm(request.id, option.id);
|
|
return true;
|
|
},
|
|
[hostOwnsEditDiffPreview, handleConfirm],
|
|
);
|
|
|
|
const shellApi = useMemo<WebShellApi>(
|
|
() => ({
|
|
refreshModels: () =>
|
|
sessionActions.refreshModels(undefined, { reloadProviders: false }),
|
|
navigateToMessage,
|
|
openSplitView: () => {
|
|
closeMobileDrawer();
|
|
requestOpenSplitView();
|
|
},
|
|
openSessionOverview: () => {
|
|
if (!projectFeaturesAvailable) return;
|
|
closeMobileDrawer();
|
|
openPanel('sessions');
|
|
},
|
|
openSessionDrawer,
|
|
createNewSession: () => createNewSession({ kind: 'global' }),
|
|
createSideTask,
|
|
respondToPendingPermission,
|
|
}),
|
|
[
|
|
sessionActions,
|
|
navigateToMessage,
|
|
closeMobileDrawer,
|
|
createNewSession,
|
|
createSideTask,
|
|
openPanel,
|
|
openSessionDrawer,
|
|
projectFeaturesAvailable,
|
|
requestOpenSplitView,
|
|
respondToPendingPermission,
|
|
],
|
|
);
|
|
useEffect(() => {
|
|
assignShellRef(shellRef, shellApi);
|
|
}, [shellApi, shellRef]);
|
|
useEffect(
|
|
() => () => {
|
|
assignShellRef(shellRef, null);
|
|
},
|
|
[shellRef],
|
|
);
|
|
const handleMissingSessionNewSession = useCallback(async () => {
|
|
if (creatingMissingSessionRef.current) return;
|
|
creatingMissingSessionRef.current = true;
|
|
setIsCreatingMissingSession(true);
|
|
try {
|
|
const success = await createNewSession({ kind: 'global' });
|
|
if (success) {
|
|
const nextContext = pendingSessionContextRef.current;
|
|
if (nextContext?.kind === 'standalone') {
|
|
onSessionIdChange?.(undefined, undefined, undefined, nextContext);
|
|
} else {
|
|
onSessionIdChange?.(undefined);
|
|
}
|
|
}
|
|
} finally {
|
|
creatingMissingSessionRef.current = false;
|
|
setIsCreatingMissingSession(false);
|
|
}
|
|
}, [createNewSession, onSessionIdChange]);
|
|
|
|
const loadSidebarSession = useCallback(
|
|
async (
|
|
sessionId: string,
|
|
workspaceCwd?: string,
|
|
sessionContext?: DaemonProductSessionContext,
|
|
) => {
|
|
setManagedHome(false);
|
|
pendingManualTitleRef.current = undefined;
|
|
splitClassificationGenerationRef.current += 1;
|
|
const invocation = ++sessionOpenInvocationRef.current;
|
|
const previousPendingContext = pendingSessionContextRef.current;
|
|
const targetContext =
|
|
sessionContext ??
|
|
(workspaceCwd
|
|
? isKnownLiveWorkspaceCwd(workspaceCwd)
|
|
? ({ kind: 'live' } as const)
|
|
: ({ kind: 'workspace', cwd: workspaceCwd } as const)
|
|
: undefined);
|
|
const selectedWorkspace = workspacesRef.current.find(
|
|
(entry) => entry.cwd === workspaceCwd,
|
|
);
|
|
navigationRef.current?.beginSessionNavigation(
|
|
sessionId,
|
|
selectedWorkspace && !selectedWorkspace.primary
|
|
? selectedWorkspace.id
|
|
: undefined,
|
|
targetContext,
|
|
);
|
|
const previousSelectedWorkspaceCwd = selectedWorkspaceCwdRef.current;
|
|
if (targetContext?.kind !== 'workspace') {
|
|
selectedWorkspaceCwdRef.current = undefined;
|
|
setSelectedWorkspaceCwd(undefined);
|
|
}
|
|
setPendingSessionContext(targetContext);
|
|
composerFocusRequestRef.current += 1;
|
|
setSidebarSwitchingSessionId(sessionId);
|
|
closeMobileDrawer();
|
|
// Loading another session should reveal its chat, not stay on the
|
|
// Settings/Status panel (no-op when the panel is closed).
|
|
closePanel();
|
|
// Explicit navigation cancels any pending shrink-fold split restore.
|
|
splitFoldedByShrinkRef.current = false;
|
|
try {
|
|
await sessionActions.loadSession(sessionId, {
|
|
workspaceCwd:
|
|
targetContext?.kind === 'workspace'
|
|
? targetContext.cwd
|
|
: targetContext
|
|
? undefined
|
|
: workspaceCwd,
|
|
sessionContext: targetContext,
|
|
});
|
|
if (
|
|
sessionOpenInvocationRef.current === invocation &&
|
|
pendingSessionContextRef.current === targetContext
|
|
) {
|
|
composerSourceVersionRef.current += 1;
|
|
setPendingSessionContext(undefined);
|
|
}
|
|
} catch (error) {
|
|
if (
|
|
sessionOpenInvocationRef.current === invocation &&
|
|
pendingSessionContextRef.current === targetContext
|
|
) {
|
|
setSidebarSwitchingSessionId(null);
|
|
navigationRef.current?.cancelSessionNavigation();
|
|
setPendingSessionContext(previousPendingContext);
|
|
if (targetContext?.kind !== 'workspace') {
|
|
selectedWorkspaceCwdRef.current = previousSelectedWorkspaceCwd;
|
|
setSelectedWorkspaceCwd(previousSelectedWorkspaceCwd);
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
[
|
|
closeMobileDrawer,
|
|
closePanel,
|
|
isKnownLiveWorkspaceCwd,
|
|
sessionActions,
|
|
setPendingSessionContext,
|
|
],
|
|
);
|
|
|
|
const handleRetryStandaloneSession = useCallback(() => {
|
|
const sessionId = connectionRef.current.sessionId;
|
|
if (!sessionId || standaloneRetryRef.current?.sessionId === sessionId)
|
|
return;
|
|
const request = { sessionId };
|
|
standaloneRetryRef.current = request;
|
|
setStandaloneRetrySessionId(sessionId);
|
|
void loadSidebarSession(sessionId, undefined, { kind: 'standalone' })
|
|
.catch(() => undefined)
|
|
.finally(() => {
|
|
if (standaloneRetryRef.current !== request) return;
|
|
standaloneRetryRef.current = undefined;
|
|
setStandaloneRetrySessionId(undefined);
|
|
});
|
|
}, [loadSidebarSession]);
|
|
|
|
// A session row opened from a fan-out host group (#13727): same in-app
|
|
// focus switch as the hosted composer pick, with an 'open' intent drained
|
|
// by the handoff effect below.
|
|
const handleOpenHostSession = useCallback(
|
|
(origin: string, sessionId: string, workspaceCwd?: string) => {
|
|
if (!daemonTarget) return;
|
|
if (origin === focusedHostOrigin) {
|
|
showChat();
|
|
void loadSidebarSession(sessionId, workspaceCwd);
|
|
return;
|
|
}
|
|
daemonTarget.focusHostWithHandoff(
|
|
{ origin },
|
|
{ kind: 'open', origin, sessionId, workspaceCwd },
|
|
);
|
|
},
|
|
[daemonTarget, focusedHostOrigin, loadSidebarSession, showChat],
|
|
);
|
|
|
|
// Drain the intent carried across a focused-host switch (#13727): fires
|
|
// once the NEW host's provider is connected; stale intents from an
|
|
// aborted switch (user moved to a third host meanwhile) are dropped.
|
|
const pendingHostHandoff = daemonTarget?.pendingHandoff;
|
|
useEffect(() => {
|
|
if (!daemonTarget || !pendingHostHandoff) return;
|
|
if (workspace.status !== 'connected') return;
|
|
if (pendingHostHandoff.origin !== daemonTarget.activeOrigin) {
|
|
daemonTarget.takePendingHandoff();
|
|
return;
|
|
}
|
|
const taken = daemonTarget.takePendingHandoff();
|
|
if (!taken) return;
|
|
if (taken.kind === 'open' && taken.sessionId) {
|
|
showChat();
|
|
void loadSidebarSession(taken.sessionId, taken.workspaceCwd);
|
|
return;
|
|
}
|
|
// 'create': preselect the picked workspace on this host and put any
|
|
// carried draft text back into the composer.
|
|
if (taken.draftText) {
|
|
handleComposerTextChange(taken.draftText);
|
|
editorRef.current?.insertText(taken.draftText);
|
|
}
|
|
if (taken.workspaceCwd) {
|
|
handleSelectComposerWorkspace(taken.workspaceCwd);
|
|
}
|
|
}, [
|
|
daemonTarget,
|
|
pendingHostHandoff,
|
|
workspace.status,
|
|
showChat,
|
|
loadSidebarSession,
|
|
handleComposerTextChange,
|
|
handleSelectComposerWorkspace,
|
|
]);
|
|
|
|
const handleCheckStandaloneRecovery = useCallback(async () => {
|
|
const recovery = connectionRef.current.standaloneSession?.creationRecovery;
|
|
const sessionId = getStandaloneRecoverySessionId(recovery);
|
|
if (!sessionId || standaloneRecoveryResolution === 'checking') return;
|
|
const owner = sessionOwnerGuard.capture();
|
|
const isCurrent = () =>
|
|
owner.isCurrent() &&
|
|
connectionRef.current.sessionId === sessionId &&
|
|
getStandaloneRecoverySessionId(
|
|
connectionRef.current.standaloneSession?.creationRecovery,
|
|
) === sessionId;
|
|
setStandaloneRecoveryResolution('checking');
|
|
try {
|
|
for (let attempt = 0; ; attempt += 1) {
|
|
let lookup: DaemonStandaloneSessionLookup;
|
|
try {
|
|
lookup = await workspace.client.getStandaloneSession(sessionId);
|
|
} catch (error) {
|
|
if (!isCurrent()) return;
|
|
if (isStandaloneSessionNotFoundError(error)) {
|
|
setStandaloneRecoveryResolution('absent');
|
|
return;
|
|
}
|
|
setStandaloneRecoveryResolution('unknown');
|
|
reportError(error, t('session.recoveryCheckFailed'));
|
|
return;
|
|
}
|
|
if (!isCurrent()) return;
|
|
if (!('state' in lookup)) {
|
|
if (lookup.isArchived) {
|
|
setStandaloneRecoveryResolution('archived');
|
|
return;
|
|
}
|
|
await loadSidebarSession(sessionId, undefined, {
|
|
kind: 'standalone',
|
|
});
|
|
return;
|
|
}
|
|
const delay = STANDALONE_RECOVERY_POLL_DELAYS_MS[attempt];
|
|
if (delay === undefined) {
|
|
setStandaloneRecoveryResolution('creating');
|
|
return;
|
|
}
|
|
await new Promise<void>((resolve) => window.setTimeout(resolve, delay));
|
|
if (!isCurrent()) return;
|
|
}
|
|
} catch (error) {
|
|
if (!isCurrent()) return;
|
|
setStandaloneRecoveryResolution('unknown');
|
|
reportError(error, t('session.recoveryCheckFailed'));
|
|
}
|
|
}, [
|
|
loadSidebarSession,
|
|
reportError,
|
|
sessionOwnerGuard,
|
|
standaloneRecoveryResolution,
|
|
t,
|
|
workspace.client,
|
|
]);
|
|
|
|
const handleUnarchiveRecoveredStandalone = useCallback(async () => {
|
|
const sessionId = getStandaloneRecoverySessionId(
|
|
connectionRef.current.standaloneSession?.creationRecovery,
|
|
);
|
|
if (!sessionId || standaloneRecoveryResolution === 'checking') return;
|
|
const request = ++standaloneRecoveryRequestRef.current;
|
|
const isCurrent = () =>
|
|
standaloneRecoveryRequestRef.current === request &&
|
|
connectionRef.current.sessionId === sessionId;
|
|
setStandaloneRecoveryResolution('checking');
|
|
try {
|
|
const result = await workspace.client.unarchiveStandaloneSessions([
|
|
sessionId,
|
|
]);
|
|
if (!isCurrent()) return;
|
|
if (
|
|
!result.unarchived.includes(sessionId) &&
|
|
!result.alreadyActive.includes(sessionId)
|
|
) {
|
|
if (result.notFound.includes(sessionId)) {
|
|
setStandaloneRecoveryResolution('absent');
|
|
return;
|
|
}
|
|
const failure = result.errors.find(
|
|
(entry) => entry.sessionId === sessionId,
|
|
);
|
|
throw new Error(failure?.message ?? t('session.unarchiveFailed'));
|
|
}
|
|
} catch (error) {
|
|
if (!isCurrent()) return;
|
|
setStandaloneRecoveryResolution('archived');
|
|
reportError(error, t('session.unarchiveFailed'));
|
|
return;
|
|
}
|
|
try {
|
|
await loadSidebarSession(sessionId, undefined, { kind: 'standalone' });
|
|
} catch (error) {
|
|
if (!isCurrent()) return;
|
|
setStandaloneRecoveryResolution('unknown');
|
|
reportError(error, t('session.recoveryCheckFailed'));
|
|
}
|
|
}, [
|
|
loadSidebarSession,
|
|
reportError,
|
|
standaloneRecoveryResolution,
|
|
t,
|
|
workspace.client,
|
|
]);
|
|
|
|
const handleRetryStandaloneCreation = useCallback(() => {
|
|
void createNewSession({ kind: 'inherit' }, { allowRecoveryReset: true });
|
|
}, [createNewSession]);
|
|
|
|
const handleRepairStandaloneDirectory = useCallback(async () => {
|
|
const current = connectionRef.current;
|
|
const sessionId = current.sessionId;
|
|
if (
|
|
!sessionId ||
|
|
current.sessionContext?.kind !== 'standalone' ||
|
|
current.standaloneSession?.errorCode !== 'working_directory_missing' ||
|
|
standaloneRepairBusy
|
|
) {
|
|
return;
|
|
}
|
|
const owner = sessionOwnerGuard.capture();
|
|
const request = ++standaloneRepairRequestRef.current;
|
|
setStandaloneRepairBusy(true);
|
|
try {
|
|
await workspace.client.repairStandaloneSessionDirectory(sessionId);
|
|
if (!owner.isCurrent() || connectionRef.current.sessionId !== sessionId) {
|
|
return;
|
|
}
|
|
await sessionActions.reloadSession(new AbortController().signal);
|
|
if (
|
|
standaloneRepairRequestRef.current === request &&
|
|
connectionRef.current.sessionId === sessionId
|
|
) {
|
|
pushToast('info', t('session.repairSucceeded'));
|
|
}
|
|
} catch (error) {
|
|
if (
|
|
standaloneRepairRequestRef.current === request &&
|
|
connectionRef.current.sessionId === sessionId
|
|
) {
|
|
reportError(error, t('session.repairFailed'));
|
|
}
|
|
} finally {
|
|
if (standaloneRepairRequestRef.current === request) {
|
|
setStandaloneRepairBusy(false);
|
|
}
|
|
}
|
|
}, [
|
|
pushToast,
|
|
reportError,
|
|
sessionActions,
|
|
sessionOwnerGuard,
|
|
standaloneRepairBusy,
|
|
t,
|
|
workspace.client,
|
|
]);
|
|
|
|
// Shared by the sidebar entry and the Worktrees tab so both start a
|
|
// worktree draft the same way.
|
|
const handleNewWorktreeSession = useCallback(
|
|
(workspaceCwd?: string) => {
|
|
// The intent travels with the draft it belongs to: set inside
|
|
// createNewSession's synchronous step, it is what the first prompt
|
|
// reads, and any later session start or workspace switch resets it
|
|
// like any other intent.
|
|
const targetWorkspaceCwd =
|
|
workspaceCwd ??
|
|
lockedWorkspaceCwd ??
|
|
workspacesRef.current.find(
|
|
(entry) => entry.primary && entry.trusted !== false,
|
|
)?.cwd;
|
|
if (!targetWorkspaceCwd) return false;
|
|
return createNewSession(
|
|
{ kind: 'workspace', cwd: targetWorkspaceCwd },
|
|
{ gitIntent: { mode: 'worktree' } },
|
|
);
|
|
},
|
|
[createNewSession, lockedWorkspaceCwd],
|
|
);
|
|
// Clicking a card in the Session Overview panel switches the current window
|
|
// to that session. loadSidebarSession already closes the panel, so this just
|
|
// returns to the chat view and reports load failures.
|
|
const handleOpenSessionFromOverview = useCallback(
|
|
(
|
|
sessionId: string,
|
|
workspaceCwd?: string,
|
|
explicitContext?: DaemonProductSessionContext,
|
|
) => {
|
|
splitClassificationGenerationRef.current += 1;
|
|
// Explicit navigation cancels any pending shrink-fold split restore.
|
|
if (mainView === 'split' || splitFoldedByShrinkRef.current) {
|
|
notifyControlledSplitClose();
|
|
clearSplitSessions();
|
|
}
|
|
splitFoldedByShrinkRef.current = false;
|
|
showChat();
|
|
const current = connectionRef.current;
|
|
if (
|
|
explicitContext &&
|
|
!pendingSessionContextRef.current &&
|
|
current.status === 'connected' &&
|
|
!current.loadingTranscript &&
|
|
!current.missingSession &&
|
|
!current.standaloneSession?.creationRecovery &&
|
|
current.sessionId === sessionId &&
|
|
current.sessionContext?.kind === explicitContext.kind &&
|
|
(explicitContext.kind !== 'workspace' ||
|
|
current.workspaceCwd === explicitContext.cwd)
|
|
) {
|
|
if (mainView === 'split')
|
|
focusComposerAfterSplitCloseRef.current = true;
|
|
closePanel();
|
|
closeMobileDrawer();
|
|
resumeChatBottomFollow('auto');
|
|
return;
|
|
}
|
|
const currentContext =
|
|
pendingSessionContextRef.current ??
|
|
connectionRef.current.sessionContext;
|
|
const inheritedContext =
|
|
workspaceCwd === undefined && currentContext?.kind !== 'workspace'
|
|
? currentContext
|
|
: undefined;
|
|
void loadSidebarSession(
|
|
sessionId,
|
|
workspaceCwd,
|
|
explicitContext ?? inheritedContext,
|
|
).catch((error: unknown) => {
|
|
reportError(error, 'Failed to open session');
|
|
});
|
|
},
|
|
[
|
|
closeMobileDrawer,
|
|
closePanel,
|
|
loadSidebarSession,
|
|
mainView,
|
|
notifyControlledSplitClose,
|
|
resumeChatBottomFollow,
|
|
reportError,
|
|
showChat,
|
|
],
|
|
);
|
|
|
|
const notificationNavigationTarget = useContext(
|
|
TurnNotificationNavigationContext,
|
|
);
|
|
|
|
// Markdown links and browser notifications share the session navigation path.
|
|
useEffect(() => {
|
|
const handler = (e: Event) => {
|
|
const detail = (
|
|
e as CustomEvent<
|
|
| string
|
|
| {
|
|
sessionId?: unknown;
|
|
workspaceCwd?: unknown;
|
|
sessionContext?: unknown;
|
|
}
|
|
>
|
|
).detail;
|
|
const sessionId = typeof detail === 'string' ? detail : detail?.sessionId;
|
|
const workspaceCwd =
|
|
typeof detail === 'object' &&
|
|
detail !== null &&
|
|
typeof detail.workspaceCwd === 'string'
|
|
? detail.workspaceCwd
|
|
: undefined;
|
|
let sessionContext: DaemonProductSessionContext | undefined;
|
|
if (
|
|
typeof detail === 'object' &&
|
|
detail !== null &&
|
|
'sessionContext' in detail
|
|
) {
|
|
const context = detail.sessionContext;
|
|
if (
|
|
typeof context !== 'object' ||
|
|
context === null ||
|
|
!('kind' in context)
|
|
)
|
|
return;
|
|
if (context.kind === 'standalone' || context.kind === 'live') {
|
|
if (workspaceCwd) return;
|
|
sessionContext = { kind: context.kind };
|
|
} else if (
|
|
context.kind === 'workspace' &&
|
|
'cwd' in context &&
|
|
typeof context.cwd === 'string' &&
|
|
context.cwd.trim()
|
|
) {
|
|
if (workspaceCwd && workspaceCwd !== context.cwd) return;
|
|
sessionContext = { kind: 'workspace', cwd: context.cwd };
|
|
} else return;
|
|
}
|
|
if (
|
|
e.currentTarget === notificationNavigationTarget &&
|
|
lockedWorkspaceCwd &&
|
|
(sessionContext?.kind !== 'workspace' ||
|
|
sessionContext.cwd !== lockedWorkspaceCwd)
|
|
)
|
|
return;
|
|
if (typeof sessionId === 'string' && sessionId.trim()) {
|
|
handleOpenSessionFromOverview(sessionId, workspaceCwd, sessionContext);
|
|
}
|
|
};
|
|
window.addEventListener('qwen:open-session', handler);
|
|
notificationNavigationTarget?.addEventListener(
|
|
'qwen:open-session',
|
|
handler,
|
|
);
|
|
return () => {
|
|
window.removeEventListener('qwen:open-session', handler);
|
|
notificationNavigationTarget?.removeEventListener(
|
|
'qwen:open-session',
|
|
handler,
|
|
);
|
|
};
|
|
}, [
|
|
handleOpenSessionFromOverview,
|
|
notificationNavigationTarget,
|
|
lockedWorkspaceCwd,
|
|
]);
|
|
|
|
// Listen for toast requests from deeply nested components (markdown links
|
|
// and artifact actions reporting a failed external open, for example).
|
|
useEffect(() => {
|
|
const handler = (e: Event) => {
|
|
const detail = (e as CustomEvent<ToastRequestDetail>).detail;
|
|
if (detail && typeof detail.message === 'string' && detail.message) {
|
|
pushToast(detail.tone, detail.message);
|
|
}
|
|
};
|
|
window.addEventListener(TOAST_REQUEST_EVENT, handler);
|
|
return () => window.removeEventListener(TOAST_REQUEST_EVENT, handler);
|
|
}, [pushToast]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
sidebarSwitchingSessionId !== null &&
|
|
connection.sessionId === sidebarSwitchingSessionId &&
|
|
!connection.loadingTranscript &&
|
|
!connection.catchingUp &&
|
|
!sessionWriteBlocked
|
|
) {
|
|
setSidebarSwitchingSessionId(null);
|
|
scheduleChatFocus(focusChatAfterNavigation, sidebarSwitchingSessionId);
|
|
}
|
|
}, [
|
|
connection.catchingUp,
|
|
connection.loadingTranscript,
|
|
connection.sessionId,
|
|
focusChatAfterNavigation,
|
|
scheduleChatFocus,
|
|
sessionWriteBlocked,
|
|
sidebarSwitchingSessionId,
|
|
]);
|
|
|
|
// Manual "run now" from the scheduled-tasks page. A bound task runs in its
|
|
// own session (so manual and scheduled runs share one transcript); an unbound
|
|
// task runs in the current session. Switching sessions is async, so a latch
|
|
// holds the prompt until the target session is fully active before sending.
|
|
//
|
|
// Returns a promise that resolves once the prompt is actually ENQUEUED and
|
|
// rejects if the bound session can't be opened (archived/deleted), supersedes,
|
|
// or times out — so the caller only records the run after it truly happened,
|
|
// never on a failed session switch. Only one bound run waits at a time.
|
|
const pendingBoundRunRef = useRef<{
|
|
sessionId: string;
|
|
prompt: string;
|
|
resolve: () => void;
|
|
reject: (err: unknown) => void;
|
|
timer?: ReturnType<typeof setTimeout>;
|
|
owner?: { isCurrent(): boolean };
|
|
} | null>(null);
|
|
const clearPendingBoundRun = useCallback((sessionId: string) => {
|
|
const cur = pendingBoundRunRef.current;
|
|
if (cur && cur.sessionId === sessionId) {
|
|
if (cur.timer !== undefined) clearTimeout(cur.timer);
|
|
pendingBoundRunRef.current = null;
|
|
}
|
|
}, []);
|
|
// Enqueue a manual-run prompt in the CURRENT session, resolving as soon as the
|
|
// daemon ADMITS it — not when the whole turn finishes. sendPrompt resolves via
|
|
// waitForAcceptedPromptCompletion (turn end), which is too late: a long or
|
|
// permission-blocked run, or a closed tab, would execute in the session but
|
|
// never get recorded. `onAdmitted` fires at submitPrompt acceptance.
|
|
//
|
|
// Deliberately NO pre-admission timeout here. `sendPrompt` isn't abortable, so
|
|
// a timer that rejected while the send was still in flight could let a LATE
|
|
// admission execute the prompt in the session AFTER the caller had already
|
|
// handled the rejection and skipped recording — an unrecorded run the user
|
|
// could retry into a duplicate. Staying tied to admission guarantees any
|
|
// accepted prompt is recorded, and the run controls stay busy (not free to
|
|
// re-fire) until the send admits or settles. The earlier "session never becomes
|
|
// active" phase is still bounded by the switch timeout in runTaskManually. If
|
|
// the send settles WITHOUT admitting (onSubmitBefore cancel) or throws before
|
|
// admission, reject so the caller skips recording a run that never reached the
|
|
// session.
|
|
const enqueueManualRun = useCallback(
|
|
(prompt: string): Promise<void> =>
|
|
new Promise<void>((resolve, reject) => {
|
|
// Session-less means no Goal can exist (and `sendPrompt` allocates a
|
|
// session itself), so gate on the shared predicate rather than on a
|
|
// bare `goalState === undefined`, which also fires with no session.
|
|
if (isGoalGateBlocked()) {
|
|
reject(new Error(t('scheduledTasks.error.goalActive')));
|
|
return;
|
|
}
|
|
let admitted = false;
|
|
const admit = () => {
|
|
if (admitted) return;
|
|
admitted = true;
|
|
resolve();
|
|
};
|
|
sendPrompt(prompt, undefined, undefined, { onAdmitted: admit }).then(
|
|
() => {
|
|
if (!admitted) {
|
|
reject(new Error('Run was cancelled before it started'));
|
|
}
|
|
},
|
|
(error: unknown) => {
|
|
if (!admitted) reject(error);
|
|
},
|
|
);
|
|
}),
|
|
[isGoalGateBlocked, sendPrompt, t],
|
|
);
|
|
// Enqueue the pending bound run once its session is the current, fully-loaded
|
|
// one — driven both by the effect below (when the session switch changes a
|
|
// dep) AND directly after loadSidebarSession resolves (when the session was
|
|
// ALREADY active, so no dep changes and the effect never re-runs — otherwise
|
|
// the run would hang until the switch timeout and falsely report a failure).
|
|
// Whoever fires first nulls the latch, so it runs exactly once.
|
|
const tryFireBoundRun = useCallback(() => {
|
|
const pending = pendingBoundRunRef.current;
|
|
const conn = connectionRef.current;
|
|
if (
|
|
!pending ||
|
|
conn.sessionId !== pending.sessionId ||
|
|
conn.loadingTranscript ||
|
|
conn.goalState === undefined
|
|
) {
|
|
return;
|
|
}
|
|
if (conn.catchingUp) {
|
|
if (pending.timer === undefined) {
|
|
pending.timer = setTimeout(() => {
|
|
clearPendingBoundRun(pending.sessionId);
|
|
pending.reject(new Error('Timed out waiting for session replay'));
|
|
}, BOUND_RUN_SWITCH_TIMEOUT_MS);
|
|
}
|
|
return;
|
|
}
|
|
if (pending.owner && !pending.owner.isCurrent()) {
|
|
clearPendingBoundRun(pending.sessionId);
|
|
pending.reject(
|
|
new DOMException('Bound run session was replaced', 'AbortError'),
|
|
);
|
|
return;
|
|
}
|
|
if (pending.timer !== undefined) clearTimeout(pending.timer);
|
|
pendingBoundRunRef.current = null;
|
|
// Resolves at prompt admission (see enqueueManualRun); the switch-timeout was
|
|
// cleared above, so a long turn can't trip it. Recording happens in the
|
|
// dialog once this resolves.
|
|
enqueueManualRun(pending.prompt).then(
|
|
() => pending.resolve(),
|
|
(error: unknown) => pending.reject(error),
|
|
);
|
|
}, [clearPendingBoundRun, enqueueManualRun]);
|
|
const runTaskManually = useCallback(
|
|
(
|
|
prompt: string,
|
|
sessionId: string | null,
|
|
// The workspace the bound session lives in: restoring it from another
|
|
// workspace's conversation would name the wrong one.
|
|
workspaceCwd?: string,
|
|
): Promise<void> => {
|
|
showChat();
|
|
if (!sessionId) {
|
|
// Unbound: runs in the current session — resolves at admission.
|
|
return enqueueManualRun(prompt);
|
|
}
|
|
// A newer bound run supersedes an older one still waiting on the latch;
|
|
// reject the old promise so its caller doesn't record a dropped run.
|
|
const prev = pendingBoundRunRef.current;
|
|
if (prev) {
|
|
if (prev.timer !== undefined) clearTimeout(prev.timer);
|
|
pendingBoundRunRef.current = null;
|
|
prev.reject(new Error('superseded by another run'));
|
|
}
|
|
return new Promise<void>((resolve, reject) => {
|
|
const pending: NonNullable<typeof pendingBoundRunRef.current> = {
|
|
sessionId,
|
|
prompt,
|
|
resolve,
|
|
reject,
|
|
};
|
|
pendingBoundRunRef.current = pending;
|
|
loadSidebarSession(sessionId, workspaceCwd)
|
|
// Fire immediately when the session was already active (no dep change
|
|
// to trigger the effect); a no-op if the load is still settling, in
|
|
// which case the effect picks it up.
|
|
.then(() => {
|
|
if (pendingBoundRunRef.current !== pending) return;
|
|
pending.owner = sessionOwnerGuard.capture();
|
|
tryFireBoundRun();
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (pendingBoundRunRef.current !== pending) return;
|
|
clearPendingBoundRun(sessionId);
|
|
reject(error);
|
|
});
|
|
});
|
|
},
|
|
[
|
|
enqueueManualRun,
|
|
loadSidebarSession,
|
|
clearPendingBoundRun,
|
|
showChat,
|
|
sessionOwnerGuard,
|
|
tryFireBoundRun,
|
|
],
|
|
);
|
|
useEffect(() => {
|
|
tryFireBoundRun();
|
|
}, [
|
|
connection.sessionId,
|
|
connection.loadingTranscript,
|
|
connection.catchingUp,
|
|
connection.goalState,
|
|
tryFireBoundRun,
|
|
]);
|
|
|
|
const openTasksPanel = useCallback(() => {
|
|
if (!requireActiveSessionForLocalCommand()) return;
|
|
const owner = sessionOwnerGuard.capture();
|
|
const request = workflowsEnabled
|
|
? sessionActions.getWorkflowTasks()
|
|
: sessionActions.getTasks();
|
|
request
|
|
.then((snapshot) => {
|
|
if (!owner.isCurrent()) return;
|
|
setTasksDialogMessage({ snapshot });
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!owner.isCurrent()) return;
|
|
if (isSessionDisconnectedError(error)) return;
|
|
reportError(error, 'Failed to load tasks');
|
|
});
|
|
}, [
|
|
reportError,
|
|
requireActiveSessionForLocalCommand,
|
|
sessionActions,
|
|
sessionOwnerGuard,
|
|
workflowsEnabled,
|
|
]);
|
|
const openCockpit = useCallback((): boolean => {
|
|
if (!sessionWorkflowEnabled || approvalOverlayActive) return false;
|
|
if (!requireActiveSessionForLocalCommand()) return false;
|
|
if (!cockpitViewRequested()) updateCockpitLocation(true);
|
|
setActivePanel(null);
|
|
setTasksDialogMessage(null);
|
|
setMainView('cockpit');
|
|
return true;
|
|
}, [
|
|
approvalOverlayActive,
|
|
requireActiveSessionForLocalCommand,
|
|
sessionWorkflowEnabled,
|
|
]);
|
|
const openWorkflowInspector = useCallback(() => {
|
|
if (!sessionWorkflowEnabled || approvalOverlayActive) return;
|
|
if (!requireActiveSessionForLocalCommand()) return;
|
|
rememberArtifactPanelTrigger();
|
|
const tab: ArtifactPanelTab = {
|
|
id: 'workflow',
|
|
kind: 'workflow',
|
|
title: t('workflow.title'),
|
|
};
|
|
setArtifactPanelTabs((tabs) =>
|
|
tabs.some((item) => item.id === tab.id) ? tabs : [...tabs, tab],
|
|
);
|
|
setActiveArtifactPanelTabId(tab.id);
|
|
setArtifactPanelWidth((width) =>
|
|
artifactPanelOpenRef.current
|
|
? width
|
|
: Math.min(440, getDefaultReviewPanelWidth()),
|
|
);
|
|
setArtifactPanelOpen(true);
|
|
}, [
|
|
approvalOverlayActive,
|
|
getDefaultReviewPanelWidth,
|
|
rememberArtifactPanelTrigger,
|
|
requireActiveSessionForLocalCommand,
|
|
sessionWorkflowEnabled,
|
|
t,
|
|
]);
|
|
const keepWorkflowDrawerClosedForCanvasRef = useRef(false);
|
|
const workflowCanvasEntryHandledRef = useRef(false);
|
|
const expandWorkflowGraph = useCallback(() => {
|
|
// Only close the floating inspector drawer when the cockpit actually
|
|
// opened: openCockpit() silently aborts on the approval-overlay and
|
|
// active-session guards, and closing the drawer anyway would drop the
|
|
// interactive workflow surface without delivering the expand action.
|
|
if (!openCockpit()) return;
|
|
if (artifactPanelFullscreen) setArtifactPanelFullscreen(false);
|
|
if (useFloatingArtifactPanel) {
|
|
keepWorkflowDrawerClosedForCanvasRef.current = true;
|
|
setArtifactPanelOpen(false);
|
|
}
|
|
}, [artifactPanelFullscreen, openCockpit, useFloatingArtifactPanel]);
|
|
useEffect(() => {
|
|
if (mainView !== 'cockpit') {
|
|
keepWorkflowDrawerClosedForCanvasRef.current = false;
|
|
workflowCanvasEntryHandledRef.current = false;
|
|
return;
|
|
}
|
|
if (workflowCanvasEntryHandledRef.current) return;
|
|
workflowCanvasEntryHandledRef.current = true;
|
|
if (
|
|
keepWorkflowDrawerClosedForCanvasRef.current ||
|
|
artifactPanelTabs.some((tab) => tab.kind === 'workflow')
|
|
) {
|
|
return;
|
|
}
|
|
openWorkflowInspector();
|
|
}, [artifactPanelTabs, mainView, openWorkflowInspector]);
|
|
const focusComposerAfterCockpitCloseRef = useRef(false);
|
|
const closeCockpit = useCallback(() => {
|
|
focusComposerAfterCockpitCloseRef.current = true;
|
|
showChat();
|
|
}, [showChat]);
|
|
useEffect(() => {
|
|
if (mainView !== 'chat' || !focusComposerAfterCockpitCloseRef.current) {
|
|
return;
|
|
}
|
|
focusComposerAfterCockpitCloseRef.current = false;
|
|
if (!activePanel && !approvalOverlayActive) focusChatAfterNavigation();
|
|
}, [activePanel, approvalOverlayActive, mainView, focusChatAfterNavigation]);
|
|
useEffect(() => {
|
|
if (navigationRef.current && navigationRef.current.route.page !== 'chat')
|
|
return;
|
|
if (!sessionWorkflowEnabled) {
|
|
if (mainView !== 'cockpit') return;
|
|
if (cockpitViewRequested()) {
|
|
if (sessionWorkflowSettingsResolved) {
|
|
updateCockpitLocation(false, true);
|
|
}
|
|
}
|
|
focusComposerAfterCockpitCloseRef.current = true;
|
|
setMainView('chat');
|
|
return;
|
|
}
|
|
if (
|
|
mainView === 'chat' &&
|
|
cockpitViewRequested() &&
|
|
!approvalOverlayActive
|
|
) {
|
|
setMainView('cockpit');
|
|
}
|
|
}, [
|
|
approvalOverlayActive,
|
|
mainView,
|
|
navigation?.revision,
|
|
sessionWorkflowEnabled,
|
|
sessionWorkflowSettingsResolved,
|
|
]);
|
|
useEffect(() => {
|
|
const handlePopState = () => {
|
|
if (navigationRef.current && navigationRef.current.route.page !== 'chat')
|
|
return;
|
|
if (cockpitViewRequested()) {
|
|
if (sessionWorkflowEnabled && !approvalOverlayActive) {
|
|
setMainView('cockpit');
|
|
} else {
|
|
setMainView('chat');
|
|
if (approvalOverlayActive || sessionWorkflowSettingsResolved) {
|
|
updateCockpitLocation(false, true);
|
|
}
|
|
}
|
|
} else if (mainViewRef.current === 'cockpit') {
|
|
if (!approvalOverlayActive) {
|
|
focusComposerAfterCockpitCloseRef.current = true;
|
|
}
|
|
setMainView('chat');
|
|
}
|
|
};
|
|
window.addEventListener('popstate', handlePopState);
|
|
return () => window.removeEventListener('popstate', handlePopState);
|
|
}, [
|
|
approvalOverlayActive,
|
|
sessionWorkflowEnabled,
|
|
sessionWorkflowSettingsResolved,
|
|
]);
|
|
useEffect(() => {
|
|
if (navigationRef.current && navigationRef.current.route.page !== 'chat')
|
|
return;
|
|
if (
|
|
mainView === 'cockpit' &&
|
|
sessionWorkflowEnabled &&
|
|
!cockpitViewRequested()
|
|
) {
|
|
updateCockpitLocation(true, true);
|
|
} else if (
|
|
mainView !== 'cockpit' &&
|
|
cockpitViewRequested() &&
|
|
!sessionWorkflowEnabled &&
|
|
sessionWorkflowSettingsResolved
|
|
) {
|
|
updateCockpitLocation(false, true);
|
|
}
|
|
}, [mainView, sessionWorkflowEnabled, sessionWorkflowSettingsResolved]);
|
|
const openEnvironmentTasksPanel = useCallback(() => {
|
|
if (!requireActiveSessionForLocalCommand()) return;
|
|
setEnvironmentPanelOpen(true);
|
|
persistEnvironmentPanelOpen(true);
|
|
setBackgroundTasksRefreshTrigger((value) => value + 1);
|
|
}, [persistEnvironmentPanelOpen, requireActiveSessionForLocalCommand]);
|
|
const openEnvironmentTask = useCallback(
|
|
(task: DaemonSessionTaskWithWorkflowStatus) => {
|
|
if (task.kind === 'monitor' || task.kind === 'shell') {
|
|
if (!artifactPanelOpenRef.current) {
|
|
preserveEnvironmentPanelOnArtifactOpenRef.current = true;
|
|
}
|
|
if (task.kind === 'monitor') {
|
|
handleOpenMonitorDetails(task);
|
|
} else {
|
|
handleOpenShellDetails(task);
|
|
}
|
|
return;
|
|
}
|
|
openTasksPanel();
|
|
},
|
|
[handleOpenMonitorDetails, handleOpenShellDetails, openTasksPanel],
|
|
);
|
|
|
|
const refreshGoal = useCallback(async () => {
|
|
const owner = sessionOwnerGuard.capture();
|
|
const response = await sessionActions.getGoal();
|
|
if (owner.isCurrent()) setGoalSnapshot(response.snapshot);
|
|
return response.snapshot;
|
|
}, [sessionActions, sessionOwnerGuard]);
|
|
|
|
const controlCurrentGoal = useCallback(
|
|
async (
|
|
action: 'create' | 'replace' | 'edit' | 'pause' | 'resume' | 'clear',
|
|
objective?: string,
|
|
) => {
|
|
const busyOwner = sessionOwnerGuard.capture();
|
|
const busySessionId = connectionRef.current.sessionId;
|
|
const expectedGoalId = goalSnapshotRef.current?.goal?.goalId;
|
|
const opId = ++goalControlOpSeqRef.current;
|
|
goalControlOwnerRef.current = { opId, sessionId: busySessionId };
|
|
setGoalControlBusy(true);
|
|
try {
|
|
const snapshot = await refreshGoal();
|
|
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'));
|
|
}
|
|
const owner = sessionOwnerGuard.capture();
|
|
try {
|
|
const response = await sessionActions.controlGoal(request);
|
|
if (owner.isCurrent()) setGoalSnapshot(response.snapshot);
|
|
return response.snapshot;
|
|
} catch (error) {
|
|
if (owner.isCurrent()) await refreshGoal().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);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
[refreshGoal, sessionActions, sessionOwnerGuard, t],
|
|
);
|
|
|
|
const createGoalForAllocatedSession = useCallback(
|
|
async (sessionId: string, objective: string) => {
|
|
const opId = ++goalControlOpSeqRef.current;
|
|
goalControlOwnerRef.current = { opId, sessionId };
|
|
setGoalControlBusy(true);
|
|
try {
|
|
const response = await workspaceActions.controlGoal(sessionId, {
|
|
action: 'create',
|
|
objective,
|
|
});
|
|
// The workspace-scoped control does not write `connection.goalState`
|
|
// the way `sessionActions.controlGoal` does, so install the create
|
|
// response directly to keep the Goal strip and controls authoritative.
|
|
sessionActions.applyGoalSnapshot(sessionId, response.snapshot);
|
|
if (
|
|
!connectionRef.current.sessionId ||
|
|
connectionRef.current.sessionId === sessionId
|
|
) {
|
|
setGoalSnapshot(response.snapshot);
|
|
}
|
|
if (connectionRef.current.sessionId === sessionId) {
|
|
await refreshGoal();
|
|
}
|
|
return response.snapshot;
|
|
} finally {
|
|
// Same ownership rule as `controlCurrentGoal`: a create that settles
|
|
// after the user switched sessions must not release a latch a newer
|
|
// control now holds, or the strip re-enables mid-control and a second
|
|
// dispatch loses the daemon's CAS with a 409.
|
|
if (goalControlOwnerRef.current?.opId === opId) {
|
|
goalControlOwnerRef.current = undefined;
|
|
setGoalControlBusy(false);
|
|
}
|
|
}
|
|
},
|
|
[refreshGoal, sessionActions, workspaceActions],
|
|
);
|
|
|
|
const loadRewindSnapshots = useCallback(
|
|
() => sessionActions.getRewindSnapshots(),
|
|
[sessionActions],
|
|
);
|
|
|
|
const rewindConversationOnly = useCallback(
|
|
(promptId: string) =>
|
|
sessionActions
|
|
.rewindSession(promptId, { rewindFiles: false })
|
|
.then(() => undefined),
|
|
[sessionActions],
|
|
);
|
|
|
|
// The inline editor is owned by the message row. This only reports whether a
|
|
// host takes the lifecycle over, so the row can skip its own editor.
|
|
const beginUserMessageEdit = useCallback(
|
|
(turnIndex: number, content: string) =>
|
|
onUserMessageEditRequest?.(turnIndex, content) === true,
|
|
[onUserMessageEditRequest],
|
|
);
|
|
|
|
const submitUserMessageEdit = useCallback(
|
|
async (turnIndex: number, content: string): Promise<boolean> => {
|
|
const trimmed = content.trim();
|
|
if (
|
|
sessionWriteBlockedRef.current ||
|
|
promptPreparationOwnerRef.current ||
|
|
streamingStateRef.current !== 'idle' ||
|
|
sessionHasActivePromptRef.current
|
|
) {
|
|
pushToast('error', t('userMessage.editBusy'));
|
|
return false;
|
|
}
|
|
if (
|
|
!modelManagementRef.current.allowAdd &&
|
|
isModelSetupCommand(trimmed, connectionRef.current.commands)
|
|
) {
|
|
pushToast('info', t('settings.models.addDisabled'));
|
|
return false;
|
|
}
|
|
const sessionId = connectionRef.current.sessionId;
|
|
if (
|
|
unknownPromptAdmissionRef.current?.payloadAvailable &&
|
|
unknownPromptAdmissionRef.current.sessionId === sessionId
|
|
)
|
|
return false;
|
|
const original = getLatestUserBlock(store.getSnapshot().blocks);
|
|
if (!sessionId || original?.kind !== 'user') return false;
|
|
const images: PromptImage[] | undefined = original.images?.map(
|
|
(image) => ({
|
|
data: image.data,
|
|
media_type: image.mimeType,
|
|
}),
|
|
);
|
|
const files: PromptFile[] | undefined = original.files?.map((file) => ({
|
|
name: file.name,
|
|
media_type: file.mimeType,
|
|
data: file.data,
|
|
text: file.text,
|
|
attachmentId: file.attachmentId,
|
|
}));
|
|
if (!trimmed && !images?.length && !files?.length) return false;
|
|
const inputAnnotations = mapRestoredInputAnnotationsAfterTextChange(
|
|
original.meta?.inputAnnotations ?? [],
|
|
original.text,
|
|
trimmed,
|
|
);
|
|
const owner = sessionOwnerGuard.capture();
|
|
const editRetryOwner: CancelledRetryOwner = {
|
|
sessionId,
|
|
workspaceCwd: getComposerWorkspaceCwd(),
|
|
sessionKey: logicalSessionKey,
|
|
sourceVersion: composerSourceVersionRef.current,
|
|
snapshot: owner,
|
|
};
|
|
const generation = sessionWriteBlockGenerationRef.current;
|
|
const isCurrent = () =>
|
|
appMountedRef.current &&
|
|
owner.isCurrent() &&
|
|
connectionRef.current.sessionId === sessionId &&
|
|
!sessionWriteBlockedRef.current &&
|
|
sessionWriteBlockGenerationRef.current === generation;
|
|
const assertCurrent = () => {
|
|
if (!isCurrent())
|
|
throw new DOMException('Edit session changed', 'AbortError');
|
|
};
|
|
const assertTarget = () => {
|
|
assertCurrent();
|
|
if (
|
|
countUserTurns(store.getSnapshot().blocks) !== turnIndex + 1 ||
|
|
!matchesUserMessageIdentity(
|
|
getLatestUserBlock(store.getSnapshot().blocks),
|
|
{ block: original },
|
|
true,
|
|
)
|
|
) {
|
|
throw new Error(t('userMessage.editStale'));
|
|
}
|
|
};
|
|
let preparedEdit: WebShellPreparedSubmit = {
|
|
prompt: trimmed,
|
|
inputAnnotations,
|
|
};
|
|
let rewindStarted = false;
|
|
let rewindApplied = false;
|
|
let admissionStarted = false;
|
|
let admitted = false;
|
|
let admissionUnknown = false;
|
|
try {
|
|
await sendPrompt(trimmed, images, files, {
|
|
submittedPrompt: trimmed,
|
|
inputAnnotations,
|
|
clearComposerOnPromptStart: false,
|
|
onPreparedSubmit: (prepared) => {
|
|
preparedEdit = prepared;
|
|
},
|
|
beforeAdmission: async () => {
|
|
assertTarget();
|
|
for (let index = 0; index < (images?.length ?? 0); index += 1) {
|
|
const attachmentId = original.images![index].attachmentId;
|
|
if (!attachmentId) continue;
|
|
const attachment =
|
|
await sessionActions.readAttachment(attachmentId);
|
|
assertTarget();
|
|
images![index] = {
|
|
data: attachment.data,
|
|
media_type: attachment.mimeType,
|
|
};
|
|
}
|
|
for (const file of files ?? []) {
|
|
if (file.data !== undefined || file.text !== undefined) continue;
|
|
if (!file.attachmentId)
|
|
throw new Error(t('userMessage.editAttachmentUnavailable'));
|
|
const attachment = await sessionActions.readAttachment(
|
|
file.attachmentId,
|
|
);
|
|
assertTarget();
|
|
file.data = base64ToBlob(attachment.data, attachment.mimeType);
|
|
file.media_type = attachment.mimeType;
|
|
}
|
|
const { snapshots } = await sessionActions.getRewindSnapshots();
|
|
assertTarget();
|
|
const current = getLatestUserBlock(store.getSnapshot().blocks);
|
|
const matches =
|
|
current?.promptId &&
|
|
findDaemonRewindUserBlock(
|
|
store.getSnapshot().blocks,
|
|
current.promptId,
|
|
)?.id === current.id
|
|
? snapshots.filter(
|
|
(entry) => entry.daemonPromptId === current.promptId,
|
|
)
|
|
: [];
|
|
const snapshot = matches.length === 1 ? matches[0] : undefined;
|
|
if (!snapshot) throw new Error(t('rewind.empty'));
|
|
rewindStarted = true;
|
|
await sessionActions.rewindSession(snapshot.promptId, {
|
|
rewindFiles: false,
|
|
});
|
|
assertCurrent();
|
|
rewindApplied = await waitForRewindApplied(
|
|
() => store.getSnapshot().blocks,
|
|
turnIndex,
|
|
isCurrent,
|
|
);
|
|
assertCurrent();
|
|
if (!rewindApplied) {
|
|
throw new Error(t('userMessage.editSyncFailed'));
|
|
}
|
|
},
|
|
onAdmissionStarted: () => {
|
|
admissionStarted = true;
|
|
},
|
|
onAdmitted: () => {
|
|
admitted = true;
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (!isCurrent()) return false;
|
|
if (
|
|
admissionStarted &&
|
|
!admitted &&
|
|
!isDefinitelyRejectedPromptAdmission(error)
|
|
) {
|
|
admissionUnknown = true;
|
|
updateUnknownPromptAdmission({
|
|
sessionId,
|
|
text: preparedEdit.prompt,
|
|
images,
|
|
files,
|
|
inputAnnotations: preparedEdit.inputAnnotations
|
|
? [...preparedEdit.inputAnnotations]
|
|
: undefined,
|
|
payloadAvailable: true,
|
|
});
|
|
pushToast('warning', t('queue.admissionUnknown'));
|
|
} else if (!isAbortError(error) && !isAlreadyDispatched(error)) {
|
|
reportError(
|
|
error,
|
|
t('userMessage.editFailed', { reason: formatError(error, '') }),
|
|
);
|
|
}
|
|
} finally {
|
|
if (rewindStarted && !admitted && !admissionUnknown && isCurrent()) {
|
|
const recover = () => {
|
|
// Only create a replacement after the old turn has been removed.
|
|
if (countUserTurns(store.getSnapshot().blocks) === turnIndex) {
|
|
store.appendLocalUserMessage(
|
|
preparedEdit.prompt,
|
|
images?.map((image) => ({
|
|
data: image.data,
|
|
mimeType: image.media_type,
|
|
})),
|
|
preparedEdit.inputAnnotations?.length
|
|
? { inputAnnotations: [...preparedEdit.inputAnnotations] }
|
|
: undefined,
|
|
files?.map((file) => ({
|
|
...file,
|
|
mimeType: file.media_type,
|
|
})),
|
|
);
|
|
}
|
|
const recoveryBlocks = store.getSnapshot().blocks;
|
|
const failedMessage = getLatestUserBlock(recoveryBlocks);
|
|
if (!failedMessage || failedMessage === original) return;
|
|
const previousMessage = getLatestUserBlock(
|
|
recoveryBlocks.slice(0, recoveryBlocks.indexOf(failedMessage)),
|
|
);
|
|
updateFailedPrompt({
|
|
sessionId,
|
|
messageId: failedMessage.id,
|
|
identity: { block: failedMessage },
|
|
previousIdentity: previousMessage
|
|
? { block: previousMessage }
|
|
: undefined,
|
|
owner: editRetryOwner,
|
|
text: preparedEdit.prompt,
|
|
images,
|
|
files,
|
|
inputAnnotations: preparedEdit.inputAnnotations
|
|
? [...preparedEdit.inputAnnotations]
|
|
: undefined,
|
|
});
|
|
};
|
|
if (
|
|
!rewindApplied &&
|
|
countUserTurns(store.getSnapshot().blocks) > turnIndex
|
|
) {
|
|
setPendingRewind({
|
|
sessionKey: logicalSessionKey,
|
|
turnIndex,
|
|
owner,
|
|
recover,
|
|
});
|
|
} else {
|
|
recover();
|
|
}
|
|
}
|
|
}
|
|
return admitted;
|
|
},
|
|
[
|
|
pushToast,
|
|
reportError,
|
|
sendPrompt,
|
|
sessionActions,
|
|
sessionOwnerGuard,
|
|
store,
|
|
t,
|
|
updateUnknownPromptAdmission,
|
|
updateFailedPrompt,
|
|
getComposerWorkspaceCwd,
|
|
logicalSessionKey,
|
|
],
|
|
);
|
|
|
|
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 goalSessionCreationInFlightRef = useRef(false);
|
|
const handleGoalSlashCommand = useCallback(
|
|
(
|
|
text: string,
|
|
hasAttachments: boolean,
|
|
commitComposerAccepted?: () => void,
|
|
) => {
|
|
if (hasAttachments) {
|
|
pushToast('error', t('goals.error.attachmentsUnsupported'));
|
|
return false;
|
|
}
|
|
const operation = parseWebShellGoalCommand(text);
|
|
if (operation.kind === 'status') {
|
|
openGoals();
|
|
return true;
|
|
}
|
|
if (operation.kind === 'error') {
|
|
pushToast(
|
|
'error',
|
|
t('goals.error.requiresObjective', { keyword: operation.keyword }),
|
|
);
|
|
return false;
|
|
}
|
|
// Returning true wipes the composer, so the preconditions that can be
|
|
// checked here must be checked before that happens — a control typed
|
|
// without a session would otherwise lose its text to a toast.
|
|
if (!connectionRef.current.sessionId && operation.kind !== 'set') {
|
|
pushToast('error', t('localCommand.noSession'));
|
|
return false;
|
|
}
|
|
// The strip disables its buttons while a control is in flight; the
|
|
// composer has no disabled state, so it has to refuse here. Two controls
|
|
// read the same snapshot and stamp the same `expectedGoalId`/
|
|
// `expectedRevision`, and the daemon rejects the loser with a 409.
|
|
if (
|
|
goalControlOwnerRef.current ||
|
|
goalSessionCreationInFlightRef.current
|
|
) {
|
|
pushToast('error', t('goals.error.controlBusy'));
|
|
return false;
|
|
}
|
|
|
|
const needsSession = !connectionRef.current.sessionId;
|
|
goalSessionCreationInFlightRef.current = needsSession;
|
|
void (async () => {
|
|
const sourceOwner = sessionOwnerGuard.capture();
|
|
const sourceSessionId = connectionRef.current.sessionId;
|
|
let allocatedSessionId: string | undefined;
|
|
if (!connectionRef.current.sessionId) {
|
|
if (operation.kind !== 'set') {
|
|
throw new Error(t('localCommand.noSession'));
|
|
}
|
|
allocatedSessionId = await ensureSessionForPrompt();
|
|
}
|
|
const currentSessionId = connectionRef.current.sessionId;
|
|
const ownAllocationSucceeded =
|
|
sourceSessionId === undefined &&
|
|
allocatedSessionId !== undefined &&
|
|
(currentSessionId === undefined ||
|
|
currentSessionId === allocatedSessionId);
|
|
if (
|
|
(!sourceOwner.isCurrent() && !ownAllocationSucceeded) ||
|
|
(sourceSessionId !== undefined
|
|
? currentSessionId !== sourceSessionId
|
|
: currentSessionId !== undefined &&
|
|
currentSessionId !== allocatedSessionId)
|
|
) {
|
|
return;
|
|
}
|
|
if (!connectionRef.current.sessionId && !allocatedSessionId) {
|
|
throw new Error(t('localCommand.noSession'));
|
|
}
|
|
if (needsSession) {
|
|
if (commitComposerAccepted) commitComposerAccepted();
|
|
else editorRef.current?.clear();
|
|
}
|
|
store.appendLocalUserMessage(text, undefined, {
|
|
localCommandId: `local-command:${crypto.randomUUID()}`,
|
|
});
|
|
const action = operation.kind === 'set' ? 'replace' : operation.kind;
|
|
const objective =
|
|
operation.kind === 'set' || operation.kind === 'edit'
|
|
? operation.objective
|
|
: undefined;
|
|
if (allocatedSessionId && operation.kind === 'set') {
|
|
await createGoalForAllocatedSession(
|
|
allocatedSessionId,
|
|
operation.objective,
|
|
);
|
|
} else {
|
|
await controlCurrentGoal(action, objective);
|
|
}
|
|
})()
|
|
.catch((error: unknown) => {
|
|
reportError(error, `Failed to ${operation.kind} /goal`);
|
|
})
|
|
.finally(() => {
|
|
goalSessionCreationInFlightRef.current = false;
|
|
});
|
|
return !needsSession;
|
|
},
|
|
[
|
|
controlCurrentGoal,
|
|
createGoalForAllocatedSession,
|
|
ensureSessionForPrompt,
|
|
openGoals,
|
|
pushToast,
|
|
reportError,
|
|
sessionOwnerGuard,
|
|
store,
|
|
t,
|
|
],
|
|
);
|
|
|
|
const runGoalControl = useCallback(
|
|
(action: 'pause' | 'resume' | 'clear') => {
|
|
void controlCurrentGoal(action).catch((error: unknown) => {
|
|
reportError(error, t(`goals.error.${action}Failed`));
|
|
});
|
|
},
|
|
[controlCurrentGoal, reportError, t],
|
|
);
|
|
|
|
const handleGoalEditSave = useCallback(
|
|
(objective: string) => {
|
|
const owner = sessionOwnerGuard.capture();
|
|
setGoalEditError(null);
|
|
void controlCurrentGoal('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'));
|
|
});
|
|
},
|
|
[controlCurrentGoal, reportError, sessionOwnerGuard, t],
|
|
);
|
|
|
|
const hiddenCommands = useMemo(
|
|
() =>
|
|
new Set(
|
|
(hiddenSlashCommands ?? []).map(normalizeHiddenCommand).filter(Boolean),
|
|
),
|
|
[hiddenSlashCommands],
|
|
);
|
|
const hideSettings = hiddenCommands.has('settings');
|
|
|
|
const handleSubmit = useCallback(
|
|
(
|
|
text: string,
|
|
images?: PromptImage[],
|
|
files?: PromptFile[],
|
|
commitComposerAccepted?: ComposerSubmitCommit,
|
|
metadata?: ComposerSubmitMetadata,
|
|
) => {
|
|
if (sessionWriteBlockedRef.current) return false;
|
|
if (connectionRef.current.runtimeStopped) {
|
|
pushToast('warning', t('capacityChoice.stopped'));
|
|
return false;
|
|
}
|
|
if (
|
|
!composerAttachmentsEnabled &&
|
|
((images?.length ?? 0) > 0 || (files?.length ?? 0) > 0)
|
|
) {
|
|
pushToast('warning', t('composerAdd.file.attachDisabled'));
|
|
return false;
|
|
}
|
|
if (
|
|
connectionRef.current.standaloneSession?.creationRecovery ||
|
|
connectionRef.current.standaloneSession?.errorCode ===
|
|
'working_directory_missing' ||
|
|
connectionRef.current.standaloneSession?.errorCode ===
|
|
'working_directory_compromised'
|
|
) {
|
|
pushToast('warning', t('session.recoveryBlocksAction'));
|
|
return false;
|
|
}
|
|
if (
|
|
unknownPromptAdmissionRef.current?.payloadAvailable &&
|
|
unknownPromptAdmissionRef.current.sessionId ===
|
|
connectionRef.current.sessionId
|
|
) {
|
|
return false;
|
|
}
|
|
// Account commands must pass login and model checks before host overrides.
|
|
const subscriptionHandled = handleSubscriptionLogout(text);
|
|
if (subscriptionHandled !== undefined) return subscriptionHandled;
|
|
if (
|
|
invokeSlashCommandHandler(text, onSlashCommandRef.current, reportError)
|
|
) {
|
|
return true;
|
|
}
|
|
if (
|
|
!modelManagementRef.current.allowAdd &&
|
|
isModelSetupCommand(text, connectionRef.current.commands)
|
|
) {
|
|
pushToast('info', t('settings.models.addDisabled'));
|
|
return true;
|
|
}
|
|
if (connectionRef.current.loadingTranscript) {
|
|
pushToast('warning', t('editor.sessionLoading'));
|
|
return false;
|
|
}
|
|
if (
|
|
shouldBlockComposerSubmit({
|
|
connectionStatus: connectionRef.current.status,
|
|
hasSession: Boolean(connectionRef.current.sessionId),
|
|
})
|
|
) {
|
|
pushToast('warning', t('editor.connectionDisconnected'));
|
|
return false;
|
|
}
|
|
const goalBlocked = isGoalGateBlocked();
|
|
const sessionActive =
|
|
streamingStateRef.current !== 'idle' ||
|
|
sessionHasActivePromptRef.current;
|
|
const commandBlocked = sessionActive || goalBlocked;
|
|
const enqueueBlockedCommand = (commandText: string) => {
|
|
if (goalBlocked) return blockCommand();
|
|
return enqueuePrompt(
|
|
commandText,
|
|
images,
|
|
files,
|
|
undefined,
|
|
commitComposerAccepted,
|
|
metadata?.inputAnnotations,
|
|
text,
|
|
);
|
|
};
|
|
const submitPromptFromEditor = (
|
|
promptText: string,
|
|
promptImages: PromptImage[] | undefined,
|
|
promptFiles: PromptFile[] | undefined,
|
|
errorMessage: string,
|
|
opts?: {
|
|
optimisticUserMessage?: boolean;
|
|
retry?: boolean;
|
|
inputAnnotations?: DaemonInputAnnotation[];
|
|
trackSendFailure?: boolean;
|
|
},
|
|
) => {
|
|
if (!daemonAvailableRef.current) return false;
|
|
const admissionAttachment = {
|
|
current: sessionOwnerGuard.capture(),
|
|
};
|
|
const admissionOwner = {
|
|
sourceVersion: composerSourceVersionRef.current,
|
|
sessionId: connectionRef.current.sessionId,
|
|
workspaceCwd: getComposerWorkspaceCwd(),
|
|
};
|
|
const admissionOwnerIsCurrent = () =>
|
|
admissionAttachment.current.isCurrent() &&
|
|
composerSourceVersionRef.current === admissionOwner.sourceVersion &&
|
|
(admissionOwner.sessionId === undefined ||
|
|
(connectionRef.current.sessionId === admissionOwner.sessionId &&
|
|
getComposerWorkspaceCwd() === admissionOwner.workspaceCwd));
|
|
const { trackSendFailure = false, ...sendOptions } = opts ?? {};
|
|
const startedWithoutSession = !connectionRef.current.sessionId;
|
|
const deferComposerCommit =
|
|
Boolean(prepareSubmitRef.current || onSubmitBeforeRef.current) ||
|
|
createSessionPromiseRef.current !== null;
|
|
const clearComposerOnPromptStart =
|
|
startedWithoutSession || deferComposerCommit;
|
|
let optimisticUserMessage: OptimisticUserMessage | undefined;
|
|
let submittedPromptText = promptText;
|
|
let submittedInputAnnotations = sendOptions.inputAnnotations;
|
|
let admitted = false;
|
|
let admissionStarted = false;
|
|
let admissionSessionId: string | undefined;
|
|
const attempt = (
|
|
recovering = false,
|
|
onSessionAllocated?: (sessionId: string) => void,
|
|
): Promise<unknown> =>
|
|
sendPrompt(
|
|
recovering ? submittedPromptText : promptText,
|
|
promptImages,
|
|
promptFiles,
|
|
{
|
|
submittedPrompt: text,
|
|
ownerRef: admissionAttachment,
|
|
...sendOptions,
|
|
clearComposerOnPromptStart:
|
|
!recovering && clearComposerOnPromptStart,
|
|
...(recovering
|
|
? {
|
|
inputAnnotations: submittedInputAnnotations,
|
|
skipPrepareSubmit: true,
|
|
skipSubmitBefore: true,
|
|
onSessionAllocated,
|
|
beforeAdmission: async () => {
|
|
if (
|
|
!metadata?.isCurrentDraft?.({
|
|
allowSessionAssignment: startedWithoutSession,
|
|
})
|
|
)
|
|
throw new DOMException(
|
|
'Composer draft changed',
|
|
'AbortError',
|
|
);
|
|
},
|
|
}
|
|
: {}),
|
|
onPreparedSubmit: (prepared) => {
|
|
submittedPromptText = prepared.prompt;
|
|
const annotations = prepared.inputAnnotations ?? [];
|
|
submittedInputAnnotations =
|
|
annotations.length > 0 ? [...annotations] : undefined;
|
|
},
|
|
commitComposerAccepted:
|
|
!recovering && clearComposerOnPromptStart
|
|
? commitComposerAccepted
|
|
: undefined,
|
|
onAdmissionStarted: (sessionId) => {
|
|
admissionStarted = true;
|
|
admissionSessionId = sessionId;
|
|
},
|
|
onAdmitted: () => {
|
|
admitted = true;
|
|
if (recovering) commitComposerAccepted?.();
|
|
},
|
|
...(trackSendFailure
|
|
? {
|
|
onOptimisticUserMessage: (
|
|
message: OptimisticUserMessage,
|
|
) => {
|
|
optimisticUserMessage = message;
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
).catch((error: unknown) => {
|
|
if (!admissionOwnerIsCurrent()) return;
|
|
if (
|
|
!recovering &&
|
|
!admissionStarted &&
|
|
!admitted &&
|
|
metadata?.isCurrentDraft &&
|
|
offerCapacityRecovery(error, {
|
|
requesterCwd: admissionOwner.workspaceCwd,
|
|
isCurrent: () =>
|
|
admissionOwnerIsCurrent() && metadata.isCurrentDraft!(),
|
|
resume: () => {
|
|
const retry = (
|
|
onSessionAllocated?: (sessionId: string) => void,
|
|
) => attempt(true, onSessionAllocated);
|
|
return metadata.retainDraftDuringSessionCreation
|
|
? metadata.retainDraftDuringSessionCreation(retry)
|
|
: retry();
|
|
},
|
|
})
|
|
)
|
|
return;
|
|
const failedMessage = optimisticUserMessage;
|
|
const definitelyRejected =
|
|
isDefinitelyRejectedPromptAdmission(error);
|
|
if (admissionStarted && !admitted && !definitelyRejected) {
|
|
updateFailedPrompt(null);
|
|
const uncertainSessionId =
|
|
failedMessage?.sessionId ??
|
|
admissionSessionId ??
|
|
connectionRef.current.sessionId;
|
|
if (uncertainSessionId) {
|
|
updateUnknownPromptAdmission({
|
|
sessionId: uncertainSessionId,
|
|
messageId: failedMessage?.messageId,
|
|
text: submittedPromptText,
|
|
images: promptImages ? [...promptImages] : undefined,
|
|
files: promptFiles ? [...promptFiles] : undefined,
|
|
inputAnnotations: submittedInputAnnotations,
|
|
payloadAvailable: true,
|
|
});
|
|
}
|
|
pushToast('warning', t('queue.admissionUnknown'));
|
|
console.warn(
|
|
'[WebShell] prompt admission outcome is unknown',
|
|
error,
|
|
);
|
|
return;
|
|
}
|
|
if (
|
|
trackSendFailure &&
|
|
!admitted &&
|
|
failedMessage &&
|
|
failedMessage.sessionId === connectionRef.current.sessionId &&
|
|
matchesUserMessageIdentity(
|
|
store
|
|
.getSnapshot()
|
|
.blocks.find(
|
|
(block) =>
|
|
block.kind === 'user' &&
|
|
block.id === failedMessage.messageId,
|
|
),
|
|
failedMessage.identity,
|
|
failedMessage.owner.snapshot.isCurrent(),
|
|
)
|
|
) {
|
|
updateFailedPrompt({
|
|
...failedMessage,
|
|
text: submittedPromptText,
|
|
images: promptImages,
|
|
files: promptFiles,
|
|
inputAnnotations: submittedInputAnnotations,
|
|
});
|
|
}
|
|
if (
|
|
!admissionStarted &&
|
|
(startedWithoutSession || !failedMessage)
|
|
) {
|
|
const editor = editorRef.current;
|
|
if (editor && !editor.hasInput()) {
|
|
editor.setText(submittedPromptText);
|
|
if (promptImages?.length) editor.restoreImages(promptImages);
|
|
if (promptFiles?.length) editor.restoreFiles(promptFiles);
|
|
if (submittedInputAnnotations?.length) {
|
|
editor.restoreInputAnnotations?.(submittedInputAnnotations);
|
|
}
|
|
}
|
|
}
|
|
reportError(error, errorMessage);
|
|
});
|
|
void attempt();
|
|
return clearComposerOnPromptStart ? false : true;
|
|
};
|
|
if (text.startsWith('/')) {
|
|
const match = text.match(SLASH_COMMAND_PATTERN);
|
|
if (match) {
|
|
const cmd = match[1];
|
|
const standaloneResumeSessionId =
|
|
cmd === 'resume' && effectiveSessionContext?.kind === 'standalone'
|
|
? text.slice(match[0].length).trim()
|
|
: '';
|
|
if (
|
|
!workspaceContextActive &&
|
|
NON_WORKSPACE_BLOCKED_COMMANDS.has(cmd) &&
|
|
!standaloneResumeSessionId
|
|
) {
|
|
pushToast('info', t('session.workspaceActionUnavailable'));
|
|
return true;
|
|
}
|
|
if (hiddenCommands.has(normalizeHiddenCommand(cmd))) {
|
|
if (commandBlocked) {
|
|
return enqueueBlockedCommand(text);
|
|
}
|
|
return submitPromptFromEditor(
|
|
text,
|
|
images,
|
|
files,
|
|
'Failed to send hidden slash command',
|
|
{ inputAnnotations: metadata?.inputAnnotations },
|
|
);
|
|
}
|
|
if (cmd === 'help') {
|
|
setShowHelpDialog(true);
|
|
return true;
|
|
}
|
|
if (cmd === 'diff') {
|
|
if (!gitDiffWorkspaceCwd) {
|
|
pushToast('info', t('localCommand.diffNoWorkspace'));
|
|
return true;
|
|
}
|
|
setGitDialog({
|
|
workspaceCwd: gitDiffWorkspaceCwd,
|
|
gitCwd: sessionWorktree?.path,
|
|
gitSessionId: sessionWorktree ? connection.sessionId : undefined,
|
|
view: 'diff',
|
|
});
|
|
return true;
|
|
}
|
|
if (cmd === 'log') {
|
|
if (!gitDiffWorkspaceCwd) {
|
|
pushToast('info', t('localCommand.logNoWorkspace'));
|
|
return true;
|
|
}
|
|
setGitDialog({
|
|
workspaceCwd: gitDiffWorkspaceCwd,
|
|
gitCwd: sessionWorktree?.path,
|
|
gitSessionId: sessionWorktree ? connection.sessionId : undefined,
|
|
view: 'log',
|
|
});
|
|
return true;
|
|
}
|
|
if (cmd === 'prs') {
|
|
if (!gitDiffWorkspaceCwd) {
|
|
pushToast('info', t('localCommand.prsNoWorkspace'));
|
|
return true;
|
|
}
|
|
if (!gitHubPrsSupported) {
|
|
pushToast('info', t('localCommand.prsUnsupported'));
|
|
return true;
|
|
}
|
|
setGitDialog({ workspaceCwd: gitDiffWorkspaceCwd, view: 'prs' });
|
|
return true;
|
|
}
|
|
if (cmd === 'tasks') {
|
|
openEnvironmentTasksPanel();
|
|
return true;
|
|
}
|
|
if (
|
|
cmd === 'workflows' &&
|
|
workspaceContextActive &&
|
|
workflowsEnabled &&
|
|
text.slice(match[0].length).trim().length === 0
|
|
) {
|
|
openWorkflows();
|
|
return true;
|
|
}
|
|
if (cmd === 'goal') {
|
|
return handleGoalSlashCommand(
|
|
text,
|
|
(images?.length ?? 0) > 0 ||
|
|
(files?.length ?? 0) > 0 ||
|
|
(metadata?.inputAnnotations?.length ?? 0) > 0,
|
|
commitComposerAccepted,
|
|
);
|
|
}
|
|
if (cmd === 'theme') {
|
|
const themeArg = text.slice(match[0].length).trim().toLowerCase();
|
|
if (themeArg === 'dark' || themeArg === 'light') {
|
|
handleThemeChange(themeArg);
|
|
} else if (!themeArg) {
|
|
setShowThemeDialog(true);
|
|
} else {
|
|
pushToast('error', t('error.unsupportedTheme'));
|
|
}
|
|
return true;
|
|
}
|
|
if (cmd === 'language') {
|
|
const args = text.slice(match[0].length).trim();
|
|
const [subCommand] = args.split(/\s+/);
|
|
if (!args) {
|
|
store.dispatch([
|
|
{
|
|
type: 'status',
|
|
promptId: `local-command:${crypto.randomUUID()}`,
|
|
text: [
|
|
t('language.current', {
|
|
language: languageLabel(selectedLanguage),
|
|
}),
|
|
t('language.usage'),
|
|
t('language.options'),
|
|
...WEB_SHELL_LANGUAGES.map(
|
|
(l) => ` - ${l}: ${languageLabel(l)}`,
|
|
),
|
|
].join('\n'),
|
|
},
|
|
]);
|
|
return true;
|
|
}
|
|
if (subCommand?.toLowerCase() === 'ui') {
|
|
const projectScope = /(?:^|\s)--project(?:\s|$)/.test(args);
|
|
const globalScope = /(?:^|\s)--global(?:\s|$)/.test(args);
|
|
if (projectScope && globalScope) {
|
|
pushToast('error', t('language.usage'));
|
|
return true;
|
|
}
|
|
const languageArg = args
|
|
.slice(subCommand.length)
|
|
.replace(/(?:^|\s)--project(?:\s|$)/, ' ')
|
|
.replace(/(?:^|\s)--global(?:\s|$)/, ' ')
|
|
.trim();
|
|
if (!languageArg) {
|
|
store.dispatch([
|
|
{
|
|
type: 'status',
|
|
promptId: `local-command:${crypto.randomUUID()}`,
|
|
text: [
|
|
t('language.set'),
|
|
'',
|
|
t('language.usage'),
|
|
'',
|
|
t('language.options'),
|
|
...WEB_SHELL_LANGUAGES.map(
|
|
(l) => ` - ${l}: ${languageLabel(l)}`,
|
|
),
|
|
].join('\n'),
|
|
},
|
|
]);
|
|
return true;
|
|
}
|
|
const nextLanguage =
|
|
languageSettingToWebShellLanguage(languageArg);
|
|
if (nextLanguage === undefined) {
|
|
pushToast('error', t('language.invalid'));
|
|
return true;
|
|
}
|
|
if (commandBlocked) return blockCommand();
|
|
if (projectScope && !uiLanguageWorkspaceCwd) {
|
|
pushToast('info', t('session.workspaceActionUnavailable'));
|
|
return true;
|
|
}
|
|
handleSettingsLanguageChange(
|
|
nextLanguage,
|
|
projectScope ? 'workspace' : 'user',
|
|
);
|
|
return true;
|
|
}
|
|
}
|
|
if (cmd === 'copy') {
|
|
const copyArg = text.slice(match[0].length).trim();
|
|
copyFromLastAssistantMessage(messagesRef.current, copyArg)
|
|
.then((result) => {
|
|
store.dispatch([
|
|
{
|
|
type: result.status === 'error' ? 'error' : 'status',
|
|
promptId: `local-command:${crypto.randomUUID()}`,
|
|
text: translateCopyMessage(result.message, t),
|
|
},
|
|
]);
|
|
})
|
|
.catch((error: unknown) => {
|
|
reportError(error, t('copy.failedFallback'));
|
|
});
|
|
return true;
|
|
}
|
|
if (cmd === 'delete') {
|
|
setShowDeleteDialog(true);
|
|
return true;
|
|
}
|
|
if (cmd === 'release') {
|
|
setShowReleaseDialog(true);
|
|
return true;
|
|
}
|
|
if (cmd === 'rewind') {
|
|
if (!requireActiveSessionForLocalCommand()) return false;
|
|
setShowRewindDialog(true);
|
|
return true;
|
|
}
|
|
if (cmd === 'branch') {
|
|
if (commandBlocked) return blockCommand();
|
|
const branchName = text.slice(match[0].length).trim();
|
|
branchCurrentSession({ name: branchName || undefined });
|
|
return true;
|
|
}
|
|
if (cmd === 'fork') {
|
|
if (commandBlocked) return blockCommand();
|
|
if (!requireActiveSessionForLocalCommand()) return false;
|
|
const directive = text.slice(match[0].length).trim();
|
|
if (!directive) {
|
|
pushToast('error', t('fork.empty'));
|
|
return true;
|
|
}
|
|
const owner = sessionOwnerGuard.capture();
|
|
sessionActions
|
|
.forkSession(directive)
|
|
.then((result) => {
|
|
if (!owner.isCurrent()) return;
|
|
if (!result.launched) {
|
|
pushToast('warning', t('fork.notStarted'));
|
|
return;
|
|
}
|
|
setBackgroundTasksRefreshTrigger((value) => value + 1);
|
|
pushToast(
|
|
'success',
|
|
t('fork.started', { name: result.description }),
|
|
);
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!owner.isCurrent()) return;
|
|
const reason =
|
|
error instanceof Error ? error.message : String(error);
|
|
reportError(error, t('fork.failed', { reason }));
|
|
});
|
|
return true;
|
|
}
|
|
if (cmd === 'auth') {
|
|
if (!modelManagementRef.current.allowAdd) {
|
|
pushToast('info', t('settings.models.addDisabled'));
|
|
return true;
|
|
}
|
|
// Take over only the surface this command owns: clearing an
|
|
// unrelated settings-launched key would disarm its exclusion
|
|
// force-close.
|
|
if (settingsDialogKeyRef.current === 'builtin:model-management') {
|
|
settingsDialogKeyRef.current = undefined;
|
|
}
|
|
setShowAuthDialog(true);
|
|
return true;
|
|
}
|
|
if (cmd === 'model') {
|
|
const modelArg = text.slice(match[0].length).trim();
|
|
if (
|
|
!workspaceContextActive &&
|
|
/^(?:--fast|--voice|--vision)(?:\s|$)/.test(modelArg)
|
|
) {
|
|
pushToast('info', t('session.workspaceActionUnavailable'));
|
|
return true;
|
|
}
|
|
if (modelArg === '--fast') {
|
|
if (settingsDialogKeyRef.current === 'fastModel') {
|
|
settingsDialogKeyRef.current = undefined;
|
|
}
|
|
setModelDialogMode('fast');
|
|
return true;
|
|
}
|
|
if (modelArg.startsWith('--fast ')) {
|
|
if (commandBlocked) {
|
|
return enqueueBlockedCommand(text);
|
|
}
|
|
return submitPromptFromEditor(
|
|
text,
|
|
images,
|
|
files,
|
|
'Failed to send /model --fast',
|
|
{ inputAnnotations: metadata?.inputAnnotations },
|
|
);
|
|
}
|
|
if (modelArg === '--voice') {
|
|
if (echoOrDeferLocalCommand(text, images)) return true;
|
|
void openVoiceModelPicker('workspace', 'command');
|
|
return true;
|
|
}
|
|
if (modelArg.startsWith('--voice ')) {
|
|
const voiceModelId = modelArg.replace(/^--voice\s+/, '');
|
|
void writeVoiceModelForTarget(voiceModelId, 'workspace').catch(
|
|
(error: unknown) => reportError(error, t('model.setVoice')),
|
|
);
|
|
return true;
|
|
}
|
|
if (modelArg === '--vision') {
|
|
if (settingsDialogKeyRef.current === 'visionModel') {
|
|
settingsDialogKeyRef.current = undefined;
|
|
}
|
|
setModelDialogMode('vision');
|
|
return true;
|
|
}
|
|
if (modelArg.startsWith('--vision ')) {
|
|
const visionModelId = modelArg.replace(/^--vision\s+/, '');
|
|
setWorkspaceSetting(
|
|
'workspace',
|
|
'visionModel',
|
|
visionModelId,
|
|
).catch((error: unknown) =>
|
|
reportError(error, t('model.setVision')),
|
|
);
|
|
return true;
|
|
}
|
|
if (modelArg) {
|
|
if (!connectionRef.current.sessionId) {
|
|
selectWelcomeModel(modelArg);
|
|
return true;
|
|
}
|
|
const owner = sessionOwnerGuard.capture();
|
|
sessionActions
|
|
.setModel(modelArg)
|
|
.then(() => {
|
|
if (!owner.isCurrent()) return;
|
|
setPendingModel(modelArg);
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!owner.isCurrent()) return;
|
|
reportError(error, t('model.switch'));
|
|
});
|
|
} else {
|
|
if (
|
|
isStandaloneModelPickerUnavailable({
|
|
sessionId: connectionRef.current.sessionId,
|
|
sessionContextKind: effectiveSessionContext?.kind,
|
|
models: connectionRef.current.models,
|
|
})
|
|
) {
|
|
pushToast('info', t('model.unavailable'));
|
|
return true;
|
|
}
|
|
setModelDialogMode('main');
|
|
}
|
|
return true;
|
|
}
|
|
if (cmd === 'plan') {
|
|
if (modeTransitionRef.current?.owner.isCurrent()) {
|
|
pushToast('warning', t('mode.changePending'));
|
|
return false;
|
|
}
|
|
const operation = parsePlanCommand(
|
|
text.slice(match[0].length),
|
|
currentModeRef.current === 'plan',
|
|
);
|
|
const { prompt } = operation;
|
|
if (prompt && refuseModelSetup(prompt)) return true;
|
|
if (prompt && commandBlocked) return blockCommand();
|
|
if (!connectionRef.current.sessionId) {
|
|
void setComposerMode(executionModeRef.current, operation.enabled);
|
|
if (prompt)
|
|
return submitPromptFromEditor(
|
|
prompt,
|
|
images,
|
|
files,
|
|
'Failed to send plan prompt',
|
|
{ inputAnnotations: metadata?.inputAnnotations },
|
|
);
|
|
return true;
|
|
}
|
|
const planPreparationToken = prompt
|
|
? ++planPreparationTokenRef.current
|
|
: undefined;
|
|
const preparationOwner = prompt
|
|
? beginPromptPreparation()
|
|
: undefined;
|
|
const owner = sessionOwnerGuard.capture();
|
|
const writeBlockGeneration = sessionWriteBlockGenerationRef.current;
|
|
void setComposerMode(executionModeRef.current, operation.enabled)
|
|
.then((applied) => {
|
|
if (
|
|
applied &&
|
|
owner.isCurrent() &&
|
|
prompt &&
|
|
!sessionWriteBlockedRef.current &&
|
|
sessionWriteBlockGenerationRef.current ===
|
|
writeBlockGeneration
|
|
) {
|
|
return sendPrompt(prompt, images, files, {
|
|
submittedPrompt: text,
|
|
clearComposerOnPromptStart: true,
|
|
inputAnnotations: metadata?.inputAnnotations,
|
|
});
|
|
}
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (owner.isCurrent())
|
|
reportError(error, 'Failed to send plan prompt');
|
|
})
|
|
.finally(() => {
|
|
if (
|
|
prompt &&
|
|
planPreparationTokenRef.current === planPreparationToken
|
|
)
|
|
finishPromptPreparation(preparationOwner);
|
|
});
|
|
return prompt ? false : true;
|
|
}
|
|
if (cmd === 'approval-mode') {
|
|
const modeArg = text.slice(match[0].length).trim();
|
|
if (modeArg) {
|
|
handleSetMode(modeArg);
|
|
} else {
|
|
setShowApprovalModeDialog(true);
|
|
}
|
|
return true;
|
|
}
|
|
if (cmd === 'mcp') {
|
|
const mcpArg = text.slice(match[0].length).trim().toLowerCase();
|
|
workspaceActions
|
|
.loadMcpStatus()
|
|
.then((status) => {
|
|
setMcpDialogMessage({
|
|
status,
|
|
toolsByServer: {},
|
|
resourcesByServer: {},
|
|
showDescriptions: mcpArg === 'desc',
|
|
showSchema: mcpArg === 'schema',
|
|
showTips: !mcpArg,
|
|
});
|
|
openPanel('mcp');
|
|
})
|
|
.catch((error: unknown) => {
|
|
reportError(error, 'Failed to load MCP status');
|
|
});
|
|
return true;
|
|
}
|
|
if (cmd === 'skills') {
|
|
const skillArg = text.slice(match[0].length).trim();
|
|
if (!skillArg || skillArg === 'detail' || skillArg === 'details') {
|
|
openPanel('skills');
|
|
} else {
|
|
const skillPrompt = `/${skillArg}`;
|
|
if (commandBlocked) {
|
|
return enqueueBlockedCommand(skillPrompt);
|
|
}
|
|
return submitPromptFromEditor(
|
|
skillPrompt,
|
|
images,
|
|
files,
|
|
'Failed to send /skills command',
|
|
{ inputAnnotations: metadata?.inputAnnotations },
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
if (cmd === 'tools') {
|
|
const toolsArg = text.slice(match[0].length).trim().toLowerCase();
|
|
if (toolsArg === 'desc' || toolsArg === 'descriptions') {
|
|
setShowToolsDialog(true);
|
|
} else {
|
|
const localCommandId = `local-command:echo:${crypto.randomUUID()}`;
|
|
if (echoOrDeferLocalCommand(text, images, localCommandId))
|
|
return true;
|
|
const owner = sessionOwnerGuard.capture();
|
|
beginLocalCommand(store, localCommandId);
|
|
workspaceActions
|
|
.loadToolsStatus()
|
|
.then((status) => {
|
|
if (!owner.isCurrent()) return;
|
|
const tools = status?.tools ?? [];
|
|
const list = tools
|
|
.map((tool) => `- ${tool.displayName || tool.name}`)
|
|
.join('\n');
|
|
dispatchReadOnlyStatus(
|
|
tools.length
|
|
? `${t('tools.available')}\n\n${list}`
|
|
: t('tools.none'),
|
|
undefined,
|
|
localCommandId,
|
|
);
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!owner.isCurrent()) return;
|
|
dispatchReadOnlyStatus(
|
|
t('messageDelete.commandFailed'),
|
|
undefined,
|
|
localCommandId,
|
|
);
|
|
reportError(error, 'Failed to load tools');
|
|
})
|
|
.finally(() => finishLocalCommand(store, localCommandId));
|
|
}
|
|
return true;
|
|
}
|
|
if (cmd === 'settings') {
|
|
openPanel('settings');
|
|
return true;
|
|
}
|
|
if (cmd === 'schedule') {
|
|
openScheduledTasks();
|
|
return true;
|
|
}
|
|
if (cmd === 'context') {
|
|
const contextArg = text.slice(match[0].length).trim().toLowerCase();
|
|
if (
|
|
contextArg === '' ||
|
|
contextArg === 'detail' ||
|
|
contextArg === '-d'
|
|
) {
|
|
showContextUsage(
|
|
text,
|
|
contextArg === 'detail' || contextArg === '-d',
|
|
);
|
|
return true;
|
|
}
|
|
}
|
|
if (cmd === 'memory') {
|
|
const memoryArg = text.slice(match[0].length).trim().toLowerCase();
|
|
if (memoryArg === 'refresh') {
|
|
setMemoryRefreshSignal((signal) => signal + 1);
|
|
} else if (memoryArg === 'add' || memoryArg.startsWith('add ')) {
|
|
const addTarget = memoryArg.slice('add'.length).trim();
|
|
setMemoryAddScope(
|
|
addTarget === 'user' || addTarget === 'global'
|
|
? 'global'
|
|
: 'workspace',
|
|
);
|
|
setMemoryAddSignal((signal) => signal + 1);
|
|
}
|
|
setShowMemoryDialog(true);
|
|
return true;
|
|
}
|
|
if (cmd === 'agents') {
|
|
const subCommand = text.slice(match[0].length).trim().toLowerCase();
|
|
if (subCommand === 'create') {
|
|
setAgentsCreateScope('global');
|
|
} else if (
|
|
subCommand === 'create user' ||
|
|
subCommand === 'create global'
|
|
) {
|
|
setAgentsCreateScope('global');
|
|
} else if (
|
|
subCommand === 'create project' ||
|
|
subCommand === 'create workspace'
|
|
) {
|
|
setAgentsCreateScope('workspace');
|
|
} else {
|
|
setAgentsCreateScope(null);
|
|
}
|
|
openPanel('agents');
|
|
return true;
|
|
}
|
|
if (cmd === 'extensions') {
|
|
const args = text.slice(match[0].length).trim();
|
|
const subCommand = args.split(/\s+/)[0]?.toLowerCase();
|
|
if (!subCommand || subCommand === 'manage') {
|
|
openPanel('extensions');
|
|
return true;
|
|
}
|
|
if (subCommand === 'install') {
|
|
// Install echoes into the transcript (and its error/usage replies
|
|
// do too); block it mid-turn so it can't split the active turn.
|
|
if (commandBlocked) return blockCommand();
|
|
const tokens = args.slice('install'.length).trim().split(/\s+/);
|
|
let source = '';
|
|
let ref: string | undefined;
|
|
let registry: string | undefined;
|
|
let autoUpdate: boolean | undefined;
|
|
let allowPreRelease: boolean | undefined;
|
|
let parseError: string | null = null;
|
|
for (let index = 0; index < tokens.length; index++) {
|
|
const token = tokens[index];
|
|
if (!token) continue;
|
|
if (token === '--auto-update') {
|
|
autoUpdate = true;
|
|
} else if (
|
|
token === '--pre-release' ||
|
|
token === '--allow-pre-release'
|
|
) {
|
|
allowPreRelease = true;
|
|
} else if (token === '--ref' || token === '--registry') {
|
|
const value = tokens[index + 1];
|
|
if (!value || value.startsWith('--')) {
|
|
parseError = t('extensions.install.missingOptionValue', {
|
|
option: token,
|
|
});
|
|
break;
|
|
}
|
|
if (token === '--ref') {
|
|
ref = value;
|
|
} else {
|
|
registry = value;
|
|
}
|
|
index += 1;
|
|
} else if (token.startsWith('--')) {
|
|
parseError = t('extensions.install.unknownOption', {
|
|
option: token,
|
|
});
|
|
break;
|
|
} else if (!source) {
|
|
source = token;
|
|
} else {
|
|
parseError = t('extensions.install.usage');
|
|
break;
|
|
}
|
|
}
|
|
const localCommandId = `local-command:${crypto.randomUUID()}`;
|
|
if (parseError) {
|
|
store.appendLocalUserMessage(text, undefined, {
|
|
localCommandId,
|
|
});
|
|
store.dispatch([
|
|
{ type: 'error', promptId: localCommandId, text: parseError },
|
|
]);
|
|
return true;
|
|
}
|
|
if (!source) {
|
|
store.appendLocalUserMessage(text, undefined, {
|
|
localCommandId,
|
|
});
|
|
store.dispatch([
|
|
{
|
|
type: 'error',
|
|
promptId: localCommandId,
|
|
text: t('extensions.install.usage'),
|
|
},
|
|
]);
|
|
return true;
|
|
}
|
|
const clientId = connectionRef.current.clientId;
|
|
if (!clientId) {
|
|
pushToast('warning', t('extensions.install.waitForSession'));
|
|
return true;
|
|
}
|
|
store.appendLocalUserMessage(text, undefined, { localCommandId });
|
|
store.dispatch([
|
|
{
|
|
type: 'status',
|
|
promptId: localCommandId,
|
|
text: t('extensions.install.started', { source }),
|
|
},
|
|
]);
|
|
workspaceActions
|
|
.installExtension(
|
|
{
|
|
source,
|
|
...(ref ? { ref } : {}),
|
|
...(registry ? { registry } : {}),
|
|
...(autoUpdate !== undefined ? { autoUpdate } : {}),
|
|
...(allowPreRelease !== undefined
|
|
? { allowPreRelease }
|
|
: {}),
|
|
consent: true,
|
|
},
|
|
clientId,
|
|
)
|
|
.then(() => undefined)
|
|
.catch((error: unknown) => {
|
|
reportError(error, t('extensions.install.requestFailed'));
|
|
});
|
|
return true;
|
|
}
|
|
const localCommandId = `local-command:echo:${crypto.randomUUID()}`;
|
|
if (echoOrDeferLocalCommand(text, images, localCommandId))
|
|
return true;
|
|
store.dispatch([
|
|
{
|
|
type: 'error',
|
|
promptId: localCommandId,
|
|
text: t('extensions.install.usage'),
|
|
},
|
|
]);
|
|
return true;
|
|
}
|
|
if (cmd === 'clear') {
|
|
const current = connectionRef.current;
|
|
const summary = currentSessionSummaryRef.current;
|
|
const carryManualTitle = current.sessionId
|
|
? current.titleSource === 'manual'
|
|
? current.displayName
|
|
: current.titleSource === undefined &&
|
|
summary?.sessionId === current.sessionId &&
|
|
summary.titleSource === 'manual'
|
|
? summary.displayName
|
|
: current.titleSource === undefined
|
|
? pendingManualTitleRef.current?.displayName
|
|
: undefined
|
|
: pendingManualTitleRef.current?.displayName;
|
|
void createNewSession(
|
|
{ kind: 'inherit' },
|
|
carryManualTitle?.trim() ? { carryManualTitle } : undefined,
|
|
);
|
|
return true;
|
|
}
|
|
if (cmd === 'new' || cmd === 'reset') {
|
|
void createNewSession({ kind: 'inherit' });
|
|
return true;
|
|
}
|
|
if (cmd === 'rename') {
|
|
pendingManualTitleRef.current = undefined;
|
|
const renameArg = parseRenameArgument(text.slice(match[0].length));
|
|
if (renameArg.type === 'auto' || renameArg.type === 'delegate') {
|
|
// `/rename --auto` renames the current session; without one it
|
|
// must not provision a session just to title it with the command.
|
|
if (!requireActiveSessionForLocalCommand()) return false;
|
|
if (commandBlocked) {
|
|
return enqueueBlockedCommand(text);
|
|
}
|
|
return submitPromptFromEditor(
|
|
text,
|
|
images,
|
|
files,
|
|
'Failed to send /rename command',
|
|
{ inputAnnotations: metadata?.inputAnnotations },
|
|
);
|
|
}
|
|
const displayName = renameArg.displayName;
|
|
if (!displayName) {
|
|
pushToast('error', t('rename.empty'));
|
|
return true;
|
|
}
|
|
if (!requireActiveSessionForLocalCommand()) return false;
|
|
const renamedSessionId = connectionRef.current.sessionId;
|
|
const renamedWorkspaceCwd = connectionRef.current.workspaceCwd;
|
|
const owner = sessionOwnerGuard.capture();
|
|
const renamePromise =
|
|
effectiveSessionContext?.kind === 'standalone' && renamedSessionId
|
|
? workspace.client.renameStandaloneSession(
|
|
renamedSessionId,
|
|
displayName,
|
|
)
|
|
: sessionActions.renameSession(displayName);
|
|
renamePromise
|
|
.then(() => {
|
|
if (renamedSessionId) {
|
|
reconcileCatalogRename(
|
|
workspaceContextActive ? renamedWorkspaceCwd : undefined,
|
|
renamedSessionId,
|
|
displayName,
|
|
);
|
|
}
|
|
if (!owner.isCurrent()) return;
|
|
store.dispatch([
|
|
{
|
|
type: 'status',
|
|
promptId: `local-command:${crypto.randomUUID()}`,
|
|
text: t('rename.success', { name: displayName }),
|
|
},
|
|
]);
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (workspaceContextActive && renamedWorkspaceCwd) {
|
|
sessionCatalogController.invalidateWorkspace(
|
|
renamedWorkspaceCwd,
|
|
);
|
|
}
|
|
if (!owner.isCurrent()) return;
|
|
reportError(error, 'Failed to rename session');
|
|
});
|
|
return true;
|
|
}
|
|
if (cmd === 'resume') {
|
|
const sessionId = text.slice(match[0].length).trim();
|
|
if (sessionId) {
|
|
loadSidebarSession(
|
|
sessionId,
|
|
undefined,
|
|
effectiveSessionContext?.kind === 'standalone'
|
|
? { kind: 'standalone' }
|
|
: undefined,
|
|
).catch((error: unknown) => {
|
|
reportError(error, 'Failed to load session');
|
|
});
|
|
} else {
|
|
closeMobileDrawer();
|
|
setShowResumeDialog(true);
|
|
}
|
|
return true;
|
|
}
|
|
if (cmd === 'recap') {
|
|
runVisibleRecap();
|
|
return true;
|
|
}
|
|
if (cmd === 'btw') {
|
|
const rawQuestion = text.slice(match[0].length).trim();
|
|
const sideTaskMatch = /^side(?:\s+|$)/i.exec(rawQuestion);
|
|
if (sideTasksAvailable && sideTaskMatch) {
|
|
const question = rawQuestion
|
|
.slice(sideTaskMatch[0].length)
|
|
.trim();
|
|
if (!question) {
|
|
pushToast('error', t('btw.side.empty'));
|
|
return true;
|
|
}
|
|
// Refuse before a session is provisioned; the panel's
|
|
// initial-prompt guard stays as the backstop for tabs created
|
|
// before a policy flip.
|
|
if (
|
|
!modelManagementRef.current.allowAdd &&
|
|
isModelSetupCommand(question, connectionRef.current.commands)
|
|
) {
|
|
pushToast('info', t('settings.models.addDisabled'));
|
|
return true;
|
|
}
|
|
createSideTask(question);
|
|
return true;
|
|
}
|
|
runVisibleBtw(rawQuestion);
|
|
return true;
|
|
}
|
|
if (cmd === 'stats') {
|
|
const statsArg = text.slice(match[0].length).trim().toLowerCase();
|
|
let statsView: StatsView = 'overview';
|
|
if (statsArg === 'model') statsView = 'model';
|
|
else if (statsArg === 'tools') statsView = 'tools';
|
|
if (!requireActiveSessionForLocalCommand()) return false;
|
|
const owner = sessionOwnerGuard.capture();
|
|
const localCommandId = echoLocalCommandIfIdle(text);
|
|
sessionActions
|
|
.getStats()
|
|
.then((result) => {
|
|
if (!owner.isCurrent()) return;
|
|
dispatchReadOnlyStatus(
|
|
t('stats.title'),
|
|
createStatsMessageData(result, statsView),
|
|
localCommandId,
|
|
);
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!owner.isCurrent()) return;
|
|
dispatchReadOnlyStatus(
|
|
t('messageDelete.commandFailed'),
|
|
undefined,
|
|
localCommandId,
|
|
);
|
|
reportError(error, 'Failed to load stats');
|
|
})
|
|
.finally(() => finishLocalCommand(store, localCommandId));
|
|
return true;
|
|
}
|
|
if (cmd === 'status' || cmd === 'about') {
|
|
const owner = sessionOwnerGuard.capture();
|
|
const localCommandId = echoLocalCommandIfIdle(text);
|
|
(workspaceContextActive
|
|
? Promise.all([
|
|
workspaceActions.loadPreflight().catch(() => null),
|
|
workspaceActions.loadProviders().catch(() => null),
|
|
workspaceActions.loadEnv().catch(() => null),
|
|
])
|
|
: Promise.resolve([null, null, null] as const)
|
|
)
|
|
.then(([preflight, providers, env]) => {
|
|
if (!owner.isCurrent()) return;
|
|
const sys = collectSystemInfo(preflight, env);
|
|
|
|
let authSource = sys.authSource;
|
|
if (!authSource && providers?.current?.authType) {
|
|
authSource = providers.current.authType;
|
|
}
|
|
|
|
const runtimeParts: string[] = [];
|
|
if (sys.nodeVersion)
|
|
runtimeParts.push(`Node.js v${sys.nodeVersion}`);
|
|
if (sys.npmVersion) runtimeParts.push(`npm ${sys.npmVersion}`);
|
|
|
|
let formattedAuth = '';
|
|
if (authSource) {
|
|
if (authSource === 'qwen-subscription') {
|
|
formattedAuth = 'Qwen Subscription';
|
|
} else if (
|
|
authSource.startsWith('oauth') ||
|
|
authSource === 'qwen-oauth'
|
|
) {
|
|
formattedAuth = 'Qwen OAuth';
|
|
} else {
|
|
formattedAuth = `API Key - ${authSource}`;
|
|
}
|
|
}
|
|
|
|
const platformStr = `${sys.platform} ${sys.arch}`.trim();
|
|
const curModel = currentModelRef.current;
|
|
const conn = connectionRef.current;
|
|
const qwenCodeVersion =
|
|
conn.capabilities?.qwenCodeVersion || '';
|
|
const info: StatusInfo = {
|
|
cliVersion: qwenCodeVersion,
|
|
runtime: runtimeParts.join(' / '),
|
|
platform: platformStr,
|
|
auth: formattedAuth,
|
|
baseUrl: providers?.current?.baseUrl || '',
|
|
model:
|
|
curModel ||
|
|
conn.currentModel ||
|
|
providers?.current?.modelId ||
|
|
'',
|
|
fastModel:
|
|
providers?.current?.fastModelId ||
|
|
curModel ||
|
|
conn.currentModel ||
|
|
providers?.current?.modelId ||
|
|
'',
|
|
sessionId: conn.sessionId || '',
|
|
sandbox: sys.sandbox,
|
|
proxy: sys.proxy,
|
|
memoryUsage: sys.memoryUsage,
|
|
};
|
|
|
|
dispatchReadOnlyStatus(
|
|
serializeStatusMessage(info),
|
|
undefined,
|
|
localCommandId,
|
|
);
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (owner.isCurrent())
|
|
dispatchReadOnlyStatus(
|
|
t('messageDelete.commandFailed'),
|
|
undefined,
|
|
localCommandId,
|
|
);
|
|
reportError(error, 'Failed to load status info');
|
|
})
|
|
.finally(() => finishLocalCommand(store, localCommandId));
|
|
return true;
|
|
}
|
|
if (cmd === 'bug') {
|
|
const bugTitle = text.slice(match[0].length).trim();
|
|
const localCommandId = `local-command:${crypto.randomUUID()}`;
|
|
if (echoOrDeferLocalCommand(text, images, localCommandId))
|
|
return true;
|
|
const owner = sessionOwnerGuard.capture();
|
|
beginLocalCommand(store, localCommandId);
|
|
(workspaceContextActive
|
|
? Promise.all([
|
|
workspaceActions.loadPreflight().catch(() => null),
|
|
workspaceActions.loadEnv().catch(() => null),
|
|
])
|
|
: Promise.resolve([null, null] as const)
|
|
)
|
|
.then(([preflight, env]) => {
|
|
const sys = collectSystemInfo(preflight, env);
|
|
const qwenCodeVersion =
|
|
connectionRef.current.capabilities?.qwenCodeVersion || '';
|
|
const sysInfo: Record<string, string> = {};
|
|
if (qwenCodeVersion) sysInfo.cliVersion = qwenCodeVersion;
|
|
if (sys.nodeVersion) sysInfo.nodeVersion = sys.nodeVersion;
|
|
if (sys.npmVersion) sysInfo.npmVersion = sys.npmVersion;
|
|
if (sys.platform) sysInfo.platform = sys.platform;
|
|
if (sys.arch) sysInfo.arch = sys.arch;
|
|
if (sys.sandbox) sysInfo.sandbox = sys.sandbox;
|
|
if (sys.memoryUsage) sysInfo.memoryUsage = sys.memoryUsage;
|
|
if (onBugReportRef.current) {
|
|
onBugReportRef.current({
|
|
title: bugTitle,
|
|
systemInfo: sysInfo,
|
|
});
|
|
if (owner.isCurrent())
|
|
dispatchReadOnlyStatus(
|
|
t('bug.submitted'),
|
|
undefined,
|
|
localCommandId,
|
|
);
|
|
} else {
|
|
const fields = Object.entries(sysInfo)
|
|
.filter(([, v]) => v)
|
|
.map(([k, v]) => `${k}: ${v}`)
|
|
.join('\n');
|
|
const url =
|
|
`https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml` +
|
|
`&title=${encodeURIComponent(bugTitle)}` +
|
|
`&info=${encodeURIComponent('\n' + fields + '\n')}`;
|
|
const win = window.open(url, '_blank');
|
|
if (win) {
|
|
win.opener = null;
|
|
if (owner.isCurrent())
|
|
dispatchReadOnlyStatus(
|
|
t('bug.submitted'),
|
|
undefined,
|
|
localCommandId,
|
|
);
|
|
} else {
|
|
if (owner.isCurrent())
|
|
dispatchReadOnlyStatus(
|
|
t('bug.popupBlocked'),
|
|
undefined,
|
|
localCommandId,
|
|
);
|
|
if (owner.isCurrent())
|
|
pushToast('error', t('bug.popupBlocked'));
|
|
}
|
|
}
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (owner.isCurrent())
|
|
dispatchReadOnlyStatus(
|
|
t('messageDelete.commandFailed'),
|
|
undefined,
|
|
localCommandId,
|
|
);
|
|
reportError(error, t('bug.failed'));
|
|
})
|
|
.finally(() => finishLocalCommand(store, localCommandId));
|
|
return true;
|
|
}
|
|
}
|
|
// Forward slash commands as prompts
|
|
if (commandBlocked) {
|
|
return enqueueBlockedCommand(text);
|
|
}
|
|
return submitPromptFromEditor(
|
|
text,
|
|
images,
|
|
files,
|
|
'Failed to send command',
|
|
{
|
|
inputAnnotations: metadata?.inputAnnotations,
|
|
},
|
|
);
|
|
} else if (!daemonAvailableRef.current) {
|
|
// Local slash commands above stay reachable during an outage; only
|
|
// network submissions are gated.
|
|
return false;
|
|
} else if (text.startsWith('!')) {
|
|
const cmd = text.slice(1).trim();
|
|
if (!cmd) return false;
|
|
if (streamingStateRef.current !== 'idle') {
|
|
queuedShellCommandsRef.current.push(cmd);
|
|
pushToast('info', t('queue.shellQueued'));
|
|
return true;
|
|
}
|
|
const needsSession = !connectionRef.current.sessionId;
|
|
let shellPromptPreparationOwner: symbol | undefined;
|
|
if (needsSession) {
|
|
if (shellSubmitInFlightRef.current) return false;
|
|
shellSubmitInFlightRef.current = true;
|
|
shellPromptPreparationOwner = beginPromptPreparation();
|
|
}
|
|
let sessionCreated = false;
|
|
const generationAtSubmit = drainGenerationRef.current;
|
|
void ensureSessionForPrompt()
|
|
.finally(() => {
|
|
if (needsSession) {
|
|
finishPromptPreparation(shellPromptPreparationOwner);
|
|
shellSubmitInFlightRef.current = false;
|
|
}
|
|
})
|
|
.then((createdSessionId) => {
|
|
if (drainGenerationRef.current !== generationAtSubmit) return;
|
|
if (needsSession && createdSessionId) {
|
|
sessionCreated = true;
|
|
if (commitComposerAccepted) {
|
|
commitComposerAccepted();
|
|
} else {
|
|
editorRef.current?.clear();
|
|
}
|
|
dispatchSessionChangeRef.current?.({
|
|
type: 'submit',
|
|
sessionId: createdSessionId,
|
|
prompt: `!${cmd}`,
|
|
queued: false,
|
|
});
|
|
}
|
|
const allocatedOwner = allocatedSessionCatalogOwnerRef.current;
|
|
const workspaceCwd = createdSessionId
|
|
? allocatedOwner?.sessionId === createdSessionId
|
|
? allocatedOwner.workspaceCwd
|
|
: undefined
|
|
: getComposerWorkspaceCwd();
|
|
return sessionActions.sendShellCommand(cmd).finally(() => {
|
|
if (workspaceCwd) {
|
|
sessionCatalogController.invalidateWorkspace(workspaceCwd);
|
|
}
|
|
});
|
|
})
|
|
.catch((error: unknown) => {
|
|
reportError(
|
|
error,
|
|
needsSession && !sessionCreated
|
|
? 'Failed to create session for shell command'
|
|
: 'Failed to execute shell command',
|
|
);
|
|
});
|
|
return !needsSession;
|
|
} else {
|
|
if (sessionActive) {
|
|
return enqueuePrompt(
|
|
text,
|
|
images,
|
|
files,
|
|
undefined,
|
|
commitComposerAccepted,
|
|
metadata?.inputAnnotations,
|
|
);
|
|
}
|
|
return submitPromptFromEditor(
|
|
text,
|
|
images,
|
|
files,
|
|
'Failed to send message',
|
|
{
|
|
inputAnnotations: metadata?.inputAnnotations,
|
|
trackSendFailure: true,
|
|
},
|
|
);
|
|
}
|
|
},
|
|
[
|
|
offerCapacityRecovery,
|
|
beginPromptPreparation,
|
|
refuseModelSetup,
|
|
sendPrompt,
|
|
sessionActions,
|
|
sessionOwnerGuard,
|
|
store,
|
|
enqueuePrompt,
|
|
echoOrDeferLocalCommand,
|
|
echoLocalCommandIfIdle,
|
|
dispatchReadOnlyStatus,
|
|
branchCurrentSession,
|
|
connection.sessionId,
|
|
closeMobileDrawer,
|
|
openPanel,
|
|
openScheduledTasks,
|
|
openWorkflows,
|
|
workflowsEnabled,
|
|
createNewSession,
|
|
ensureSessionForPrompt,
|
|
finishPromptPreparation,
|
|
getComposerWorkspaceCwd,
|
|
sessionCatalogController,
|
|
gitDiffWorkspaceCwd,
|
|
sessionWorktree,
|
|
gitHubPrsSupported,
|
|
handleGoalSlashCommand,
|
|
handleSubscriptionLogout,
|
|
handleThemeChange,
|
|
handleSetMode,
|
|
setComposerMode,
|
|
handleSettingsLanguageChange,
|
|
uiLanguageWorkspaceCwd,
|
|
blockCommand,
|
|
createSideTask,
|
|
sideTasksAvailable,
|
|
openEnvironmentTasksPanel,
|
|
hiddenCommands,
|
|
loadSidebarSession,
|
|
pushToast,
|
|
reportError,
|
|
runVisibleRecap,
|
|
runVisibleBtw,
|
|
reconcileCatalogRename,
|
|
requireActiveSessionForLocalCommand,
|
|
selectedLanguage,
|
|
setPendingModel,
|
|
selectWelcomeModel,
|
|
setWorkspaceSetting,
|
|
openVoiceModelPicker,
|
|
writeVoiceModelForTarget,
|
|
showContextUsage,
|
|
t,
|
|
workspaceActions,
|
|
workspace.client,
|
|
workspaceContextActive,
|
|
composerAttachmentsEnabled,
|
|
effectiveSessionContext,
|
|
updateFailedPrompt,
|
|
updateUnknownPromptAdmission,
|
|
isGoalGateBlocked,
|
|
],
|
|
);
|
|
|
|
const handleSubmitRef = useRef(handleSubmit);
|
|
handleSubmitRef.current = handleSubmit;
|
|
const handleEditorSubmit = useCallback(
|
|
(
|
|
text: string,
|
|
images?: PromptImage[],
|
|
files?: PromptFile[],
|
|
commitComposerAccepted?: ComposerSubmitCommit,
|
|
metadata?: ComposerSubmitMetadata,
|
|
) => {
|
|
const accepted = handleSubmitRef.current(
|
|
text,
|
|
images,
|
|
files,
|
|
commitComposerAccepted,
|
|
metadata,
|
|
);
|
|
if (accepted !== false) {
|
|
resumeChatBottomFollow('smooth');
|
|
}
|
|
return accepted;
|
|
},
|
|
[resumeChatBottomFollow],
|
|
);
|
|
|
|
const handleAskUserConfirm = useCallback(
|
|
(id: string, selectedOption: string, answers?: Record<string, string>) =>
|
|
sessionActions.submitPermission(id, selectedOption, answers),
|
|
[sessionActions],
|
|
);
|
|
|
|
// Runs once the daemon has settled the cancelled turn, so the transcript
|
|
// holds everything the turn produced.
|
|
const takeBackCancelledPrompt = useCallback(
|
|
async ({
|
|
prompt: cancelled,
|
|
sessionId,
|
|
owner,
|
|
historyComplete,
|
|
}: CancelledPromptTakeBack) => {
|
|
const isCurrent = () =>
|
|
appMountedRef.current &&
|
|
owner.isCurrent() &&
|
|
connectionRef.current.sessionId === sessionId;
|
|
// The turn index of the cancelled prompt while it is still the newest
|
|
// turn and nothing but thoughts and notices came after it.
|
|
const bareTurnIndex = () => {
|
|
if (!isCurrent()) return undefined;
|
|
const blocks = store.getSnapshot().blocks;
|
|
const prompt = getLatestUserBlock(blocks);
|
|
return prompt &&
|
|
matchesUserMessageIdentity(
|
|
prompt,
|
|
{ block: cancelled.block },
|
|
true,
|
|
) &&
|
|
cancelledTurnProducedNothing(blocks.slice(blocks.indexOf(prompt) + 1))
|
|
? countUserTurns(blocks) - 1
|
|
: undefined;
|
|
};
|
|
const turnIndex = bareTurnIndex();
|
|
const editor = editorRef.current;
|
|
if (
|
|
turnIndex === undefined ||
|
|
sessionWriteBlockedRef.current ||
|
|
!editor ||
|
|
editor.hasInput()
|
|
) {
|
|
return;
|
|
}
|
|
editor.setText(cancelled.text);
|
|
if (cancelled.images?.length) editor.restoreImages(cancelled.images);
|
|
if (cancelled.files?.length) editor.restoreFiles(cancelled.files);
|
|
if (cancelled.inputAnnotations?.length) {
|
|
editor.restoreInputAnnotations?.(cancelled.inputAnnotations);
|
|
}
|
|
// A partial transcript cannot tell which turn the prompt was, so the
|
|
// prompt goes back to the composer but stays in history.
|
|
if (!historyComplete) return;
|
|
// Hold prompts from here until the rewind has shown up in the
|
|
// transcript or is known not to happen.
|
|
let lifted = false;
|
|
const pending = {
|
|
sessionKey: logicalSessionKey,
|
|
turnIndex,
|
|
owner,
|
|
recover: () => {
|
|
lifted = true;
|
|
},
|
|
};
|
|
setPendingRewind(pending);
|
|
const release = () => {
|
|
lifted = true;
|
|
setPendingRewind((current) => (current === pending ? null : current));
|
|
};
|
|
const listSnapshots = () =>
|
|
sessionActions.getRewindSnapshots({ silent: true });
|
|
let rewound = false;
|
|
let target: string | undefined;
|
|
try {
|
|
const { snapshots } = await listSnapshots();
|
|
// Only ever rewind the daemon's newest turn: a prompt cancelled before
|
|
// it reached the model has no snapshot of its own, and the newest one
|
|
// then belongs to the turn before it.
|
|
const newest = snapshots[snapshots.length - 1];
|
|
const blocks = store.getSnapshot().blocks;
|
|
const current = getLatestUserBlock(blocks);
|
|
if (
|
|
bareTurnIndex() === undefined ||
|
|
!newest?.daemonPromptId ||
|
|
!current?.promptId ||
|
|
newest.daemonPromptId !== current.promptId ||
|
|
findDaemonRewindUserBlock(blocks, current.promptId)?.id !==
|
|
current.id ||
|
|
snapshots.filter((entry) => entry.daemonPromptId === current.promptId)
|
|
.length !== 1
|
|
)
|
|
return;
|
|
target = newest.promptId;
|
|
await sessionActions.rewindSession(target, {
|
|
rewindFiles: false,
|
|
silent: true,
|
|
});
|
|
// `pendingRewind` releases itself once the transcript shows the rewind.
|
|
rewound = true;
|
|
} catch (error) {
|
|
// Nobody asked for a rewind, so a failed one is not worth a toast:
|
|
// the prompt is back in the composer and simply stays in history. A
|
|
// daemon refusal (an SSH workspace, say) did not rewind. Any other
|
|
// failure of an issued rewind leaves it open whether the daemon
|
|
// applied it, and a wait proves nothing: prompts stay held until
|
|
// the daemon says the turn is still there or the transcript shows
|
|
// it gone.
|
|
if (target !== undefined && !(error instanceof DaemonHttpError)) {
|
|
rewound = !(await rewindMissed(
|
|
target,
|
|
listSnapshots,
|
|
() => isCurrent() && !lifted,
|
|
));
|
|
}
|
|
} finally {
|
|
if (!rewound) release();
|
|
}
|
|
},
|
|
[logicalSessionKey, sessionActions, store],
|
|
);
|
|
|
|
const cancelledPromptTakeBackRef = useRef<CancelledPromptTakeBack | null>(
|
|
null,
|
|
);
|
|
useDaemonPromptSettled((event) => {
|
|
const takeBack = cancelledPromptTakeBackRef.current;
|
|
if (
|
|
!takeBack ||
|
|
event.sessionId !== takeBack.sessionId ||
|
|
!settlesCancelledPrompt(event, takeBack)
|
|
) {
|
|
return;
|
|
}
|
|
cancelledPromptTakeBackRef.current = null;
|
|
// A turn that finished or failed on its own keeps its result.
|
|
if (
|
|
event.outcome !== 'cancelled' ||
|
|
Date.now() - takeBack.requestedAt > CANCELLED_TURN_SETTLE_TIMEOUT_MS
|
|
) {
|
|
return;
|
|
}
|
|
void takeBackCancelledPrompt(takeBack);
|
|
});
|
|
|
|
const transcriptHistoryComplete =
|
|
!transcriptHistory.hasMore && !transcriptHistory.capacityReached;
|
|
const hasQueuedPrompts = queuedPrompts.length > 0;
|
|
const handleCancel = useCallback(() => {
|
|
const owner = sessionOwnerGuard.capture();
|
|
const dropped = queuedShellCommandsRef.current.length;
|
|
queuedShellCommandsRef.current = [];
|
|
drainGenerationRef.current++;
|
|
isDrainingRef.current = false;
|
|
shellSubmitInFlightRef.current = false;
|
|
if (dropped > 0) {
|
|
pushToast('warning', t('queue.shellDropped', { count: dropped }));
|
|
}
|
|
// Read before cancelling: the abort settles the send, which clears the ref.
|
|
// Queued prompts mean the user has moved on, so the turn is just stopped.
|
|
const cancelled = hasQueuedPrompts ? null : inFlightPromptRef.current;
|
|
cancelledPromptTakeBackRef.current = cancelled
|
|
? {
|
|
prompt: cancelled,
|
|
promptId: cancelled.promptId,
|
|
clientId: connectionRef.current.clientId,
|
|
sessionId: connectionRef.current.sessionId,
|
|
owner,
|
|
historyComplete: transcriptHistoryComplete,
|
|
requestedAt: Date.now(),
|
|
}
|
|
: null;
|
|
sessionActions.cancel().catch((error: unknown) => {
|
|
if (!owner.isCurrent()) return;
|
|
reportError(error, 'Failed to cancel request');
|
|
});
|
|
}, [
|
|
sessionActions,
|
|
reportError,
|
|
pushToast,
|
|
sessionOwnerGuard,
|
|
t,
|
|
transcriptHistoryComplete,
|
|
hasQueuedPrompts,
|
|
]);
|
|
|
|
const handleFocusTaskPill = useCallback((): boolean => {
|
|
if (interactionBlocked) return false;
|
|
return statusBarRef.current?.focusTaskPill() ?? false;
|
|
}, [interactionBlocked]);
|
|
|
|
const handleReturnToEditor = useCallback((text?: string) => {
|
|
if (text) {
|
|
editorRef.current?.insertText(text);
|
|
return;
|
|
}
|
|
editorRef.current?.focus();
|
|
}, []);
|
|
const discardUnknownPromptPayload = useCallback(() => {
|
|
const current = unknownPromptAdmissionRef.current;
|
|
if (
|
|
!current?.payloadAvailable ||
|
|
current.sessionId !== connectionRef.current.sessionId
|
|
) {
|
|
return;
|
|
}
|
|
updateUnknownPromptAdmission({
|
|
sessionId: current.sessionId,
|
|
messageId: current.messageId,
|
|
payloadAvailable: false,
|
|
});
|
|
}, [updateUnknownPromptAdmission]);
|
|
const restoreUnknownPromptPayload = useCallback(() => {
|
|
const current = unknownPromptAdmissionRef.current;
|
|
const editor = editorRef.current;
|
|
if (
|
|
!current?.payloadAvailable ||
|
|
current.sessionId !== connectionRef.current.sessionId ||
|
|
!editor
|
|
) {
|
|
return;
|
|
}
|
|
if (!window.confirm(t('queue.continueEditingConfirm'))) return;
|
|
const draft = editor.getText();
|
|
const restoredText = current.text?.trim()
|
|
? draft.trim()
|
|
? `${current.text}\n${draft}`
|
|
: current.text
|
|
: draft;
|
|
if (restoredText !== draft) editor.setText(restoredText);
|
|
if (current.images?.length) editor.restoreImages(current.images);
|
|
if (current.files?.length) editor.restoreFiles(current.files);
|
|
if (current.inputAnnotations?.length) {
|
|
editor.restoreInputAnnotations?.(current.inputAnnotations);
|
|
}
|
|
editor.focus();
|
|
updateUnknownPromptAdmission({
|
|
sessionId: current.sessionId,
|
|
messageId: current.messageId,
|
|
payloadAvailable: false,
|
|
});
|
|
}, [t, updateUnknownPromptAdmission]);
|
|
const handleCanScrollToBottomChange = useCallback(
|
|
(canScrollToBottom: boolean) => {
|
|
setCanScrollMessageListToBottom(canScrollToBottom);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleRetry = useCallback(() => {
|
|
if (sessionWriteBlockedRef.current || promptPreparationOwnerRef.current) {
|
|
return;
|
|
}
|
|
if (
|
|
showRetryHintRef.current &&
|
|
connected &&
|
|
streamingStateRef.current === 'idle' &&
|
|
!sessionHasActivePromptRef.current &&
|
|
retryableTurnErrorIdRef.current &&
|
|
retryableTurnErrorIdentityRef.current &&
|
|
connectionRef.current.sessionId &&
|
|
(lastSubmittedPromptRef.current ||
|
|
(lastSubmittedImagesRef.current?.length ?? 0) > 0 ||
|
|
(lastSubmittedFilesRef.current?.length ?? 0) > 0)
|
|
) {
|
|
const savedRetryErrorIdentity = retryableTurnErrorIdentityRef.current;
|
|
const currentRetryError = getRetryableTurnError(
|
|
store.getSnapshot().blocks,
|
|
);
|
|
if (
|
|
!savedRetryErrorIdentity ||
|
|
!currentRetryError ||
|
|
!matchesTurnErrorIdentity(currentRetryError, savedRetryErrorIdentity)
|
|
) {
|
|
lastSubmittedPromptRef.current = '';
|
|
lastSubmittedImagesRef.current = undefined;
|
|
lastSubmittedFilesRef.current = undefined;
|
|
lastSubmittedInputAnnotationsRef.current = undefined;
|
|
lastSubmittedSourceVersionRef.current = -1;
|
|
retryableTurnErrorIdRef.current = null;
|
|
retryableTurnErrorIdentityRef.current = undefined;
|
|
retriedTurnErrorIdRef.current = null;
|
|
setShowRetryHint(false);
|
|
return;
|
|
}
|
|
const retryErrorId = currentRetryError.id;
|
|
const retryErrorIdentity = { block: currentRetryError };
|
|
const retrySessionId = connectionRef.current.sessionId;
|
|
const retryText = lastSubmittedPromptRef.current;
|
|
if (refuseModelSetup(retryText)) {
|
|
disarmSubmittedPromptRetry();
|
|
setShowRetryHint(false);
|
|
return;
|
|
}
|
|
const retryImages = lastSubmittedImagesRef.current;
|
|
const retryFiles = lastSubmittedFilesRef.current;
|
|
const retryInputAnnotations = lastSubmittedInputAnnotationsRef.current;
|
|
const previousRetriedTurnErrorId = retriedTurnErrorIdRef.current;
|
|
const previousShowRetryHint = showRetryHintRef.current;
|
|
const retryAttemptId = ++cancelledRetryAttemptRef.current;
|
|
const retryOwner = retryOwnerRef.current;
|
|
if (!retryOwnerIsCurrent(retryOwner)) {
|
|
setShowRetryHint(false);
|
|
return;
|
|
}
|
|
retriedTurnErrorIdRef.current = retryErrorId;
|
|
setShowRetryHint(false);
|
|
const retryTranscriptIdentity: FailedPromptRetry['transcriptIdentity'] = {
|
|
kind: 'turn-error',
|
|
identity: retryErrorIdentity,
|
|
};
|
|
const retryTranscriptIsCurrent = () =>
|
|
retryTranscriptIdentityMatches(
|
|
store.getSnapshot().blocks,
|
|
retryTranscriptIdentity,
|
|
);
|
|
setFailedPromptRetry({
|
|
sessionId: retrySessionId,
|
|
messageId: retryErrorId,
|
|
startedAt: Date.now(),
|
|
admitted: false,
|
|
settled: false,
|
|
owner: retryOwner,
|
|
transcriptIdentity: retryTranscriptIdentity,
|
|
});
|
|
let admissionStarted = false;
|
|
let admitted = false;
|
|
sendPrompt(retryText, retryImages, retryFiles, {
|
|
optimisticUserMessage: false,
|
|
retry: true,
|
|
inputAnnotations: retryInputAnnotations,
|
|
onAdmissionStarted: () => {
|
|
admissionStarted = true;
|
|
},
|
|
onAdmitted: () => {
|
|
admitted = true;
|
|
if (!retryOwnerIsCurrent(retryOwner)) return;
|
|
setFailedPromptRetry((current) =>
|
|
current?.transcriptIdentity === retryTranscriptIdentity
|
|
? retryTranscriptIsCurrent()
|
|
? { ...current, admitted: true }
|
|
: null
|
|
: current,
|
|
);
|
|
},
|
|
onCancelledBeforeAdmission: () => {
|
|
restoreOrDeferCancelledRetry(retryOwner, {
|
|
kind: 'turn-error',
|
|
attemptId: retryAttemptId,
|
|
errorId: retryErrorId,
|
|
identity: retryErrorIdentity,
|
|
text: retryText,
|
|
images: retryImages,
|
|
files: retryFiles,
|
|
inputAnnotations: retryInputAnnotations,
|
|
previousRetriedTurnErrorId,
|
|
previousShowRetryHint,
|
|
});
|
|
},
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!retryOwnerIsCurrent(retryOwner)) return;
|
|
const definitelyRejected = isDefinitelyRejectedPromptAdmission(error);
|
|
if (admissionStarted && !admitted && !definitelyRejected) {
|
|
if (!retryTranscriptIsCurrent()) return;
|
|
updateUnknownPromptAdmission({
|
|
sessionId: retrySessionId,
|
|
messageId: retryErrorId,
|
|
text: retryText,
|
|
images: retryImages ? [...retryImages] : undefined,
|
|
files: retryFiles ? [...retryFiles] : undefined,
|
|
inputAnnotations: retryInputAnnotations,
|
|
payloadAvailable: true,
|
|
});
|
|
pushToast('warning', t('queue.admissionUnknown'));
|
|
console.warn(
|
|
'[WebShell] post-turn retry admission outcome is unknown',
|
|
error,
|
|
);
|
|
return;
|
|
}
|
|
if (!admitted) {
|
|
restoreOrDeferCancelledRetry(retryOwner, {
|
|
kind: 'turn-error',
|
|
attemptId: retryAttemptId,
|
|
errorId: retryErrorId,
|
|
identity: retryErrorIdentity,
|
|
text: retryText,
|
|
images: retryImages,
|
|
files: retryFiles,
|
|
inputAnnotations: retryInputAnnotations,
|
|
previousRetriedTurnErrorId,
|
|
previousShowRetryHint,
|
|
});
|
|
}
|
|
if (isDaemonTurnError(error)) {
|
|
// A loop-detected rejection ends the retry lineage: the
|
|
// retried turn itself was stopped for loop protection, so
|
|
// the stashed prompt must not be re-offered — resubmitting
|
|
// it tends to re-loop.
|
|
if (error.body !== 'LOOP_DETECTED') {
|
|
failedTurnErrorRetryRef.current = {
|
|
errorId: retryErrorId,
|
|
text: retryText,
|
|
images: retryImages,
|
|
files: retryFiles,
|
|
inputAnnotations: retryInputAnnotations,
|
|
owner: retryOwner,
|
|
};
|
|
}
|
|
const nextTurnError = getRetryableTurnError(
|
|
store.getSnapshot().blocks,
|
|
);
|
|
if (
|
|
nextTurnError &&
|
|
nextTurnError.kind === 'error' &&
|
|
isRetryableTurnErrorKind(nextTurnError.errorKind)
|
|
) {
|
|
rearmFailedTurnErrorRetry(
|
|
nextTurnError,
|
|
store.getSnapshot().blocks,
|
|
);
|
|
}
|
|
}
|
|
if (retryTranscriptIsCurrent()) {
|
|
reportError(error, 'Failed to retry prompt');
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!retryOwnerIsCurrent(retryOwner)) return;
|
|
setFailedPromptRetry((current) =>
|
|
current?.transcriptIdentity === retryTranscriptIdentity
|
|
? retryTranscriptIsCurrent()
|
|
? { ...current, settled: true }
|
|
: null
|
|
: current,
|
|
);
|
|
});
|
|
} else {
|
|
store.dispatch([
|
|
{
|
|
type: 'status',
|
|
promptId: `local-command:${crypto.randomUUID()}`,
|
|
text: t('retry.none'),
|
|
},
|
|
]);
|
|
}
|
|
}, [
|
|
connected,
|
|
disarmSubmittedPromptRetry,
|
|
pushToast,
|
|
reportError,
|
|
rearmFailedTurnErrorRetry,
|
|
restoreOrDeferCancelledRetry,
|
|
retryOwnerIsCurrent,
|
|
refuseModelSetup,
|
|
sendPrompt,
|
|
store,
|
|
t,
|
|
updateUnknownPromptAdmission,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const onGlobalShortcut = (e: KeyboardEvent) => {
|
|
if (interactionBlocked || isWebTerminalTarget(e)) return;
|
|
if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
|
|
if (e.key === 'l') {
|
|
e.preventDefault();
|
|
handleClearScreen();
|
|
return;
|
|
}
|
|
if (e.key === 'o') {
|
|
// The compact toggle is gone; keep Ctrl+O suppressed everywhere so
|
|
// the key never falls through to the browser's Open File dialog.
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
if (e.key === 'y') {
|
|
e.preventDefault();
|
|
handleRetry();
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
window.addEventListener('keydown', onGlobalShortcut, true);
|
|
return () => window.removeEventListener('keydown', onGlobalShortcut, true);
|
|
}, [interactionBlocked, handleClearScreen, handleRetry, store, t]);
|
|
|
|
// The sidebar footer buttons for Session Overview and Split View; the view
|
|
// hotkeys below run these same callbacks.
|
|
const openSessionsOverviewFromSidebar = useCallback(() => {
|
|
closeMobileDrawer();
|
|
openPanel('sessions');
|
|
}, [closeMobileDrawer, openPanel]);
|
|
const openSplitViewFromSidebar = useCallback(() => {
|
|
closeMobileDrawer();
|
|
openSplitView();
|
|
}, [closeMobileDrawer, openSplitView]);
|
|
// The sidebar buttons' own availability gates, passed to WebShellSidebar
|
|
// below and shared with the view hotkeys; the rail table reads the same
|
|
// predicate, so button and chord cannot drift apart.
|
|
const canOpenSessionsOverview = isSidebarFooterItemAvailable(
|
|
'sessionsOverview',
|
|
projectFeaturesAvailable,
|
|
isLargeScreen,
|
|
);
|
|
const canOpenSplitView = isSidebarFooterItemAvailable(
|
|
'splitView',
|
|
projectFeaturesAvailable,
|
|
isLargeScreen,
|
|
);
|
|
// Each chord is live only while the sidebar offers its button (sidebar
|
|
// enabled, item in the resolved footer, the button's availability gate),
|
|
// so a key never opens what the host's chrome withholds and an inert chord
|
|
// leaves the key to the browser.
|
|
const sidebarFooterItems =
|
|
sidebarOptions.enabled && sidebarOptions.footer !== false
|
|
? (sidebarOptions.footer?.items ?? [])
|
|
: [];
|
|
const sessionsOverviewHotkeyAvailable =
|
|
canOpenSessionsOverview && sidebarFooterItems.includes('sessionsOverview');
|
|
const splitViewHotkeyAvailable =
|
|
canOpenSplitView && sidebarFooterItems.includes('splitView');
|
|
|
|
useEffect(() => {
|
|
// View navigation hotkeys (#13175): Cmd/Ctrl+Shift+O presses the Session
|
|
// Overview button, Cmd/Ctrl+\ the Split View button. Match the physical
|
|
// key (e.code) rather than the produced character so the chords work on
|
|
// non-QWERTY layouts; ISO keyboards report the key that types "\" as
|
|
// IntlBackslash. Capture phase, so the chords also work while the
|
|
// composer has focus.
|
|
const onViewShortcut = (e: KeyboardEvent) => {
|
|
if (e.repeat || e.isComposing) return;
|
|
if (!(e.metaKey || e.ctrlKey) || e.altKey) return;
|
|
let open: (() => void) | undefined;
|
|
if (e.shiftKey && e.code === 'KeyO') {
|
|
if (sessionsOverviewHotkeyAvailable) {
|
|
open = openSessionsOverviewFromSidebar;
|
|
}
|
|
} else if (
|
|
!e.shiftKey &&
|
|
(e.code === 'Backslash' || e.code === 'IntlBackslash')
|
|
) {
|
|
if (splitViewHotkeyAvailable) open = openSplitViewFromSidebar;
|
|
}
|
|
if (!open) return;
|
|
// Covering surfaces keep the keyboard: a shell modal or a host
|
|
// interaction block (both in modalDialogOpen), the docked fullscreen
|
|
// artifact panel, a component-owned modal (dialog, confirm, drawer),
|
|
// and the web terminal.
|
|
if (modalDialogOpen || artifactPanelFullscreen) return;
|
|
if (isWebTerminalTarget(e)) return;
|
|
// Other editable targets — Session Overview search, session rename,
|
|
// settings inputs, the Live shortcut recorder — must not have the view
|
|
// yanked away while the user is typing, matching the codebase's
|
|
// isEditableTarget convention. The composer stays exempt: the chords
|
|
// produce no text there (issue #13175). Shadow-DOM portal hosts
|
|
// retarget the event to the host element, so resolve the real target.
|
|
const target = (e.composedPath()[0] ?? e.target) as HTMLElement | null;
|
|
if (
|
|
isEditableTarget(target) &&
|
|
!target?.closest('[data-web-shell-composer-editor]')
|
|
) {
|
|
return;
|
|
}
|
|
if (hasOpenModalSurface(portalRoot)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
open();
|
|
};
|
|
window.addEventListener('keydown', onViewShortcut, true);
|
|
return () => window.removeEventListener('keydown', onViewShortcut, true);
|
|
}, [
|
|
artifactPanelFullscreen,
|
|
modalDialogOpen,
|
|
openSessionsOverviewFromSidebar,
|
|
openSplitViewFromSidebar,
|
|
portalRoot,
|
|
sessionsOverviewHotkeyAvailable,
|
|
splitViewHotkeyAvailable,
|
|
]);
|
|
|
|
const resetEscapeState = useCallback(() => {
|
|
escArmedActionRef.current = null;
|
|
setEscapeHintVisible(false);
|
|
setCancelArmed(false);
|
|
if (escapeTimerRef.current) {
|
|
clearTimeout(escapeTimerRef.current);
|
|
escapeTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
// The Esc handler reads live state, but its global keydown listener must mount
|
|
// ONCE: streamingState flips among 'waiting'/'responding'/'thinking' mid-turn,
|
|
// and if it were an effect dep each flip would tear the listener down and run
|
|
// resetEscapeState(), wiping a half-armed two-press cancel. Read live values
|
|
// through a ref so the listener stays put across re-renders.
|
|
const escLiveRef = useRef({
|
|
streamingState,
|
|
sessionHasActivePrompt,
|
|
pendingApproval,
|
|
interactionBlocked,
|
|
activePanel,
|
|
returnToChat,
|
|
handleCancel,
|
|
handleCycleMode,
|
|
artifactPanelFullscreen,
|
|
});
|
|
escLiveRef.current = {
|
|
streamingState,
|
|
sessionHasActivePrompt,
|
|
pendingApproval,
|
|
interactionBlocked,
|
|
activePanel,
|
|
returnToChat,
|
|
handleCancel,
|
|
handleCycleMode,
|
|
artifactPanelFullscreen,
|
|
};
|
|
|
|
// Clear a half-armed two-press whenever the streaming/idle boundary flips — the
|
|
// relevant action (cancel vs clear) changes with it, so a leftover arm is now
|
|
// stale. Keyed on the boolean, so intra-turn sub-state flips don't reset it.
|
|
const escStreamingBoundary =
|
|
streamingState !== 'idle' || sessionHasActivePrompt;
|
|
useEffect(() => {
|
|
resetEscapeState();
|
|
}, [escStreamingBoundary, managedMode, resetEscapeState]);
|
|
|
|
useEffect(() => {
|
|
// Arm a two-press action: the first Esc shows the affordance and starts a
|
|
// confirm window; a second Esc within it confirms, any other key resets it.
|
|
const ESC_CANCEL_CONFIRM_WINDOW_MS = 2000;
|
|
const ESC_CLEAR_CONFIRM_WINDOW_MS = 500;
|
|
const armEscape = (action: 'cancel' | 'clear', windowMs: number) => {
|
|
escArmedActionRef.current = action;
|
|
if (action === 'cancel') setCancelArmed(true);
|
|
else setEscapeHintVisible(true);
|
|
if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current);
|
|
escapeTimerRef.current = setTimeout(resetEscapeState, windowMs);
|
|
};
|
|
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
// keyCode 229: WebKit marks IME-owned keys this way while isComposing
|
|
// is still false; the IME owns them exactly like composing keys.
|
|
if (
|
|
e.defaultPrevented ||
|
|
e.isComposing ||
|
|
e.keyCode === 229 ||
|
|
isWebTerminalTarget(e)
|
|
)
|
|
return;
|
|
const live = escLiveRef.current;
|
|
|
|
// The fullscreen right panel (artifacts/subagents) is the topmost
|
|
// surface; Escape shrinks it back to its dock/drawer. Modals opened on
|
|
// top are DialogShells, whose own handler stops Escape before this
|
|
// listener, so this only fires when the panel itself is topmost.
|
|
if (e.key === 'Escape' && live.artifactPanelFullscreen) {
|
|
// Mirror the activePanel branch below: the sidebar search input
|
|
// clears on Escape without stopping the event, so don't also shrink
|
|
// the panel when Escape is being handled inside the sidebar.
|
|
const target = e.target as HTMLElement | null;
|
|
if (!target?.closest('[data-sidebar-shell]')) {
|
|
e.preventDefault();
|
|
setArtifactPanelFullscreen(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// A full-view panel (Settings / Daemon Status) replaces the chat rather
|
|
// than overlaying it; Escape returns to the chat. Any modal opened on top
|
|
// of the panel is a DialogShell, whose own handler stops Escape from
|
|
// reaching this window listener, so this only fires when the panel itself
|
|
// is the topmost surface.
|
|
if (e.key === 'Escape' && live.activePanel) {
|
|
// The sidebar stays usable beside the panel and its search input clears
|
|
// on Escape without stopping the event; don't also close the panel when
|
|
// Escape is being handled inside the sidebar. Scope the panel close to
|
|
// Escape originating outside the sidebar drawer.
|
|
const target = e.target as HTMLElement | null;
|
|
if (!target?.closest('[data-sidebar-shell]')) {
|
|
e.preventDefault();
|
|
live.returnToChat();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (e.key !== 'Escape') {
|
|
if (escArmedActionRef.current !== null) {
|
|
resetEscapeState();
|
|
}
|
|
if (
|
|
e.key === 'Tab' &&
|
|
e.shiftKey &&
|
|
!live.interactionBlocked &&
|
|
!e
|
|
.composedPath()
|
|
.some(
|
|
(node) =>
|
|
node instanceof Element &&
|
|
node.matches('[role="dialog"], [role="alertdialog"]'),
|
|
)
|
|
) {
|
|
e.preventDefault();
|
|
live.handleCycleMode();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Streaming takes priority over clearing text (queued prompts stay intact
|
|
// and drain after the turn settles); see decideEscapeIntent for the rules.
|
|
const intent = decideEscapeIntent({
|
|
blocked: !!live.pendingApproval || live.interactionBlocked,
|
|
streaming:
|
|
live.streamingState !== 'idle' || live.sessionHasActivePrompt,
|
|
hasInput: !!editorRef.current?.hasInput(),
|
|
armed: escArmedActionRef.current,
|
|
});
|
|
if (intent.kind === 'ignore') return;
|
|
e.preventDefault();
|
|
switch (intent.kind) {
|
|
case 'cancel':
|
|
live.handleCancel();
|
|
resetEscapeState();
|
|
break;
|
|
case 'clear':
|
|
editorRef.current?.clear();
|
|
resetEscapeState();
|
|
break;
|
|
case 'arm':
|
|
armEscape(
|
|
intent.action,
|
|
intent.action === 'cancel'
|
|
? ESC_CANCEL_CONFIRM_WINDOW_MS
|
|
: ESC_CLEAR_CONFIRM_WINDOW_MS,
|
|
);
|
|
break;
|
|
}
|
|
};
|
|
window.addEventListener('keydown', onKeyDown);
|
|
return () => {
|
|
window.removeEventListener('keydown', onKeyDown);
|
|
resetEscapeState();
|
|
};
|
|
}, [resetEscapeState]);
|
|
|
|
const standaloneInteractionBlocked = Boolean(
|
|
standaloneCreationRecovery ||
|
|
standaloneWriterBlocked ||
|
|
standaloneDirectoryErrorCode === 'working_directory_missing' ||
|
|
standaloneDirectoryErrorCode === 'working_directory_compromised',
|
|
);
|
|
const isDisabled =
|
|
sessionWriteBlocked ||
|
|
standaloneInteractionBlocked ||
|
|
shouldDisableComposerInput({
|
|
pendingApproval: pendingApproval !== null,
|
|
isPreparingPrompt,
|
|
});
|
|
const retryableTurnErrorIdentity = retryableTurnErrorIdentityRef.current;
|
|
const showCurrentRetryHint = Boolean(
|
|
showRetryHint &&
|
|
!isPreparingPrompt &&
|
|
!sessionHasActivePrompt &&
|
|
retryableTurnErrorIdentity &&
|
|
matchesTurnErrorIdentity(
|
|
getRetryableTurnError(blocks),
|
|
retryableTurnErrorIdentity,
|
|
) &&
|
|
retryOwnerIsCurrent(retryOwnerRef.current),
|
|
);
|
|
const latestUserBlock = getLatestUserBlock(blocks);
|
|
const visibleFailedPromptBlock =
|
|
failedPrompt &&
|
|
latestUserBlock &&
|
|
matchesUserMessageIdentity(
|
|
latestUserBlock,
|
|
failedPrompt.identity,
|
|
failedPrompt.owner.snapshot.isCurrent(),
|
|
) &&
|
|
retryOwnerIsCurrent(failedPrompt.owner)
|
|
? latestUserBlock
|
|
: undefined;
|
|
const composerPlaceholderInputState = {
|
|
isPreparingPrompt,
|
|
isStreaming: streamingState !== 'idle',
|
|
};
|
|
const composerPlaceholderState = getComposerPlaceholderState(
|
|
composerPlaceholderInputState,
|
|
);
|
|
const customComposerPlaceholder =
|
|
composerPlaceholders?.[composerPlaceholderState];
|
|
const composerPlaceholderText = customComposerPlaceholder?.trim()
|
|
? customComposerPlaceholder
|
|
: t(getComposerPlaceholderKey(composerPlaceholderInputState));
|
|
|
|
const handleModelSelect = useCallback(
|
|
(modelId: string) => {
|
|
if (sessionWriteBlocked) return;
|
|
if (!connectionRef.current.sessionId) {
|
|
selectWelcomeModel(modelId);
|
|
return;
|
|
}
|
|
// Drive the shared busy flag so the model-management rows disable while a
|
|
// selection is in flight — rapid Set current clicks would otherwise launch
|
|
// concurrent setModel calls that can resolve out of order and leave a
|
|
// model other than the user's last click active.
|
|
const owner = sessionOwnerGuard.capture();
|
|
const modelActionToken = ++modelActionTokenRef.current;
|
|
setModelActionBusy(true);
|
|
sessionActions
|
|
.setModel(modelId)
|
|
.then((result) => {
|
|
if (!owner.isCurrent()) return;
|
|
const summary = getModelSwitchSummary(result);
|
|
setPendingModel(modelId);
|
|
if (summary) {
|
|
const displayName = getModelDisplayName(
|
|
connectionRef.current.models?.find(
|
|
(model) => model.id === modelId,
|
|
)?.label || summary.modelId,
|
|
);
|
|
store.dispatch({
|
|
type: 'debug',
|
|
text: serializeModelSwitchSummary(summary, displayName, t),
|
|
source: 'model_switch_summary',
|
|
data: summary,
|
|
});
|
|
}
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!owner.isCurrent()) return;
|
|
reportError(error, t('model.switch'));
|
|
})
|
|
.finally(() => {
|
|
if (modelActionTokenRef.current === modelActionToken) {
|
|
setModelActionBusy(false);
|
|
}
|
|
});
|
|
},
|
|
[
|
|
sessionWriteBlocked,
|
|
reportError,
|
|
sessionActions,
|
|
sessionOwnerGuard,
|
|
setPendingModel,
|
|
selectWelcomeModel,
|
|
store,
|
|
t,
|
|
],
|
|
);
|
|
|
|
const subscriptionModelRefreshes = useRef(new Map<string, string>());
|
|
const refreshSubscriptionModels = useCallback(() => {
|
|
const { status, sessionId, workspaceCwd, currentModel } =
|
|
connectionRef.current;
|
|
const key = getLogicalSessionKey(sessionId, workspaceCwd);
|
|
const modelId = key && subscriptionModelRefreshes.current.get(key);
|
|
if (
|
|
status !== 'connected' ||
|
|
!key ||
|
|
!modelId ||
|
|
currentModel !== modelId
|
|
) {
|
|
return;
|
|
}
|
|
const owner = sessionOwnerGuard.capture({ includeRecovery: true });
|
|
if (!owner.isCurrent()) return;
|
|
return sessionActions
|
|
.refreshModels(
|
|
(target) => {
|
|
if (
|
|
getLogicalSessionKey(target.sessionId, target.workspaceCwd) !== key
|
|
)
|
|
throw new Error('Session changed.');
|
|
},
|
|
{ reloadProviders: false },
|
|
)
|
|
.then(() => {
|
|
const current = connectionRef.current;
|
|
if (
|
|
owner.isCurrent() &&
|
|
current.status === 'connected' &&
|
|
getLogicalSessionKey(current.sessionId, current.workspaceCwd) ===
|
|
key &&
|
|
current.currentModel === modelId &&
|
|
subscriptionModelRefreshes.current.get(key) === modelId
|
|
) {
|
|
subscriptionModelRefreshes.current.delete(key);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// The model picker's refresh remains available if this read fails.
|
|
});
|
|
}, [sessionActions, sessionOwnerGuard]);
|
|
useEffect(() => {
|
|
void refreshSubscriptionModels();
|
|
}, [
|
|
connection.currentModel,
|
|
connection.status,
|
|
logicalSessionKey,
|
|
refreshSubscriptionModels,
|
|
]);
|
|
|
|
const activateSessionSubscriptionModel = useCallback(
|
|
async (
|
|
workspaceCwd: string | undefined,
|
|
sessionId: string,
|
|
modelId: string,
|
|
) => {
|
|
const key = getLogicalSessionKey(sessionId, workspaceCwd);
|
|
const isTarget = () => {
|
|
const current = connectionRef.current;
|
|
return (
|
|
getLogicalSessionKey(current.sessionId, current.workspaceCwd) === key
|
|
);
|
|
};
|
|
if (!key || !isTarget()) {
|
|
throw new DOMException('Subscription target changed.', 'AbortError');
|
|
}
|
|
if (connectionRef.current.status === 'connected') {
|
|
await sessionActions.setModel(modelId);
|
|
} else {
|
|
// The daemon can switch a live session while its event stream reconnects.
|
|
await workspace.client.setSessionModel(sessionId, modelId);
|
|
}
|
|
if (!isTarget()) return;
|
|
subscriptionModelRefreshes.current.set(key, modelId);
|
|
await refreshSubscriptionModels();
|
|
},
|
|
[refreshSubscriptionModels, sessionActions, workspace.client],
|
|
);
|
|
|
|
const handleWorkflowModeChange = useCallback(
|
|
(requested: boolean) => {
|
|
if (sessionWriteBlockedRef.current || !connectionRef.current.sessionId) {
|
|
return;
|
|
}
|
|
const owner = sessionOwnerGuard.capture();
|
|
void sessionActions.setWorkflowMode(requested).catch((error: unknown) => {
|
|
if (owner.isCurrent())
|
|
reportError(
|
|
error,
|
|
getWorkflowModeMessages(selectedLanguage).updateFailed,
|
|
);
|
|
});
|
|
},
|
|
[reportError, sessionActions, sessionOwnerGuard, selectedLanguage],
|
|
);
|
|
|
|
const handleReasoningEffort = useCallback(
|
|
(value: ReasoningSelection) => {
|
|
if (sessionWriteBlocked || !connectionRef.current.sessionId) {
|
|
return Promise.resolve();
|
|
}
|
|
return sessionActions
|
|
.setReasoningEffort(value, {
|
|
persist: connectionRef.current.sessionContext?.kind !== 'standalone',
|
|
})
|
|
.catch((error: unknown) =>
|
|
reportError(error, t('reasoning.updateFailed')),
|
|
);
|
|
},
|
|
[reportError, sessionActions, sessionWriteBlocked, t],
|
|
);
|
|
|
|
const handleWelcomeReasoningEffort = useCallback(
|
|
(value: ReasoningSelection, source?: 'toggle') => {
|
|
const activeConnection = connectionRef.current;
|
|
if (
|
|
sessionWriteBlockedRef.current ||
|
|
activeConnection.sessionId ||
|
|
activeConnection.context
|
|
) {
|
|
return;
|
|
}
|
|
const modelId = currentModelRef.current;
|
|
const preview = activeConnection.models?.find(
|
|
(model) => model.id === modelId,
|
|
)?.reasoningPreview;
|
|
if (!preview) return;
|
|
if (value === 'none') {
|
|
if (preview.canDisable === false) return;
|
|
setPendingReasoningIntent({ modelId, value });
|
|
return;
|
|
}
|
|
if (value !== 'default' && !preview.efforts.includes(value)) return;
|
|
setPendingReasoningIntent({
|
|
modelId,
|
|
value,
|
|
...(source === 'toggle' ? { fromToggle: true } : {}),
|
|
});
|
|
},
|
|
[setPendingReasoningIntent],
|
|
);
|
|
|
|
const handleDeleteModel = useCallback(
|
|
(target: { authType: string; modelId: string; baseUrl?: string }) => {
|
|
if (!modelManagementRef.current.allowDelete) return;
|
|
const owner = sessionOwnerGuard.capture();
|
|
const modelActionToken = ++modelActionTokenRef.current;
|
|
setModelActionBusy(true);
|
|
workspaceActions
|
|
.deleteModel(target)
|
|
.then((result) => {
|
|
if (owner.isCurrent() && result?.runtimeSync?.status === 'failed') {
|
|
store.dispatch([
|
|
{
|
|
type: 'status',
|
|
promptId: `local-command:${crypto.randomUUID()}`,
|
|
text: t('settings.models.runtimeSyncFailed'),
|
|
},
|
|
]);
|
|
}
|
|
// A scrubbed fallback requires a restart — surface it like the
|
|
// settings panel does.
|
|
if (owner.isCurrent() && result?.requiresRestart) {
|
|
store.dispatch([
|
|
{
|
|
type: 'status',
|
|
promptId: `local-command:${crypto.randomUUID()}`,
|
|
text: t('settings.requiresRestart'),
|
|
},
|
|
]);
|
|
}
|
|
// A transient reload failure shouldn't surface as "delete failed" —
|
|
// the model was already removed. Just log it. Reload settings too so a
|
|
// cleared active model / scrubbed fallback isn't shown stale.
|
|
void reloadModelConfigurations().catch((error: unknown) =>
|
|
console.warn(
|
|
'[web-shell] failed to reload model configurations',
|
|
error,
|
|
),
|
|
);
|
|
reloadProviders().catch((err: unknown) => {
|
|
console.warn(
|
|
'[web-shell] failed to reload providers after delete',
|
|
err,
|
|
);
|
|
});
|
|
reloadWorkspaceSettings().catch((err: unknown) => {
|
|
console.warn(
|
|
'[web-shell] failed to reload settings after delete',
|
|
err,
|
|
);
|
|
});
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!owner.isCurrent()) return;
|
|
reportError(error, t('settings.models.deleteFailed'));
|
|
})
|
|
.finally(() => {
|
|
if (modelActionTokenRef.current === modelActionToken) {
|
|
setModelActionBusy(false);
|
|
}
|
|
});
|
|
},
|
|
// Depend on the stable `reload` fn, not the whole providersState object,
|
|
// which useProviders returns fresh each render (would defeat the memo).
|
|
[
|
|
workspaceActions,
|
|
reloadProviders,
|
|
reloadModelConfigurations,
|
|
reloadWorkspaceSettings,
|
|
reportError,
|
|
sessionOwnerGuard,
|
|
store,
|
|
t,
|
|
],
|
|
);
|
|
|
|
const handleCloseAuthDialog = useCallback(() => {
|
|
setShowAuthDialog(false);
|
|
if (!projectFeaturesAvailable) return;
|
|
void reloadModelConfigurations().catch((error: unknown) =>
|
|
console.warn('[web-shell] failed to reload model configurations', error),
|
|
);
|
|
// Refresh visible Settings after provider installation; hidden Settings
|
|
// reloads when opened.
|
|
reloadProviders().catch((err: unknown) => {
|
|
console.warn(
|
|
'[web-shell] failed to reload providers after auth dialog close',
|
|
err,
|
|
);
|
|
});
|
|
}, [reloadProviders, reloadModelConfigurations, projectFeaturesAvailable]);
|
|
|
|
const handleFallbacksConfirm = useCallback(
|
|
(baseIds: string[]) => {
|
|
setShowFallbacksDialog(false);
|
|
if (!projectFeaturesAvailable) return;
|
|
setWorkspaceSetting(
|
|
modelSettingScope,
|
|
'modelFallbacks',
|
|
baseIds.join(','),
|
|
)
|
|
.then((result) => {
|
|
// modelFallbacks requiresRestart — tell the user, like the settings
|
|
// panel does for restart-required edits.
|
|
if (result?.requiresRestart) {
|
|
store.dispatch([
|
|
{
|
|
type: 'status',
|
|
promptId: `local-command:${crypto.randomUUID()}`,
|
|
text: t('settings.requiresRestart'),
|
|
},
|
|
]);
|
|
}
|
|
// A reload failure shouldn't surface as "save failed" — the value
|
|
// was already persisted. Just log it.
|
|
reloadWorkspaceSettings().catch((err: unknown) => {
|
|
console.warn(
|
|
'[web-shell] failed to reload settings after fallbacks save',
|
|
err,
|
|
);
|
|
});
|
|
})
|
|
.catch((error: unknown) =>
|
|
reportError(error, t('settings.models.fallbacks.saveFailed')),
|
|
);
|
|
},
|
|
[
|
|
modelSettingScope,
|
|
setWorkspaceSetting,
|
|
reloadWorkspaceSettings,
|
|
reportError,
|
|
store,
|
|
t,
|
|
projectFeaturesAvailable,
|
|
],
|
|
);
|
|
|
|
const handleFastModelSelect = useCallback(
|
|
(modelId: string) => {
|
|
if (!projectFeaturesAvailable) return;
|
|
if (!workspaceContextActive) {
|
|
void setWorkspaceSetting(modelSettingScope, 'fastModel', modelId)
|
|
.then(() => {
|
|
void reloadWorkspaceSettings().catch((error: unknown) => {
|
|
console.warn(
|
|
'[web-shell] failed to reload settings after fast-model save',
|
|
error,
|
|
);
|
|
});
|
|
})
|
|
.catch((error: unknown) =>
|
|
reportError(error, 'Failed to switch fast model'),
|
|
);
|
|
return;
|
|
}
|
|
if (
|
|
streamingState !== 'idle' ||
|
|
sessionHasActivePromptRef.current ||
|
|
isGoalGateBlocked()
|
|
) {
|
|
blockCommand();
|
|
return;
|
|
}
|
|
// Close the panel before sending: unlike the vision/voice pickers (silent
|
|
// setWorkspaceSetting), `/model --fast` runs a real turn whose response
|
|
// lands in the message list. With the panel open the chat is hidden, so
|
|
// that response would pile up behind it and surprise the user on close.
|
|
// Closing first returns them to the chat to see it in context (matching
|
|
// the pre-panel modal behavior).
|
|
closePanel();
|
|
// Persist to the scope the picker was opened for (matching the silent
|
|
// vision/voice pickers). `/model` parses --global/--project as the persist
|
|
// scope; without a flag the command would default to its own scope logic
|
|
// and ignore the user's User-vs-Workspace choice.
|
|
const scopeFlag =
|
|
modelSettingScope === 'user' ? ' --global' : ' --project';
|
|
const owner = { current: sessionOwnerGuard.capture() };
|
|
sendPrompt(`/model --fast ${modelId}${scopeFlag}`, undefined, undefined, {
|
|
ownerRef: owner,
|
|
suppressSessionTitle: true,
|
|
})
|
|
.then(() => {
|
|
if (!owner.current.isCurrent()) return;
|
|
// sendPrompt resolves only after the `/model --fast` turn *completes*
|
|
// (actions.ts → waitForAcceptedPromptCompletion), so the change is
|
|
// already applied here — this reload reads the new value, not a stale
|
|
// one. It keeps the workspace-settings state fresh for the next time
|
|
// Settings is opened (the command path, unlike setWorkspaceSetting,
|
|
// doesn't bump the settingsVersion signal). Guard its own rejection —
|
|
// the .catch below only covers sendPrompt — and log it so a failed
|
|
// reload (leaving stale settings on next open) leaves a trace.
|
|
reloadWorkspaceSettings().catch((err: unknown) => {
|
|
console.warn(
|
|
'[web-shell] failed to reload workspace settings after fast-model switch',
|
|
err,
|
|
);
|
|
});
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!owner.current.isCurrent()) return;
|
|
reportError(error, 'Failed to switch fast model');
|
|
});
|
|
},
|
|
[
|
|
blockCommand,
|
|
closePanel,
|
|
sendPrompt,
|
|
streamingState,
|
|
reportError,
|
|
reloadWorkspaceSettings,
|
|
modelSettingScope,
|
|
sessionOwnerGuard,
|
|
isGoalGateBlocked,
|
|
projectFeaturesAvailable,
|
|
setWorkspaceSetting,
|
|
workspaceContextActive,
|
|
],
|
|
);
|
|
|
|
const handleVoiceModelSelect = useCallback(
|
|
(modelId: string) => {
|
|
// Model IDs from the voice picker arrive as bare model IDs (baseModelId),
|
|
// not ACP format. The voice status API supplies the raw model id.
|
|
const bareModelId = extractBareModelId(modelId);
|
|
const target = voicePickerTargetRef.current;
|
|
if (!target || target.ownerKey !== mainVoiceTargetRef.current?.ownerKey) {
|
|
reportError(
|
|
new Error(
|
|
'The Voice workspace changed before the selection applied.',
|
|
),
|
|
t('model.setVoice'),
|
|
);
|
|
return;
|
|
}
|
|
void writeVoiceModelForTarget(
|
|
bareModelId,
|
|
modelSettingScope,
|
|
target,
|
|
).catch((error: unknown) => reportError(error, t('model.setVoice')));
|
|
},
|
|
[modelSettingScope, reportError, t, writeVoiceModelForTarget],
|
|
);
|
|
|
|
const handleVisionModelSelect = useCallback(
|
|
(modelId: string) => {
|
|
if (!projectFeaturesAvailable) return;
|
|
// Model IDs from the picker arrive in ACP format: `modelId(authType)`.
|
|
// Core's resolveVisionModelSelection() expects `authType:modelId`.
|
|
const encoded = encodeVisionModelForSetting(modelId);
|
|
setWorkspaceSetting(modelSettingScope, 'visionModel', encoded)
|
|
.then(() => {
|
|
void reloadWorkspaceSettings().catch((error: unknown) => {
|
|
console.warn(
|
|
'[web-shell] failed to reload settings after vision-model save',
|
|
error,
|
|
);
|
|
});
|
|
})
|
|
.catch((error: unknown) => reportError(error, t('model.setVision')));
|
|
},
|
|
[
|
|
modelSettingScope,
|
|
reportError,
|
|
setWorkspaceSetting,
|
|
reloadWorkspaceSettings,
|
|
t,
|
|
projectFeaturesAvailable,
|
|
],
|
|
);
|
|
|
|
const handleRoleModelSelect = (
|
|
key: 'advisorModel' | 'imageModel',
|
|
value: string,
|
|
) => {
|
|
if (
|
|
!projectFeaturesAvailable ||
|
|
!roleModelsReady ||
|
|
!(
|
|
key === 'advisorModel' ? advisorModels : [{ id: '' }, ...imageModels]
|
|
).some((model) => model.id === value)
|
|
)
|
|
return;
|
|
const owner = sessionOwnerGuard.capture();
|
|
void setWorkspaceSetting(modelSettingScope, key, value)
|
|
.then((result) => {
|
|
if (owner.isCurrent() && result?.requiresRestart) {
|
|
store.dispatch([
|
|
{
|
|
type: 'status',
|
|
promptId: `local-command:${crypto.randomUUID()}`,
|
|
text: t('settings.requiresRestart'),
|
|
},
|
|
]);
|
|
}
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (owner.isCurrent())
|
|
reportError(
|
|
error,
|
|
t(key === 'imageModel' ? 'model.setImage' : 'model.setAdvisor'),
|
|
);
|
|
});
|
|
};
|
|
|
|
const handleModelContextWindowUpdate = async (
|
|
key: string,
|
|
size: number | null,
|
|
) => {
|
|
const token = ++modelActionTokenRef.current;
|
|
setModelActionBusy(true);
|
|
try {
|
|
const result = await workspace.client.updateModelContextWindow(key, size);
|
|
await Promise.allSettled([
|
|
reloadModelConfigurations(),
|
|
reloadProviders(),
|
|
]);
|
|
return result;
|
|
} finally {
|
|
if (modelActionTokenRef.current === token) setModelActionBusy(false);
|
|
}
|
|
};
|
|
const modelDialogModels: Partial<
|
|
Record<ModelDialogMode, ModelDialogModel[]>
|
|
> = {
|
|
voice: voiceModels,
|
|
advisor: advisorModels,
|
|
image: roleModelsReady
|
|
? [{ id: '', label: t('model.disabled') }, ...imageModels]
|
|
: [],
|
|
};
|
|
const modelDialogCurrent: Partial<Record<ModelDialogMode, string>> = {
|
|
voice: currentVoiceModel,
|
|
vision: currentVisionModel,
|
|
fast: currentFastModel,
|
|
advisor:
|
|
typeof currentAdvisorModel === 'string'
|
|
? (advisorModels.find(
|
|
(model) => model.id === `${currentAdvisorModel}\0`,
|
|
)?.id ?? currentAdvisorModel)
|
|
: '',
|
|
image: typeof currentImageModel === 'string' ? currentImageModel : '',
|
|
};
|
|
|
|
const modelHandlers: Record<ModelDialogMode, (id: string) => void> = {
|
|
main: handleModelSelect,
|
|
fast: handleFastModelSelect,
|
|
voice: handleVoiceModelSelect,
|
|
vision: handleVisionModelSelect,
|
|
advisor: (id) => handleRoleModelSelect('advisorModel', id),
|
|
image: (id) => handleRoleModelSelect('imageModel', id),
|
|
};
|
|
|
|
// Once every settings-launched model surface is closed (the model picker via
|
|
// modelDialogMode, the fallbacks dialog, or the Add Model / auth dialog),
|
|
// reset the persist scope so a later command-launched picker defaults back
|
|
// to workspace.
|
|
useEffect(() => {
|
|
if (!modelDialogMode && !showFallbacksDialog && !showAuthDialog) {
|
|
setModelSettingScope('workspace');
|
|
}
|
|
}, [modelDialogMode, showFallbacksDialog, showAuthDialog]);
|
|
|
|
useEffect(() => {
|
|
const key = settingsDialogKeyRef.current;
|
|
if (!key) return;
|
|
const visible =
|
|
key === 'builtin:model-management'
|
|
? isItemVisible(key, settingsPresentation)
|
|
: isSettingVisible(key, settingsPresentation);
|
|
if (!visible) {
|
|
if (pendingVoicePickerSourceRef.current === 'settings') {
|
|
voicePickerRequestRef.current++;
|
|
pendingVoicePickerSourceRef.current = undefined;
|
|
}
|
|
// Close only the dialog the held key owns: a stale key left over from a
|
|
// settings launch must not close a surface a command or the status bar
|
|
// opened afterwards.
|
|
if (key === 'modelFallbacks') {
|
|
setShowFallbacksDialog(false);
|
|
} else if (key === 'builtin:model-management') {
|
|
handleCloseAuthDialog();
|
|
} else {
|
|
const ownedMode =
|
|
key === 'fastModel'
|
|
? 'fast'
|
|
: key === 'visionModel'
|
|
? 'vision'
|
|
: key === 'advisorModel'
|
|
? 'advisor'
|
|
: key === 'imageModel'
|
|
? 'image'
|
|
: key === 'voiceModel'
|
|
? 'voice'
|
|
: null;
|
|
if (ownedMode) {
|
|
setModelDialogMode((cur) => (cur === ownedMode ? null : cur));
|
|
}
|
|
}
|
|
settingsDialogKeyRef.current = undefined;
|
|
} else if (
|
|
!modelDialogMode &&
|
|
!showFallbacksDialog &&
|
|
!showApprovalModeDialog &&
|
|
!showAuthDialog &&
|
|
pendingVoicePickerSourceRef.current !== 'settings'
|
|
) {
|
|
settingsDialogKeyRef.current = undefined;
|
|
}
|
|
}, [
|
|
settingsPresentation,
|
|
showAuthDialog,
|
|
handleCloseAuthDialog,
|
|
modelDialogMode,
|
|
showFallbacksDialog,
|
|
showApprovalModeDialog,
|
|
]);
|
|
|
|
const useWorkspaceSkillSnapshot =
|
|
workspaceContextActive &&
|
|
loadedSkillsReady &&
|
|
(!connection.sessionId ||
|
|
connection.skills === undefined ||
|
|
(loadedSkillsFallback?.sessionId === connection.sessionId &&
|
|
loadedSkillsFallback.workspaceCwd === connection.workspaceCwd));
|
|
const composerSkills = useMemo(() => {
|
|
if (!workspaceContextActive) {
|
|
return pendingSessionContext === undefined && connection.sessionId
|
|
? availableSessionSkillInfos(
|
|
connection.skills ?? [],
|
|
connection.commands ?? [],
|
|
)
|
|
: [];
|
|
}
|
|
return useWorkspaceSkillSnapshot
|
|
? loadedSkills
|
|
: availableSessionSkillInfos(
|
|
connection.skills ?? [],
|
|
connection.commands ?? [],
|
|
);
|
|
}, [
|
|
connection.commands,
|
|
connection.sessionId,
|
|
connection.skills,
|
|
loadedSkills,
|
|
pendingSessionContext,
|
|
useWorkspaceSkillSnapshot,
|
|
workspaceContextActive,
|
|
]);
|
|
const commands = useMemo(() => {
|
|
const previousSkillNames = new Set(
|
|
(connection.skills ?? []).map((skill) => skill.toLowerCase()),
|
|
);
|
|
const connectionCommands =
|
|
!workspaceContextActive &&
|
|
(pendingSessionContext !== undefined || !connection.sessionId)
|
|
? []
|
|
: (connection.commands ?? []);
|
|
const retainedCommands = useWorkspaceSkillSnapshot
|
|
? connectionCommands.filter(
|
|
(command) =>
|
|
command.source !== 'skill' &&
|
|
!previousSkillNames.has(command.name.toLowerCase()),
|
|
)
|
|
: connectionCommands;
|
|
const refreshedSkillCommands = useWorkspaceSkillSnapshot
|
|
? loadedSkills.map((skill) => ({
|
|
name: skill.name,
|
|
description: skill.description,
|
|
...(skill.argumentHint ? { argumentHint: skill.argumentHint } : {}),
|
|
source: 'skill',
|
|
displayCategory: 'skill' as const,
|
|
}))
|
|
: [];
|
|
return localizeBuiltinDescriptions(
|
|
mergeCommands(
|
|
retainedCommands,
|
|
refreshedSkillCommands,
|
|
getLocalCommands(t, {
|
|
sideTaskAvailable: sideTasksAvailable,
|
|
subscriptionAvailable,
|
|
}),
|
|
[...additionalSlashCommands],
|
|
),
|
|
t,
|
|
)
|
|
.filter(
|
|
(command) =>
|
|
subscriptionAvailable ||
|
|
!['logout', 'upgrade'].includes(command.name) ||
|
|
Boolean(command.source && command.source !== 'builtin-command'),
|
|
)
|
|
.filter(
|
|
(command) =>
|
|
workspaceContextActive ||
|
|
!NON_WORKSPACE_BLOCKED_COMMANDS.has(command.name.toLowerCase()),
|
|
)
|
|
.filter(
|
|
(command) =>
|
|
!hiddenCommands.has(normalizeHiddenCommand(command.name)) &&
|
|
(modelManagementPolicy.allowAdd ||
|
|
!(
|
|
command.name === 'auth' && command.source === 'builtin-command'
|
|
) ||
|
|
additionalSlashCommands.some(
|
|
(entry) => entry.name === command.name,
|
|
)),
|
|
)
|
|
.map((command) => {
|
|
const skillKey = skillDescriptionKey(command.name);
|
|
if (!skillKey) return command;
|
|
return {
|
|
...command,
|
|
displayCategory: 'skill' as const,
|
|
description: t(skillKey),
|
|
};
|
|
});
|
|
}, [
|
|
additionalSlashCommands,
|
|
subscriptionAvailable,
|
|
modelManagementPolicy.allowAdd,
|
|
connection.commands,
|
|
connection.sessionId,
|
|
connection.skills,
|
|
hiddenCommands,
|
|
loadedSkills,
|
|
pendingSessionContext,
|
|
sideTasksAvailable,
|
|
t,
|
|
useWorkspaceSkillSnapshot,
|
|
workspaceContextActive,
|
|
]);
|
|
|
|
const welcomeHeaderProps = useMemo(
|
|
() => ({
|
|
version: connection.capabilities?.qwenCodeVersion || '',
|
|
cwd: workspaceContextActive ? connection.workspaceCwd || '' : '',
|
|
currentModel,
|
|
currentMode,
|
|
hideTips: hideTipsSetting?.values.effective === true,
|
|
}),
|
|
[
|
|
connection.capabilities?.qwenCodeVersion,
|
|
connection.workspaceCwd,
|
|
currentModel,
|
|
currentMode,
|
|
hideTipsSetting?.values.effective,
|
|
workspaceContextActive,
|
|
],
|
|
);
|
|
|
|
const welcomeHeader = useMemo(
|
|
() => (
|
|
<>
|
|
{renderWelcomeHeader ? (
|
|
renderWelcomeHeader(welcomeHeaderProps)
|
|
) : (
|
|
<WelcomeHeader {...welcomeHeaderProps} />
|
|
)}
|
|
</>
|
|
),
|
|
[renderWelcomeHeader, welcomeHeaderProps],
|
|
);
|
|
const welcomeFooter = useMemo(
|
|
() => renderWelcomeFooter?.(welcomeHeaderProps),
|
|
[renderWelcomeFooter, welcomeHeaderProps],
|
|
);
|
|
const isChatEmptyState =
|
|
!managedMode &&
|
|
!connection.sessionId &&
|
|
displayMessages.length === 0 &&
|
|
!showFloatingTodos &&
|
|
!pendingApproval &&
|
|
!btwMessage;
|
|
// Custom header renderers require a session. The built-in empty-state
|
|
// header exposes only configured workspace actions with a resolved owner.
|
|
const emptyStateChatHeader =
|
|
isChatEmptyState &&
|
|
Boolean(header) &&
|
|
!renderChatHeader &&
|
|
workspaceContextActive &&
|
|
Boolean(activeWorkspaceCwd) &&
|
|
mainView === 'chat' &&
|
|
((rightPanelHeaderItemVisible && emptyStateRightPanelItems.length > 0) ||
|
|
showMobileAccess ||
|
|
workspaceHostsEnabled);
|
|
const handleAgentCollaborationError = useCallback(
|
|
(message: string) => pushToast('error', message),
|
|
[pushToast],
|
|
);
|
|
// Agents @-mentioned in this chat answer inside it: the session routes of
|
|
// the session's workspace.
|
|
const sessionAgentsApi = useMemo(
|
|
() =>
|
|
collaborationAvailable && legacyWorkspaceContextCwd
|
|
? createSessionAgentsHttpApi(
|
|
workspace.baseUrl,
|
|
workspace.token,
|
|
legacyWorkspaceContextCwd,
|
|
workspace.client,
|
|
)
|
|
: undefined,
|
|
[
|
|
collaborationAvailable,
|
|
legacyWorkspaceContextCwd,
|
|
workspace.baseUrl,
|
|
workspace.token,
|
|
workspace.client,
|
|
],
|
|
);
|
|
// The session an @-mention goes to, with the workspace it lives in: a new
|
|
// chat's session is created in the composer's workspace, which can differ
|
|
// from the one this hook's routes were built for. Same resolution as an
|
|
// ordinary first prompt (`promptWorkspaceCwd` in the submit path).
|
|
const ensureAgentMentionSession = useCallback(async () => {
|
|
const existing = connectionRef.current.sessionId;
|
|
const allocated = existing ? undefined : await ensureSessionForPrompt();
|
|
const sessionId = existing ?? allocated ?? connectionRef.current.sessionId;
|
|
if (!sessionId) return undefined;
|
|
const allocatedOwner = allocatedSessionCatalogOwnerRef.current;
|
|
const workspaceCwd =
|
|
allocatedOwner?.sessionId === sessionId
|
|
? allocatedOwner.workspaceCwd
|
|
: getComposerWorkspaceCwd();
|
|
return { sessionId, ...(workspaceCwd ? { workspaceCwd } : {}) };
|
|
}, [ensureSessionForPrompt, getComposerWorkspaceCwd]);
|
|
const settledAgentRunsKey = useMemo(
|
|
() => settledAgentRunKey(blocks),
|
|
[blocks],
|
|
);
|
|
const settledAgentRunIds = useMemo(
|
|
() => new Set(settledAgentRunsKey ? settledAgentRunsKey.split('\n') : []),
|
|
[settledAgentRunsKey],
|
|
);
|
|
const sessionAgentRuns = useSessionAgentRuns({
|
|
api: sessionAgentsApi,
|
|
sessionId: connection.sessionId,
|
|
settledRunIds: settledAgentRunIds,
|
|
});
|
|
const reportAgentError = useCallback(
|
|
(error: unknown) =>
|
|
pushToast(
|
|
'error',
|
|
error instanceof Error ? error.message : String(error),
|
|
),
|
|
[pushToast],
|
|
);
|
|
const cancelSessionAgentRun = useCallback(
|
|
async (runId: string) => {
|
|
const sessionId = connectionRef.current.sessionId;
|
|
if (!sessionAgentsApi || !sessionId) return;
|
|
try {
|
|
await sessionAgentsApi.cancelRun(sessionId, runId);
|
|
} catch (error) {
|
|
reportAgentError(error);
|
|
}
|
|
},
|
|
[reportAgentError, sessionAgentsApi],
|
|
);
|
|
const stopAllSessionAgents = useCallback(async () => {
|
|
const sessionId = connectionRef.current.sessionId;
|
|
if (!sessionAgentsApi || !sessionId) return;
|
|
try {
|
|
await sessionAgentsApi.stopAll(sessionId);
|
|
} catch (error) {
|
|
reportAgentError(error);
|
|
}
|
|
}, [reportAgentError, sessionAgentsApi]);
|
|
const respondToSessionAgentPermission = useCallback(
|
|
async (runId: string, requestId: string, optionId: string) => {
|
|
const sessionId = connectionRef.current.sessionId;
|
|
if (!sessionAgentsApi || !sessionId) return;
|
|
try {
|
|
await sessionAgentsApi.respondToPermission(
|
|
sessionId,
|
|
runId,
|
|
requestId,
|
|
optionId,
|
|
);
|
|
} catch (error) {
|
|
reportAgentError(error);
|
|
// Rethrown so the card comes back for another vote.
|
|
throw error;
|
|
}
|
|
},
|
|
[reportAgentError, sessionAgentsApi],
|
|
);
|
|
const retrySessionAgentRunRequest = sessionAgentRuns.retry;
|
|
const retrySessionAgentRun = useCallback(
|
|
async (runId: string) => {
|
|
try {
|
|
await retrySessionAgentRunRequest(runId);
|
|
} catch (error) {
|
|
reportAgentError(error);
|
|
}
|
|
},
|
|
[reportAgentError, retrySessionAgentRunRequest],
|
|
);
|
|
// The @-mentions recorded in the shown session, keyed so the list keeps its
|
|
// identity across streamed deltas (it changes far less often than blocks).
|
|
const recordedMentionTextsKey = useMemo(
|
|
() => JSON.stringify(recordedAgentMentionTexts(blocks)),
|
|
[blocks],
|
|
);
|
|
const recordedMentionTexts = useMemo(
|
|
() => JSON.parse(recordedMentionTextsKey) as string[],
|
|
[recordedMentionTextsKey],
|
|
);
|
|
const agentChatEntry = useAgentChatEntry({
|
|
enabled: collaborationAvailable,
|
|
t,
|
|
cwd: legacyWorkspaceContextCwd,
|
|
baseUrl: workspace.baseUrl,
|
|
token: workspace.token,
|
|
client: workspace.client,
|
|
sessionApi: sessionAgentsApi,
|
|
ensureSession: ensureAgentMentionSession,
|
|
onSubmit: handleEditorSubmit,
|
|
onError: handleAgentCollaborationError,
|
|
sessionId: connection.sessionId,
|
|
recordedMentionTexts,
|
|
onCreateAgent: () => {
|
|
setAgentsNav((current) => ({
|
|
view: 'new-agent',
|
|
request: current.request + 1,
|
|
}));
|
|
setAgentsCreateScope(null);
|
|
openPanel('agents');
|
|
},
|
|
});
|
|
const sessionAgentTail = useMemo(
|
|
() =>
|
|
sessionAgentRuns.runs.length > 0 ||
|
|
agentChatEntry.pendingMentions.length > 0 ? (
|
|
<>
|
|
<PendingAgentMentions mentions={agentChatEntry.pendingMentions} />
|
|
<SessionAgentLiveRuns
|
|
runs={sessionAgentRuns.runs}
|
|
onCancel={cancelSessionAgentRun}
|
|
onRespond={respondToSessionAgentPermission}
|
|
onRetry={retrySessionAgentRun}
|
|
/>
|
|
</>
|
|
) : undefined,
|
|
[
|
|
agentChatEntry.pendingMentions,
|
|
cancelSessionAgentRun,
|
|
respondToSessionAgentPermission,
|
|
retrySessionAgentRun,
|
|
sessionAgentRuns.runs,
|
|
],
|
|
);
|
|
const composerAtProviders = useMemo(
|
|
() =>
|
|
collaborationAvailable
|
|
? [...(atProviders ?? []), ...agentChatEntry.providers]
|
|
: atProviders,
|
|
[atProviders, collaborationAvailable, agentChatEntry.providers],
|
|
);
|
|
const visibleComposerToolbarActions = useMemo<
|
|
readonly ComposerToolbarAction[]
|
|
>(() => {
|
|
const defaults =
|
|
isChatEmptyState || !environmentGitReplacementEnabled
|
|
? DEFAULT_EMPTY_COMPOSER_TOOLBAR_ACTIONS
|
|
: DEFAULT_COMPOSER_TOOLBAR_ACTIONS;
|
|
const configured =
|
|
composerToolbarActions ??
|
|
(composerToolbarAdditionalActions?.length
|
|
? [...defaults, ...composerToolbarAdditionalActions]
|
|
: defaults);
|
|
return isStandaloneModelPickerUnavailable({
|
|
sessionId: connection.sessionId,
|
|
sessionContextKind: effectiveSessionContext?.kind,
|
|
models: connection.models,
|
|
})
|
|
? configured.filter((action) => action !== 'model')
|
|
: configured;
|
|
}, [
|
|
composerToolbarActions,
|
|
composerToolbarAdditionalActions,
|
|
connection.models,
|
|
connection.sessionId,
|
|
environmentGitReplacementEnabled,
|
|
effectiveSessionContext?.kind,
|
|
isChatEmptyState,
|
|
]);
|
|
const composerNamesWorkspace =
|
|
composerWorkspaceSelectEnabled &&
|
|
visibleComposerToolbarActions.includes('workspace') &&
|
|
composerWorkspaces !== undefined &&
|
|
(composerWorkspaces.length > 1 ||
|
|
scratchWorkspaceRegistrationSupported ||
|
|
dynamicWorkspaceRegistrationSupported ||
|
|
(standaloneSessionsSupported && !lockedWorkspaceCwd));
|
|
const useMobileWelcomeMiddleLayout =
|
|
isChatEmptyState && mobileWelcomeFooterMiddle;
|
|
const showMobileWelcomeFooterMiddle =
|
|
useMobileWelcomeMiddleLayout && Boolean(welcomeFooter);
|
|
const hasWelcomeMiddle = isChatEmptyState && showMobileWelcomeFooterMiddle;
|
|
const hasMobileComposerBottom =
|
|
isChatEmptyState && useMobileWelcomeMiddleLayout;
|
|
const missingSession =
|
|
connection.status !== 'connecting' &&
|
|
!connection.sessionId &&
|
|
connection.missingSession === true;
|
|
const showMissingSessionState =
|
|
missingSession && !activePanel && mainView === 'chat';
|
|
const effectiveChatWidthMode: ChatWidthMode = isChatEmptyState
|
|
? getDefaultChatWidthMode()
|
|
: chatWidthMode;
|
|
const activeGitBranch = sessionWorktree
|
|
? (selectedWorkspaceGitStatus?.branch ?? sessionWorktree.branch)
|
|
: sessionBranch
|
|
? (selectedWorkspaceGitStatus?.branch ?? sessionBranch.name)
|
|
: connection.sessionId
|
|
? (connection.gitBranch ??
|
|
selectedWorkspaceGitStatus?.branch ??
|
|
undefined)
|
|
: (selectedWorkspaceGitStatus?.branch ?? undefined);
|
|
// The rail adds 56px of persistent navigation chrome beside the Home
|
|
// column. Exclude it from the dock budget so the environment panel keeps
|
|
// docking at the same window widths hosts had before the rail (1440px
|
|
// laptops included): the message area yields those 56px rather than the
|
|
// panel losing its dock.
|
|
const environmentPanelCanDock =
|
|
contextBodyWidth === null ||
|
|
contextBodyWidth +
|
|
(navigationRailVisible && !sidebarCollapsedEffective
|
|
? SIDEBAR_RAIL_WIDTH
|
|
: 0) >=
|
|
MIN_DOCKED_MESSAGE_AREA_WIDTH + DOCKED_ENVIRONMENT_PANEL_WIDTH;
|
|
const environmentPanelFits =
|
|
chatWidthMode !== 'wide' && environmentPanelCanDock;
|
|
const environmentPanelVisible =
|
|
!managedMode &&
|
|
environmentPanelOpen &&
|
|
!isChatEmptyState &&
|
|
!activePanel &&
|
|
mainView === 'chat';
|
|
const workspaceGitStatusEnabled =
|
|
!managedMode &&
|
|
!activePanel &&
|
|
!artifactPanelFullscreen &&
|
|
!showMissingSessionState &&
|
|
mainView === 'chat' &&
|
|
(visibleComposerToolbarActions.includes('gitBranch') ||
|
|
(environmentGitReplacementEnabled && environmentPanelVisible));
|
|
// Worktree sessions query git status with the worktree path (?cwd=
|
|
// parameter); the chip prefers the live branch from that status, falling
|
|
// back to the creation-time sessionWorktree.branch.
|
|
const gitStatusTarget = activeWorkspaceCwd
|
|
? `${connection.sessionId ?? ''}:${sessionWorktree?.path ?? activeWorkspaceCwd}`
|
|
: undefined;
|
|
useEffect(() => {
|
|
if (!activeWorkspaceCwd || isKnownLiveWorkspaceCwd(activeWorkspaceCwd)) {
|
|
gitStatusWorkspaceCwdRef.current = undefined;
|
|
setSelectedWorkspaceGitStatus(undefined);
|
|
return;
|
|
}
|
|
if (gitStatusWorkspaceCwdRef.current !== gitStatusTarget) {
|
|
gitStatusWorkspaceCwdRef.current = gitStatusTarget;
|
|
setSelectedWorkspaceGitStatus(undefined);
|
|
}
|
|
if (!workspaceGitStatusEnabled || !daemonAvailable) return;
|
|
let cancelled = false;
|
|
const fetchStatus = () => {
|
|
const git = workspace.client.workspaceByCwd(activeWorkspaceCwd);
|
|
// Fast path: last-known cache (branch-only on a cold start) paints the
|
|
// chip immediately.
|
|
void git
|
|
.workspaceGit({
|
|
cwd: sessionWorktree?.path,
|
|
sessionId: connection.sessionId,
|
|
})
|
|
.then((status) => {
|
|
if (!cancelled) {
|
|
setSelectedWorkspaceGitStatus((current) =>
|
|
isSameGitStatus(current, status) ? current : status,
|
|
);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
if (!cancelled && !isDaemonUnavailableError(err)) {
|
|
setSelectedWorkspaceGitStatus(undefined);
|
|
}
|
|
});
|
|
// Fresh path: resolves when the daemon's recomputation lands, so the
|
|
// enriched counters fill in without depending on SSE — the
|
|
// `git_status_changed` push only flows on a per-session event stream,
|
|
// which doesn't exist before the first prompt (deferred connect).
|
|
// Daemon-side in-flight dedup shares one `git status` computation
|
|
// across both requests. Worktree `?cwd=` reads always compute
|
|
// directly, so a second request would be a duplicate there.
|
|
if (!sessionWorktree) {
|
|
void git
|
|
.workspaceGit({ wait: true, sessionId: connection.sessionId })
|
|
.then((status) => {
|
|
if (!cancelled) {
|
|
setSelectedWorkspaceGitStatus((current) =>
|
|
isSameGitStatus(current, status) ? current : status,
|
|
);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
if (!cancelled && !isDaemonUnavailableError(err)) {
|
|
console.warn('[web-shell] git status fresh path failed:', err);
|
|
}
|
|
});
|
|
}
|
|
};
|
|
fetchStatus();
|
|
// Refresh triggers stay on focus and on a slow poll for the active
|
|
// workspace only. A live branch change re-runs this effect via the
|
|
// connection.gitBranch dependency. With an active session the daemon's
|
|
// `git_status_changed` push (mirrored into chip state) additionally
|
|
// covers realtime updates between polls.
|
|
const onFocus = () => fetchStatus();
|
|
window.addEventListener('focus', onFocus);
|
|
const poll = window.setInterval(() => {
|
|
if (document.visibilityState === 'visible') fetchStatus();
|
|
}, 30_000);
|
|
return () => {
|
|
cancelled = true;
|
|
window.removeEventListener('focus', onFocus);
|
|
window.clearInterval(poll);
|
|
};
|
|
}, [
|
|
activeWorkspaceCwd,
|
|
connection.gitBranch,
|
|
connection.sessionId,
|
|
workspaceGitStatusEnabled,
|
|
daemonAvailable,
|
|
isKnownLiveWorkspaceCwd,
|
|
workspace.client,
|
|
sessionWorktree,
|
|
gitStatusTarget,
|
|
]);
|
|
const handleEnvironmentPanelOpenChange = useCallback(
|
|
(open: boolean) => {
|
|
persistEnvironmentPanelOpen(open);
|
|
if (!open) {
|
|
preserveEnvironmentPanelOnArtifactOpenRef.current = false;
|
|
setEnvironmentPanelOpen(false);
|
|
return;
|
|
}
|
|
setEnvironmentPanelOpen(true);
|
|
setBackgroundTasksRefreshTrigger((value) => value + 1);
|
|
},
|
|
[persistEnvironmentPanelOpen],
|
|
);
|
|
const dismissEnvironmentPanel = useCallback(() => {
|
|
preserveEnvironmentPanelOnArtifactOpenRef.current = false;
|
|
persistEnvironmentPanelOpen(false);
|
|
setEnvironmentPanelOpen(false);
|
|
}, [persistEnvironmentPanelOpen]);
|
|
const handleRightPanelOpenChange = useCallback(
|
|
(open: boolean) => {
|
|
if (open) {
|
|
setArtifactPanelOpen(true);
|
|
} else {
|
|
closeArtifactPanel();
|
|
}
|
|
},
|
|
[closeArtifactPanel],
|
|
);
|
|
useLayoutEffect(() => {
|
|
const body = contextBodyRef.current;
|
|
if (!body) return;
|
|
const updateWidth = () => {
|
|
const availableWidth = body.getBoundingClientRect().width;
|
|
if (availableWidth <= 0) return;
|
|
setContextBodyWidth((current) =>
|
|
current === availableWidth ? current : availableWidth,
|
|
);
|
|
};
|
|
const handleWindowResize = () => {
|
|
preserveEnvironmentPanelOnArtifactOpenRef.current = false;
|
|
updateWidth();
|
|
};
|
|
updateWidth();
|
|
window.addEventListener('resize', handleWindowResize);
|
|
const observer = new ResizeObserver(updateWidth);
|
|
observer.observe(body);
|
|
return () => {
|
|
window.removeEventListener('resize', handleWindowResize);
|
|
observer.disconnect();
|
|
};
|
|
}, []);
|
|
const previousEnvironmentCanDockRef = useRef(environmentPanelCanDock);
|
|
useLayoutEffect(() => {
|
|
const crossedDockBreakpoint =
|
|
previousEnvironmentCanDockRef.current && !environmentPanelCanDock;
|
|
previousEnvironmentCanDockRef.current = environmentPanelCanDock;
|
|
if (
|
|
crossedDockBreakpoint &&
|
|
!preserveEnvironmentPanelOnArtifactOpenRef.current
|
|
) {
|
|
setEnvironmentPanelOpen(false);
|
|
}
|
|
}, [environmentPanelCanDock]);
|
|
const previousArtifactPanelOpenForEnvironmentRef = useRef(artifactPanelOpen);
|
|
useLayoutEffect(() => {
|
|
const artifactPanelJustOpened =
|
|
!previousArtifactPanelOpenForEnvironmentRef.current && artifactPanelOpen;
|
|
previousArtifactPanelOpenForEnvironmentRef.current = artifactPanelOpen;
|
|
if (!artifactPanelOpen) {
|
|
preserveEnvironmentPanelOnArtifactOpenRef.current = false;
|
|
return;
|
|
}
|
|
if (!artifactPanelJustOpened) return;
|
|
const preserveEnvironmentPanel =
|
|
preserveEnvironmentPanelOnArtifactOpenRef.current;
|
|
if (!preserveEnvironmentPanel && !environmentPanelFits) {
|
|
setEnvironmentPanelOpen(false);
|
|
}
|
|
}, [artifactPanelOpen, environmentPanelFits]);
|
|
const environmentPanelMounted =
|
|
!isChatEmptyState && !activePanel && mainView === 'chat';
|
|
const chatWidthToggleMin = getChatMaxWidth(chatMaxWidth);
|
|
|
|
const appClassName = [
|
|
styles.app,
|
|
styles.appChat,
|
|
isChatEmptyState ? styles.appChatEmpty : undefined,
|
|
sidebarOptions.enabled ? styles.appWithSidebar : undefined,
|
|
selectedTheme === WebShellThemeId.Light
|
|
? styles.themeLight
|
|
: styles.themeDark,
|
|
selectedTheme === WebShellThemeId.Dark ? 'dark' : undefined,
|
|
externalClassName,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ');
|
|
const appStyle = useMemo(
|
|
() => ({
|
|
...externalStyle,
|
|
...getChatWidthStyle(effectiveChatWidthMode, chatMaxWidth),
|
|
}),
|
|
[chatMaxWidth, effectiveChatWidthMode, 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]);
|
|
|
|
useLayoutEffect(() => {
|
|
const host = shadowDomOptions.portals
|
|
? document.createElement('div')
|
|
: null;
|
|
const shadowRoot = host?.attachShadow({ mode: 'open' }) ?? null;
|
|
const root = document.createElement('div');
|
|
root.dataset.webShellPortalRoot = '';
|
|
root.dataset.webShellShadcn = '';
|
|
if (host && shadowRoot) {
|
|
host.dataset.webShellShadowHost = 'portals';
|
|
host.style.setProperty('all', 'initial', 'important');
|
|
host.style.setProperty('position', 'fixed', 'important');
|
|
host.style.setProperty('inset', '0', 'important');
|
|
host.style.setProperty('width', '0', 'important');
|
|
host.style.setProperty('height', '0', 'important');
|
|
host.style.setProperty(
|
|
'z-index',
|
|
'var(--web-shell-portal-root-z-index, 1000)',
|
|
'important',
|
|
);
|
|
const removeStyles = installWebShellShadowStyles(
|
|
shadowRoot,
|
|
shadowDomOptions.styles,
|
|
);
|
|
shadowRoot.appendChild(root);
|
|
document.body.appendChild(host);
|
|
setPortalRoot(root);
|
|
return () => {
|
|
host.remove();
|
|
removeStyles();
|
|
setPortalRoot(null);
|
|
};
|
|
} else {
|
|
document.body.appendChild(root);
|
|
setPortalRoot(root);
|
|
return () => {
|
|
root.remove();
|
|
setPortalRoot(null);
|
|
};
|
|
}
|
|
}, [shadowDomOptions.portals, shadowDomOptions.styles]);
|
|
|
|
useLayoutEffect(() => {
|
|
const root = appRootRef.current;
|
|
if (!root || !portalRoot) return;
|
|
const portalRootNode = portalRoot.getRootNode();
|
|
const portalHost =
|
|
portalRootNode instanceof ShadowRoot
|
|
? (portalRootNode.host as HTMLElement)
|
|
: null;
|
|
let frameId: number | null = null;
|
|
const syncVariables = () => {
|
|
frameId = null;
|
|
const computedStyle = getComputedStyle(root);
|
|
const nextNames = new Set<string>();
|
|
portalRoot.dataset.webShellShadcn = '';
|
|
portalRoot.classList.toggle(
|
|
'dark',
|
|
selectedTheme === WebShellThemeId.Dark,
|
|
);
|
|
portalRoot.lang = selectedLanguage;
|
|
for (let index = 0; index < computedStyle.length; index += 1) {
|
|
const name = computedStyle[index];
|
|
if (!name.startsWith('--')) continue;
|
|
nextNames.add(name);
|
|
const value = computedStyle.getPropertyValue(name);
|
|
portalRoot.style.setProperty(name, value);
|
|
portalHost?.style.setProperty(name, value);
|
|
}
|
|
for (const name of portalRootVariableNamesRef.current) {
|
|
if (!nextNames.has(name)) {
|
|
portalRoot.style.removeProperty(name);
|
|
portalHost?.style.removeProperty(name);
|
|
}
|
|
}
|
|
portalRootVariableNamesRef.current = nextNames;
|
|
};
|
|
const scheduleSync = () => {
|
|
if (frameId === null) frameId = requestAnimationFrame(syncVariables);
|
|
};
|
|
syncVariables();
|
|
const observer = new MutationObserver(scheduleSync);
|
|
let element: HTMLElement | null = root;
|
|
while (element) {
|
|
observer.observe(element, {
|
|
attributes: true,
|
|
attributeFilter: ['class', 'style', 'data-theme', 'lang'],
|
|
});
|
|
element = element.parentElement;
|
|
}
|
|
window.addEventListener('resize', scheduleSync);
|
|
return () => {
|
|
observer.disconnect();
|
|
window.removeEventListener('resize', scheduleSync);
|
|
if (frameId !== null) cancelAnimationFrame(frameId);
|
|
};
|
|
}, [appClassName, appStyle, portalRoot, selectedLanguage, selectedTheme]);
|
|
|
|
const prepareContextCompression = useCallback(() => {
|
|
disarmSubmittedPromptRetry();
|
|
setShowRetryHint(false);
|
|
clearFollowup();
|
|
}, [clearFollowup, disarmSubmittedPromptRetry]);
|
|
const preparePaneContextCompression = useCallback(
|
|
(sessionId: string) => {
|
|
if (sessionId === connection.sessionId) prepareContextCompression();
|
|
},
|
|
[connection.sessionId, prepareContextCompression],
|
|
);
|
|
const primaryContextControls = useContextUsageControls({
|
|
connection,
|
|
actions: sessionActions,
|
|
ownerGuard: sessionOwnerGuard,
|
|
onBeforeCompress: prepareContextCompression,
|
|
busy: streamingState !== 'idle' || sessionHasActivePrompt,
|
|
writeBlocked:
|
|
isDisabled ||
|
|
isStartingNewSessionSuggestion ||
|
|
approvalOverlayActive ||
|
|
unknownPromptAdmission?.payloadAvailable === true,
|
|
});
|
|
const [paneContextControls, setPaneContextControls] = useState<
|
|
Record<string, ContextUsageControls>
|
|
>({});
|
|
const registerContextUsageControls =
|
|
useCallback<RegisterContextUsageControls>((controls) => {
|
|
setPaneContextControls((current) => ({
|
|
...current,
|
|
[controls.sessionId]: controls,
|
|
}));
|
|
return () =>
|
|
setPaneContextControls((current) => {
|
|
if (current[controls.sessionId] !== controls) return current;
|
|
const next = { ...current };
|
|
delete next[controls.sessionId];
|
|
return next;
|
|
});
|
|
}, []);
|
|
const [compressionResults, setCompressionResults] = useState<
|
|
Record<string, NonNullable<ContextUsageControls['result']>>
|
|
>({});
|
|
const observedCompressionResults = useRef(
|
|
new WeakSet<NonNullable<ContextUsageControls['result']>>(),
|
|
);
|
|
const reconciledContextReaders = useRef(
|
|
new WeakMap<
|
|
ContextUsageControls['getContextUsage'],
|
|
{
|
|
result: NonNullable<ContextUsageControls['result']>;
|
|
owner: ReturnType<ContextUsageControls['captureOwner']>;
|
|
}
|
|
>(),
|
|
);
|
|
useEffect(() => {
|
|
const updates: typeof compressionResults = {};
|
|
const owners = [
|
|
primaryContextControls,
|
|
...Object.values(paneContextControls),
|
|
];
|
|
for (const controls of owners) {
|
|
const result = controls?.result;
|
|
if (result && !observedCompressionResults.current.has(result)) {
|
|
observedCompressionResults.current.add(result);
|
|
updates[controls.sessionId] = result;
|
|
reconciledContextReaders.current.set(controls.getContextUsage, {
|
|
result,
|
|
owner: controls.captureOwner(),
|
|
});
|
|
}
|
|
}
|
|
if (Object.keys(updates).length > 0)
|
|
setCompressionResults((current) => ({ ...current, ...updates }));
|
|
for (const controls of owners) {
|
|
const result =
|
|
controls &&
|
|
(updates[controls.sessionId] ?? compressionResults[controls.sessionId]);
|
|
const reconciled =
|
|
controls &&
|
|
reconciledContextReaders.current.get(controls.getContextUsage);
|
|
if (
|
|
!controls?.canCompress ||
|
|
!result ||
|
|
result.kind === 'failed' ||
|
|
(reconciled?.result === result && reconciled.owner.isCurrent())
|
|
)
|
|
continue;
|
|
reconciledContextReaders.current.set(controls.getContextUsage, {
|
|
result,
|
|
owner: controls.captureOwner(),
|
|
});
|
|
// Reattachment can restore old replay counters even on the same reader.
|
|
// Reconcile through its current owner, never copy another one's counters.
|
|
void controls
|
|
.getContextUsage({ silent: true, syncCounters: true })
|
|
.catch(() => undefined);
|
|
}
|
|
}, [primaryContextControls, paneContextControls, compressionResults]);
|
|
// A compression typed into the composer never reaches the controls above, so
|
|
// the transcript outcome reconciles the ring for both entry points.
|
|
useContextCompressionReconcile({
|
|
blocks,
|
|
sessionId: connection.sessionId,
|
|
live:
|
|
connection.status === 'connected' &&
|
|
!connection.catchingUp &&
|
|
!connection.loadingTranscript,
|
|
currentModel: connection.currentModel ?? undefined,
|
|
contextWindow: connection.contextWindow ?? undefined,
|
|
getContextUsage: sessionActions.getContextUsage,
|
|
});
|
|
const reconciledContextControls = useRef(
|
|
new WeakMap<ContextUsageControls, ContextUsageControls>(),
|
|
);
|
|
const contextUsageControls = useMemo(() => {
|
|
const live =
|
|
primaryContextControls &&
|
|
(mainView !== 'split' ||
|
|
!splitSessionIds.includes(primaryContextControls.sessionId))
|
|
? {
|
|
...paneContextControls,
|
|
[primaryContextControls.sessionId]: primaryContextControls,
|
|
}
|
|
: paneContextControls;
|
|
return Object.fromEntries(
|
|
Object.entries(live).map(([id, controls]) => {
|
|
const result = controls.compressing
|
|
? undefined
|
|
: controls.result &&
|
|
!observedCompressionResults.current.has(controls.result)
|
|
? controls.result
|
|
: (compressionResults[id] ?? controls.result);
|
|
const previous = reconciledContextControls.current.get(controls);
|
|
const reconciled =
|
|
previous && previous.result === result
|
|
? previous
|
|
: { ...controls, result };
|
|
reconciledContextControls.current.set(controls, reconciled);
|
|
return [id, reconciled];
|
|
}),
|
|
);
|
|
}, [
|
|
mainView,
|
|
splitSessionIds,
|
|
paneContextControls,
|
|
primaryContextControls,
|
|
compressionResults,
|
|
]);
|
|
|
|
// Shared by the drawer and docked render sites below; only the genuine
|
|
// per-variant props (variant / panelWidth) stay at each site.
|
|
const artifactPanelSharedProps = {
|
|
onSelectTurnCallsPrompt: openTurnCalls,
|
|
artifacts: artifactPanelArtifacts,
|
|
tabs: artifactPanelTabs,
|
|
contextUsageControls,
|
|
activeTabId: activeArtifactPanelTabId,
|
|
reviewChanges,
|
|
selectedReviewPath,
|
|
workspaceCwd: panelWorkspaceCwd || '',
|
|
loading: artifactsLoading,
|
|
restoring: artifactPanelRestoring,
|
|
error: artifactsError,
|
|
onSelectTab: selectArtifactPanelTab,
|
|
onCloseTab: closeArtifactPanelTab,
|
|
onOpenFilePreview: openFilePreview,
|
|
onOpenMemoryFile: openMemoryFilePreview,
|
|
onOpenFiles: filesAvailable ? openFilesPanel : undefined,
|
|
onReferenceFile:
|
|
workspaceContextActive && !dialogOpen && mainView === 'chat'
|
|
? (path: string) => {
|
|
const tag = createComposerTagForItem(
|
|
FILE_PROVIDER_ID,
|
|
sanitizeAtMentionItem({
|
|
id: `file:${path}`,
|
|
label: path.split('/').at(-1) ?? path,
|
|
description: path,
|
|
kind: 'insert',
|
|
fileKind: 'file',
|
|
}),
|
|
fileReferenceInsertText(path),
|
|
);
|
|
if (!tag) return;
|
|
if (useFloatingArtifactPanel) {
|
|
focusComposerAfterArtifactPanelCloseRef.current = true;
|
|
closeArtifactPanel();
|
|
} else {
|
|
setArtifactPanelFullscreen(false);
|
|
}
|
|
editorRef.current?.addTags([tag], {
|
|
placement: 'inline',
|
|
position: 'end',
|
|
});
|
|
requestAnimationFrame(() => editorRef.current?.focus());
|
|
}
|
|
: undefined,
|
|
onOpenWebPreview: workspaceContextActive ? openWebPreviewTab : undefined,
|
|
onWebPreviewChange: updateWebPreviewTab,
|
|
latestReviewAvailable: latestReviewChanges.length > 0,
|
|
onOpenLatestReview: openLatestReviewPanel,
|
|
onOpenTrajectory: connection.sessionId
|
|
? () => openTrajectoryPanel(connection.sessionId!)
|
|
: undefined,
|
|
trajectoryTabId: connection.sessionId
|
|
? trajectoryTabId(connection.sessionId)
|
|
: undefined,
|
|
items: isChatEmptyState ? emptyStateRightPanelItems : rightPanelItems,
|
|
sideTaskAvailable: sideTasksAvailable,
|
|
sideTasks: visibleSideTasks,
|
|
archivedSideTasks: visibleArchivedSideTasks,
|
|
sideTasksLoading,
|
|
onCreateSideTask: createEmptySideTask,
|
|
onOpenSideTask: openSideTask,
|
|
onArchiveSideTasks: sideTaskArchiveAvailable ? archiveSideTasks : undefined,
|
|
onUnarchiveSideTasks: sideTaskRestoreAvailable
|
|
? unarchiveSideTasks
|
|
: undefined,
|
|
onSideTaskMenuOpen: refreshSideTaskCatalog,
|
|
onCreateSideTaskSession: createSideTaskSession,
|
|
onSideTaskCreated: handleSideTaskCreated,
|
|
onSideTaskTitleChange: handleSideTaskTitleChange,
|
|
onSideTaskInitialPromptRefused: handleSideTaskInitialPromptRefused,
|
|
onNestedRightPanelOpen: handleTurnOutputOpen,
|
|
onNestedArtifactsChange: handlePaneArtifactsChange,
|
|
onOpenNestedSubagent: openSubagentPanelForSession,
|
|
agentTasks: tracedEnvironmentAgentTasks,
|
|
agentTraceLoading: agentTraceInitialLoading,
|
|
agentTraceError: agentTraceFailed ? t('workflow.loadFailed') : undefined,
|
|
onOpenWorkflowAgent: openEnvironmentAgent,
|
|
onError: reportError,
|
|
sessionWorkflowEnabled,
|
|
modelManagement,
|
|
unavailableModels,
|
|
mountUnavailableModelTooltip,
|
|
workflow: sessionWorkflowEnabled
|
|
? {
|
|
todos: sessionWorkflowTodos,
|
|
tools: planAgentTools,
|
|
tasks: environmentAgentTasks,
|
|
projection: sessionWorkflowProjection,
|
|
artifacts,
|
|
selectedTodoId: selectedWorkflowTodoId,
|
|
onSelectedTodoIdChange: setSelectedWorkflowTodoId,
|
|
onExpandGraph: expandWorkflowGraph,
|
|
onOpenSubagent: openSubagentPanel,
|
|
onOpenArtifact: openArtifactPanel,
|
|
canvasMode: mainView === 'cockpit',
|
|
}
|
|
: undefined,
|
|
onImageIngestionNotice: pushToast,
|
|
deferSubagentMount,
|
|
onClose: closeArtifactPanel,
|
|
fullscreen: artifactPanelFullscreen,
|
|
onToggleFullscreen: toggleArtifactPanelFullscreen,
|
|
};
|
|
const environmentPanelOwner = sessionOwnerGuard.capture();
|
|
const panelAttachments = logicalSessionKey
|
|
? sessionAttachmentsBySessionRef.current.get(logicalSessionKey)
|
|
: undefined;
|
|
const sourceEntries = useMemo(
|
|
() =>
|
|
getSourceEntries(
|
|
sourcesState.supported ? sourcesState.sources : [],
|
|
panelAttachments ?? [],
|
|
),
|
|
[sourcesState.supported, sourcesState.sources, panelAttachments],
|
|
);
|
|
const openTurnSource = useCallback(
|
|
(entry: WebShellSource) => {
|
|
const owner = sourcesState.owner;
|
|
if (!owner.isCurrent()) return;
|
|
openSourceEntry(entry, {
|
|
onOpen: openSourcePanel,
|
|
onReadImage: readSessionImage,
|
|
onImagePreview: (src, alt, source) => {
|
|
if (owner.isCurrent()) openImagePanel(src, alt, source);
|
|
},
|
|
onAttachmentPreview: (file) => {
|
|
if (owner.isCurrent())
|
|
openAttachmentPanel(file, undefined, undefined, true);
|
|
},
|
|
onAttachmentPreviewError: (error) => {
|
|
if (owner.isCurrent())
|
|
pushToast(
|
|
'error',
|
|
t('rightPanel.attachmentLoadFailed', {
|
|
error: formatError(error, t('environment.unavailable')),
|
|
}),
|
|
);
|
|
},
|
|
});
|
|
},
|
|
[
|
|
sourcesState.owner,
|
|
openSourcePanel,
|
|
readSessionImage,
|
|
openImagePanel,
|
|
openAttachmentPanel,
|
|
pushToast,
|
|
t,
|
|
],
|
|
);
|
|
|
|
// BrandProvider sits above I18nProvider so portals and every pane see it. The
|
|
// prettier-ignore keeps adding it from re-indenting the whole subtree, the
|
|
// same reason WebShellPortalRootContext below carries one.
|
|
return (
|
|
<ThemeProvider value={selectedTheme}>
|
|
{/* prettier-ignore */}
|
|
<BrandProvider value={resolvedBrand}>
|
|
<I18nProvider language={selectedLanguage}>
|
|
<McpAppMessagesContext.Provider value={mcpAppMessages.context}>
|
|
<McpAppModelContextContext.Provider value={mcpAppModelContext}>
|
|
<McpAppHostContext.Provider value={workspace.baseUrl}>
|
|
{/* prettier-ignore */}
|
|
<WebShellPortalRootContext.Provider value={portalRoot}>
|
|
{/* Compact view is fixed on for every message surface (main chat,
|
|
split panes, subagent detail, drawer) — no toggle remains. */}
|
|
<CompactModeContext.Provider value={true}>
|
|
<div
|
|
ref={appRootRef}
|
|
className={appClassName}
|
|
style={appStyle}
|
|
data-web-shell-root
|
|
data-web-shell-shadcn
|
|
data-compact-sidebar={compactShell ? '' : undefined}
|
|
lang={selectedLanguage}
|
|
>
|
|
<GeneratedUiRuntimeProvider>
|
|
{mcpAppMessages.review}
|
|
{capacityRecovery && <CapacityRecoveryDialog intent={capacityRecovery} onClose={dismissCapacityRecovery} />}
|
|
{!onToast && (
|
|
<ToastHost
|
|
toasts={toasts}
|
|
onDismiss={dismissToast}
|
|
elevated={artifactPanelFullscreen}
|
|
/>
|
|
)}
|
|
{workspaceContextActive && showResumeDialog && (
|
|
<DialogShell
|
|
title={t('resume.title')}
|
|
size="lg"
|
|
onClose={() => setShowResumeDialog(false)}
|
|
>
|
|
<ResumeDialog
|
|
workspaceCwd={lockedWorkspaceCwd}
|
|
onSelect={(sessionId) => {
|
|
loadSidebarSession(sessionId).catch((error: unknown) => {
|
|
reportError(error, 'Failed to load session');
|
|
});
|
|
}}
|
|
onClose={() => setShowResumeDialog(false)}
|
|
/>
|
|
</DialogShell>
|
|
)}
|
|
{modelDialogMode &&
|
|
(projectFeaturesAvailable || modelDialogMode === 'main') && (
|
|
<DialogShell
|
|
title={t(MODE_TITLE_KEY[modelDialogMode])}
|
|
size="lg"
|
|
onClose={() => setModelDialogMode(null)}
|
|
>
|
|
<ModelDialog
|
|
mode={modelDialogMode}
|
|
modelRefresh={
|
|
modelDialogMode === 'main' ? modelRefresh : undefined
|
|
}
|
|
models={modelDialogModels[modelDialogMode]}
|
|
loading={
|
|
(modelDialogMode === 'advisor' && providersState.loading) ||
|
|
((modelDialogMode === 'advisor' || modelDialogMode === 'image') &&
|
|
modelConfigurations.loading)
|
|
}
|
|
error={
|
|
modelDialogMode === 'advisor'
|
|
? providersState.error ?? modelConfigurations.error
|
|
: modelDialogMode === 'image'
|
|
? modelConfigurations.error
|
|
: undefined
|
|
}
|
|
filterModel={
|
|
modelDialogMode === 'main' ? mainModelFilter : undefined
|
|
}
|
|
currentModelId={modelDialogCurrent[modelDialogMode]}
|
|
onSelect={(modelId) => {
|
|
if (modelDialogMode) {
|
|
modelHandlers[modelDialogMode](modelId);
|
|
}
|
|
setModelDialogMode(null);
|
|
}}
|
|
/>
|
|
</DialogShell>
|
|
)}
|
|
{!managedMode && showApprovalModeDialog && (
|
|
<DialogShell
|
|
title={t('mode.select')}
|
|
size="sm"
|
|
onClose={() => setShowApprovalModeDialog(false)}
|
|
>
|
|
<ApprovalModeDialog
|
|
currentMode={executionMode}
|
|
availableModes={availableApprovalModes}
|
|
sessionWorkflowEnabled={sessionWorkflowEnabled}
|
|
onSelect={(modeId) => {
|
|
handleSetMode(modeId);
|
|
setShowApprovalModeDialog(false);
|
|
}}
|
|
/>
|
|
</DialogShell>
|
|
)}
|
|
{showToolsDialog && (
|
|
<DialogShell
|
|
title={t('tools.title')}
|
|
size="lg"
|
|
onClose={() => setShowToolsDialog(false)}
|
|
>
|
|
<ToolsDialog />
|
|
</DialogShell>
|
|
)}
|
|
{projectFeaturesAvailable && gitDialog && (
|
|
<GitDialog
|
|
key={`${gitDialog.workspaceCwd}:${gitDialog.gitCwd ?? ''}:${gitDialog.gitSessionId ?? ''}:${gitDialog.view}`}
|
|
workspaceCwd={gitDialog.workspaceCwd}
|
|
gitCwd={gitDialog.gitCwd}
|
|
initialView={gitDialog.view}
|
|
sessionId={gitDialog.gitSessionId ?? connection.sessionId}
|
|
resolveSessionForWorkspace={resolveSessionForWorkspace}
|
|
onOpenSession={(sessionId) => {
|
|
const { workspaceCwd } = gitDialog;
|
|
setGitDialog(undefined);
|
|
handleOpenSessionFromOverview(sessionId, workspaceCwd);
|
|
}}
|
|
onNewWorktreeSession={() => {
|
|
const { workspaceCwd } = gitDialog;
|
|
setGitDialog(undefined);
|
|
void handleNewWorktreeSession(workspaceCwd);
|
|
}}
|
|
onClose={() => setGitDialog(undefined)}
|
|
/>
|
|
)}
|
|
{branchSessionDialog && (
|
|
<DialogShell
|
|
title={t('branch.dialog.title')}
|
|
size="sm"
|
|
onClose={() => {
|
|
if (!branchSessionDialogBusy) {
|
|
setBranchSessionDialog(undefined);
|
|
}
|
|
}}
|
|
>
|
|
<BranchSessionDialog
|
|
busy={branchSessionDialogBusy}
|
|
onCancel={() => setBranchSessionDialog(undefined)}
|
|
onConfirm={confirmBranchSession}
|
|
/>
|
|
</DialogShell>
|
|
)}
|
|
{tasksDialogMessage && (
|
|
<DialogShell
|
|
title={
|
|
sessionWorkflowEnabled && floatingTodos.length > 0
|
|
? t('planExecution.dialogTitle')
|
|
: t('tasks.title')
|
|
}
|
|
size="auto"
|
|
onClose={() => setTasksDialogMessage(null)}
|
|
>
|
|
<TasksStatusMessage
|
|
message={tasksDialogMessage}
|
|
embedded
|
|
manageActiveEvent={false}
|
|
includeWorkflows={workflowsEnabled}
|
|
onWorkflowRunStarted={() =>
|
|
setBackgroundTasksRefreshTrigger((value) => value + 1)
|
|
}
|
|
onClose={() => setTasksDialogMessage(null)}
|
|
planTodos={sessionWorkflowEnabled ? floatingTodos : []}
|
|
agentTools={
|
|
sessionWorkflowEnabled ? tasksDialogAgentTools : []
|
|
}
|
|
onOpenSubagent={(tool, parentSessionId) => {
|
|
setTasksDialogMessage(null);
|
|
openSubagentPanel(tool, parentSessionId);
|
|
}}
|
|
onOpenMonitor={handleOpenMonitorDetails}
|
|
/>
|
|
</DialogShell>
|
|
)}
|
|
{workspaceContextActive && showMemoryDialog && (
|
|
<DialogShell
|
|
title={t('memory.menu')}
|
|
size="lg"
|
|
onClose={() => setShowMemoryDialog(false)}
|
|
>
|
|
<MemoryMessage
|
|
refreshSignal={memoryRefreshSignal}
|
|
addSignal={memoryAddSignal}
|
|
addScope={memoryAddScope}
|
|
onMessage={(text, type = 'status') => {
|
|
store.dispatch([{ type, text }]);
|
|
}}
|
|
/>
|
|
</DialogShell>
|
|
)}
|
|
{showHelpDialog && (
|
|
<DialogShell
|
|
title={t('help.title')}
|
|
size="md"
|
|
onClose={() => setShowHelpDialog(false)}
|
|
>
|
|
<HelpDialog commands={commands} />
|
|
</DialogShell>
|
|
)}
|
|
{showThemeDialog && (
|
|
<DialogShell
|
|
title={t('theme.title')}
|
|
size="sm"
|
|
onClose={() => setShowThemeDialog(false)}
|
|
>
|
|
<ThemeDialog
|
|
currentTheme={selectedTheme}
|
|
onSelect={handleThemeChange}
|
|
onClose={() => setShowThemeDialog(false)}
|
|
/>
|
|
</DialogShell>
|
|
)}
|
|
{goalEditOpen && goalSnapshot?.goal && (
|
|
<GoalEditDialog
|
|
objective={goalSnapshot.goal.objective}
|
|
saving={goalControlBusy}
|
|
error={goalEditError}
|
|
onSave={handleGoalEditSave}
|
|
onClose={() => {
|
|
if (goalControlBusy) return;
|
|
setGoalEditOpen(false);
|
|
setGoalEditError(null);
|
|
}}
|
|
/>
|
|
)}
|
|
{modelManagementPolicy.allowAdd && showAuthDialog && (
|
|
<AuthMessage
|
|
dialog
|
|
standalone={isDesktopShell() && sessionIsStandalone}
|
|
allowAdd={modelManagementPolicy.allowAdd}
|
|
getWorkspaceCwd={getComposerWorkspaceCwd}
|
|
onModelActivated={selectWelcomeModel}
|
|
activateSessionSubscriptionModel={activateSessionSubscriptionModel}
|
|
onMessage={(text, type = 'status') => {
|
|
store.dispatch([
|
|
type === 'error'
|
|
? { type: 'error', text }
|
|
: { type: 'status', promptId: `local-command:${crypto.randomUUID()}`, text },
|
|
]);
|
|
}}
|
|
onClose={handleCloseAuthDialog}
|
|
/>
|
|
)}
|
|
{projectFeaturesAvailable && showFallbacksDialog && (
|
|
<DialogShell
|
|
title={t('settings.models.fallbacks.title')}
|
|
size="md"
|
|
onClose={() => setShowFallbacksDialog(false)}
|
|
>
|
|
<ModelFallbacksDialog
|
|
models={fallbackModelOptions}
|
|
current={currentModelFallbacks}
|
|
max={3}
|
|
onConfirm={handleFallbacksConfirm}
|
|
onClose={() => setShowFallbacksDialog(false)}
|
|
/>
|
|
</DialogShell>
|
|
)}
|
|
{workspaceContextActive && showDeleteDialog && (
|
|
<DialogShell
|
|
title={t('delete.title')}
|
|
size="lg"
|
|
onClose={() => setShowDeleteDialog(false)}
|
|
>
|
|
<DeleteSessionDialog
|
|
workspaceCwd={lockedWorkspaceCwd}
|
|
onDeleted={(sessionIds, meta) => {
|
|
closeUsageTabs(sessionIds);
|
|
// Deleting the attached session must leave the deleted
|
|
// conversation: open a fresh draft in the same context,
|
|
// mirroring the Session Overview (#12619). The daemon's
|
|
// terminal `session_closed` frame clears the attachment
|
|
// before the delete response resolves, so fall back to the
|
|
// id the dialog captured at confirm time.
|
|
const current = connectionRef.current;
|
|
const attachedId =
|
|
current.sessionId ?? meta?.attachedSessionId;
|
|
if (attachedId && sessionIds.includes(attachedId)) {
|
|
void handleCurrentSessionRemoved({
|
|
sessionId: attachedId,
|
|
workspaceCwd:
|
|
current.workspaceCwd ||
|
|
lockedWorkspaceCwd ||
|
|
workspacesRef.current.find((entry) => entry.primary)
|
|
?.cwd ||
|
|
'',
|
|
});
|
|
}
|
|
store.dispatch([
|
|
{
|
|
type: 'status',
|
|
promptId: `local-command:${crypto.randomUUID()}`,
|
|
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)}
|
|
/>
|
|
</DialogShell>
|
|
)}
|
|
{workspaceContextActive && showReleaseDialog && (
|
|
<DialogShell
|
|
title={t('release.title')}
|
|
size="lg"
|
|
onClose={() => setShowReleaseDialog(false)}
|
|
>
|
|
<ReleaseSessionDialog
|
|
workspaceCwd={lockedWorkspaceCwd}
|
|
onReleased={(sessionId) => {
|
|
store.dispatch([
|
|
{
|
|
type: 'status',
|
|
promptId: `local-command:${crypto.randomUUID()}`,
|
|
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)}
|
|
/>
|
|
</DialogShell>
|
|
)}
|
|
{showRewindDialog && (
|
|
<DialogShell
|
|
title={t('rewind.title')}
|
|
subtitle={t('rewind.subtitle')}
|
|
size="lg"
|
|
onClose={() => setShowRewindDialog(false)}
|
|
>
|
|
<RewindDialog
|
|
blocks={blocks}
|
|
loadSnapshots={loadRewindSnapshots}
|
|
rewind={rewindConversationOnly}
|
|
onError={handleRewindError}
|
|
onClose={() => setShowRewindDialog(false)}
|
|
/>
|
|
</DialogShell>
|
|
)}
|
|
{!lockedWorkspaceCwd &&
|
|
showAddWorkspaceDialog &&
|
|
(workspaceBrowseActiveRef.current && !workspaceCapabilitiesReady ? (
|
|
<WorkspaceAddStatusDialog
|
|
message={t(
|
|
workspace.status === 'error'
|
|
? 'workspaceHost.connectionError'
|
|
: 'workspaceHost.loadingFolders',
|
|
)}
|
|
tone={workspace.status === 'error' ? 'alert' : 'status'}
|
|
subtitle={workspaceAddLocationSubtitle}
|
|
onClose={closeAddWorkspaceDialog}
|
|
>
|
|
{workspaceExecutionField}
|
|
</WorkspaceAddStatusDialog>
|
|
) : workspaceBrowseActiveRef.current &&
|
|
workspaceAddLocationIsConnected &&
|
|
!dynamicWorkspaceRegistrationSupported ? (
|
|
<WorkspaceAddStatusDialog
|
|
message={t('workspaceHost.unsupported')}
|
|
tone="alert"
|
|
subtitle={workspaceAddLocationSubtitle}
|
|
onClose={closeAddWorkspaceDialog}
|
|
>
|
|
{workspaceExecutionField}
|
|
</WorkspaceAddStatusDialog>
|
|
) : (
|
|
<AddWorkspaceDialog
|
|
executionMode={
|
|
managedAgentProvider?.workspaceBinding?.createWorkspace
|
|
? 'daemon'
|
|
: undefined
|
|
}
|
|
onExecutionModeChange={(mode) => {
|
|
if (mode === 'managed') handleOpenManagedWorkspace();
|
|
}}
|
|
browseDirectories={workspaceBrowseActiveRef.current}
|
|
initialPath={
|
|
!workspaceBrowseActiveRef.current
|
|
? undefined
|
|
: workspaceAddLocationIsConnected
|
|
? workspace.capabilities?.workspaceCwd?.replace(
|
|
/[^\\/]+[\\/]?$/,
|
|
'',
|
|
) ||
|
|
workspace.capabilities?.workspaceCwd ||
|
|
'/'
|
|
: // The connected daemon's cwd is a path on a different
|
|
// machine; seeding it here makes the target answer with
|
|
// an empty list (or a 400 across platforms) instead of
|
|
// a usable browse. Root is absolute on every platform.
|
|
'/'
|
|
}
|
|
locations={workspaceAddLocations}
|
|
selectedLocation={workspaceAddSelectedLocation}
|
|
onLocationChange={changeWorkspaceAddLocation}
|
|
onClose={closeAddWorkspaceDialog}
|
|
onAdd={
|
|
workspaceBrowseActiveRef.current
|
|
? handleAddRemoteWorkspace
|
|
: handleAddWorkspace
|
|
}
|
|
onSuggest={suggestWorkspacePathsForLocation}
|
|
gitClone={gitCloneControls}
|
|
onPick={workspaceDirectoryPicker}
|
|
pickerTarget={workspacePickerTarget}
|
|
pickerUsesHostUi={Boolean(onPickWorkspaceDirectory)}
|
|
persistenceSupported={
|
|
workspaceAddLocationIsConnected &&
|
|
persistentWorkspaceRegistrationSupported
|
|
}
|
|
displayNameEnabled={
|
|
workspaceAddLocationIsConnected && workspaceDisplayNameSupported
|
|
}
|
|
/>
|
|
))}
|
|
{scratchOutcomeUnknown !== 'clear' && (
|
|
<DialogShell
|
|
title={t('sidebar.scratchOutcomeUnknownTitle')}
|
|
size="md"
|
|
dismissible={false}
|
|
onClose={() => undefined}
|
|
>
|
|
<div className="flex flex-col gap-4">
|
|
<p>{t('sidebar.scratchOutcomeUnknown')}</p>
|
|
<ul className="max-h-48 overflow-y-auto text-sm text-muted-foreground">
|
|
{ordinaryWorkspaces.map((entry) => (
|
|
<li key={entry.id}>{entry.cwd}</li>
|
|
))}
|
|
</ul>
|
|
<div className="flex justify-end">
|
|
{scratchOutcomeUnknown === 'refreshing' ? (
|
|
<Button
|
|
type="button"
|
|
onClick={() => void refreshScratchOutcome()}
|
|
>
|
|
{t('sidebar.scratchOutcomeRefresh')}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
onClick={() => setScratchOutcome('clear')}
|
|
>
|
|
{t('sidebar.scratchOutcomeAcknowledge')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogShell>
|
|
)}
|
|
|
|
<div
|
|
className={styles.appShell}
|
|
ref={sidebarLayoutRef}
|
|
>
|
|
{sidebarOptions.enabled && (
|
|
<div
|
|
data-sidebar-shell=""
|
|
{...(mobileDrawerOpen
|
|
? { role: 'dialog', 'aria-modal': 'true' as const }
|
|
: {})}
|
|
aria-label={t('sidebar.label')}
|
|
aria-hidden={artifactPanelFullscreen || undefined}
|
|
className={[
|
|
styles.mobileDrawer,
|
|
mobileDrawerOpen ? styles.mobileDrawerOpen : undefined,
|
|
forceMobileDrawer ? styles.mobileDrawerForced : undefined,
|
|
artifactPanelFullscreen ? styles.chatViewHidden : undefined,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
>
|
|
<div
|
|
className={styles.mobileBackdrop}
|
|
onClick={closeMobileDrawer}
|
|
aria-hidden="true"
|
|
/>
|
|
<WebShellSidebar
|
|
collapsed={sidebarCollapsedEffective}
|
|
layout={sidebarRailEnabled ? 'rail' : 'single'}
|
|
containerWidth={sidebarLayoutWidth}
|
|
activePage={sidebarPage}
|
|
onOpenHome={() => {
|
|
// No handleSidebarCollapsedChange here in the general
|
|
// case: the rail's openNavigation already restores the
|
|
// column when it is actually collapsed, and writing from
|
|
// inside the forced-open mobile drawer would silently
|
|
// clear the desktop preference. Leaving split view in the
|
|
// fold band is the one exception — the rail's
|
|
// restoresColumn call is clamped by design for taps that
|
|
// stay in split view, so the exit-split entry points
|
|
// must restore the user's column themselves.
|
|
closeMobileDrawer();
|
|
setSidebarSection('home');
|
|
splitFoldedByShrinkRef.current = false;
|
|
returnToChat();
|
|
if (splitFoldedSidebarRef.current) {
|
|
handleSidebarCollapsedChange(false, {
|
|
exitFoldBand: true,
|
|
});
|
|
}
|
|
}}
|
|
onCollapsedChange={handleSidebarCollapsedChange}
|
|
onOpenSettings={() => {
|
|
closeMobileDrawer();
|
|
openPanel('settings');
|
|
}}
|
|
onOpenLive={
|
|
liveSidebarEnabled
|
|
? () => {
|
|
closeMobileDrawer();
|
|
openPanel('live');
|
|
// Leaving split view for a section column restores
|
|
// the rail column just like the Home entry does.
|
|
if (splitFoldedSidebarRef.current) {
|
|
handleSidebarCollapsedChange(false, {
|
|
exitFoldBand: true,
|
|
});
|
|
}
|
|
}
|
|
: undefined
|
|
}
|
|
onLiveVoiceSlotChange={setLiveVoiceSlot}
|
|
onOpenAgents={
|
|
collaborationAvailable
|
|
? (view = 'agents') => {
|
|
setAgentsNav((current) => ({
|
|
view,
|
|
request: current.request + 1,
|
|
}));
|
|
closeMobileDrawer();
|
|
setAgentsCreateScope(null);
|
|
openPanel('agents');
|
|
}
|
|
: undefined
|
|
}
|
|
onOpenPlugins={() => {
|
|
setPluginInitialTab('extensions');
|
|
closeMobileDrawer();
|
|
openPanel('plugins');
|
|
}}
|
|
onOpenChannels={() => {
|
|
closeMobileDrawer();
|
|
openPanel('channels');
|
|
if (
|
|
splitFoldedSidebarRef.current &&
|
|
channelSidebarEnabled
|
|
) {
|
|
handleSidebarCollapsedChange(false, {
|
|
exitFoldBand: true,
|
|
});
|
|
}
|
|
}}
|
|
onOpenChannelEvents={
|
|
channelNavigationEnabled
|
|
? () => {
|
|
closeMobileDrawer();
|
|
openPanel('channel-events');
|
|
}
|
|
: undefined
|
|
}
|
|
onOpenManagedSessions={
|
|
managedAgentProvider
|
|
? () => {
|
|
closeMobileDrawer();
|
|
setManagedHome(true);
|
|
returnToChat();
|
|
}
|
|
: undefined
|
|
}
|
|
onOpenDaemonStatus={() => {
|
|
closeMobileDrawer();
|
|
openPanel('status');
|
|
}}
|
|
onOpenScheduledTasks={() => {
|
|
closeMobileDrawer();
|
|
openScheduledTasks();
|
|
}}
|
|
onOpenWorkflows={() => {
|
|
closeMobileDrawer();
|
|
openWorkflows();
|
|
}}
|
|
onOpenGoals={() => {
|
|
closeMobileDrawer();
|
|
openGoals();
|
|
}}
|
|
{...(prWatchesAvailable
|
|
? {
|
|
onOpenPrWatches: () => {
|
|
closeMobileDrawer();
|
|
openPrWatches();
|
|
},
|
|
}
|
|
: {})}
|
|
onOpenSessions={openSessionsOverviewFromSidebar}
|
|
canOpenSettings={settingsAvailable}
|
|
canOpenSessionsOverview={canOpenSessionsOverview}
|
|
onOpenSplitView={openSplitViewFromSidebar}
|
|
canOpenSplitView={canOpenSplitView}
|
|
onAddSessionToSplitView={addSessionToSplitView}
|
|
splitSessionIds={
|
|
mainView === 'split'
|
|
? splitSessionIds
|
|
: pendingSplitPaneIds?.generation ===
|
|
splitClassificationGenerationRef.current
|
|
? pendingSplitPaneIds.sessionIds
|
|
: undefined
|
|
}
|
|
theme={selectedTheme}
|
|
onThemeChange={(theme) => {
|
|
handleThemeChange(theme);
|
|
if (workspaceContextActive) {
|
|
void setWorkspaceSetting(
|
|
'workspace',
|
|
THEME_SETTING_KEY,
|
|
webShellThemeToSettingValue(theme),
|
|
);
|
|
}
|
|
}}
|
|
// A cwd-less New task inherits the current context: a
|
|
// workspace chat stays in its workspace and a cold draft
|
|
// lands on the primary one. Projectless targets are chosen
|
|
// in the composer's workspace picker instead. From a Live
|
|
// chat it opens an ordinary task; a fresh voice conversation
|
|
// comes from the Live controls.
|
|
onNewSession={(workspaceCwd) => {
|
|
if (managedMode && typeof workspaceCwd !== 'string') {
|
|
if (
|
|
managedSessionId &&
|
|
managedAgentProvider?.workspaceBinding &&
|
|
managedWorkspaceId === undefined
|
|
) {
|
|
return false;
|
|
}
|
|
setManagedSessionId(undefined);
|
|
closeMobileDrawer();
|
|
returnToChat();
|
|
return true;
|
|
}
|
|
return createNewSession(
|
|
typeof workspaceCwd === 'string'
|
|
? { kind: 'workspace', cwd: workspaceCwd }
|
|
: { kind: 'inherit', leaveLive: true },
|
|
);
|
|
}}
|
|
managedWorkspaces={
|
|
managedAgentProvider ? (
|
|
<div ref={setManagedNavigationTarget} />
|
|
) : undefined
|
|
}
|
|
archivedSessionsTarget={archivedSessionsTarget}
|
|
managedArchivedSessions={
|
|
managedAgentProvider?.lifecycle
|
|
? {
|
|
...managedArchivedStatus,
|
|
content: <div ref={setManagedArchivedNavigationTarget} />,
|
|
}
|
|
: undefined
|
|
}
|
|
onSearchQueryChange={
|
|
managedAgentProvider ? setManagedNavigationSearch : undefined
|
|
}
|
|
currentSessionActive={!managedMode}
|
|
onSessionArchived={(sessionId, workspaceCwd) => {
|
|
closeUsageTabs([sessionId]);
|
|
void handleCurrentSessionRemoved({ sessionId, workspaceCwd });
|
|
}}
|
|
onNewStandaloneSession={() =>
|
|
createNewSession({ kind: 'global' })
|
|
}
|
|
onLoadSession={(sessionId, workspaceCwd) => {
|
|
showChat();
|
|
return loadSidebarSession(sessionId, workspaceCwd);
|
|
}}
|
|
onOpenHostSession={handleOpenHostSession}
|
|
onLoadStandaloneSession={(sessionId, displayName) => {
|
|
sidebarStandaloneTitleRef.current = { sessionId, displayName, client: workspace.client };
|
|
setMainView('chat');
|
|
return loadSidebarSession(
|
|
sessionId,
|
|
undefined,
|
|
{ kind: 'standalone' },
|
|
);
|
|
}}
|
|
onStandaloneNotice={(message) =>
|
|
pushToast('info', message)
|
|
}
|
|
projectFeaturesEnabled={projectFeaturesAvailable}
|
|
onSelectCurrentSession={() => {
|
|
setManagedHome(false);
|
|
closeMobileDrawer();
|
|
splitFoldedByShrinkRef.current = false;
|
|
returnToChat();
|
|
}}
|
|
onSessionRenameConfirmed={reconcileCatalogRename}
|
|
onSessionsDeleted={(sessionIds, meta) => {
|
|
closeUsageTabs(sessionIds);
|
|
// Deleting the attached session must leave the deleted
|
|
// conversation: open a fresh draft in the same context,
|
|
// mirroring the Session Overview (#12619). The daemon's
|
|
// terminal `session_closed` frame clears the attachment
|
|
// before the delete response resolves, so fall back to the
|
|
// id the sidebar captured at confirm time.
|
|
const current = connectionRef.current;
|
|
const attachedId =
|
|
current.sessionId ?? meta?.attachedSessionId;
|
|
if (!attachedId || !sessionIds.includes(attachedId)) {
|
|
return;
|
|
}
|
|
void handleCurrentSessionRemoved({
|
|
sessionId: attachedId,
|
|
workspaceCwd:
|
|
current.workspaceCwd ||
|
|
lockedWorkspaceCwd ||
|
|
workspacesRef.current.find((entry) => entry.primary)
|
|
?.cwd ||
|
|
'',
|
|
});
|
|
}}
|
|
onError={reportError}
|
|
mobileOpen={mobileDrawerOpen}
|
|
onMobileClose={closeMobileDrawer}
|
|
selectedWorkspaceCwd={selectedWorkspaceCwd}
|
|
onSelectWorkspace={(workspaceCwd) => {
|
|
splitClassificationGenerationRef.current += 1;
|
|
const targetWorkspaceCwd =
|
|
workspaceCwd ??
|
|
workspacesRef.current.find(
|
|
(entry) =>
|
|
entry.primary && entry.trusted !== false,
|
|
)?.cwd;
|
|
const sameTarget =
|
|
workspaceCwd === selectedWorkspaceCwdRef.current;
|
|
composerSourceVersionRef.current += 1;
|
|
selectedWorkspaceCwdRef.current = workspaceCwd;
|
|
setSelectedWorkspaceCwd(workspaceCwd);
|
|
setPendingSessionContext(
|
|
targetWorkspaceCwd
|
|
? { kind: 'workspace', cwd: targetWorkspaceCwd }
|
|
: undefined,
|
|
);
|
|
if (!sameTarget) {
|
|
gitModeIntentRef.current = { mode: 'current' };
|
|
setGitModeIntent({ mode: 'current' });
|
|
}
|
|
}}
|
|
onOpenGitDiff={
|
|
projectFeaturesAvailable
|
|
? (workspaceCwd) =>
|
|
setGitDialog({
|
|
workspaceCwd,
|
|
gitCwd:
|
|
workspaceCwd === activeWorkspaceCwd
|
|
? sessionWorktree?.path
|
|
: undefined,
|
|
view: 'diff',
|
|
})
|
|
: undefined
|
|
}
|
|
onOpenCommit={
|
|
projectFeaturesAvailable
|
|
? (workspaceCwd) =>
|
|
setGitDialog({
|
|
workspaceCwd,
|
|
// A worktree session commits in the worktree checkout,
|
|
// not the base workspace cwd — but only for the active
|
|
// session's own workspace row; another workspace's row
|
|
// has no association with this session's worktree.
|
|
gitCwd:
|
|
workspaceCwd === activeWorkspaceCwd
|
|
? sessionWorktree?.path
|
|
: undefined,
|
|
view: 'commit',
|
|
})
|
|
: undefined
|
|
}
|
|
onOpenAddWorkspace={
|
|
dynamicWorkspaceRegistrationSupported ||
|
|
hasRemoteWorkspaceLocation ||
|
|
Boolean(managedAgentProvider?.workspaceBinding?.createWorkspace)
|
|
? handleOpenExistingWorkspace
|
|
: undefined
|
|
}
|
|
workspaces={workspaces}
|
|
lockedWorkspaceCwd={lockedWorkspaceCwd}
|
|
lockedWorkspace={sidebarOptions.lockedWorkspace}
|
|
workspaceOverview={sidebarOptions.workspaceOverview}
|
|
onOpenWorkspaceManagement={(target) => {
|
|
closeMobileDrawer();
|
|
openPanel(target);
|
|
}}
|
|
onOpenWorkspacesOverview={() => {
|
|
closeMobileDrawer();
|
|
openPanel('workspaces');
|
|
}}
|
|
onNewWorktreeSession={handleNewWorktreeSession}
|
|
branding={sidebarOptions.branding}
|
|
primaryNav={sidebarOptions.primaryNav}
|
|
showSessionSourceSwitch={
|
|
sidebarOptions.showSessionSourceSwitch
|
|
}
|
|
showLive={sidebarOptions.showLive}
|
|
hideProjectHeader={sidebarOptions.hideProjectHeader}
|
|
sessionActions={sidebarOptions.sessionActions}
|
|
footer={sidebarOptions.footer}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div
|
|
className={[
|
|
styles.contextShell,
|
|
artifactPanelFullscreen ? styles.chatViewHidden : undefined,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
aria-hidden={artifactPanelFullscreen || undefined}
|
|
>
|
|
{managedMode &&
|
|
!activePanel &&
|
|
mainView === 'chat' &&
|
|
sidebarOptions.enabled &&
|
|
sidebarOptions.showCompactToggle && (
|
|
<button
|
|
type="button"
|
|
className={styles.hamburgerButton}
|
|
style={{ margin: '8px 20px' }}
|
|
aria-label={t('sidebar.toggleMenu')}
|
|
aria-expanded={mobileDrawerOpen}
|
|
onClick={() => {
|
|
setForceMobileDrawer(false);
|
|
setMobileDrawerOpen((open) => !open);
|
|
}}
|
|
>
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
width="20"
|
|
height="20"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M3 6h18M3 12h18M3 18h18" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
{chatHeaderEnabled &&
|
|
!managedMode &&
|
|
(!isChatEmptyState || emptyStateChatHeader) &&
|
|
!activePanel &&
|
|
(mainView === 'chat' || mainView === 'cockpit') && (
|
|
<div className={styles.chatHeaderRow}>
|
|
{sidebarOptions.enabled &&
|
|
sidebarOptions.showCompactToggle && (
|
|
<button
|
|
type="button"
|
|
className={styles.hamburgerButton}
|
|
onClick={() => {
|
|
setForceMobileDrawer(false);
|
|
setMobileDrawerOpen((open) => !open);
|
|
}}
|
|
aria-label={t('sidebar.toggleMenu')}
|
|
aria-expanded={mobileDrawerOpen}
|
|
>
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<line x1="3" y1="6" x2="21" y2="6" />
|
|
<line x1="3" y1="12" x2="21" y2="12" />
|
|
<line x1="3" y1="18" x2="21" y2="18" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
{renderChatHeader && mainView === 'chat' ? (
|
|
<div className={styles.customChatHeader}>
|
|
{renderChatHeader({
|
|
sessionId: connection.sessionId,
|
|
sessionName: sessionDisplayName,
|
|
workspaceCwd: workspaceContextActive
|
|
? connection.workspaceCwd
|
|
: undefined,
|
|
items: chatHeaderItems,
|
|
environmentPanelOpen: environmentPanelVisible,
|
|
rightPanelOpen: artifactPanelOpen,
|
|
onEnvironmentPanelOpenChange:
|
|
handleEnvironmentPanelOpenChange,
|
|
onRightPanelOpenChange: handleRightPanelOpenChange,
|
|
...(contextUsageHeaderItemVisible && connection.sessionId
|
|
? {
|
|
onOpenContextUsage: () =>
|
|
openContextUsagePanel(
|
|
connection.sessionId!,
|
|
sessionActions,
|
|
),
|
|
}
|
|
: {}),
|
|
...(tokenUsageHeaderItemVisible && connection.sessionId
|
|
? {
|
|
onOpenTokenUsage: () =>
|
|
openTokenUsagePanel(
|
|
connection.sessionId!,
|
|
sessionActions,
|
|
),
|
|
}
|
|
: {}),
|
|
onOpenLocalControlSettings:
|
|
showMobileAccess && workspaceContextActive
|
|
? handleOpenLocalControlSettings
|
|
: undefined,
|
|
})}
|
|
</div>
|
|
) : (
|
|
<ChatContextHeader
|
|
location={
|
|
workspaceHostsEnabled && mainView === 'chat' ? (
|
|
<WorkspaceLocation
|
|
cwd={panelWorkspaceCwd}
|
|
hostOrigin={focusedHostOrigin}
|
|
/>
|
|
) : undefined
|
|
}
|
|
content={
|
|
titleHeaderItemVisible && !isChatEmptyState
|
|
? (sessionDisplayName ?? t('session.new'))
|
|
: null
|
|
}
|
|
workspaceName={headerWorkspaceName}
|
|
workspacePath={activeWorkspaceCwd}
|
|
hideWorkspaceIcon={isChatEmptyState && composerNamesWorkspace}
|
|
environmentOpen={environmentPanelVisible}
|
|
environmentAvailable={
|
|
!isChatEmptyState &&
|
|
mainView === 'chat' &&
|
|
environmentHeaderItemVisible
|
|
}
|
|
environmentRunning={environmentRunning}
|
|
rightPanelOpen={artifactPanelOpen}
|
|
rightPanelAvailable={
|
|
mainView === 'chat' &&
|
|
rightPanelHeaderItemVisible &&
|
|
!artifactPanelOpen &&
|
|
(!isChatEmptyState ||
|
|
(Boolean(activeWorkspaceCwd) &&
|
|
emptyStateRightPanelItems.length > 0))
|
|
}
|
|
onOpenFiles={
|
|
mainView === 'chat' && filesAvailable
|
|
? openFilesPanel
|
|
: undefined
|
|
}
|
|
onToggleEnvironment={() =>
|
|
handleEnvironmentPanelOpenChange(
|
|
!environmentPanelVisible,
|
|
)
|
|
}
|
|
onToggleRightPanel={() =>
|
|
handleRightPanelOpenChange(!artifactPanelOpen)
|
|
}
|
|
onOpenContextUsage={
|
|
contextUsageHeaderItemVisible && connection.sessionId
|
|
? () =>
|
|
openContextUsagePanel(
|
|
connection.sessionId!,
|
|
sessionActions,
|
|
)
|
|
: undefined
|
|
}
|
|
onOpenTokenUsage={
|
|
tokenUsageHeaderItemVisible && connection.sessionId
|
|
? () =>
|
|
openTokenUsagePanel(
|
|
connection.sessionId!,
|
|
sessionActions,
|
|
)
|
|
: undefined
|
|
}
|
|
onOpenLocalControlSettings={
|
|
showMobileAccess && workspaceContextActive
|
|
? handleOpenLocalControlSettings
|
|
: undefined
|
|
}
|
|
/>
|
|
)}
|
|
{sessionWorkflowEnabled &&
|
|
(sessionWorkflowTodos.length > 0 ||
|
|
mainView === 'cockpit') && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="mr-3"
|
|
data-testid="open-workflow"
|
|
disabled={approvalOverlayActive}
|
|
onClick={openWorkflowInspector}
|
|
>
|
|
{t('workflow.title')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{!artifactPanelFullscreen &&
|
|
(managedMode ||
|
|
!(artifactPanelOpen && useFloatingArtifactPanel)) && (
|
|
<DaemonConnectionBanner />
|
|
)}
|
|
<div
|
|
ref={contextBodyRef}
|
|
data-testid="context-body"
|
|
className={[
|
|
styles.contextBody,
|
|
environmentPanelVisible && environmentPanelFits
|
|
? styles.contextBodyWithEnvironmentPanel
|
|
: undefined,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
>
|
|
<div
|
|
ref={chatPaneRef}
|
|
data-testid="chat-pane-container"
|
|
tabIndex={-1}
|
|
className={[
|
|
styles.chatPane,
|
|
mainView !== 'chat' ? styles.chatPaneShowingPage : undefined,
|
|
hasMobileComposerBottom
|
|
? styles.chatPaneWithMobileComposerBottom
|
|
: undefined,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
>
|
|
{sidebarOptions.enabled &&
|
|
!managedMode &&
|
|
sidebarOptions.showCompactToggle &&
|
|
(!chatHeaderEnabled ||
|
|
(isChatEmptyState && !emptyStateChatHeader)) &&
|
|
!activePanel &&
|
|
mainView === 'chat' && (
|
|
<button
|
|
type="button"
|
|
className={`${styles.hamburgerButton} ${styles.hamburgerButtonFloating}`}
|
|
onClick={() => {
|
|
setForceMobileDrawer(false);
|
|
setMobileDrawerOpen((open) => !open);
|
|
}}
|
|
aria-label={t('sidebar.toggleMenu')}
|
|
aria-expanded={mobileDrawerOpen}
|
|
>
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<line x1="3" y1="6" x2="21" y2="6" />
|
|
<line x1="3" y1="12" x2="21" y2="12" />
|
|
<line x1="3" y1="18" x2="21" y2="18" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
{activePanel &&
|
|
(projectFeaturesAvailable || activePanel === 'status' ||
|
|
(activePanel === 'settings' && settingsAvailable)) && (
|
|
<section
|
|
className={styles.panelHost}
|
|
role="region"
|
|
data-testid="inline-panel"
|
|
aria-label={
|
|
activePanel === 'settings'
|
|
? t('settings.title')
|
|
: activePanel === 'status'
|
|
? t('daemon.title')
|
|
: activePanel === 'extensions'
|
|
? t('extensions.manage.title')
|
|
: activePanel === 'mcp'
|
|
? t('mcp.title')
|
|
: activePanel === 'skills'
|
|
? t('skills.title')
|
|
: activePanel === 'agents'
|
|
? t('agents.title')
|
|
: activePanel === 'plugins'
|
|
? t('plugins.title')
|
|
: activePanel === 'channels'
|
|
? t('sidebar.channelSettings')
|
|
: activePanel === 'live'
|
|
? t('sidebar.liveSettings')
|
|
: activePanel === 'workspaces'
|
|
? t('workspacesOverview.title')
|
|
: t('sessionsOverview.title')
|
|
}
|
|
>
|
|
{activePanel !== 'extensions' &&
|
|
activePanel !== 'mcp' &&
|
|
activePanel !== 'skills' &&
|
|
activePanel !== 'agents' &&
|
|
activePanel !== 'plugins' &&
|
|
activePanel !== 'channels' &&
|
|
activePanel !== 'live' &&
|
|
activePanel !== 'workspaces' && (
|
|
<div className={styles.panelHeader}>
|
|
<button
|
|
ref={panelBackRef}
|
|
type="button"
|
|
className={styles.panelBack}
|
|
data-testid="panel-back"
|
|
onClick={() => {
|
|
if (activePanel !== 'sessions') {
|
|
returnToChat();
|
|
return;
|
|
}
|
|
if (!workspaceContextActive) {
|
|
closePanel();
|
|
return;
|
|
}
|
|
// Explicit navigation cancels any pending shrink-fold
|
|
// split restore.
|
|
splitFoldedByShrinkRef.current = false;
|
|
const current = connectionRef.current;
|
|
const currentSessionId = current.sessionId;
|
|
const currentWorkspaceCwd =
|
|
current.workspaceCwd ||
|
|
lockedWorkspaceCwd ||
|
|
workspacesRef.current.find(
|
|
(entry) => entry.primary,
|
|
)?.cwd;
|
|
if (!currentWorkspaceCwd) {
|
|
closePanel();
|
|
return;
|
|
}
|
|
const creation = createNewSession({
|
|
kind: 'workspace',
|
|
cwd: currentWorkspaceCwd,
|
|
});
|
|
const openInvocation = sessionOpenInvocationRef.current;
|
|
void creation.then(
|
|
(created) => {
|
|
const latest = connectionRef.current;
|
|
const latestWorkspaceCwd =
|
|
latest.workspaceCwd ||
|
|
lockedWorkspaceCwd ||
|
|
workspacesRef.current.find(
|
|
(entry) => entry.primary,
|
|
)?.cwd;
|
|
if (
|
|
created &&
|
|
sessionOpenInvocationRef.current ===
|
|
openInvocation &&
|
|
(latest.sessionId === undefined ||
|
|
(latest.sessionId === currentSessionId &&
|
|
latestWorkspaceCwd === currentWorkspaceCwd))
|
|
) {
|
|
onSessionIdChange?.(undefined);
|
|
}
|
|
},
|
|
);
|
|
}}
|
|
aria-label={t('common.back')}
|
|
title={t('common.back')}
|
|
>
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<path
|
|
d="M15 5l-7 7 7 7"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
<div className={styles.panelTitle}>
|
|
{activePanel === 'settings'
|
|
? t('settings.title')
|
|
: activePanel === 'status'
|
|
? t('daemon.title')
|
|
: t('sessionsOverview.title')}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className={styles.panelBody} key={activePanel}>
|
|
<ShadowDomBoundary
|
|
enabled={
|
|
shadowDomOptions.plugins &&
|
|
isPluginShadowPanel(activePanel)
|
|
}
|
|
language={selectedLanguage}
|
|
themeClassName={[
|
|
selectedTheme === WebShellThemeId.Light
|
|
? styles.themeLight
|
|
: styles.themeDark,
|
|
selectedTheme === WebShellThemeId.Dark
|
|
? 'dark'
|
|
: undefined,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
styles={shadowDomOptions.styles}
|
|
initialFocusRef={
|
|
activePanel === 'plugins'
|
|
? pluginTabRef
|
|
: activePanel === 'extensions' ||
|
|
activePanel === 'channels' ||
|
|
activePanel === 'live' ||
|
|
activePanel === 'workspaces'
|
|
? panelHeadingRef
|
|
: undefined
|
|
}
|
|
>
|
|
{activePanel === 'settings' ? (
|
|
<SettingsMessage
|
|
settingsState={targetedWorkspaceSettingsState}
|
|
presentation={settingsPresentation}
|
|
embedded
|
|
initialCategory={settingsInitialCategory}
|
|
onLanguageChange={handleSettingsLanguageChange}
|
|
onOutputLanguageChange={
|
|
handleSettingsOutputLanguageChange
|
|
}
|
|
workspaceSettingsWritable={!sessionIsStandalone}
|
|
onThemeChange={handleThemeChange}
|
|
chatWidthMode={chatWidthMode}
|
|
onChatWidthModeChange={handleChatWidthModeChange}
|
|
connections={
|
|
standalone ? <DaemonConnectionsSettings /> : undefined
|
|
}
|
|
archivedSessions={
|
|
archiveManagementAvailable ? (
|
|
<div ref={setArchivedSessionsTarget} data-web-shell-archived-sessions />
|
|
) : undefined
|
|
}
|
|
workspaceDirectoriesSection={
|
|
workspaceDirectoriesSupported ? (
|
|
<WorkspaceDirectoriesSection />
|
|
) : undefined
|
|
}
|
|
modelManagementSectionProps={projectFeaturesAvailable ? {
|
|
...modelManagementPolicy,
|
|
providers: providersState.providers,
|
|
configurations: modelConfigurations.models,
|
|
onUpdateContextWindow: handleModelContextWindowUpdate,
|
|
currentModelId:
|
|
connection.currentModel ?? undefined,
|
|
loading:
|
|
providersState.loading ||
|
|
modelConfigurations.loading,
|
|
error:
|
|
providersState.error ?? modelConfigurations.error,
|
|
busy: modelActionBusy,
|
|
onSelectModel: handleModelSelect,
|
|
onDeleteModel: handleDeleteModel,
|
|
onAddModel: () => {
|
|
if (!modelManagementRef.current.allowAdd) return;
|
|
if (
|
|
!isItemVisible(
|
|
'builtin:model-management',
|
|
settingsPresentation,
|
|
)
|
|
)
|
|
return;
|
|
settingsDialogKeyRef.current =
|
|
'builtin:model-management';
|
|
setShowAuthDialog(true);
|
|
},
|
|
} : undefined}
|
|
onSubDialog={(key, scope) => {
|
|
if (!isSettingVisible(key, settingsPresentation)) return;
|
|
settingsDialogKeyRef.current = key;
|
|
// Record the persist scope only for model settings —
|
|
// the reset effect is gated on the dialog/fallback/auth
|
|
// flags, so it never runs for the approvalMode dialog
|
|
// and would leave a stale scope behind.
|
|
if (key === 'advisorModel' || key === 'imageModel') {
|
|
void reloadModelConfigurations();
|
|
if (key === 'advisorModel') void reloadProviders();
|
|
setModelSettingScope(scope);
|
|
setModelDialogMode(
|
|
key === 'advisorModel' ? 'advisor' : 'image',
|
|
);
|
|
} else if (key === 'fastModel') {
|
|
setModelSettingScope(scope);
|
|
setModelDialogMode('fast');
|
|
} else if (key === 'visionModel') {
|
|
setModelSettingScope(scope);
|
|
setModelDialogMode('vision');
|
|
} else if (key === 'voiceModel') {
|
|
void openVoiceModelPicker(scope, 'settings');
|
|
} else if (key === 'modelFallbacks') {
|
|
setModelSettingScope(scope);
|
|
setShowFallbacksDialog(true);
|
|
} else if (key === 'tools.approvalMode') {
|
|
// Not a model setting — leave modelSettingScope alone.
|
|
setShowApprovalModeDialog(true);
|
|
}
|
|
}}
|
|
/>
|
|
) : activePanel === 'status' ? (
|
|
<DaemonStatusDialog />
|
|
) : activePanel === 'extensions' ? (
|
|
<ExtensionsManagerPage
|
|
managed={managedMode && managedAgentProvider ? { provider: managedAgentProvider, workspaceId: managedWorkspaceId ?? undefined, sessionId: managedSessionId } : undefined}
|
|
onClose={closePanel}
|
|
initialFocusRef={panelHeadingRef}
|
|
/>
|
|
) : activePanel === 'mcp' && managedMode && managedAgentProvider ? (
|
|
<ManagedMcpManagerPage
|
|
provider={managedAgentProvider}
|
|
sessionId={managedSessionId}
|
|
workspaceId={managedWorkspaceId ?? undefined}
|
|
onClose={closePanel}
|
|
/>
|
|
) : activePanel === 'mcp' && mcpDialogMessage ? (
|
|
<McpManagerPage
|
|
message={mcpDialogMessage}
|
|
onClose={() => {
|
|
setMcpDialogMessage(null);
|
|
closePanel();
|
|
}}
|
|
/>
|
|
) : activePanel === 'skills' ? (
|
|
<SkillsManagerPage
|
|
onClose={closePanel}
|
|
onUseSkill={handleUseSkill}
|
|
/>
|
|
) : activePanel === 'agents' ? (
|
|
<AgentsManagerPage
|
|
key={agentsNav.request}
|
|
workspaceCwd={legacyWorkspaceContextCwd}
|
|
initialAgentView={agentsNav.view}
|
|
onClose={() => {
|
|
setAgentsCreateScope(null);
|
|
closePanel();
|
|
}}
|
|
initialCreateScope={agentsCreateScope}
|
|
onMentionAgent={(name) => {
|
|
// Agents answer inside the chat: "Mention in chat"
|
|
// starts the @ there.
|
|
setAgentsCreateScope(null);
|
|
closePanel();
|
|
window.setTimeout(() => {
|
|
editorRef.current?.setText(`@${name} `);
|
|
editorRef.current?.focus();
|
|
}, 0);
|
|
}}
|
|
/>
|
|
) : activePanel === 'plugins' ? (
|
|
<PluginManagerPage
|
|
onClose={returnToChat}
|
|
onUseSkill={handleUseSkill}
|
|
showBackButton={!navigationRailVisible}
|
|
initialFocusRef={pluginTabRef}
|
|
initialTab={pluginInitialTab}
|
|
skillsWorkspaceCwd={skillsSourceCwd}
|
|
onSkillsWorkspaceChange={selectSkillsWorkspace}
|
|
managedSkills={managedAgentProvider !== undefined}
|
|
managedExtensions={managedMode && managedAgentProvider ? {
|
|
provider: managedAgentProvider,
|
|
sessionId: managedSessionId,
|
|
workspaceId: managedWorkspaceId ?? undefined,
|
|
} : undefined}
|
|
managedMcp={managedMode && managedAgentProvider ? {
|
|
provider: managedAgentProvider,
|
|
sessionId: managedSessionId,
|
|
workspaceId: managedWorkspaceId ?? undefined,
|
|
} : undefined}
|
|
/>
|
|
) : activePanel === 'channels' ? (
|
|
<ChannelsManagerPage
|
|
onClose={
|
|
channelSidebarEnabled && navigationRailVisible
|
|
? undefined
|
|
: returnToChat
|
|
}
|
|
initialFocusRef={panelHeadingRef}
|
|
/>
|
|
) : activePanel === 'channel-events' ? (
|
|
<ChannelEventsPage
|
|
onClose={
|
|
channelNavigationEnabled && navigationRailVisible
|
|
? undefined
|
|
: returnToChat
|
|
}
|
|
initialFocusRef={panelHeadingRef}
|
|
/>
|
|
) : activePanel === 'live' ? (
|
|
<div className="-mx-5 -mt-4">
|
|
<div className={styles.fullPageHeader}>
|
|
{!navigationRailVisible && (
|
|
<button
|
|
type="button"
|
|
className={styles.fullPageBack}
|
|
onClick={returnToChat}
|
|
aria-label={t('common.back')}
|
|
>
|
|
<ChevronLeftIcon size={18} />
|
|
</button>
|
|
)}
|
|
<h1
|
|
ref={panelHeadingRef}
|
|
tabIndex={-1}
|
|
className={`${styles.fullPageTitle} outline-none`}
|
|
>
|
|
{t('sidebar.liveSettings')}
|
|
</h1>
|
|
<div
|
|
ref={setLiveVoicePageSlot}
|
|
className={styles.liveVoicePageSlot}
|
|
data-live-voice-page-slot
|
|
/>
|
|
</div>
|
|
{liveSetup.supported ? (
|
|
<LiveVoiceSettingsCard setup={liveSetup} />
|
|
) : (
|
|
<p className="p-5 text-sm text-muted-foreground" role="status">
|
|
{workspaceSettingsState.loading
|
|
? t('common.loading')
|
|
: workspaceSettingsState.error?.message ||
|
|
t('sidebar.liveSettingsUnavailable')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : activePanel === 'workspaces' ? (
|
|
<WorkspacesOverviewPanel
|
|
onClose={closePanel}
|
|
// A new draft replaces the panel with the chat view;
|
|
// createNewSession's default (no keepPanel) does that.
|
|
onNewSession={handlePanelNewSession}
|
|
onAddWorkspace={
|
|
dynamicWorkspaceRegistrationSupported ||
|
|
hasRemoteWorkspaceLocation
|
|
? handleOpenExistingWorkspace
|
|
: undefined
|
|
}
|
|
onError={reportError}
|
|
initialFocusRef={panelHeadingRef}
|
|
/>
|
|
) : (
|
|
<SessionOverviewPanel
|
|
onOpenSession={handleOpenSessionFromOverview}
|
|
// Split view cannot exist below the breakpoint; the
|
|
// panel hides the action when the prop is absent.
|
|
onOpenSplit={isLargeScreen ? openSplitView : undefined}
|
|
onCurrentSessionRemoved={handleCurrentSessionRemoved}
|
|
includeOtherWorkspaces={!lockedWorkspaceCwd}
|
|
workspaceCwd={lockedWorkspaceCwd}
|
|
manageLiveState={false}
|
|
/>
|
|
)}
|
|
</ShadowDomBoundary>
|
|
</div>
|
|
</section>
|
|
)}
|
|
{projectFeaturesAvailable && mainView === 'scheduledTasks' && (
|
|
<div
|
|
className={styles.fullPage}
|
|
data-testid="scheduled-tasks-page"
|
|
>
|
|
<div className={styles.fullPageHeader}>
|
|
{!navigationRailVisible && (
|
|
<button
|
|
type="button"
|
|
className={styles.fullPageBack}
|
|
onClick={returnToChat}
|
|
aria-label={t('common.back')}
|
|
title={t('common.back')}
|
|
>
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
width="18"
|
|
height="18"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M15 18l-6-6 6-6" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
<div className={styles.fullPageTitle}>
|
|
{t('scheduledTasks.title')}
|
|
</div>
|
|
</div>
|
|
<div className={styles.fullPageBody}>
|
|
<ScheduledTasksDialog
|
|
onRunPrompt={runTaskManually}
|
|
// Registered workspaces (multi-workspace daemons only) so
|
|
// the page aggregates every project's schedule and the New
|
|
// form can target one; absent/single → primary-only view.
|
|
workspaces={
|
|
lockedWorkspaceCwd
|
|
? visibleWorkspaces
|
|
: ordinaryWorkspaces
|
|
}
|
|
lockedWorkspace={lockedWorkspaceCapability}
|
|
currentSession={
|
|
currentSessionSummary
|
|
? {
|
|
...currentSessionSummary,
|
|
hasActivePrompt:
|
|
currentSessionSummary.hasActivePrompt === true ||
|
|
sessionHasActivePrompt,
|
|
}
|
|
: undefined
|
|
}
|
|
currentSessionSchedulingAvailable={workspace.capabilities?.features?.includes(
|
|
'scheduled_task_session_reuse',
|
|
)}
|
|
gateAvailable={workspace.capabilities?.features?.includes(
|
|
'scheduled_task_gate',
|
|
)}
|
|
autofixPrWatchesAvailable={prWatchesAvailable}
|
|
onCreateViaChat={() => {
|
|
// Start a FRESH session and jump to it so the task-
|
|
// creation chat doesn't pile onto the current
|
|
// conversation, then prime the composer so the user can
|
|
// describe the task in natural language; the agent
|
|
// creates it via its cron_create tool. Focus is deferred
|
|
// so the new session's composer is mounted/visible first.
|
|
const targetCwd =
|
|
resolveWorkspaceMaintenanceTargetCwd();
|
|
if (!targetCwd) return;
|
|
void createNewSession({
|
|
kind: 'workspace',
|
|
cwd: targetCwd,
|
|
}).then((created) => {
|
|
// If the new session couldn't be started,
|
|
// createNewSession already surfaced the error — do NOT
|
|
// prime the (still-current) session with the task
|
|
// starter, which would land in the wrong conversation.
|
|
if (!created) return;
|
|
onSessionIdChange?.(undefined);
|
|
window.setTimeout(() => {
|
|
editorRef.current?.insertText(
|
|
t('scheduledTasks.chatStarter'),
|
|
{ mode: 'replace' },
|
|
);
|
|
editorRef.current?.focus();
|
|
}, 0);
|
|
});
|
|
}}
|
|
onOpenSession={(sessionId, workspaceCwd) => {
|
|
// The task's bound session IS its run history — switch
|
|
// to the chat view and load that session's transcript,
|
|
// in the task's own workspace.
|
|
showChat();
|
|
loadSidebarSession(sessionId, workspaceCwd).catch(
|
|
(error: unknown) => {
|
|
reportError(error, 'Failed to open session');
|
|
},
|
|
);
|
|
}}
|
|
onError={reportError}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{projectFeaturesAvailable &&
|
|
mainView === 'workflows' &&
|
|
workflowsEnabled && (
|
|
<div
|
|
className={styles.fullPage}
|
|
data-testid="workflow-runs-page"
|
|
>
|
|
<div className={styles.fullPageHeader}>
|
|
<button
|
|
type="button"
|
|
className={styles.fullPageBack}
|
|
onClick={returnToChat}
|
|
aria-label={t('common.back')}
|
|
title={t('common.back')}
|
|
>
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
width="18"
|
|
height="18"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M15 18l-6-6 6-6" />
|
|
</svg>
|
|
</button>
|
|
<div className={styles.fullPageTitle}>
|
|
{t('workflowRuns.title')}
|
|
</div>
|
|
</div>
|
|
<div className={styles.fullPageBody}>
|
|
<WorkflowRunsPage
|
|
onOpenSubagent={openSubagentPanel}
|
|
onWorkflowRunStarted={() =>
|
|
setBackgroundTasksRefreshTrigger((value) => value + 1)
|
|
}
|
|
onCreateViaChat={() => {
|
|
const targetCwd =
|
|
resolveWorkspaceMaintenanceTargetCwd();
|
|
if (!targetCwd) return;
|
|
void createNewSession({
|
|
kind: 'workspace',
|
|
cwd: targetCwd,
|
|
}).then((created) => {
|
|
if (!created) return;
|
|
onSessionIdChange?.(undefined);
|
|
window.setTimeout(() => {
|
|
editorRef.current?.insertText(
|
|
'/workflow-creator ',
|
|
{ mode: 'replace' },
|
|
);
|
|
editorRef.current?.focus();
|
|
}, 0);
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{projectFeaturesAvailable &&
|
|
prWatchesAvailable &&
|
|
mainView === 'prWatches' && (
|
|
<div className={styles.fullPage} data-testid="pr-watches-page">
|
|
<div className={styles.fullPageHeader}>
|
|
{!navigationRailVisible && (
|
|
<button
|
|
type="button"
|
|
className={styles.fullPageBack}
|
|
onClick={returnToChat}
|
|
aria-label={t('common.back')}
|
|
title={t('common.back')}
|
|
>
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
width="18"
|
|
height="18"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M15 18l-6-6 6-6" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
<div className={styles.fullPageTitle}>
|
|
{t('autofixPrWatches.title')}
|
|
</div>
|
|
</div>
|
|
<div className={styles.fullPageBody}>
|
|
<AutofixPrWatchesDialog
|
|
// Stopping runs the skill in the watch's own session,
|
|
// as a scheduled task's manual run does.
|
|
onRunPrompt={runTaskManually}
|
|
workspaces={
|
|
lockedWorkspaceCwd
|
|
? visibleWorkspaces
|
|
: ordinaryWorkspaces
|
|
}
|
|
lockedWorkspace={lockedWorkspaceCapability}
|
|
onOpenSession={(sessionId, workspaceCwd) => {
|
|
showChat();
|
|
loadSidebarSession(sessionId, workspaceCwd).catch(
|
|
(error: unknown) => {
|
|
reportError(error, 'Failed to open session');
|
|
},
|
|
);
|
|
}}
|
|
onError={reportError}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{projectFeaturesAvailable && mainView === 'goals' && (
|
|
<div className={styles.fullPage} data-testid="goals-page">
|
|
<div className={styles.fullPageHeader}>
|
|
{!navigationRailVisible && (
|
|
<button
|
|
type="button"
|
|
className={styles.fullPageBack}
|
|
onClick={returnToChat}
|
|
aria-label={t('common.back')}
|
|
title={t('common.back')}
|
|
>
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
width="18"
|
|
height="18"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M15 18l-6-6 6-6" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
<div className={styles.fullPageTitle}>
|
|
{t('goals.title')}
|
|
</div>
|
|
</div>
|
|
<div className={styles.fullPageBody}>
|
|
<GoalsDialog
|
|
onCreateGoal={async (condition) => {
|
|
const stranded = strandedGoalSessionRef.current;
|
|
const canReuseStranded =
|
|
stranded !== undefined &&
|
|
connectionRef.current.sessionId === stranded;
|
|
if (!canReuseStranded) {
|
|
strandedGoalSessionRef.current = undefined;
|
|
const targetCwd =
|
|
resolveWorkspaceMaintenanceTargetCwd();
|
|
if (!targetCwd) return false;
|
|
const created = await createNewSession(
|
|
{ kind: 'workspace', cwd: targetCwd },
|
|
{ keepView: true },
|
|
);
|
|
if (!created) return false;
|
|
}
|
|
const allocationOwner = sessionOwnerGuard.capture();
|
|
const sourceSessionId =
|
|
connectionRef.current.sessionId;
|
|
const allocatedSessionId =
|
|
await ensureSessionForPrompt();
|
|
const currentSessionId =
|
|
connectionRef.current.sessionId;
|
|
const ownAllocationSucceeded =
|
|
sourceSessionId === undefined &&
|
|
allocatedSessionId !== undefined &&
|
|
(currentSessionId === undefined ||
|
|
currentSessionId === allocatedSessionId);
|
|
if (
|
|
(!allocationOwner.isCurrent() &&
|
|
!ownAllocationSucceeded) ||
|
|
(sourceSessionId !== undefined
|
|
? currentSessionId !== sourceSessionId
|
|
: currentSessionId !== undefined &&
|
|
currentSessionId !== allocatedSessionId)
|
|
) {
|
|
return false;
|
|
}
|
|
if (
|
|
!connectionRef.current.sessionId &&
|
|
!allocatedSessionId
|
|
) {
|
|
return false;
|
|
}
|
|
const owner = sessionOwnerGuard.capture();
|
|
try {
|
|
if (allocatedSessionId) {
|
|
await createGoalForAllocatedSession(
|
|
allocatedSessionId,
|
|
condition,
|
|
);
|
|
} else {
|
|
await controlCurrentGoal('create', condition);
|
|
}
|
|
} catch (error) {
|
|
if (
|
|
owner.isCurrent() &&
|
|
mainViewRef.current === 'goals'
|
|
) {
|
|
strandedGoalSessionRef.current =
|
|
allocatedSessionId ??
|
|
connectionRef.current.sessionId;
|
|
}
|
|
throw error;
|
|
}
|
|
if (!owner.isCurrent()) return false;
|
|
strandedGoalSessionRef.current = undefined;
|
|
showChat();
|
|
}}
|
|
onOpenSession={(sessionId) => {
|
|
// The goal's session transcript IS its history.
|
|
showChat();
|
|
loadSidebarSession(sessionId).catch(
|
|
(error: unknown) => {
|
|
reportError(error, 'Failed to open session');
|
|
},
|
|
);
|
|
}}
|
|
onError={reportError}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{workspaceContextActive && mainView === 'cockpit' && (
|
|
<div className={styles.fullPage} data-testid="cockpit-page">
|
|
<SessionWorkflowCockpit
|
|
sessionId={connection.sessionId ?? ''}
|
|
connected={connected}
|
|
todos={sessionWorkflowTodos}
|
|
tools={planAgentTools}
|
|
tasks={environmentAgentTasks}
|
|
projection={sessionWorkflowProjection}
|
|
selectedTodoId={selectedWorkflowTodoId}
|
|
onSelectedTodoIdChange={setSelectedWorkflowTodoId}
|
|
onBackToChat={closeCockpit}
|
|
onOpenSubagent={openSubagentPanel}
|
|
{...(sessionDisplayName
|
|
? { sessionName: sessionDisplayName }
|
|
: {})}
|
|
{...(connection.workspaceCwd
|
|
? { workspaceCwd: connection.workspaceCwd }
|
|
: {})}
|
|
/>
|
|
</div>
|
|
)}
|
|
{projectFeaturesAvailable && mainView === 'split' && (
|
|
<div className={styles.fullPage} data-testid="split-view-page">
|
|
{/* The outer session's approval overlay is suppressed under the
|
|
split (it would own ghost keyboard shortcuts). If that
|
|
session's pane hasn't surfaced its approval (including
|
|
failed or still-attaching panes), show a way back to it. */}
|
|
{approvalOverlayActive &&
|
|
!outerSplitPanePending && (
|
|
<div
|
|
className={styles.splitApprovalNotice}
|
|
role="status"
|
|
data-testid="split-approval-notice"
|
|
>
|
|
<span>{t('splitView.outerApprovalPending')}</span>
|
|
<button type="button" onClick={showChat}>
|
|
{t('splitView.goToApproval')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{/* Share the app-level customization so split panes render
|
|
markdown/tool-headers/thinking the same way the single-
|
|
session chat does (todo contexts stay chat-only — they
|
|
belong to the outer session, not the panes). */}
|
|
<WebShellCustomizationProvider value={customization}>
|
|
<McpAppVisibilityContext.Provider value={!activePanel && !artifactPanelFullscreen}>
|
|
<SplitView
|
|
showResponseTokensPerSecond={showResponseTokensPerSecond}
|
|
modelManagement={modelManagement}
|
|
unavailableModels={unavailableModels}
|
|
mountUnavailableModelTooltip={
|
|
mountUnavailableModelTooltip
|
|
}
|
|
planControlVisible={visibleComposerToolbarActions.includes('plan')}
|
|
sessionIds={splitSessionIds}
|
|
onAssistantTurnSettled={onAssistantTurnSettled}
|
|
showSessionDetails={
|
|
(sidebarOptions.sessionActions?.items ??
|
|
DEFAULT_SESSION_ACTION_ITEMS).includes('details')
|
|
}
|
|
// Mirror live pane add/remove back up so switching away
|
|
// and re-entering restores the same panes. Keep this
|
|
// callback stable to avoid looping SplitView's reporting
|
|
// effect.
|
|
onPanesChange={handleSplitPanesChange}
|
|
onPendingPanesChange={handleSplitPendingPanesChange}
|
|
includeOtherWorkspaces={!lockedWorkspaceCwd}
|
|
workspaceCwd={lockedWorkspaceCwd}
|
|
// Back returns to the Session Overview (the hub the split
|
|
// is launched from), not the single-session chat.
|
|
onExit={handleSplitExit}
|
|
onError={reportError}
|
|
onImageIngestionNotice={pushToast}
|
|
onSlashCommand={onSlashCommand}
|
|
onOpenGoals={openGoals}
|
|
onRightPanelOpen={handleTurnOutputOpen}
|
|
onOpenMonitor={openMonitorPanel}
|
|
onPaneArtifactsChange={handlePaneArtifactsChange}
|
|
registerContextUsageControls={registerContextUsageControls}
|
|
onBeforeContextCompress={preparePaneContextCompression}
|
|
onOpenContextUsage={openPaneContextUsagePanel}
|
|
messageTurnOutputs={messageTurnOutputs}
|
|
restartSseOnPrompt={restartSseOnPrompt}
|
|
historyPageSize={historyPageSize}
|
|
renderPaneHeaderActions={resolvedPaneHeaderActions}
|
|
voiceUserRevision={voiceUserRevision}
|
|
voiceWorkspaceRevisions={voiceWorkspaceRevisions}
|
|
voiceWorkspaces={
|
|
workspace.capabilities?.workspaces ||
|
|
lockedWorkspaceCapability
|
|
? ordinaryWorkspaces
|
|
: undefined
|
|
}
|
|
sessionWorkflowEnabled={sessionWorkflowEnabled}
|
|
/>
|
|
</McpAppVisibilityContext.Provider>
|
|
</WebShellCustomizationProvider>
|
|
</div>
|
|
)}
|
|
{managedAgentProvider && (
|
|
<div
|
|
className={`${styles.managedHome} ${managedChatVisible ? '' : styles.chatViewHidden}`}
|
|
aria-hidden={!managedChatVisible || undefined}
|
|
data-testid="managed-home"
|
|
>
|
|
<WebShellCustomizationProvider value={customization}>
|
|
<ManagedSessionsPage
|
|
key={managedAgentProvider.storageKey}
|
|
sessionId={managedSessionId}
|
|
workspaceCreationOpen={managedWorkspaceCreationOpen}
|
|
onWorkspaceCreationOpenChange={setManagedWorkspaceCreationOpen}
|
|
onWorkspaceCreated={(workspaceId) => {
|
|
if (!managedWorkspaceCreationOpenRef.current) return;
|
|
setManagedWorkspaceId(workspaceId);
|
|
setManagedSessionId(undefined);
|
|
setManagedHome(true);
|
|
returnToChat();
|
|
}}
|
|
onOpenLocalWorkspace={
|
|
!lockedWorkspaceCwd &&
|
|
(dynamicWorkspaceRegistrationSupported || hasRemoteWorkspaceLocation)
|
|
? handleOpenLocalWorkspace
|
|
: undefined
|
|
}
|
|
workspaceId={managedWorkspaceId ?? undefined}
|
|
onSessionWorkspaceChange={setManagedWorkspaceId}
|
|
onSelectWorkspace={handleSelectManagedWorkspace}
|
|
daemonWorkspaces={composerWorkspaces}
|
|
onSelectDaemonWorkspace={handleSelectDaemonWorkspace}
|
|
onWorkspacesChange={handleManagedWorkspacesChange}
|
|
navigationActive={managedMode}
|
|
navigationSearch={managedNavigationSearch}
|
|
onOpenSession={(id, workspaceId) => {
|
|
if (
|
|
id !== managedSessionId ||
|
|
workspaceId !== undefined
|
|
) {
|
|
setManagedWorkspaceId(workspaceId);
|
|
}
|
|
setManagedSessionId(id);
|
|
setManagedHome(true);
|
|
closeMobileDrawer();
|
|
returnToChat();
|
|
}}
|
|
onSelectSession={(id, workspaceId) => {
|
|
if (
|
|
id !== managedSessionId ||
|
|
workspaceId !== undefined
|
|
) {
|
|
setManagedWorkspaceId(workspaceId);
|
|
}
|
|
setManagedSessionId(id);
|
|
if (managedModeRef.current) {
|
|
closeMobileDrawer();
|
|
returnToChat();
|
|
}
|
|
}}
|
|
managedAgentProvider={managedAgentProvider}
|
|
creationSkills={managedCreationSkills}
|
|
interactive={managedChatVisible}
|
|
navigationTarget={
|
|
sidebarOptions.enabled
|
|
? managedNavigationTarget
|
|
: undefined
|
|
}
|
|
archivedNavigationTarget={managedArchivedNavigationTarget}
|
|
onArchivedSessionsStatusChange={setManagedArchivedStatus}
|
|
/>
|
|
</WebShellCustomizationProvider>
|
|
</div>
|
|
)}
|
|
<div
|
|
className={[
|
|
styles.chatViewWrap,
|
|
hasMobileComposerBottom
|
|
? styles.chatViewWithMobileComposerBottom
|
|
: undefined,
|
|
hasWelcomeMiddle
|
|
? styles.chatViewWithWelcomeMiddle
|
|
: undefined,
|
|
// Positioning hook: completes the compound selector in
|
|
// App.module.css that keeps this wrap relative so the
|
|
// absolutely-positioned bottom panels keep their anchor.
|
|
CustomFooter ? styles.chatViewWithCustomFooter : undefined,
|
|
managedMode || activePanel ||
|
|
mainView !== 'chat' ||
|
|
artifactPanelFullscreen
|
|
? styles.chatViewHidden
|
|
: undefined,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
// Hide the outer chat whenever a panel, a full-page view (split
|
|
// / scheduled tasks), or the fullscreen artifact panel covers
|
|
// it. `display:none` drops the subtree from layout and the tab
|
|
// order, and aria-hidden keeps AT out — so no keyboard/AT can
|
|
// reach the outer composer/toolbar behind the covering surface.
|
|
// State is preserved (the node stays mounted).
|
|
aria-hidden={
|
|
managedMode || activePanel ||
|
|
mainView !== 'chat' ||
|
|
artifactPanelFullscreen
|
|
? true
|
|
: undefined
|
|
}
|
|
>
|
|
{showMissingSessionState && (
|
|
<div className={styles.missingSessionState}>
|
|
<div className={styles.missingSessionMessage}>
|
|
{t('session.missing')}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={styles.missingSessionButton}
|
|
disabled={isCreatingMissingSession}
|
|
onClick={handleMissingSessionNewSession}
|
|
>
|
|
{t('session.new')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div
|
|
className={
|
|
showMissingSessionState
|
|
? styles.chatSubtreeHidden
|
|
: styles.chatSubtree
|
|
}
|
|
>
|
|
<WebShellCustomizationProvider value={mainChatCustomization}>
|
|
<McpAppVisibilityContext.Provider value={chatActive && !showMissingSessionState}>
|
|
<TodoContextsProvider
|
|
timeline={todoTimeline}
|
|
details={todoDetails}
|
|
>
|
|
<InteractionBlockContext.Provider
|
|
value={registerInteractionBlocker}
|
|
>
|
|
{(() => {
|
|
const contentClassName = [
|
|
styles.content,
|
|
showFloatingTodos ||
|
|
displayMessages.length > 0 ||
|
|
pendingApproval
|
|
? styles.contentHasMessages
|
|
: undefined,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ');
|
|
|
|
const messageListContent = (
|
|
<ConversationSearch
|
|
key={`${connection.workspaceCwd ?? connection.sessionContext?.kind}:${connection.sessionId}`}
|
|
threshold={conversationSearchThreshold}
|
|
active={chatActive}
|
|
registerInteractionBlocker={registerInteractionBlocker}
|
|
messageListRef={messageListRef}
|
|
className={styles.conversationSearchButton}
|
|
onRestoreFocus={restoreConversationSearchFocus}
|
|
>
|
|
{(searchTrigger) => (
|
|
<LiveMessageList
|
|
ref={messageListRef}
|
|
sessionKey={connection.sessionId}
|
|
baselineBlocks={blocks}
|
|
baselineSummary={blockChangeSummary}
|
|
baselineMessages={messages}
|
|
recapMessage={recapMessage}
|
|
messagesRef={messagesRef}
|
|
onTranscriptChange={onTranscriptChange}
|
|
t={t}
|
|
terminalBackgroundShellTaskIds={
|
|
terminalBackgroundShellTaskIds
|
|
}
|
|
pendingApproval={pendingToolApproval}
|
|
onShowContextDetail={
|
|
contextUsageAvailable
|
|
? openCurrentContextUsagePanel
|
|
: undefined
|
|
}
|
|
onOpenMemoryFile={
|
|
contextUsageAvailable
|
|
? openMemoryFilePreview
|
|
: undefined
|
|
}
|
|
loadingTranscript={connection.loadingTranscript}
|
|
catchingUp={connection.catchingUp}
|
|
hasOlderHistory={transcriptHistory.hasMore}
|
|
loadingOlderHistory={transcriptHistory.loading}
|
|
historyCapacityReached={
|
|
transcriptHistory.capacityReached
|
|
}
|
|
historyPaginationError={
|
|
transcriptHistory.paginationError}
|
|
onLoadOlderHistory={transcriptHistory.loadMore}
|
|
transcriptActivity={store}
|
|
onReloadTranscript={
|
|
transcriptReloadSupported
|
|
? reloadTranscript
|
|
: undefined
|
|
}
|
|
isResponding={
|
|
streamingState !== 'idle' &&
|
|
!suppressFailedPromptRetryStreaming
|
|
}
|
|
transcriptReloadPaused={
|
|
streamingState !== 'idle'
|
|
}
|
|
activeTurnStartedAt={
|
|
suppressFailedPromptRetryStreaming
|
|
? undefined
|
|
: (failedPromptRetry?.startedAt ??
|
|
activeTurnStartedAt)
|
|
}
|
|
workspaceCwd={connection.workspaceCwd || ''}
|
|
timelineAction={connection.sessionId ? searchTrigger : undefined}
|
|
hideSessionTimeline={
|
|
effectiveChatWidthMode === 'wide'
|
|
}
|
|
showRetryHint={showCurrentRetryHint}
|
|
onRetryClick={handleRetry}
|
|
failedPromptMessageId={
|
|
isPreparingPrompt ||
|
|
streamingState !== 'idle' ||
|
|
sessionHasActivePrompt
|
|
? undefined
|
|
: visibleFailedPromptBlock?.id
|
|
}
|
|
onRetryFailedPrompt={handleFailedPromptRetry}
|
|
onEditUserMessage={
|
|
userMessageEditing
|
|
? beginUserMessageEdit
|
|
: undefined
|
|
}
|
|
onSubmitUserMessageEdit={
|
|
userMessageEditing
|
|
? submitUserMessageEdit
|
|
: undefined
|
|
}
|
|
onBranchSession={handleBranchCurrentSession}
|
|
bottomOverlayInset={bottomPanelInset}
|
|
welcomeHeader={
|
|
isChatEmptyState ? welcomeHeader : undefined
|
|
}
|
|
centerWelcomeHeader={
|
|
showMobileWelcomeFooterMiddle || undefined
|
|
}
|
|
tailContent={sessionAgentTail}
|
|
tailKey="session-agent-runs"
|
|
onCanScrollToBottomChange={
|
|
handleCanScrollToBottomChange
|
|
}
|
|
virtualScrollThreshold={virtualScrollThreshold}
|
|
turnFileChanges={
|
|
visibleTurnOutputKinds.has('file')
|
|
? fileChangesByTurn
|
|
: undefined
|
|
}
|
|
sourceEntries={sourceEntries}
|
|
sourceSessionId={connection.sessionId}
|
|
onSourceOpen={openTurnSource}
|
|
turnArtifacts={
|
|
visibleTurnOutputKinds.has('artifact')
|
|
? artifactsByTurn
|
|
: undefined
|
|
}
|
|
turnScheduledTasks={
|
|
visibleTurnOutputKinds.has('scheduled_task')
|
|
? scheduledTasksByTurn
|
|
: undefined
|
|
}
|
|
onTurnOutputOpen={handleTurnOutputOpen}
|
|
onImagePreview={openImagePanel}
|
|
onAttachmentPreview={openAttachmentPanel}
|
|
onInsightReportOpen={onInsightReportOpen}
|
|
onReviewChanges={openReviewPanel}
|
|
onOpenArtifact={openArtifactPanel}
|
|
onOpenScheduledTask={openScheduledTaskPanel}
|
|
onError={reportError}
|
|
generateContent={
|
|
connection.capabilities?.features.includes(
|
|
'session_generation',
|
|
)
|
|
? sessionActions.generateSessionContent
|
|
: undefined
|
|
}
|
|
/>
|
|
)}
|
|
</ConversationSearch>
|
|
);
|
|
const messageListWithTurnCalls = (
|
|
<TurnCallsProvider
|
|
onOpen={showToolCalls ? openTurnCalls : undefined}
|
|
>
|
|
{messageListContent}
|
|
</TurnCallsProvider>
|
|
);
|
|
const messageListWithWorkflowDetails = (
|
|
<WorkflowDetailsProvider tasks={sessionTasks}>
|
|
{messageListWithTurnCalls}
|
|
</WorkflowDetailsProvider>
|
|
);
|
|
const messageListWithSubagentDetails = (
|
|
<SubagentDetailsProvider
|
|
onOpen={openSubagentPanel}
|
|
onOpenBackground={(turn) => {
|
|
if (!connection.sessionId) return;
|
|
handleTurnOutputOpen({
|
|
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,
|
|
});
|
|
}}
|
|
>
|
|
{messageListWithWorkflowDetails}
|
|
</SubagentDetailsProvider>
|
|
);
|
|
const messageList = monitorDetailsSupported ? (
|
|
<MonitorDetailsProvider
|
|
onOpen={openMonitorPanelFromTool}
|
|
>
|
|
{messageListWithSubagentDetails}
|
|
</MonitorDetailsProvider>
|
|
) : (
|
|
messageListWithSubagentDetails
|
|
);
|
|
|
|
const btwPanel =
|
|
!showMobileWelcomeFooterMiddle &&
|
|
btwMessage?.role === 'btw' ? (
|
|
<div className={styles.btwPanel}>
|
|
<BtwMessage
|
|
key={btwMessage.id}
|
|
question={btwMessage.question}
|
|
answer={btwMessage.answer}
|
|
isPending={btwMessage.isPending}
|
|
/>
|
|
</div>
|
|
) : null;
|
|
|
|
if (showMobileWelcomeFooterMiddle) {
|
|
return (
|
|
<div className={styles.mobileWelcomeGroup}>
|
|
<div
|
|
style={contentStyle}
|
|
className={contentClassName}
|
|
>
|
|
{messageList}
|
|
{btwPanel}
|
|
</div>
|
|
<div
|
|
className={styles.mobileWelcomeFooterMiddle}
|
|
>
|
|
{welcomeFooter}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div
|
|
style={contentStyle}
|
|
className={contentClassName}
|
|
>
|
|
{messageList}
|
|
{btwPanel}
|
|
</div>
|
|
);
|
|
})()}
|
|
</InteractionBlockContext.Provider>
|
|
</TodoContextsProvider>
|
|
|
|
<div
|
|
ref={footerRef}
|
|
style={contentStyle}
|
|
className={
|
|
CustomFooter
|
|
? `${styles.footer} ${styles.footerWithCustomFooter}`
|
|
: styles.footer
|
|
}
|
|
>
|
|
{canScrollMessageListToBottom && (
|
|
<div
|
|
className={
|
|
showBottomPanels
|
|
? `${styles.scrollToBottomLayer} ${styles.scrollToBottomLayerWithTodos}`
|
|
: styles.scrollToBottomLayer
|
|
}
|
|
>
|
|
<button
|
|
type="button"
|
|
className={styles.scrollToBottomButton}
|
|
aria-label={t('chat.scrollToBottom')}
|
|
onClick={() => resumeChatBottomFollow('smooth')}
|
|
>
|
|
<svg
|
|
className={styles.scrollToBottomIcon}
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
d="M12 5v13M6.5 12.5 12 18l5.5-5.5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
{showBottomPanels && (
|
|
<div
|
|
ref={bottomPanelsRef}
|
|
className={styles.bottomPanels}
|
|
>
|
|
<TodoPanel
|
|
todos={showFloatingTodos ? floatingTodos : []}
|
|
statusItems={floatingBottomStatusItems}
|
|
hasLiveActivity={
|
|
streamingState !== 'idle' ||
|
|
sessionHasActivePrompt ||
|
|
sessionActiveWorkState === 'active'
|
|
}
|
|
onOpen={
|
|
sessionWorkflowEnabled && showFloatingTodos
|
|
? floatingTodosUseSessionWorkflow
|
|
? openWorkflowInspector
|
|
: openTasksPanel
|
|
: undefined
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
{/* Only render the outer session's approval on the chat
|
|
view. Under a full-page view (Workflow / split / scheduled tasks)
|
|
it would sit hidden and unreachable. Each split pane
|
|
surfaces its own approval. `keyboardActive` tells the
|
|
overlay to grab focus only when it's the topmost one. */}
|
|
{pendingToolApproval && mainView === 'chat' && (
|
|
<div
|
|
data-testid="approval-overlay"
|
|
className={styles.approvalOverlay}
|
|
>
|
|
<ToolApproval
|
|
showBrowserNotifications
|
|
disabled={isExitPlanApprovalRequest(pendingToolApproval) && modeControlsBusy}
|
|
planExecutionMode={connection.planExecutionMode}
|
|
request={pendingToolApproval}
|
|
onConfirm={handleConfirm}
|
|
variant="floating"
|
|
keyboardActive={toolApprovalOverlayVisible}
|
|
generateContent={
|
|
connection.capabilities?.features.includes(
|
|
'session_generation',
|
|
)
|
|
? sessionActions.generateSessionContent
|
|
: undefined
|
|
}
|
|
planTodos={
|
|
sessionWorkflowEnabled ? approvalPlanTodos : []
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
{/* Workspace-level hooks trust decision uses the same
|
|
approval slot and card as tool approvals; it is not
|
|
a session permission request. */}
|
|
{!managedMode &&
|
|
!pendingToolApproval &&
|
|
mainView === 'chat' && (
|
|
<div className={styles.approvalOverlay}>
|
|
<ProjectHooksApprovalCard
|
|
onActiveChange={setProjectHooksApprovalActive}
|
|
/>
|
|
</div>
|
|
)}
|
|
{pendingAskUserApproval && mainView === 'chat' && (
|
|
<div
|
|
data-testid="approval-overlay"
|
|
className={styles.approvalOverlay}
|
|
>
|
|
<AskUserQuestion
|
|
request={pendingAskUserApproval}
|
|
onConfirm={handleAskUserConfirm}
|
|
onError={reportError}
|
|
variant="floating"
|
|
keyboardActive={askUserOverlayVisible}
|
|
customInputLabel={askUserFreeTextLabel}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div
|
|
className={
|
|
approvalOverlayActive && mainView === 'chat'
|
|
? `${styles.composer} ${styles.composerHidden}`
|
|
: styles.composer
|
|
}
|
|
>
|
|
{connection.runtimeStopped && (
|
|
<div className={styles.composerActionTip} role="status" data-testid="workspace-runtime-stopped">
|
|
<span className={styles.composerActionTipText}>{t('capacityChoice.stopped')}
|
|
{connection.runtimeStopPersistenceUnconfirmed ? ` ${t('capacityChoice.persistenceUnconfirmed')}` : ''}
|
|
</span>
|
|
<button type="button" className={styles.composerActionTipButton}
|
|
onClick={() => { if (connection.sessionId) void sessionActions.loadSession(connection.sessionId, { sessionContext: connection.sessionContext }).catch((error: unknown) => reportError(error, 'Failed to resume session')); }}>
|
|
{t('capacityChoice.resume')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{standaloneWriterBlocked && (
|
|
<div
|
|
className={styles.composerActionTip}
|
|
role="alert"
|
|
data-testid="standalone-writer-blocked"
|
|
>
|
|
<span className={styles.composerActionTipText}>
|
|
{connection.sessionId?.slice(0, 8)}:{' '}
|
|
{t('session.writerBlocked')}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className={styles.composerActionTipButton}
|
|
disabled={standaloneRetrySessionId === connection.sessionId}
|
|
onClick={handleRetryStandaloneSession}
|
|
>
|
|
{t('common.retry')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{standaloneCreationRecovery && (
|
|
<div
|
|
className={styles.composerActionTip}
|
|
role="alert"
|
|
data-testid="standalone-creation-recovery"
|
|
>
|
|
<span
|
|
className={styles.composerActionTipIcon}
|
|
aria-hidden="true"
|
|
>
|
|
!
|
|
</span>
|
|
<span className={styles.composerActionTipText}>
|
|
{standaloneRecoveryResolution === 'checking'
|
|
? t('session.recoveryChecking')
|
|
: standaloneRecoveryResolution === 'creating'
|
|
? t('session.recoveryStillCreating')
|
|
: standaloneRecoveryResolution === 'absent'
|
|
? t('session.recoveryAbsent')
|
|
: standaloneRecoveryResolution ===
|
|
'unknown'
|
|
? t('session.recoveryUnknown')
|
|
: standaloneRecoveryResolution ===
|
|
'archived'
|
|
? t('session.recoveryArchived')
|
|
: t('session.recoveryPending')}
|
|
</span>
|
|
<div className={styles.composerActionTipActions}>
|
|
{standaloneRecoveryResolution === 'archived' ? (
|
|
<button
|
|
type="button"
|
|
className={`${styles.composerActionTipButton} ${styles.composerActionTipButtonPrimary}`}
|
|
onClick={() =>
|
|
void handleUnarchiveRecoveredStandalone()
|
|
}
|
|
>
|
|
{t('session.unarchive')}
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className={`${styles.composerActionTipButton} ${styles.composerActionTipButtonPrimary}`}
|
|
disabled={
|
|
standaloneRecoveryResolution === 'checking'
|
|
}
|
|
onClick={() =>
|
|
void handleCheckStandaloneRecovery()
|
|
}
|
|
>
|
|
{t('session.checkStatus')}
|
|
</button>
|
|
)}
|
|
{standaloneRecoveryResolution === 'absent' && (
|
|
<button
|
|
type="button"
|
|
className={styles.composerActionTipButton}
|
|
onClick={handleRetryStandaloneCreation}
|
|
>
|
|
{t('session.retryCreation')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{standaloneWorkingDirectory?.state === 'recreated' && (
|
|
<div
|
|
className={styles.composerActionTip}
|
|
role="status"
|
|
data-testid="standalone-directory-recreated"
|
|
>
|
|
<span
|
|
className={styles.composerActionTipIcon}
|
|
aria-hidden="true"
|
|
>
|
|
!
|
|
</span>
|
|
<span className={styles.composerActionTipText}>
|
|
{t('session.directoryRecreated')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{standaloneDirectoryErrorCode ===
|
|
'working_directory_missing' && (
|
|
<div
|
|
className={styles.composerActionTip}
|
|
role="alert"
|
|
data-testid="standalone-directory-missing"
|
|
>
|
|
<span className={styles.composerActionTipText}>
|
|
{t('session.directoryMissing')}
|
|
</span>
|
|
<div className={styles.composerActionTipActions}>
|
|
<button
|
|
type="button"
|
|
className={`${styles.composerActionTipButton} ${styles.composerActionTipButtonPrimary}`}
|
|
disabled={standaloneRepairBusy}
|
|
onClick={() =>
|
|
void handleRepairStandaloneDirectory()
|
|
}
|
|
>
|
|
{standaloneRepairBusy
|
|
? t('session.repairing')
|
|
: t('session.repair')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{standaloneDirectoryErrorCode ===
|
|
'working_directory_compromised' && (
|
|
<div
|
|
className={styles.composerActionTip}
|
|
role="alert"
|
|
data-testid="standalone-directory-compromised"
|
|
>
|
|
<span className={styles.composerActionTipText}>
|
|
{t('session.directoryCompromised')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{streamingState !== 'idle' ||
|
|
sessionHasActivePrompt ? (
|
|
suppressFailedPromptRetryStreaming ? null : (
|
|
<StreamingStatus
|
|
showPhrase={enableLoadingPhrases}
|
|
showResponseTokensPerSecond={showResponseTokensPerSecond && !approvalOverlayActive}
|
|
startedAt={
|
|
failedPromptRetry?.startedAt ??
|
|
activeTurnStartedAt
|
|
}
|
|
hasActivePrompt={sessionHasActivePrompt}
|
|
backgroundLabel={
|
|
connection.backgroundTurn?.label ??
|
|
connection.backgroundTurn?.kind
|
|
}
|
|
/>
|
|
)
|
|
) : newSessionSuggestion ? (
|
|
<div
|
|
className={styles.composerActionTip}
|
|
role="status"
|
|
data-testid={
|
|
newSessionSuggestion.suggestion === 'btw'
|
|
? 'btw-suggestion'
|
|
: 'new-session-suggestion'
|
|
}
|
|
>
|
|
<span
|
|
className={styles.composerActionTipIcon}
|
|
aria-hidden="true"
|
|
>
|
|
✦
|
|
</span>
|
|
<span className={styles.composerActionTipText}>
|
|
{newSessionSuggestion.suggestion === 'btw'
|
|
? t('editor.btwSuggestionTitle')
|
|
: t('editor.newSessionSuggestionTitle')}
|
|
</span>
|
|
<div className={styles.composerActionTipActions}>
|
|
<button
|
|
type="button"
|
|
className={`${styles.composerActionTipButton} ${styles.composerActionTipButtonPrimary}`}
|
|
data-testid={
|
|
newSessionSuggestion.suggestion === 'btw'
|
|
? 'btw-suggestion-send'
|
|
: 'new-session-suggestion-start'
|
|
}
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
onClick={
|
|
newSessionSuggestion.suggestion === 'btw'
|
|
? handleAcceptBtwSuggestion
|
|
: handleAcceptNewSessionSuggestion
|
|
}
|
|
>
|
|
{newSessionSuggestion.suggestion === 'btw'
|
|
? t('editor.btwSuggestionSend')
|
|
: t('editor.newSessionSuggestionStart')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : escapeHintVisible && streamingState === 'idle' ? (
|
|
<div className={styles.escClearStatus} role="status">
|
|
{t('editor.escClearHint')}
|
|
</div>
|
|
) : null}
|
|
{unknownPromptAdmission &&
|
|
unknownPromptAdmission.sessionId ===
|
|
connection.sessionId && (
|
|
<div
|
|
className={styles.composerActionTip}
|
|
role="status"
|
|
data-testid="prompt-admission-unknown"
|
|
>
|
|
<span
|
|
className={styles.composerActionTipIcon}
|
|
aria-hidden="true"
|
|
>
|
|
!
|
|
</span>
|
|
<span className={styles.composerActionTipText}>
|
|
{t('queue.admissionUnknown')}
|
|
</span>
|
|
{unknownPromptAdmission.payloadAvailable && (
|
|
<div
|
|
className={styles.composerActionTipActions}
|
|
>
|
|
<button
|
|
type="button"
|
|
className={`${styles.composerActionTipButton} ${styles.composerActionTipButtonPrimary}`}
|
|
onClick={restoreUnknownPromptPayload}
|
|
>
|
|
{t('queue.restoreUnknown')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={styles.composerActionTipButton}
|
|
onClick={discardUnknownPromptPayload}
|
|
>
|
|
{t('queue.discardUnknown')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{(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={openImagePanel}
|
|
onAttachmentPreview={openAttachmentPanel}
|
|
/>
|
|
{liveGoalSnapshot?.goal && (
|
|
<GoalStatusStrip
|
|
snapshot={liveGoalSnapshot}
|
|
busy={goalControlBusy}
|
|
onEdit={() => {
|
|
setGoalEditError(null);
|
|
setGoalEditOpen(true);
|
|
}}
|
|
onPause={() => runGoalControl('pause')}
|
|
onResume={() => runGoalControl('resume')}
|
|
onClear={() => runGoalControl('clear')}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
{CustomComposerHeader && (
|
|
<div className={styles.composerHeader}>
|
|
<CustomComposerHeader
|
|
disabled={
|
|
isDisabled ||
|
|
unknownPromptAdmission?.payloadAvailable === true
|
|
}
|
|
isRunning={
|
|
streamingState !== 'idle' ||
|
|
sessionHasActivePrompt
|
|
}
|
|
currentMode={currentMode}
|
|
currentModel={currentModel}
|
|
sessionName={sessionDisplayName}
|
|
/>
|
|
</div>
|
|
)}
|
|
{sessionAgentRuns.anyLive && (
|
|
<StopAllAgentsButton
|
|
onStopAll={stopAllSessionAgents}
|
|
/>
|
|
)}
|
|
<SessionRecoveryBanner
|
|
blocked={
|
|
isDisabled ||
|
|
isStartingNewSessionSuggestion ||
|
|
interactionBlocked ||
|
|
sessionHasActivePrompt ||
|
|
unknownPromptAdmission?.payloadAvailable === true
|
|
}
|
|
/>
|
|
<ChatEditor
|
|
ref={setEditorHandle}
|
|
liveVoicePortalContainer={
|
|
liveVoiceSlot ?? liveVoicePageSlot
|
|
}
|
|
compactOverlays={compactComposerOverlays}
|
|
onSubmit={
|
|
collaborationAvailable
|
|
? agentChatEntry.submit
|
|
: handleEditorSubmit
|
|
}
|
|
onInputTextChange={handleComposerTextChange}
|
|
onAttachmentsChange={
|
|
handleComposerAttachmentsChange
|
|
}
|
|
btwEnabled={
|
|
Boolean(connection.sessionId) &&
|
|
!hiddenCommands.has('btw')
|
|
}
|
|
onImageIngestionNotice={pushToast}
|
|
onImagePreview={openImagePanel}
|
|
onAttachmentPreview={openAttachmentPanel}
|
|
onCycleMode={handleCycleMode}
|
|
cycleModeOnTab={cycleModeOnTab}
|
|
onToggleShortcuts={handleToggleShortcuts}
|
|
onCancel={handleCancel}
|
|
isRunning={
|
|
streamingState !== 'idle' ||
|
|
sessionHasActivePrompt
|
|
}
|
|
isPreparing={
|
|
isPreparingPrompt || isStartingNewSessionSuggestion
|
|
}
|
|
cancelArmed={cancelArmed}
|
|
disabled={
|
|
(collaborationAvailable && agentChatEntry.pending) ||
|
|
isDisabled ||
|
|
isStartingNewSessionSuggestion ||
|
|
interactionBlocked ||
|
|
approvalOverlayActive ||
|
|
unknownPromptAdmission?.payloadAvailable === true
|
|
}
|
|
commands={commands}
|
|
skills={composerSkills}
|
|
onSkillsOpenChange={
|
|
workspaceContextActive
|
|
? setComposerSkillsOpen
|
|
: undefined
|
|
}
|
|
skillsLoading={
|
|
skillsLoading || Boolean(
|
|
connection.sessionId &&
|
|
commandsRefreshingSession === connection.sessionId,
|
|
)
|
|
}
|
|
skillsLoadError={
|
|
skillsLoadError ||
|
|
commandsRefreshErrorOwner?.isCurrent() === true
|
|
}
|
|
skillsLoaded={
|
|
!sessionCatalogPending &&
|
|
(Boolean(
|
|
connection.sessionId &&
|
|
connection.skills !== undefined,
|
|
) ||
|
|
(loadedSkillsReady &&
|
|
skillsCatalogCacheRef.current?.key ===
|
|
skillsCatalogKey))
|
|
}
|
|
slashCommandCategoryOrder={slashCommandCategoryOrder}
|
|
autoSubmitSlashCommands={autoSubmitSlashCommands}
|
|
builtinAtProviders={
|
|
workspaceContextActive ? builtinAtProviders : false
|
|
}
|
|
atProviders={composerAtProviders}
|
|
composerTagIcons={composerTagIcons}
|
|
voiceTarget={
|
|
activePanel !== null || mainView !== 'chat'
|
|
? undefined
|
|
: mainVoiceTarget
|
|
}
|
|
voiceStatusRevision={mainVoiceStatusRevision}
|
|
queuedMessages={queuedTexts}
|
|
onFocusFooter={handleFocusTaskPill}
|
|
onPopQueuedMessages={editLastQueuedPrompt}
|
|
onClearQueuedMessages={clearQueuedPrompts}
|
|
currentMode={executionMode}
|
|
availableModes={availableApprovalModes}
|
|
modeControlsDisabled={modeControlsBusy}
|
|
planMode={currentMode === 'plan'}
|
|
onTogglePlan={handleTogglePlan}
|
|
sessionWorkflowEnabled={sessionWorkflowEnabled}
|
|
currentModel={currentModel}
|
|
gitBranch={
|
|
workspaceContextActive
|
|
? activeGitBranch
|
|
: undefined
|
|
}
|
|
gitWorktree={
|
|
workspaceContextActive && Boolean(sessionWorktree)
|
|
}
|
|
gitCwd={
|
|
workspaceContextActive
|
|
? sessionWorktree?.path
|
|
: undefined
|
|
}
|
|
gitModeIntent={gitModeEligible ? gitModeIntent : undefined}
|
|
onGitModeIntentChange={gitModeEligible ? setGitModeIntent : undefined}
|
|
gitStatus={
|
|
workspaceContextActive
|
|
? selectedWorkspaceGitStatus
|
|
: undefined
|
|
}
|
|
onOpenGitDiff={
|
|
gitDiffWorkspaceCwd
|
|
? handleOpenGitDiff
|
|
: undefined
|
|
}
|
|
onOpenCommit={
|
|
gitDiffWorkspaceCwd
|
|
? handleOpenCommit
|
|
: undefined
|
|
}
|
|
onOpenWorktrees={
|
|
gitDiffWorkspaceCwd && gitWorktreesSupported
|
|
? handleOpenWorktrees
|
|
: undefined
|
|
}
|
|
onOpenLog={
|
|
gitDiffWorkspaceCwd
|
|
? handleOpenLog
|
|
: undefined
|
|
}
|
|
chatWidthMode={chatWidthMode}
|
|
showChatWidthToggle={!isChatEmptyState}
|
|
chatWidthToggleMin={chatWidthToggleMin}
|
|
visibleToolbarActions={visibleComposerToolbarActions}
|
|
// Before the session exists the workspace and git
|
|
// chips sit under the composer, next to the prompt
|
|
// they describe; once it does, the header owns the
|
|
// workspace and the composer keeps only git.
|
|
contextChipPlacement={
|
|
isChatEmptyState ? 'below' : 'header'
|
|
}
|
|
tokenCount={
|
|
contextUsageAvailable ? (connection.tokenCount ?? 0) : 0
|
|
}
|
|
contextWindow={
|
|
contextUsageAvailable ? (connection.contextWindow ?? 0) : 0
|
|
}
|
|
contextUsageAlwaysVisible={
|
|
contextUsageAlwaysVisible
|
|
}
|
|
contextUsageControls={
|
|
connection.sessionId
|
|
? contextUsageControls[connection.sessionId]
|
|
: undefined
|
|
}
|
|
onOpenContextUsage={
|
|
contextUsageAvailable ? openCurrentContextUsagePanel : undefined
|
|
}
|
|
modelRefresh={modelRefresh}
|
|
availableModels={availableModels}
|
|
unavailableModels={unavailableModels}
|
|
mountUnavailableModelTooltip={
|
|
mountUnavailableModelTooltip
|
|
}
|
|
onSelectMode={handleSetMode}
|
|
onSelectModel={handleModelSelect}
|
|
workflowMode={connection.workflowMode}
|
|
workflowModeStatus={
|
|
managedMode
|
|
? undefined
|
|
: !connection.sessionId
|
|
? 'unbound'
|
|
: sessionWriteBlocked ||
|
|
connection.catchingUp ||
|
|
connection.status !== 'connected'
|
|
? 'syncing'
|
|
: (connection.workflowModeStatus ?? 'unknown')
|
|
}
|
|
workflowModePending={connection.workflowModePending}
|
|
onWorkflowModeChange={handleWorkflowModeChange}
|
|
reasoning={displayedReasoning}
|
|
onSelectReasoningEffort={
|
|
hasAuthoritativeReasoningContext
|
|
? handleReasoningEffort
|
|
: welcomeReasoningPreview && !sessionWriteBlocked
|
|
? handleWelcomeReasoningEffort
|
|
: undefined
|
|
}
|
|
workspaces={
|
|
composerWorkspaceSelectEnabled
|
|
? composerWorkspaces
|
|
: undefined
|
|
}
|
|
workspaceSelectorAlwaysVisible={Boolean(managedAgentProvider) && composerWorkspaceSelectEnabled}
|
|
workspaceMenuItems={
|
|
composerWorkspaceSelectEnabled && !lockedWorkspaceCwd &&
|
|
managedAgentProvider &&
|
|
managedWorkspaceCatalog &&
|
|
managedWorkspaceCatalog.providerKey === managedProviderKey &&
|
|
managedWorkspaceCatalog.workspaces.map((item) => (
|
|
<DropdownMenuItem
|
|
key={item.workspaceId}
|
|
disabled={!item.canCreateSession}
|
|
onSelect={() => handleSelectManagedWorkspace(item.workspaceId)}
|
|
>
|
|
{item.displayName} · {t('managed.managedMode')}
|
|
</DropdownMenuItem>
|
|
))
|
|
}
|
|
selectedWorkspaceCwd={
|
|
workspaceContextActive
|
|
? connection.sessionId
|
|
? ordinaryWorkspaces.find(
|
|
(entry) =>
|
|
entry.cwd === connection.workspaceCwd &&
|
|
!entry.primary,
|
|
)?.cwd
|
|
: selectedWorkspaceCwd
|
|
: undefined
|
|
}
|
|
workspaceSelectionDisabled={
|
|
!composerWorkspaceSelectEnabled
|
|
}
|
|
atWorkspaceCwd={
|
|
workspaceContextActive
|
|
? (ordinaryWorkspaces.find(
|
|
(entry) => entry.cwd === lockedWorkspaceCwd,
|
|
)?.cwd ??
|
|
(connection.sessionId
|
|
? connection.workspaceCwd
|
|
: resolveWorkspaceMaintenanceTargetCwd()))
|
|
: undefined
|
|
}
|
|
composerScopeKey={
|
|
effectiveSessionContext?.kind === 'standalone'
|
|
? 'standalone'
|
|
: effectiveSessionContext?.kind === 'live'
|
|
? 'live'
|
|
: undefined
|
|
}
|
|
workspaceFeaturesEnabled={workspaceContextActive}
|
|
attachmentsEnabled={composerAttachmentsEnabled}
|
|
onSelectWorkspace={
|
|
composerWorkspaceSelectEnabled
|
|
? handleSelectComposerWorkspace
|
|
: undefined
|
|
}
|
|
onSelectHostedWorkspace={
|
|
composerWorkspaceSelectEnabled
|
|
? handleSelectHostedWorkspace
|
|
: undefined
|
|
}
|
|
standaloneTargetSupported={
|
|
composerWorkspaceSelectEnabled &&
|
|
standaloneSessionsSupported &&
|
|
!lockedWorkspaceCwd
|
|
}
|
|
selectedStandaloneTarget={
|
|
effectiveSessionContext?.kind === 'standalone'
|
|
}
|
|
onSelectStandaloneTarget={
|
|
composerWorkspaceSelectEnabled
|
|
? handleSelectComposerStandalone
|
|
: undefined
|
|
}
|
|
scratchWorkspaceSupported={
|
|
composerWorkspaceSelectEnabled &&
|
|
scratchWorkspaceRegistrationSupported
|
|
}
|
|
existingFolderWorkspaceSupported={
|
|
composerWorkspaceSelectEnabled &&
|
|
dynamicWorkspaceRegistrationSupported
|
|
}
|
|
workspaceMutationBusy={workspaceMutationBusy}
|
|
onCreateScratchWorkspace={
|
|
composerWorkspaceSelectEnabled
|
|
? handleCreateComposerScratchWorkspace
|
|
: undefined
|
|
}
|
|
onOpenExistingWorkspace={
|
|
composerWorkspaceSelectEnabled
|
|
? handleOpenExistingWorkspace
|
|
: undefined
|
|
}
|
|
onChatWidthModeChange={handleChatWidthModeChange}
|
|
sessionId={connection.sessionId}
|
|
sessionName={sessionDisplayName}
|
|
dialogOpen={
|
|
interactionBlocked || approvalOverlayActive
|
|
}
|
|
followupState={followupState}
|
|
onAcceptFollowup={onAcceptFollowup}
|
|
onDismissFollowup={onDismissFollowup}
|
|
composerInput={composerInput}
|
|
composerInputVersion={composerInputVersion}
|
|
placeholderText={composerPlaceholderText}
|
|
/>
|
|
{CustomComposerFooter && (
|
|
<CustomComposerFooter
|
|
disabled={isDisabled}
|
|
isRunning={
|
|
streamingState !== 'idle' ||
|
|
sessionHasActivePrompt
|
|
}
|
|
currentMode={currentMode}
|
|
currentModel={currentModel}
|
|
sessionName={sessionDisplayName}
|
|
/>
|
|
)}
|
|
</div>
|
|
{CustomFooter ? (
|
|
hasMobileComposerBottom ? (
|
|
<div className={styles.customFooter}>
|
|
<CustomFooter
|
|
connected={connected}
|
|
mode={currentMode}
|
|
model={currentModel}
|
|
streamingState={streamingState}
|
|
contextUsageRatio={
|
|
(connection.contextWindow ?? 0) > 0
|
|
? (connection.tokenCount ?? 0) /
|
|
(connection.contextWindow ?? 0)
|
|
: 0
|
|
}
|
|
goalSnapshot={goalSnapshot}
|
|
activeGoal={activeGoal}
|
|
tasks={footerTasks}
|
|
availableModes={availableApprovalModes}
|
|
availableModels={(connection.models ?? [])
|
|
.filter(isVisibleComposerModel)
|
|
.map((m) => ({
|
|
id: m.id,
|
|
label: getModelDisplayName(m.label || m.id),
|
|
contextWindow: m.contextWindow,
|
|
}))}
|
|
skills={composerSkills}
|
|
onSelectMode={handleSetMode}
|
|
onSelectModel={handleModelSelect}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<CustomFooter
|
|
connected={connected}
|
|
mode={currentMode}
|
|
model={currentModel}
|
|
streamingState={streamingState}
|
|
contextUsageRatio={
|
|
(connection.contextWindow ?? 0) > 0
|
|
? (connection.tokenCount ?? 0) /
|
|
(connection.contextWindow ?? 0)
|
|
: 0
|
|
}
|
|
goalSnapshot={goalSnapshot}
|
|
activeGoal={activeGoal}
|
|
tasks={footerTasks}
|
|
availableModes={availableApprovalModes}
|
|
availableModels={(connection.models ?? [])
|
|
.filter(isVisibleComposerModel)
|
|
.map((m) => ({
|
|
id: m.id,
|
|
label: getModelDisplayName(m.label || m.id),
|
|
contextWindow: m.contextWindow,
|
|
}))}
|
|
skills={composerSkills}
|
|
onSelectMode={handleSetMode}
|
|
onSelectModel={handleModelSelect}
|
|
/>
|
|
)
|
|
) : (
|
|
<StatusBar
|
|
onSelectMode={() =>
|
|
setShowApprovalModeDialog((v) => !v)
|
|
}
|
|
onSelectModel={() =>
|
|
isStandaloneModelPickerUnavailable({
|
|
sessionId: connection.sessionId,
|
|
sessionContextKind: effectiveSessionContext?.kind,
|
|
models: connection.models,
|
|
})
|
|
? pushToast('info', t('model.unavailable'))
|
|
: setModelDialogMode((v) =>
|
|
v ? null : 'main',
|
|
)
|
|
}
|
|
onShowContext={() =>
|
|
showContextUsage('/context', false)
|
|
}
|
|
onOpenSettings={() => openPanel('settings')}
|
|
ref={statusBarRef}
|
|
onOpenTasks={() => openTasksPanel()}
|
|
onReturnToInput={handleReturnToEditor}
|
|
// The pill filters composer tasks itself; it also
|
|
// counts background agents, which the
|
|
// pre-filtered list would have dropped. The full
|
|
// snapshot reaches the pill only while a
|
|
// background agent is still running in the
|
|
// shipped entry, where the environment panel
|
|
// replaces the footer task list; the empty chat
|
|
// and headerless embeds keep the pre-filtered
|
|
// composer tasks, so agents never reach them.
|
|
tasks={
|
|
isChatEmptyState ||
|
|
!environmentTasksReplacementEnabled
|
|
? backgroundTasks
|
|
: hasActiveBackgroundAgent(sessionTasks)
|
|
? sessionTasks
|
|
: []
|
|
}
|
|
hideSettings={
|
|
hideSettings || !workspaceContextActive
|
|
}
|
|
onToggleShortcuts={handleToggleShortcuts}
|
|
compact={true}
|
|
/>
|
|
)}
|
|
{isChatEmptyState && welcomeFooter && (
|
|
<div
|
|
className={[
|
|
styles.emptyWelcomeFooter,
|
|
showMobileWelcomeFooterMiddle
|
|
? styles.desktopWelcomeFooter
|
|
: undefined,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
>
|
|
{welcomeFooter}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</McpAppVisibilityContext.Provider>
|
|
</WebShellCustomizationProvider>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{environmentPanelMounted && (
|
|
<EnvironmentPanel
|
|
floating={!environmentPanelFits}
|
|
hidden={!environmentPanelVisible || artifactPanelFullscreen}
|
|
workspaceCwd={
|
|
workspaceContextActive
|
|
? (sessionWorktree?.path ?? activeWorkspaceCwd)
|
|
: undefined
|
|
}
|
|
gitWorkspaceCwd={
|
|
workspaceContextActive && connection.sessionId
|
|
? gitDiffWorkspaceCwd
|
|
: undefined
|
|
}
|
|
gitCwd={
|
|
workspaceContextActive ? sessionWorktree?.path : undefined
|
|
}
|
|
gitSessionId={
|
|
workspaceContextActive && sessionWorktree
|
|
? connection.sessionId
|
|
: undefined
|
|
}
|
|
branch={workspaceContextActive ? activeGitBranch : undefined}
|
|
gitStatus={
|
|
workspaceContextActive ? selectedWorkspaceGitStatus : undefined
|
|
}
|
|
tasks={sessionTasks}
|
|
agentTasks={environmentAgentTasks}
|
|
attachments={
|
|
logicalSessionKey
|
|
? (sessionAttachmentsBySessionRef.current.get(
|
|
logicalSessionKey,
|
|
) ?? [])
|
|
: []
|
|
}
|
|
attachmentsLoading={sessionAttachmentsSkeletonLoading}
|
|
attachmentsError={
|
|
sessionAttachmentsError?.owner === sessionAttachmentsOwner &&
|
|
sessionAttachmentsOwner.isCurrent()
|
|
? sessionAttachmentsError.message
|
|
: undefined
|
|
}
|
|
onRetryAttachments={() =>
|
|
setAttachmentRefreshNonce((value) => value + 1)
|
|
}
|
|
sources={sourcesState}
|
|
onOpenSource={openSourcePanel}
|
|
retrySourceRegistration={
|
|
sourceRegistrationRetries.length
|
|
? retrySourceRegistrations
|
|
: undefined
|
|
}
|
|
artifacts={artifacts}
|
|
artifactsLoading={artifactsLoading}
|
|
items={environmentPanelItems}
|
|
onOpenGitDiff={
|
|
workspaceContextActive && gitDiffWorkspaceCwd
|
|
? handleOpenGitDiff
|
|
: undefined
|
|
}
|
|
onOpenGitCommit={
|
|
workspaceContextActive && gitDiffWorkspaceCwd
|
|
? handleOpenCommit
|
|
: undefined
|
|
}
|
|
onOpenGitWorktrees={
|
|
workspaceContextActive &&
|
|
gitDiffWorkspaceCwd &&
|
|
gitWorktreesSupported
|
|
? handleOpenWorktrees
|
|
: undefined
|
|
}
|
|
onOpenGitLog={
|
|
workspaceContextActive && gitDiffWorkspaceCwd
|
|
? handleOpenLog
|
|
: undefined
|
|
}
|
|
onOpenAgent={openEnvironmentAgent}
|
|
onOpenAgentWorkflow={
|
|
sessionAgentTraceSupported ? openAgentWorkflow : undefined
|
|
}
|
|
onOpenTask={openEnvironmentTask}
|
|
onReadImage={readSessionImage}
|
|
onImagePreview={(src, alt, source) => {
|
|
if (environmentPanelOwner.isCurrent()) {
|
|
openImagePanel(src, alt, source);
|
|
}
|
|
}}
|
|
onAttachmentPreview={(file) =>
|
|
openAttachmentPanel(file, undefined, undefined, true)
|
|
}
|
|
onAttachmentPreviewError={(error) => {
|
|
if (!environmentPanelOwner.isCurrent()) return;
|
|
pushToast(
|
|
'error',
|
|
t('rightPanel.attachmentLoadFailed', {
|
|
error: formatError(error, t('environment.unavailable')),
|
|
}),
|
|
);
|
|
}}
|
|
onOpenArtifact={openArtifactPanel}
|
|
onDismiss={dismissEnvironmentPanel}
|
|
/>
|
|
)}
|
|
{!managedMode && artifactPanelOpen && useFloatingArtifactPanel && (
|
|
<Drawer
|
|
open
|
|
autoFocus={floatingArtifactDrawerAutoFocus}
|
|
direction="right"
|
|
shouldScaleBackground={false}
|
|
onOpenChange={(open) => {
|
|
if (!open) closeArtifactPanel();
|
|
}}
|
|
>
|
|
<DrawerContent
|
|
overlayProps={
|
|
suppressArtifactDockOpenAnimation
|
|
? {
|
|
className:
|
|
'bg-black/35 backdrop-blur-[1px] !duration-0 data-[state=open]:!animate-none',
|
|
}
|
|
: { className: 'bg-black/35 backdrop-blur-[1px]' }
|
|
}
|
|
onAnimationEnd={(event) => {
|
|
if (event.target === event.currentTarget) {
|
|
setWaitForSubagentPanelAnimation(false);
|
|
}
|
|
}}
|
|
className={`${
|
|
artifactPanelFullscreen
|
|
? 'data-[vaul-drawer-direction=right]:w-full data-[vaul-drawer-direction=right]:sm:max-w-none data-[vaul-drawer-direction=right]:rounded-none data-[vaul-drawer-direction=right]:border-0 data-[vaul-drawer-direction=right]:pt-[env(safe-area-inset-top)] data-[vaul-drawer-direction=right]:pr-[env(safe-area-inset-right)] data-[vaul-drawer-direction=right]:pb-[env(safe-area-inset-bottom)] data-[vaul-drawer-direction=right]:pl-[env(safe-area-inset-left)]'
|
|
: 'shadow-2xl data-[vaul-drawer-direction=right]:w-[min(520px,calc(100vw-16px))] data-[vaul-drawer-direction=right]:sm:max-w-[520px]'
|
|
} ${
|
|
suppressArtifactDockOpenAnimation
|
|
? '!duration-0 !transition-none data-[state=open]:!animate-none'
|
|
: ''
|
|
}`}
|
|
onCloseAutoFocus={(event) => {
|
|
const trigger = artifactPanelTriggerRef.current;
|
|
artifactPanelTriggerRef.current = null;
|
|
if (focusComposerAfterArtifactPanelCloseRef.current) {
|
|
focusComposerAfterArtifactPanelCloseRef.current = false;
|
|
event.preventDefault();
|
|
editorRef.current?.focus();
|
|
return;
|
|
}
|
|
if (!trigger?.isConnected) return;
|
|
event.preventDefault();
|
|
trigger.focus();
|
|
}}
|
|
onEscapeKeyDown={(event) => {
|
|
if (isWebTerminalTarget(event)) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
if (event.isComposing || event.keyCode === 229) {
|
|
// IME owns Escape; keep the dismiss layer from acting
|
|
// on it. The preserveImeEscape mask above normally
|
|
// hides composition Escapes from this handler entirely.
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
const target = event.composedPath()[0];
|
|
if (
|
|
target instanceof HTMLInputElement &&
|
|
target.hasAttribute('data-workspace-file-filter') &&
|
|
target.value
|
|
) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
// Fullscreen is the topmost surface: Escape shrinks the
|
|
// panel back to its drawer width. Without this the
|
|
// drawer's dismiss layer would close the panel instead.
|
|
if (!artifactPanelFullscreen) return;
|
|
event.preventDefault();
|
|
setArtifactPanelFullscreen(false);
|
|
}}
|
|
>
|
|
<DrawerTitle className="sr-only">Right panel</DrawerTitle>
|
|
<div
|
|
className="flex min-h-0 flex-1"
|
|
onKeyDown={handleArtifactPanelSurfaceKeyDown}
|
|
>
|
|
<WebShellCustomizationProvider value={customization}>
|
|
<ArtifactPanel
|
|
{...artifactPanelSharedProps}
|
|
onOpenTerminal={
|
|
webTerminalAvailable ? openTerminalTab : undefined
|
|
}
|
|
variant="drawer"
|
|
/>
|
|
</WebShellCustomizationProvider>
|
|
</div>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* Stays mounted even when the panel is closed: the fullscreen
|
|
effect moves it to the portal root, and React must never
|
|
delete it while it is parked there. */}
|
|
<div
|
|
ref={setArtifactPanelSlotEl}
|
|
className={styles.artifactPanelSlot}
|
|
/>
|
|
{/* The wrapper portals into the slot above, and the fullscreen
|
|
effect moves that slot to the portal root (and back), so the
|
|
panel stays mounted across the mode change. While fullscreen
|
|
it is the modal surface: dialog role/name plus Tab containment
|
|
(what the floating variant gets from vaul's Radix dialog). */}
|
|
{!managedMode && artifactPanelOpen &&
|
|
!useFloatingArtifactPanel &&
|
|
artifactPanelSlotEl &&
|
|
createPortal(
|
|
<div
|
|
ref={artifactPanelFullscreenSurfaceRef}
|
|
tabIndex={-1}
|
|
onKeyDown={handleArtifactPanelSurfaceKeyDown}
|
|
onAnimationEnd={(event) => {
|
|
if (event.target === event.currentTarget) {
|
|
setWaitForSubagentPanelAnimation(false);
|
|
}
|
|
}}
|
|
{...(artifactPanelFullscreen
|
|
? { role: 'dialog' as const, 'aria-label': 'Right panel' }
|
|
: {})}
|
|
className={
|
|
artifactPanelFullscreen
|
|
? styles.artifactPanelFullscreen
|
|
: [
|
|
styles.artifactPanelDock,
|
|
suppressArtifactDockOpenAnimation
|
|
? styles.artifactPanelDockNoOpenAnimation
|
|
: undefined,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
}
|
|
style={
|
|
{
|
|
'--artifact-panel-dock-width': `${artifactPanelWidth + 4}px`,
|
|
} as CSSProperties
|
|
}
|
|
>
|
|
{!artifactPanelFullscreen && (
|
|
<div
|
|
className={styles.artifactResizeHandle}
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
aria-valuemin={MIN_ARTIFACT_PANEL_WIDTH}
|
|
aria-valuemax={getMaxArtifactPanelWidth()}
|
|
aria-valuenow={artifactPanelWidth}
|
|
onPointerDown={handleArtifactPanelResizeStart}
|
|
/>
|
|
)}
|
|
<WebShellCustomizationProvider value={customization}>
|
|
<div className={styles.artifactPanelClip}>
|
|
<ArtifactPanel
|
|
{...artifactPanelSharedProps}
|
|
onOpenTerminal={
|
|
webTerminalAvailable ? openTerminalTab : undefined
|
|
}
|
|
panelWidth={artifactPanelWidth}
|
|
/>
|
|
</div>
|
|
</WebShellCustomizationProvider>
|
|
</div>,
|
|
artifactPanelSlotEl,
|
|
)}
|
|
</div>
|
|
</GeneratedUiRuntimeProvider>
|
|
</div>
|
|
</CompactModeContext.Provider>
|
|
</WebShellPortalRootContext.Provider>
|
|
</McpAppHostContext.Provider>
|
|
</McpAppModelContextContext.Provider>
|
|
</McpAppMessagesContext.Provider>
|
|
</I18nProvider>
|
|
</BrandProvider>
|
|
</ThemeProvider>
|
|
);
|
|
}
|