import { forwardRef, memo, useImperativeHandle, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import type { ReactNode, RefObject } from 'react'; import { Tooltip as TooltipPrimitive } from 'radix-ui'; import { DAEMON_APPROVAL_MODES } from '@qwen-code/webui/daemon-react-sdk'; import type { CommandInfo } from '../adapters/types'; import type { UseDaemonFollowupSuggestionReturn } from '@qwen-code/webui/daemon-react-sdk'; import type { DaemonSessionGroupPresetColor, DaemonWorkspaceGitStatus, } from '@qwen-code/sdk/daemon'; import type { CommandDisplayCategoryOrder } from '../utils/commandDisplay'; import type { SkillInfo } from '../completions/slashCompletion'; import { useI18n } from '../i18n'; import { useWebShellPortalRoot } from '../portalRoot'; import { useWebShellCustomization, type WebShellComposerInput, type WebShellComposerTag, type WebShellComposerTagIconMap, type WebShellAtProvider, type WebShellBuiltinAtProvidersConfig, } from '../customization'; import { useComposerCore, type ComposerSubmitMetadata, type EditorHandle, type SlashMenuState, getComposerTagDisplay, getComposerTagLabel, getComposerTagValue, } from '../hooks/useComposerCore'; import { AtMentionPanel } from './AtMentionPanel'; import { cssUrlVar } from '../utils/cssUrlVar'; import { getComposerTagIconUrl, isBuiltinComposerTagIconUrl, } from '../utils/composerTag'; import { isSafeImageSrc } from './messages/Markdown'; import { ModeIcon } from './ModeIcon'; import { planSlashSectionRows } from '../utils/slashSectionPlan'; import { getModelDisplayName } from '../utils/modelDisplay'; import { VoiceButton } from '../voice/VoiceButton'; import type { VoiceStatusRevision, VoiceWorkspaceTarget, } from '../voice/voice-workspace-target'; import { GitBranchChipContent, GitBranchIndicator, gitBranchAriaLabel, } from './GitBranchIndicator'; import { GitModePopover, type SessionGitIntent } from './GitModePopover'; import { BranchPickerPopover } from './BranchPickerPopover'; import { WorkspaceIndicator } from './WorkspaceIndicator'; import { ChevronDownIcon, FolderClosedIcon } from 'lucide-react'; import { WorkspaceSelector } from './WorkspaceSelector'; import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger, } from './ui/popover'; import { Input } from './ui/input'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from './ui/tooltip'; import { filterToolbarDropdownItems, getToolbarExpansionBudget, getToolbarItemVisibilityWithHysteresis, resolveToolbarModelLabel, type ToolbarDropdownItem, } from './toolbarDropdown'; import styles from './ChatEditor.module.css'; export type ComposerToolbarAction = | 'approvalMode' | 'gitBranch' | 'model' | 'commands' | 'files' | 'widthMode' | 'voice' | 'workspace'; const ACTIVE_TOOLBAR_ACTIONS = [ 'approvalMode', 'gitBranch', 'model', 'widthMode', 'voice', 'workspace', ] as const satisfies readonly ComposerToolbarAction[]; const ACTIVE_TOOLBAR_ACTION_SET = new Set( ACTIVE_TOOLBAR_ACTIONS, ); interface ChatEditorProps { onSubmit: ( text: string, images?: import('../adapters/promptTypes').PromptImage[], commitAccepted?: import('../hooks/useComposerCore').ComposerSubmitCommit, metadata?: ComposerSubmitMetadata, ) => boolean | void; onInputTextChange?: (text: string) => void; onAttachmentsChange?: (hasAttachments: boolean) => void; onCycleMode?: () => void; onToggleShortcuts?: () => void; onCancel?: () => void; isRunning?: boolean; isPreparing?: boolean; /** First Esc armed a cancel — the send button shows an "Esc to stop" hint. */ cancelArmed?: boolean; disabled?: boolean; placeholderText?: string; commands: CommandInfo[]; skills?: SkillInfo[]; slashCommandCategoryOrder?: CommandDisplayCategoryOrder; queuedMessages?: string[]; onPopQueuedMessages?: () => boolean; onClearQueuedMessages?: () => boolean; currentMode?: string; currentModel?: string; gitBranch?: string; /** Whether the session is in a worktree (styles the git chip purple). */ gitWorktree?: boolean; /** Git working directory for worktree sessions; targets git operations. */ gitCwd?: string; /** Git mode intent for the empty-state composer chip (branch/worktree selection). */ gitModeIntent?: SessionGitIntent; /** Callback when the user changes the git mode intent via the composer chip popover. */ onGitModeIntentChange?: (intent: SessionGitIntent) => void; /** Enriched working-tree summary (dirty / ahead-behind / stash / operation). */ gitStatus?: DaemonWorkspaceGitStatus; /** Opens the working-tree Changes dialog; makes the git chip clickable. */ onOpenGitDiff?: () => void; /** Opens the commit dialog. */ onOpenCommit?: () => void; /** Workspace name shown in the pane composer's `workspace` toolbar chip. */ workspaceName?: string; /** Full workspace cwd, used as the chip's tooltip. */ workspaceTitle?: string; /** * Stable per-workspace accent color for the chip, so it stays distinguishable * from other panes' chips even when it collapses to an icon on a narrow split. */ workspaceColor?: DaemonSessionGroupPresetColor; chatWidthMode?: '1000' | 'wide'; showChatWidthToggle?: boolean; chatWidthToggleMin?: number; visibleToolbarActions?: readonly ComposerToolbarAction[]; availableModels?: Array<{ id: string; label?: string }>; onSelectMode?: (mode: string) => void; onSelectModel?: (model: string) => void; workspaces?: Array<{ id: string; cwd: string; label: string; primary: boolean; trusted: boolean; }>; selectedWorkspaceCwd?: string; workspaceSelectionDisabled?: boolean; onSelectWorkspace?: (workspaceCwd: string | undefined) => void; scratchWorkspaceSupported?: boolean; existingFolderWorkspaceSupported?: boolean; workspaceMutationBusy?: boolean; onCreateScratchWorkspace?: () => void; onOpenExistingWorkspace?: () => void; atWorkspaceCwd?: string; onChatWidthModeChange?: (mode: '1000' | 'wide') => void; onFocusFooter?: () => boolean; dialogOpen?: boolean; followupState?: UseDaemonFollowupSuggestionReturn['followupState']; onAcceptFollowup?: UseDaemonFollowupSuggestionReturn['onAcceptFollowup']; onDismissFollowup?: UseDaemonFollowupSuggestionReturn['onDismissFollowup']; sessionId?: string; sessionName?: string; composerInput?: WebShellComposerInput; composerInputVersion?: number; builtinAtProviders?: WebShellBuiltinAtProvidersConfig; atProviders?: readonly WebShellAtProvider[]; composerTagIcons?: WebShellComposerTagIconMap; voiceTarget?: VoiceWorkspaceTarget; voiceStatusRevision?: VoiceStatusRevision; } const CHAT_EDITOR_THEME = { '&': { fontSize: '14px', background: 'transparent', border: 'none', }, '&.cm-focused': { outline: 'none', }, '.cm-scroller': { maxHeight: 'var(--chat-editor-input-max-height, 300px)', overflowX: 'hidden', overflowY: 'auto', }, '.cm-content': { padding: '0', fontFamily: 'var(--font-sans, system-ui, sans-serif)', color: 'var(--chat-editor-text-primary, #e0e0e0)', caretColor: 'var(--chat-editor-accent-color, #4a9eff)', fontSize: '14px', lineHeight: '1.6', }, '.cm-line': { padding: '0', }, '.cm-placeholder': { color: 'var(--chat-editor-text-dimmed, #666)', }, '.cm-followup-ghost': { color: 'var(--chat-editor-text-dimmed, #666)', opacity: '0.72', pointerEvents: 'none', userSelect: 'none', }, '.cm-selectionBackground, &.cm-focused .cm-selectionBackground': { backgroundColor: 'var(--chat-editor-selection-bg) !important', }, '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': { backgroundColor: 'var(--chat-editor-selection-bg) !important', }, '&.cm-focused .cm-content ::selection': { backgroundColor: 'var(--chat-editor-selection-bg)', color: 'var(--chat-editor-selection-color)', }, '.cm-content ::selection': { backgroundColor: 'var(--chat-editor-selection-bg)', color: 'var(--chat-editor-selection-color)', }, '.cm-cursor': { borderLeftColor: 'var(--chat-editor-accent-color, #4a9eff)', borderLeftWidth: '2px', }, }; function isTouchLikeDevice(): boolean { if (typeof window === 'undefined') return false; return ( (typeof navigator !== 'undefined' && navigator.maxTouchPoints > 0) || (typeof window.matchMedia === 'function' && window.matchMedia('(hover: none), (pointer: coarse)').matches) ); } function TopComposerTag({ tag, content, tooltip, onActivate, onRemove, }: { tag: WebShellComposerTag; content: ReactNode; tooltip: ReactNode | null | undefined; onActivate?: (anchorRect: DOMRectReadOnly) => void; onRemove?: () => void; }) { const anchorRef = useRef(null); const portalRoot = useWebShellPortalRoot(); const hasTooltip = tooltip !== undefined && tooltip !== null; const tagContent = ( { if (!onActivate) return; event.stopPropagation(); onActivate( anchorRef.current?.getBoundingClientRect() ?? event.currentTarget.getBoundingClientRect(), ); }} onKeyDown={(event) => { if (!onActivate) return; if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); onActivate( anchorRef.current?.getBoundingClientRect() ?? event.currentTarget.getBoundingClientRect(), ); }} > {content} ); const tagElement = ( {hasTooltip ? ( {tagContent} ) : ( tagContent )} {onRemove && ( )} ); if (!hasTooltip) return tagElement; return ( {tagElement} {tooltip} ); } function SendIcon() { return ( ); } function StopIcon() { return