diff --git a/src/web-ui/src/flow_chat/components/ChatInput.scss b/src/web-ui/src/flow_chat/components/ChatInput.scss index a904960f9..9aabb1c5b 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.scss +++ b/src/web-ui/src/flow_chat/components/ChatInput.scss @@ -47,265 +47,6 @@ } } -.bitfun-chat-input__voice-cluster { - display: inline-flex; - align-items: center; - gap: $size-gap-1; - flex-shrink: 0; - min-width: 24px; - - &--recording { - min-width: 270px; - } -} - -.bitfun-chat-input__voice-control.icon-btn { - width: 24px; - height: 24px; - min-width: 24px; - min-height: 24px; - border-radius: 50%; - color: var(--color-text-secondary); - flex-shrink: 0; - - &:not(:disabled):hover { - color: var(--color-text-primary); - } -} - -.bitfun-chat-input__voice-spinner { - animation: bitfun-voice-input-spin 0.9s linear infinite; -} - -.bitfun-chat-input__voice-pill { - display: inline-flex; - align-items: center; - width: clamp(270px, 27vw, 320px); - max-width: 100%; - min-width: 270px; - height: 32px; - padding: 0 2px 0 9px; - gap: 5px; - border: 1px solid var(--border-base); - border-radius: $size-radius-full; - background: color-mix(in srgb, var(--color-bg-elevated) 94%, var(--element-bg-base)); - box-shadow: - 0 1px 4px var(--color-overlay-black-12), - inset 0 1px 0 var(--color-overlay-white-04); - animation: bitfun-voice-pill-in 220ms cubic-bezier(0.18, 0.9, 0.3, 1.18) both; -} - -.bitfun-chat-input__voice-pill-status { - display: inline-flex; - align-items: center; - justify-content: center; - width: 12px; - height: 12px; - flex: 0 0 12px; - color: var(--color-error); -} - -.bitfun-chat-input__voice-pill-recording-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--color-error); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-error) 9%, transparent); - animation: bitfun-voice-recording-dot 1.6s ease-in-out infinite; -} - -.bitfun-chat-input__voice-low-volume { - color: var(--color-warning); - animation: bitfun-voice-low-volume 1.2s ease-in-out infinite; -} - -.bitfun-chat-input__voice-pill-time { - min-width: 36px; - color: var(--color-text-primary); - font-size: 12px; - font-weight: 550; - font-variant-numeric: tabular-nums; - line-height: 1; -} - -.bitfun-chat-input__voice-pill-timeline { - position: relative; - display: grid; - grid-template-columns: repeat(32, minmax(1px, 1fr)); - align-items: center; - flex: 1 1 auto; - min-width: 78px; - height: 18px; - gap: 2px; - overflow: hidden; - color: var(--color-text-secondary); - pointer-events: none; - - &--paused { opacity: 0.58; } -} - -.bitfun-chat-input__voice-pill-timeline-bar { - justify-self: center; - width: min(2px, 100%); - height: 18px; - border-radius: $size-radius-full; - background: currentColor; - transform: scaleY(0.12); - transform-origin: center; - transition: transform 72ms linear, opacity 72ms linear; -} - -.bitfun-chat-input__voice-pill-divider { - width: 1px; - height: 16px; - flex: 0 0 1px; - background: var(--border-subtle); -} - -.bitfun-chat-input__voice-pill-action.icon-btn { - width: 24px; - height: 24px; - min-width: 24px; - min-height: 24px; - border: none; - border-radius: 50%; - background: transparent; - color: var(--color-text-muted); - box-shadow: none; - flex-shrink: 0; - transition: - color 150ms ease, - background-color 150ms ease, - transform 180ms cubic-bezier(0.18, 0.9, 0.3, 1.24); - - &:not(:disabled):hover { - border: none; - background: var(--element-bg-soft); - color: var(--color-text-primary); - transform: scale(1.08); - } - - &:active:not(:disabled) { - transform: scale(0.9); - } - - &.bitfun-chat-input__voice-pill-action--transcribe { - color: var(--color-text-secondary); - } -} - -.bitfun-chat-input__voice-pill-send.icon-btn { - width: 28px; - height: 28px; - min-width: 28px; - min-height: 28px; - border: 1px solid transparent; - border-radius: 50%; - background: rgba(var(--private-chat-input-send-surface-rgb), 0.96); - color: rgb(var(--private-chat-input-send-dark-icon-rgb)); - box-shadow: none; - flex: 0 0 28px; - transition: - background-color 150ms ease, - box-shadow 150ms ease, - transform 190ms cubic-bezier(0.18, 0.9, 0.3, 1.28); - - &:not(:disabled):hover { - border: 1px solid rgba(var(--color-static-white-rgb), 0.52); - background: linear-gradient( - 165deg, - var(--color-static-white) 0%, - rgba(var(--private-chat-input-send-surface-hover-rgb), 0.99) 100% - ); - color: rgb(var(--private-chat-input-send-dark-icon-strong-rgb)); - box-shadow: - 0 0 0 1px rgba(var(--color-static-black-rgb), 0.32), - 0 6px 14px rgba(var(--color-static-black-rgb), 0.28); - transform: scale(1.05); - } - - &:active:not(:disabled) { - transform: scale(0.9); - } - - &:disabled { - border: 1px solid transparent; - background: rgba(var(--private-chat-input-send-surface-disabled-rgb), 0.18); - color: rgba(var(--private-chat-input-send-dark-icon-rgb), 0.38); - opacity: 1; - box-shadow: none; - } -} - -:root[data-theme="light"] .bitfun-chat-input__voice-pill-send.icon-btn, -:root[data-theme-type="light"] .bitfun-chat-input__voice-pill-send.icon-btn, -.light .bitfun-chat-input__voice-pill-send.icon-btn { - &:not(:disabled) { - background: linear-gradient( - 165deg, - rgb(var(--private-chat-input-send-inverse-surface-start-rgb)) 0%, - rgb(var(--private-chat-input-send-inverse-surface-end-rgb)) 100% - ); - color: rgb(var(--private-chat-input-send-inverse-text-rgb)); - } - - &:hover:not(:disabled) { - border-color: rgba(var(--color-static-black-rgb), 0.58); - background: linear-gradient( - 165deg, - rgb(var(--private-chat-input-send-inverse-hover-start-rgb)) 0%, - rgb(var(--private-chat-input-send-inverse-hover-end-rgb)) 100% - ); - color: rgb(var(--private-chat-input-send-inverse-hover-text-rgb)); - box-shadow: - 0 0 0 1px rgba(var(--color-static-white-rgb), 0.28), - 0 6px 14px var(--color-overlay-black-20); - } - - &:disabled { - background: rgba(var(--private-chat-input-send-inverse-disabled-bg-rgb), 0.18); - color: rgba(var(--private-chat-input-send-inverse-disabled-text-rgb), 0.8); - } -} - -@keyframes bitfun-voice-input-spin { to { transform: rotate(360deg); } } -@keyframes bitfun-voice-pill-in { - from { opacity: 0; transform: scaleX(0.94) scaleY(0.88); } - to { opacity: 1; transform: scale(1); } -} -@keyframes bitfun-voice-recording-dot { - 0%, 100% { transform: scale(0.92); opacity: 0.76; } - 50% { transform: scale(1.08); opacity: 1; } -} -@keyframes bitfun-voice-low-volume { - 50% { opacity: 0.55; } -} - -@media (prefers-reduced-motion: reduce) { - .bitfun-chat-input__voice-spinner, - .bitfun-chat-input__voice-pill, - .bitfun-chat-input__voice-pill-recording-dot, - .bitfun-chat-input__voice-low-volume { - animation: none; - } -} - -@media (max-width: 640px) { - .bitfun-chat-input__voice-cluster--recording { - min-width: 220px; - } - - .bitfun-chat-input__voice-pill { - width: min(100%, 240px); - min-width: 220px; - } - - .bitfun-chat-input__voice-pill-timeline { - min-width: 28px; - gap: 1px; - } -} - .bitfun-chat-input { --private-chat-input-capsule-bg-rgb: 18, 18, 28; --private-chat-input-capsule-hover-rgb: 25, 25, 40; @@ -443,6 +184,7 @@ .bitfun-chat-input__model-usage-group { display: flex; align-items: center; + gap: 4px; } } @@ -1715,8 +1457,8 @@ display: flex; align-items: center; justify-content: space-between; - // Must fit the expanded voice-input pill without overlapping the input above. - min-height: 34px; + // Must fit IconButton small (28px); fixed 20px caused vertical overflow into the input above + min-height: 28px; margin-top: 4px; padding: 0 0 $size-gap-2; } @@ -1725,7 +1467,6 @@ display: flex; align-items: center; gap: $size-gap-1; - min-width: 0; animation: bitfun-stacked-reveal 0.28s cubic-bezier(0.4, 0, 0.2, 1) 0.17s both; } diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 70d9356f3..88eb4a51b 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -9,12 +9,7 @@ import { useTranslation } from 'react-i18next'; import { ArrowUp, BotMessageSquare, Image, RotateCcw, Plus, X, Sparkles, Loader2, ChevronRight, Files, MessageSquarePlus, Star } from 'lucide-react'; import { ContextDropZone, useContextStore } from '../../shared/context-system'; import { useActiveSessionState } from '@/flow_chat/hooks'; -import { - RichTextInput, - type InlineTriggerState, - type MentionState, - type RichTextInputElement, -} from './RichTextInput'; +import { RichTextInput, type MentionState, type InlineTriggerState } from './RichTextInput'; import { FileMentionPicker } from './FileMentionPicker'; import { globalEventBus } from '@/infrastructure/event-bus'; import { @@ -24,19 +19,14 @@ import { } from '../hooks/useSessionStateMachine'; import { SessionExecutionEvent } from '../state-machine/types'; import { ModelSelector } from './ModelSelector'; +import { ModelBrainstormControl } from './ModelBrainstormControl'; import { FlowChatStore } from '../store/FlowChatStore'; import { useAcpPlan } from '../hooks/useAcpPlan'; import { filterSlashCommands, useAcpSlashCommands } from '../hooks/useAcpSlashCommands'; import { acpSessionRef, acpSlashCommandText } from '../utils/acpSession'; import { AcpPlanPanel } from './AcpPlanPanel'; import type { FlowChatState } from '../types/flow-chat'; -import type { - ContextItem, - DirectoryContext, - FileContext, - ImageContext, - SessionReferenceContext, -} from '@/types/context.ts'; +import type { ContextItem, FileContext, DirectoryContext, ImageContext } from '@/types/context.ts'; import { SmartRecommendations } from './smart-recommendations'; import { useCurrentWorkspace, useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { flowChatSessionConfigForCurrentWorkspace } from '@/app/utils/projectSessionWorkspace'; @@ -47,26 +37,26 @@ import { type SlashActionId, } from '../utils/slashActionSelection'; import { notificationService } from '@/shared/notification-system'; -import { inputReducer, initialInputState, type InputAction } from '../reducers/inputReducer'; +import { inputReducer, initialInputState } from '../reducers/inputReducer'; import { modeReducer, initialModeState } from '../reducers/modeReducer'; import { CHAT_INPUT_CONFIG } from '../constants/chatInputConfig'; import { useMessageSender } from '../hooks/useMessageSender'; import { useChatInputState } from '../store/chatInputStateStore'; import { useInputHistoryStore } from '../store/inputHistoryStore'; -import { - sessionComposerStore, - type PendingLargePasteMap, -} from '../store/sessionComposerStore'; +import type { ModelBrainstormContextMode } from '../store/modelBrainstormStore'; import { startBtwThread } from '../services/BtwThreadService'; import { runUsageReportCommand } from '../services/usageReportService'; import { buildImagePayload } from '../utils/imagePayload'; +import { + launchModelBrainstorm, + MODEL_BRAINSTORM_MIN_CANDIDATES, +} from '../services/ModelBrainstormService'; import { isGoalSlashCommand, parseGoalCommand } from '../services/goalService'; import { getHistorySessionOpenTransitionSnapshot, subscribeHistorySessionOpenTransition, } from '../services/sessionOpenIntent'; import { useThreadGoalController } from '../hooks/useThreadGoalController'; -import { useComposerDefaultFocus } from '../hooks/useComposerDefaultFocus'; import { ThreadGoalDialogs } from './thread-goal/ThreadGoalDialogs'; import { FlowChatManager } from '@/flow_chat/services/FlowChatManager'; import { @@ -79,7 +69,7 @@ import { import { isReviewSlashCommand } from '../deep-review/launch/commandParser'; import { createLogger } from '@/shared/utils/logger'; import { isTauriRuntime } from '@/infrastructure/runtime'; -import { Tooltip, IconButton, confirmDanger, confirmWarning } from '@/component-library'; +import { Tooltip, IconButton, confirmWarning } from '@/component-library'; import { PendingQueuePanel } from './PendingQueuePanel'; import { useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores'; import { openBtwSessionInAuxPane, selectActiveBtwSessionTab } from '../services/btwSessionPane'; @@ -101,32 +91,12 @@ import { useSceneStore } from '@/app/stores/sceneStore'; import type { SceneTabId } from '@/app/components/SceneBar/types'; import { useAgentsStore } from '@/app/scenes/agents/agentsStore'; import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; -import { - configManager, - DEFAULT_TOOL_PERMISSION_CONFIG, - normalizeToolPermissionConfig, - permissionConfigService, -} from '@/infrastructure/config'; -import { useComputerUseEnabled } from '@/infrastructure/config/hooks/useComputerUseEnabled'; -import type { ToolPermissionConfig } from '@/infrastructure/config/types'; +import { configManager } from '@/infrastructure/config'; import type { ModeSkillInfo } from '@/infrastructure/config/types'; import { SubagentAPI, type SubagentInfo } from '@/infrastructure/api/service-api/SubagentAPI'; import MCPAPI, { type MCPPrompt, type MCPPromptMessage, type MCPServerInfo } from '@/infrastructure/api/service-api/MCPAPI'; -import { - ChatInputWorkspaceStrip, - type ChatInputPermissionMode, -} from './ChatInputWorkspaceStrip'; -import { ComposerVoiceInputButton } from './voice/ComposerVoiceInputButton'; -import { useComposerVoiceInput } from './voice/useComposerVoiceInput'; +import { ChatInputWorkspaceStrip } from './ChatInputWorkspaceStrip'; import { expandWidgetPromptReferenceTokens } from '@/tools/generative-widget/widgetPromptReference'; -import { - composerPresentationContexts, - composerPresentationToEditorText, - composerPresentationToModelText, - hasComposerPresentationReferences, - parseComposerPresentation, - type ComposerPresentation, -} from '../utils/composerPresentation'; import { appendSkillPromptReferenceToken, createSkillPromptReferenceToken, @@ -143,22 +113,6 @@ import { type ContextUsageDisplay, } from '../utils/tokenUsageDisplay'; import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; -import { - ExternalSourceApiError, - externalSourcesAPI, - type NativePromptCommandDescriptor, -} from '@/infrastructure/api/service-api/ExternalSourcesAPI'; -import { externalSourceDiscoveryPollDelay } from '@/infrastructure/api/service-api/externalSourceDiscovery'; -import { - buildExternalPromptCommandItems, - classifyExternalPromptCommandCatalogIssue, - externalPromptComposerIsUnchanged, - isExternalPromptSubmissionTargetCurrent, - routeUnmatchedExternalPromptCommand, - resolveExternalPromptCommandInvocation, - type ExternalPromptCommandCatalogIssue, - type ExternalPromptCommandItem, -} from '../utils/externalPromptCommands'; import './ChatInput.scss'; import { setChatPopupActive } from './chatPopupState'; @@ -168,7 +122,6 @@ const log = createLogger('ChatInput'); export interface ChatInputProps { className?: string; onSendMessage?: (message: string) => void; - isSceneActive?: boolean; } type SlashActionItem = { @@ -215,61 +168,14 @@ type SlashSkillItem = { skillName: string; }; -type SlashExternalPromptCommandItem = ExternalPromptCommandItem & { - kind: 'externalCommand'; -}; - -function toSlashExternalPromptCommands( - snapshot: Parameters[0], -): SlashExternalPromptCommandItem[] { - return buildExternalPromptCommandItems(snapshot).map(item => ({ - ...item, - kind: 'externalCommand' as const, - })); -} - type SlashPickerItem = | SlashActionItem | SlashModeItem | SlashMcpPromptItem | SlashAcpCommandItem - | SlashSkillItem - | SlashExternalPromptCommandItem; + | SlashSkillItem; type ChatInputTarget = 'main' | 'btw'; - -function nativePromptCommandCandidateId( - kind: Exclude, - id: string, -): string { - return `bitfun.desktop:${kind}:${id}`; -} - -function toNativePromptCommandDescriptor( - item: Exclude, -): NativePromptCommandDescriptor { - const command = item.kind === 'mode' ? `/${item.id}` : item.command; - const commandName = command.slice(1).split(/\s+/, 1)[0]?.toLowerCase() ?? ''; - const behaviorVersion = item.kind === 'mcpPrompt' - ? JSON.stringify({ - kind: item.kind, - serverId: item.serverId, - promptName: item.promptName, - arguments: item.arguments.map(argument => ({ - name: argument.name, - required: argument.required, - })), - }) - : JSON.stringify(item.kind === 'mode' - ? { kind: item.kind, id: item.id } - : item.kind === 'skill' - ? { kind: item.kind, id: item.id, skillName: item.skillName } - : { kind: item.kind, id: item.id, command }); - return { - commandName, - candidateId: nativePromptCommandCandidateId(item.kind, item.id), - behaviorVersion, - }; -} +type PendingLargePasteMap = Record; function getCharacterCount(text: string): number { return Array.from(text).length; @@ -365,16 +271,15 @@ function renderMcpPromptMessages(messages: MCPPromptMessage[]): string { export const ChatInput: React.FC = ({ className = '', - onSendMessage, - isSceneActive = true, + onSendMessage }) => { const { t } = useTranslation('flow-chat'); const canLaunchReview = isTauriRuntime(); - const [inputState, dispatchLocalInput] = useReducer(inputReducer, initialInputState); + const [inputState, dispatchInput] = useReducer(inputReducer, initialInputState); const [modeState, dispatchMode] = useReducer(modeReducer, initialModeState); - const richTextInputRef = useRef(null); + const richTextInputRef = useRef(null); const containerRef = useRef(null); const agentBoostRef = useRef(null); const isImeComposingRef = useRef(false); @@ -384,25 +289,17 @@ export const ChatInput: React.FC = ({ const reviewLaunchPendingRef = useRef(false); const largePasteCountersRef = useRef>({}); const undoImageStackRef = useRef([]); - const nativePromptModeSelectionGenerationRef = useRef(0); - const nativePromptModeSelectionQueueRef = useRef>(Promise.resolve()); // History navigation state const [historyIndex, setHistoryIndex] = useState(-1); const [savedDraft, setSavedDraft] = useState(''); const [inputTarget, setInputTarget] = useState('main'); - const [toolPermissionConfig, setToolPermissionConfig] = useState( - DEFAULT_TOOL_PERMISSION_CONFIG, - ); - const [permissionModeSaving, setPermissionModeSaving] = useState(false); - const [showPermissionModeControl, setShowPermissionModeControl] = useState(true); const { addMessage: addToHistory, getSessionHistory } = useInputHistoryStore(); const contexts = useContextStore(state => state.contexts); const addContext = useContextStore(state => state.addContext); const removeContext = useContextStore(state => state.removeContext); const clearContexts = useContextStore(state => state.clearContexts); - const replaceContexts = useContextStore(state => state.replaceContexts); const contextsRef = useRef(contexts); contextsRef.current = contexts; @@ -426,36 +323,6 @@ export const ChatInput: React.FC = ({ : undefined; const effectiveTargetSessionId = inputTarget === 'btw' && activeBtwSessionId ? activeBtwSessionId : currentSessionId; - const effectiveTargetSessionIdRef = useRef(effectiveTargetSessionId); - effectiveTargetSessionIdRef.current = effectiveTargetSessionId; - - useComposerDefaultFocus({ - editorRef: richTextInputRef, - sessionId: effectiveTargetSessionId, - isSceneActive, - }); - - const dispatchInput = useCallback((action: InputAction) => { - const changesValue = (action.type === 'SET_VALUE' && action.payload !== inputValueRef.current) - || (action.type === 'CLEAR_VALUE' && inputValueRef.current !== ''); - if (changesValue) { - nativePromptModeSelectionGenerationRef.current += 1; - } - dispatchLocalInput(action); - - const sessionId = effectiveTargetSessionIdRef.current; - if (!sessionId) { - return; - } - - if (action.type === 'SET_VALUE') { - inputValueRef.current = action.payload; - sessionComposerStore.getState().setValue(sessionId, action.payload); - } else if (action.type === 'CLEAR_VALUE') { - inputValueRef.current = ''; - sessionComposerStore.getState().setValue(sessionId, ''); - } - }, []); const effectiveTargetSession = effectiveTargetSessionId ? flowChatState.sessions.get(effectiveTargetSessionId) : undefined; @@ -780,8 +647,6 @@ export const ChatInput: React.FC = ({ const { transition, setQueuedInput } = useSessionStateMachineActions(effectiveTargetSessionId); const { workspace, workspacePath, workspaceName } = useCurrentWorkspace(); - const workspacePathRef = useRef(workspacePath || ''); - workspacePathRef.current = workspacePath || ''; const { openedWorkspaces } = useWorkspaceContext(); const chatStripRepositoryPath = useMemo(() => { @@ -801,6 +666,41 @@ export const ChatInput: React.FC = ({ getSessionContextUsageDisplay() ); const [isModelSwitching, setIsModelSwitching] = useState(false); + const [brainstormEnabled, setBrainstormEnabled] = useState(false); + const [brainstormModelIds, setBrainstormModelIds] = useState([]); + const [brainstormContextMode, setBrainstormContextMode] = useState('independent'); + const brainstormSubmitStateRef = useRef<{ + enabled: boolean; + modelIds: string[]; + canUse: boolean; + contextMode: ModelBrainstormContextMode; + }>({ + enabled: false, + modelIds: [], + canUse: false, + contextMode: 'independent', + }); + const updateBrainstormEnabled = useCallback((enabled: boolean) => { + brainstormSubmitStateRef.current = { + ...brainstormSubmitStateRef.current, + enabled, + }; + setBrainstormEnabled(enabled); + }, []); + const updateBrainstormModelIds = useCallback((modelIds: string[]) => { + brainstormSubmitStateRef.current = { + ...brainstormSubmitStateRef.current, + modelIds, + }; + setBrainstormModelIds(modelIds); + }, []); + const updateBrainstormContextMode = useCallback((contextMode: ModelBrainstormContextMode) => { + brainstormSubmitStateRef.current = { + ...brainstormSubmitStateRef.current, + contextMode, + }; + setBrainstormContextMode(contextMode); + }, []); const isAssistantWorkspace = useMemo( () => resolveSessionAssistantWorkspace({ currentWorkspace: workspace, @@ -818,13 +718,6 @@ export const ChatInput: React.FC = ({ [effectiveTargetSession] ); const isAcpTargetSession = Boolean(acpTargetAgentType); - const permissionMode: ChatInputPermissionMode = isAcpTargetSession - ? 'acp' - : toolPermissionConfig.policy.preset === 'full_access' - ? 'full_access' - : toolPermissionConfig.interaction.auto_approve_ask - ? 'auto' - : 'ask'; const activeSessionMode = effectiveTargetSessionId ? acpTargetAgentType || flowChatState.sessions.get(effectiveTargetSessionId)?.mode : undefined; @@ -870,7 +763,24 @@ export const ChatInput: React.FC = ({ const [targetModeEnabledTools, setTargetModeEnabledTools] = useState(null); const [userDefaultModeId, setUserDefaultModeId] = useState(null); const [defaultModeSavingId, setDefaultModeSavingId] = useState(null); - const { computerUseEnabled } = useComputerUseEnabled(); + const [computerUseEnabled, setComputerUseEnabled] = useState(true); + + useEffect(() => { + let cancelled = false; + const loadComputerUseEnabled = () => { + void configManager.getConfig('ai.computer_use_enabled').then((enabled) => { + if (!cancelled) setComputerUseEnabled(enabled ?? false); + }); + }; + loadComputerUseEnabled(); + const unsubscribe = configManager.onConfigChange((path) => { + if (path === 'ai.computer_use_enabled' || path === 'ai') loadComputerUseEnabled(); + }); + return () => { + cancelled = true; + unsubscribe(); + }; + }, []); const [skillsFlyoutOpen, setSkillsFlyoutOpen] = useState(false); const [skillsFlyoutLeft, setSkillsFlyoutLeft] = useState(false); @@ -1039,6 +949,22 @@ export const ChatInput: React.FC = ({ composerMode: currentMode, }); const targetWorkspacePath = (workspacePath || effectiveTargetSession?.workspacePath || '').trim(); + const canUseModelBrainstorm = !isAcpInputSession && !isBtwSession && !isSubagentInputTarget; + + useEffect(() => { + brainstormSubmitStateRef.current = { + enabled: brainstormEnabled, + modelIds: brainstormModelIds, + canUse: canUseModelBrainstorm, + contextMode: brainstormContextMode, + }; + }, [brainstormEnabled, brainstormModelIds, brainstormContextMode, canUseModelBrainstorm]); + + useEffect(() => { + if (!canUseModelBrainstorm && brainstormEnabled) { + updateBrainstormEnabled(false); + } + }, [brainstormEnabled, canUseModelBrainstorm, updateBrainstormEnabled]); useEffect(() => { if (!isSubagentInputTarget) { @@ -1183,98 +1109,6 @@ export const ChatInput: React.FC = ({ const [mcpPromptCommands, setMcpPromptCommands] = useState([]); const [mcpPromptCommandsLoading, setMcpPromptCommandsLoading] = useState(false); - const [externalPromptCommands, setExternalPromptCommands] = useState([]); - const [externalPromptCommandsLoading, setExternalPromptCommandsLoading] = useState(false); - const [externalPromptCommandsPending, setExternalPromptCommandsPending] = useState(false); - const [externalPromptCommandsIssue, setExternalPromptCommandsIssue] = useState(); - const [selectedExternalPromptCandidateId, setSelectedExternalPromptCandidateId] = useState(); - const [selectedNonExternalSlashCommand, setSelectedNonExternalSlashCommand] = useState(); - const [selectedNonExternalSlashCandidateId, setSelectedNonExternalSlashCandidateId] = useState(); - const externalPromptCatalogRequestRef = useRef(0); - - const refreshExternalPromptCommands = useCallback(async ( - showLoading: boolean, - forceRefresh = false, - ) => { - const requestId = ++externalPromptCatalogRequestRef.current; - if (isAcpInputSession) { - setExternalPromptCommands([]); - setExternalPromptCommandsPending(false); - setExternalPromptCommandsIssue(undefined); - setExternalPromptCommandsLoading(false); - return undefined; - } - if (showLoading) { - setExternalPromptCommandsLoading(true); - } - try { - const snapshot = await externalSourcesAPI.getSnapshot( - workspacePath || undefined, - forceRefresh, - ); - if (requestId !== externalPromptCatalogRequestRef.current) return undefined; - setExternalPromptCommands(toSlashExternalPromptCommands(snapshot)); - setExternalPromptCommandsPending(snapshot.discoveryPending); - setExternalPromptCommandsIssue(undefined); - return snapshot; - } catch (error) { - if (requestId !== externalPromptCatalogRequestRef.current) return undefined; - const issue = classifyExternalPromptCommandCatalogIssue(error); - setExternalPromptCommands([]); - setSelectedExternalPromptCandidateId(undefined); - setExternalPromptCommandsIssue(issue); - setExternalPromptCommandsPending(false); - if (issue === 'host_unavailable') { - log.debug('External prompt commands are unavailable on this host', { - code: error instanceof ExternalSourceApiError ? error.code : 'internal', - }); - } else { - log.warn('Failed to load external prompt command catalog', { - code: error instanceof ExternalSourceApiError ? error.code : 'internal', - }); - } - return undefined; - } finally { - if (showLoading && requestId === externalPromptCatalogRequestRef.current) { - setExternalPromptCommandsLoading(false); - } - } - }, [isAcpInputSession, workspacePath]); - - useEffect(() => { - externalPromptCatalogRequestRef.current += 1; - setExternalPromptCommands([]); - setExternalPromptCommandsPending(false); - setExternalPromptCommandsIssue(undefined); - setSelectedExternalPromptCandidateId(undefined); - setSelectedNonExternalSlashCommand(undefined); - setSelectedNonExternalSlashCandidateId(undefined); - void refreshExternalPromptCommands(true); - - return () => { - externalPromptCatalogRequestRef.current += 1; - }; - }, [refreshExternalPromptCommands]); - - useEffect(() => { - if (!externalPromptCommandsPending) return undefined; - let cancelled = false; - let timer: number | undefined; - let attempt = 0; - const schedulePoll = () => { - timer = window.setTimeout(async () => { - const snapshot = await refreshExternalPromptCommands(false); - if (cancelled || !snapshot || !snapshot.discoveryPending) return; - attempt += 1; - schedulePoll(); - }, externalSourceDiscoveryPollDelay(attempt)); - }; - schedulePoll(); - return () => { - cancelled = true; - if (timer !== undefined) window.clearTimeout(timer); - }; - }, [externalPromptCommandsPending, refreshExternalPromptCommands]); const loadMcpPromptCommands = useCallback(async () => { setMcpPromptCommandsLoading(true); @@ -1360,15 +1194,6 @@ export const ChatInput: React.FC = ({ selectedIndex: 0, }); - const slashPickerWasActiveRef = useRef(false); - useEffect(() => { - const opening = slashCommandState.isActive && !slashPickerWasActiveRef.current; - slashPickerWasActiveRef.current = slashCommandState.isActive; - if (opening && !externalPromptCommandsLoading && !externalPromptCommandsPending) { - void refreshExternalPromptCommands(false); - } - }, [externalPromptCommandsLoading, externalPromptCommandsPending, refreshExternalPromptCommands, slashCommandState.isActive]); - // Keep the module-level popup-active flag in sync so ModernFlowChatContainer // can disable the global Escape shortcut while popups are open. useEffect(() => { @@ -1439,75 +1264,9 @@ export const ChatInput: React.FC = ({ } }, [canUseSkillsForTarget, derivedState?.isProcessing, inlineTriggerState, isAcpInputSession, slashCommandState.isActive, slashCommandState.kind]); - const previousComposerSessionIdRef = useRef(null); - - React.useLayoutEffect(() => { - const previousSessionId = previousComposerSessionIdRef.current; - const draft = sessionComposerStore.getState().activateDraft( - previousSessionId, - effectiveTargetSessionId, - useContextStore.getState().contexts, - ); - previousComposerSessionIdRef.current = effectiveTargetSessionId; - - const nextValue = draft.value; - const nextContexts = draft.contexts; - const nextPendingLargePastes = draft.pendingLargePastes; - - dispatchLocalInput({ type: 'SET_VALUE', payload: nextValue }); - inputValueRef.current = nextValue; - pendingLargePastesRef.current = { ...nextPendingLargePastes }; - replaceContexts(nextContexts); - setHistoryIndex(-1); - setSavedDraft(''); - setMentionState({ isActive: false, query: '', startOffset: 0 }); - setInlineTriggerState({ - isActive: false, - trigger: null, - query: '', - startOffset: 0, - }); - setSlashCommandState({ - isActive: false, - kind: 'modes', - query: '', - selectedIndex: 0, - }); - }, [effectiveTargetSessionId, replaceContexts]); - - useEffect(() => { - const unsubscribe = useContextStore.subscribe((state) => { - const sessionId = effectiveTargetSessionIdRef.current; - if (sessionId) { - sessionComposerStore.getState().setContexts(sessionId, state.contexts); - } - }); - - return () => { - const sessionId = effectiveTargetSessionIdRef.current; - if (sessionId) { - sessionComposerStore.getState().setContexts( - sessionId, - useContextStore.getState().contexts, - ); - } - unsubscribe(); - }; - }, []); - - const replacePendingLargePastes = useCallback((pendingLargePastes: PendingLargePasteMap) => { - const nextPendingLargePastes = { ...pendingLargePastes }; - pendingLargePastesRef.current = nextPendingLargePastes; - - const sessionId = effectiveTargetSessionIdRef.current; - if (sessionId) { - sessionComposerStore.getState().setPendingLargePastes(sessionId, nextPendingLargePastes); - } - }, []); - const clearPendingLargePastes = useCallback(() => { - replacePendingLargePastes({}); - }, [replacePendingLargePastes]); + pendingLargePastesRef.current = {}; + }, []); const createLargePastePlaceholder = useCallback((text: string): string | null => { const charCount = getCharacterCount(text); @@ -1524,13 +1283,13 @@ export const ChatInput: React.FC = ({ }); const placeholder = nextSuffix === 1 ? base : `${base} #${nextSuffix}`; - replacePendingLargePastes({ + pendingLargePastesRef.current = { ...pendingLargePastesRef.current, [placeholder]: text, - }); + }; return placeholder; - }, [replacePendingLargePastes, t]); + }, [t]); const prunePendingLargePastes = useCallback((text: string) => { const entries = Object.entries(pendingLargePastesRef.current); @@ -1538,10 +1297,10 @@ export const ChatInput: React.FC = ({ return; } - replacePendingLargePastes(Object.fromEntries( + pendingLargePastesRef.current = Object.fromEntries( entries.filter(([placeholder]) => text.includes(placeholder)) - )); - }, [replacePendingLargePastes]); + ); + }, []); const expandPendingLargePastes = useCallback((text: string) => { let expanded = text; @@ -1584,13 +1343,12 @@ export const ChatInput: React.FC = ({ return () => { window.removeEventListener('fill-chat-input', handleFillInput); }; - }, [clearPendingLargePastes, dispatchInput]); + }, [clearPendingLargePastes]); React.useEffect(() => { const handleFillChatInput = (data: { content?: string; context?: ContextItem; - composerPresentation?: ComposerPresentation; onlyIfEmpty?: boolean; mode?: 'replace' | 'append'; separator?: string; @@ -1612,19 +1370,6 @@ export const ChatInput: React.FC = ({ return; } - const composerPresentation = parseComposerPresentation(data.composerPresentation); - if (composerPresentation && data.mode !== 'append') { - const restoredValue = composerPresentationToEditorText(composerPresentation); - replaceContexts(composerPresentationContexts(composerPresentation)); - clearPendingLargePastes(); - dispatchInput({ type: 'ACTIVATE' }); - dispatchInput({ type: 'SET_VALUE', payload: restoredValue }); - inputValueRef.current = restoredValue; - richTextInputRef.current?.restoreComposerPresentation?.(composerPresentation); - richTextInputRef.current?.focus(); - return; - } - const content = data.content ?? ''; const nextValue = @@ -1657,7 +1402,7 @@ export const ChatInput: React.FC = ({ return () => { globalEventBus.off('fill-chat-input', handleFillChatInput); }; - }, [addContext, clearPendingLargePastes, dispatchInput, replaceContexts]); + }, [addContext, clearPendingLargePastes]); // Expose current input value for external queries (e.g. deep review fill-back confirmation) React.useEffect(() => { @@ -1672,111 +1417,6 @@ export const ChatInput: React.FC = ({ }; }, []); - React.useEffect(() => { - const configPath = 'app.flow_chat.show_permission_mode_control'; - let cancelled = false; - const applyVisibility = (value: unknown) => { - if (!cancelled) { - setShowPermissionModeControl(value !== false); - } - }; - const loadVisibility = async () => { - try { - applyVisibility(await configManager.getConfig(configPath)); - } catch (error) { - log.warn('Failed to load permission mode control visibility preference', error); - applyVisibility(true); - } - }; - - void loadVisibility(); - const unsubscribe = configManager.onConfigChange((path, _oldValue, value) => { - if (path === configPath) { - applyVisibility(value); - } - }); - return () => { - cancelled = true; - unsubscribe(); - }; - }, []); - - React.useEffect(() => { - let cancelled = false; - const applyConfig = (config: ToolPermissionConfig) => { - if (!cancelled) { - setToolPermissionConfig(config); - } - }; - const loadConfig = async () => { - applyConfig(await permissionConfigService.getConfig()); - }; - const handlePermissionConfigUpdated = (value?: ToolPermissionConfig) => { - if (value) { - applyConfig(normalizeToolPermissionConfig(value)); - } else { - void loadConfig(); - } - }; - - void loadConfig(); - globalEventBus.on('permission:config:updated', handlePermissionConfigUpdated); - return () => { - cancelled = true; - globalEventBus.off('permission:config:updated', handlePermissionConfigUpdated); - }; - }, []); - - const handlePermissionModeChange = useCallback(async ( - nextMode: Exclude, - ) => { - if (permissionModeSaving || isAcpTargetSession) return; - if (nextMode === 'full_access') { - const confirmed = await confirmDanger( - t('chatInput.permissionMode.fullAccessWarningTitle'), - t('chatInput.permissionMode.fullAccessWarningMessage'), - { - confirmText: t('chatInput.permissionMode.fullAccessConfirm'), - cancelText: t('chatInput.permissionMode.cancel'), - }, - ); - if (!confirmed) return; - } - - const previousConfig = toolPermissionConfig; - const nextConfig: ToolPermissionConfig = { - policy: { - ...previousConfig.policy, - preset: nextMode === 'full_access' ? 'full_access' : 'ask', - }, - interaction: { - ...previousConfig.interaction, - auto_approve_ask: nextMode === 'auto', - }, - }; - setToolPermissionConfig(nextConfig); - setPermissionModeSaving(true); - try { - const saved = await permissionConfigService.saveConfig(nextConfig); - setToolPermissionConfig(saved); - } catch (error) { - log.error('Failed to change permission mode', error); - setToolPermissionConfig(previousConfig); - notificationService.error(t('chatInput.permissionMode.changeFailed')); - } finally { - setPermissionModeSaving(false); - } - }, [isAcpTargetSession, permissionModeSaving, t, toolPermissionConfig]); - - const handleHidePermissionModeControl = useCallback(async () => { - try { - await configManager.setConfig('app.flow_chat.show_permission_mode_control', false); - } catch (error) { - log.error('Failed to hide permission mode control', error); - notificationService.error(t('chatInput.permissionMode.hideControlFailed')); - } - }, [t]); - React.useEffect(() => { if (!slashCommandState.isActive || slashCommandState.kind !== 'all' || derivedState?.isProcessing) { return; @@ -1870,7 +1510,7 @@ export const ChatInput: React.FC = ({ return () => { globalEventBus.off('mcp-app:message', handleMcpAppMessage); }; - }, [addContext, clearPendingLargePastes, currentImageCount, dispatchInput]); + }, [addContext, clearPendingLargePastes, currentImageCount]); React.useEffect(() => { const handleInsertContextTag = (event: Event) => { @@ -1905,7 +1545,7 @@ export const ChatInput: React.FC = ({ return () => { window.removeEventListener('insert-context-tag', handleInsertContextTag); }; - }, [dispatchInput, inputState.isActive]); + }, [inputState.isActive]); React.useEffect(() => { const fetchAvailableModes = async () => { @@ -2068,7 +1708,6 @@ export const ChatInput: React.FC = ({ derivedState?.queuedInput, effectiveTargetSessionId, clearPendingLargePastes, - dispatchInput, ]); React.useEffect(() => { @@ -2191,7 +1830,7 @@ export const ChatInput: React.FC = ({ inputElement.removeEventListener('imagePaste', handleImagePaste); } }; - }, [addContext, currentImageCount, dispatchInput, inputState.isActive, t]); + }, [addContext, currentImageCount, inputState.isActive, t]); React.useEffect(() => { if (!effectiveTargetSessionId || !workspacePath) { @@ -2319,21 +1958,6 @@ export const ChatInput: React.FC = ({ }); }, [isAcpInputSession, mcpPromptCommands, slashCommandState.query]); - const getFilteredExternalPromptCommands = useCallback((): SlashExternalPromptCommandItem[] => { - if (isAcpInputSession - || derivedState?.isProcessing - || (inlineTriggerState.isActive && inlineTriggerState.startOffset > 0)) { - return []; - } - const q = (slashCommandState.query || '').trim().toLowerCase(); - if (!q) { - return externalPromptCommands; - } - return externalPromptCommands.filter(item => - item.command.slice(1).toLowerCase().includes(q) - || item.label.toLowerCase().includes(q)); - }, [derivedState?.isProcessing, externalPromptCommands, inlineTriggerState, isAcpInputSession, slashCommandState.query]); - const getFilteredAcpCommands = useCallback((): SlashAcpCommandItem[] => { return filterSlashCommands(acpAgentCommands, slashCommandState.query).map(command => ({ kind: 'acpCommand', @@ -2409,7 +2033,6 @@ export const ChatInput: React.FC = ({ } const actions = getFilteredActions(); - const externalCommands = getFilteredExternalPromptCommands(); const mcpPrompts = getFilteredMcpPromptCommands(); const skills = getFilteredSkills(); let modeList = incrementalCodeModes; @@ -2426,8 +2049,8 @@ export const ChatInput: React.FC = ({ id: mode.id, name: mode.name, })); - return [...acpCommands, ...actions, ...externalCommands, ...mcpPrompts, ...modes, ...skills]; - }, [canSwitchModes, getFilteredActions, getFilteredAcpCommands, getFilteredExternalPromptCommands, getFilteredMcpPromptCommands, getFilteredSkills, incrementalCodeModes, isAcpInputSession, slashCommandState.query]); + return [...acpCommands, ...actions, ...mcpPrompts, ...modes, ...skills]; + }, [canSwitchModes, getFilteredActions, getFilteredAcpCommands, getFilteredMcpPromptCommands, getFilteredSkills, incrementalCodeModes, isAcpInputSession, slashCommandState.query]); const getActiveSlashPickerItems = useCallback((): SlashPickerItem[] => { if (slashCommandState.kind === 'actions') { @@ -2459,20 +2082,6 @@ export const ChatInput: React.FC = ({ dispatchInput({ type: 'SET_VALUE', payload: text }); inputValueRef.current = text; - if (selectedExternalPromptCandidateId) { - const selected = externalPromptCommands.find( - item => item.candidateId === selectedExternalPromptCandidateId, - ); - if (!selected || !isSlashCommand(text.trim(), selected.command as `/${string}`)) { - setSelectedExternalPromptCandidateId(undefined); - } - } - if (selectedNonExternalSlashCommand - && !isSlashCommand(text.trim(), selectedNonExternalSlashCommand as `/${string}`)) { - setSelectedNonExternalSlashCommand(undefined); - setSelectedNonExternalSlashCandidateId(undefined); - } - const localSlashCommandsEnabled = !isAcpInputSession; const trimmed = text.trim(); const isBtwCommand = localSlashCommandsEnabled && isSlashCommand(trimmed, '/btw'); @@ -2551,7 +2160,7 @@ export const ChatInput: React.FC = ({ selectedIndex: 0, }); } - }, [contexts, derivedState, dispatchInput, externalPromptCommands, inputState.isActive, isAcpInputSession, prunePendingLargePastes, removeContext, resolveTypedMcpPromptCommand, selectedExternalPromptCandidateId, selectedNonExternalSlashCommand, setQueuedInput, slashCommandState.isActive, slashCommandState.kind]); + }, [contexts, derivedState, inputState.isActive, isAcpInputSession, prunePendingLargePastes, removeContext, resolveTypedMcpPromptCommand, setQueuedInput, slashCommandState.isActive, slashCommandState.kind]); const submitBtwFromInput = useCallback(async () => { if (!derivedState) return; @@ -2590,7 +2199,7 @@ export const ChatInput: React.FC = ({ }), { duration: 4000 } ); - replacePendingLargePastes(originalPendingLargePastes); + pendingLargePastesRef.current = originalPendingLargePastes; dispatchInput({ type: 'ACTIVATE' }); dispatchInput({ type: 'SET_VALUE', payload: originalMessage }); return; @@ -2627,10 +2236,10 @@ export const ChatInput: React.FC = ({ } catch (e) { log.error('Failed to start /btw thread', { e }); dispatchInput({ type: 'ACTIVATE' }); - replacePendingLargePastes(originalPendingLargePastes); + pendingLargePastesRef.current = originalPendingLargePastes; dispatchInput({ type: 'SET_VALUE', payload: originalMessage }); } - }, [clearPendingLargePastes, currentSessionId, derivedState, dispatchInput, expandComposerSpecialTokens, imageContexts, inputState.value, isBtwSession, removeContext, replacePendingLargePastes, setQueuedInput, t, workspacePath]); + }, [clearPendingLargePastes, currentSessionId, derivedState, expandComposerSpecialTokens, imageContexts, inputState.value, isBtwSession, removeContext, setQueuedInput, t, workspacePath]); const submitCompactFromInput = useCallback(async () => { if (!effectiveTargetSessionId || !effectiveTargetSession) { @@ -2683,7 +2292,6 @@ export const ChatInput: React.FC = ({ } }, [ derivedState?.isProcessing, - dispatchInput, effectiveTargetSession, effectiveTargetSessionId, inputState.value, @@ -2722,7 +2330,6 @@ export const ChatInput: React.FC = ({ } }, [ derivedState?.isProcessing, - dispatchInput, effectiveTargetSession, effectiveTargetSessionId, t, @@ -2755,7 +2362,6 @@ export const ChatInput: React.FC = ({ dispatchInput({ type: 'SET_VALUE', payload: message }); } }, [ - dispatchInput, effectiveTargetSession, effectiveTargetSessionId, inputState.value, @@ -2829,7 +2435,6 @@ export const ChatInput: React.FC = ({ } }, [ derivedState?.isProcessing, - dispatchInput, effectiveTargetSession, effectiveTargetSessionId, inputState.value, @@ -2877,7 +2482,6 @@ export const ChatInput: React.FC = ({ dispatchInput({ type: 'DEACTIVATE' }); }, [ - dispatchInput, effectiveTargetSession, effectiveTargetSessionId, inputState.value, @@ -2926,7 +2530,7 @@ export const ChatInput: React.FC = ({ } ); } - }, [dispatchInput, inputState.value, setQueuedInput, t, workspacePath]); + }, [inputState.value, setQueuedInput, t, workspacePath]); const submitReviewFromInput = useCallback(async () => { if (!canLaunchReview) { @@ -3016,7 +2620,7 @@ export const ChatInput: React.FC = ({ error, sessionId: effectiveTargetSessionId, }); - replacePendingLargePastes(originalPendingLargePastes); + pendingLargePastesRef.current = originalPendingLargePastes; dispatchInput({ type: 'ACTIVATE' }); dispatchInput({ type: 'SET_VALUE', payload: message }); notificationService.error( @@ -3035,13 +2639,11 @@ export const ChatInput: React.FC = ({ clearPendingLargePastes, confirmDeepReviewLaunch, currentReviewActivity, - dispatchInput, effectiveTargetSession, effectiveTargetSessionId, flowChatState, inputState.value, isBtwSession, - replacePendingLargePastes, setQueuedInput, t, ]); @@ -3123,7 +2725,7 @@ export const ChatInput: React.FC = ({ command: originalMessage, error, }); - replacePendingLargePastes(originalPendingLargePastes); + pendingLargePastesRef.current = originalPendingLargePastes; dispatchInput({ type: 'ACTIVATE' }); dispatchInput({ type: 'SET_VALUE', payload: originalMessage }); notificationService.error( @@ -3138,289 +2740,15 @@ export const ChatInput: React.FC = ({ clearPendingLargePastes, addToHistory, confirmPromptCacheGuardIfNeeded, - dispatchInput, effectiveTargetSessionId, inputState.value, loadMcpPromptCommands, resolveTypedMcpPromptCommand, - replacePendingLargePastes, sendMessage, setQueuedInput, t, ]); - const submitExternalPromptCommandFromInput = useCallback(async ( - message: string, - originalMessage: string, - originalPendingLargePastes: PendingLargePasteMap, - ): Promise => { - const submissionSessionId = effectiveTargetSessionId; - const submissionWorkspacePath = workspacePath || ''; - const submissionComposerValue = inputValueRef.current; - const submissionTargetIsCurrent = () => isExternalPromptSubmissionTargetCurrent( - submissionSessionId, - effectiveTargetSessionIdRef.current, - submissionWorkspacePath, - workspacePathRef.current, - ); - let composerCleared = false; - const trimmedMessage = message.trim(); - const commandWhitespaceIndex = trimmedMessage.search(/\s/); - const command = trimmedMessage.startsWith('/') - ? (commandWhitespaceIndex === -1 - ? trimmedMessage - : trimmedMessage.slice(0, commandWhitespaceIndex)).toLowerCase() - : ''; - const externalCandidates = externalPromptCommands.filter( - item => item.command.toLowerCase() === command, - ); - const nativeCommands = getSlashPickerItems() - .filter((item): item is Exclude => ( - item.kind !== 'externalCommand' - )) - .map(toNativePromptCommandDescriptor) - .filter(item => `/${item.commandName}` === command) - .filter((item, index, all) => ( - all.findIndex(candidate => candidate.candidateId === item.candidateId) === index - )); - const reservedCommands = new Set(nativeCommands.map(item => `/${item.commandName}`)); - const externalCandidateIds = new Set( - externalCandidates.map(candidate => candidate.candidateId), - ); - const explicitNativeCandidate = selectedNonExternalSlashCommand === command - ? nativeCommands.find(candidate => ( - candidate.candidateId === selectedNonExternalSlashCandidateId - )) - : undefined; - - if (externalCandidates.length === 0) { - const unmatchedRoute = routeUnmatchedExternalPromptCommand({ - hasNativeCommand: nativeCommands.length > 0, - catalogLoading: externalPromptCommandsLoading, - discoveryPending: externalPromptCommandsPending, - catalogIssue: externalPromptCommandsIssue, - }); - if (unmatchedRoute === 'native' || unmatchedRoute === 'ordinary') return false; - notificationService.warning(t(unmatchedRoute === 'load_failed' - ? 'chatInput.externalCommandsLoadFailed' - : 'chatInput.externalCommandsLoading')); - return true; - } - - if (explicitNativeCandidate) { - try { - const nativeConflictSnapshot = await externalSourcesAPI.getNativePromptCommandConflicts( - submissionWorkspacePath || undefined, - nativeCommands, - ); - if (!submissionTargetIsCurrent()) return true; - const nativeConflict = nativeConflictSnapshot.conflicts.find(conflict => ( - externalCandidateIds.has(conflict.externalCandidateId) - )); - const nativeReconfirmation = nativeConflictSnapshot.reconfirmations?.some(item => ( - item.nativeCandidateId === explicitNativeCandidate.candidateId - )); - if ((nativeConflict - && nativeConflict.selectedCandidateId !== explicitNativeCandidate.candidateId) - || nativeReconfirmation) { - await externalSourcesAPI.setNativePromptCommandConflictChoice( - submissionWorkspacePath || undefined, - nativeCommands, - explicitNativeCandidate.candidateId, - nativeConflictSnapshot.preferenceRevision, - ); - } - } catch (error) { - log.warn('Failed to persist native prompt command conflict choice', { - code: error instanceof ExternalSourceApiError ? error.code : 'internal', - }); - if (!submissionTargetIsCurrent()) return true; - notificationService.warning(t('chatInput.nativeCommandChoiceNotSaved')); - } - if (!submissionTargetIsCurrent()) return true; - return false; - } - - try { - const nativeConflictSnapshot = nativeCommands.length > 0 - ? await externalSourcesAPI.getNativePromptCommandConflicts( - submissionWorkspacePath || undefined, - nativeCommands, - ) - : undefined; - if (!submissionTargetIsCurrent()) return true; - const nativeConflict = nativeConflictSnapshot?.conflicts.find(conflict => ( - externalCandidateIds.has(conflict.externalCandidateId) - )); - if (externalCandidates.length === 0) { - const requiresReconfirmation = nativeConflictSnapshot?.reconfirmations?.some(item => ( - nativeCommands.some(commandItem => ( - commandItem.candidateId === item.nativeCandidateId - )) - )); - if (requiresReconfirmation) { - notificationService.warning(t('chatInput.nativeCommandReconfirmationRequired')); - return true; - } - return false; - } - const persistedCandidateId = nativeConflict?.selectedCandidateId; - if (persistedCandidateId - && nativeCommands.some(candidate => candidate.candidateId === persistedCandidateId)) { - return false; - } - const selectedExternalCandidateId = selectedExternalPromptCandidateId - ?? (persistedCandidateId && externalCandidateIds.has(persistedCandidateId) - ? persistedCandidateId - : undefined); - const resolution = resolveExternalPromptCommandInvocation( - message, - externalPromptCommands, - reservedCommands, - selectedExternalCandidateId, - ); - if (resolution.state === 'none') { - return false; - } - if (resolution.state === 'conflict') { - setSlashCommandState({ - isActive: true, - kind: 'all', - query: resolution.command.slice(1), - selectedIndex: 0, - }); - notificationService.warning(t('chatInput.selectHint')); - return true; - } - if (resolution.state === 'unavailable') { - notificationService.warning( - resolution.item.unavailableReason || t('chatInput.noMatchingCommand'), - ); - return true; - } - - let expectedPreferenceRevision = nativeConflictSnapshot?.preferenceRevision ?? 0; - let nativeConflictKey = nativeConflict?.conflictKey; - if (resolution.item.conflictKey) { - const snapshot = await externalSourcesAPI.setConflictChoice( - submissionWorkspacePath || undefined, - resolution.item.conflictKey, - resolution.item.candidateId, - resolution.item.expectedPreferenceRevision ?? 0, - ); - expectedPreferenceRevision = snapshot.preferenceRevision ?? expectedPreferenceRevision; - if (!submissionTargetIsCurrent()) return true; - } - if (nativeConflict - && selectedExternalPromptCandidateId === resolution.item.candidateId - && nativeConflict.selectedCandidateId !== resolution.item.candidateId) { - const updatedNativeConflicts = await externalSourcesAPI.setNativePromptCommandConflictChoice( - submissionWorkspacePath || undefined, - nativeCommands, - resolution.item.candidateId, - expectedPreferenceRevision, - ); - expectedPreferenceRevision = updatedNativeConflicts.preferenceRevision; - nativeConflictKey = updatedNativeConflicts.conflicts.find(conflict => ( - conflict.externalCandidateId === resolution.item.candidateId - ))?.conflictKey; - if (!nativeConflictKey) { - throw new Error('Native prompt command conflict guard is unavailable'); - } - if (!submissionTargetIsCurrent()) return true; - } - const expanded = await externalSourcesAPI.expandPromptCommand( - submissionWorkspacePath || undefined, - resolution.item.command.slice(1), - resolution.arguments, - resolution.item.candidateId, - resolution.item.contentVersion, - nativeCommands, - nativeConflictKey ? { - conflictKey: nativeConflictKey, - expectedPreferenceRevision, - } : undefined, - ); - if (!submissionTargetIsCurrent()) return true; - const expandedCharCount = getCharacterCount(expanded.content); - if (expandedCharCount > CHAT_INPUT_CONFIG.largePaste.maxMessageChars) { - notificationService.error( - t('input.messageTooLarge', { - max: CHAT_INPUT_CONFIG.largePaste.maxMessageChars, - count: expandedCharCount, - }), - { duration: 4000 }, - ); - return true; - } - if (!(await confirmPromptCacheGuardIfNeeded())) { - return true; - } - if (!submissionTargetIsCurrent()) return true; - - if (submissionSessionId) { - addToHistory(submissionSessionId, message); - } - if (externalPromptComposerIsUnchanged( - submissionComposerValue, - inputValueRef.current, - )) { - setHistoryIndex(-1); - setSavedDraft(''); - dispatchInput({ type: 'CLEAR_VALUE' }); - composerCleared = true; - clearPendingLargePastes(); - setQueuedInput(null); - setSelectedExternalPromptCandidateId(undefined); - setSelectedNonExternalSlashCommand(undefined); - setSelectedNonExternalSlashCandidateId(undefined); - } - await sendMessage(expanded.content, { displayMessage: originalMessage }); - if (!submissionTargetIsCurrent()) return true; - if (composerCleared && inputValueRef.current === '') { - dispatchInput({ type: 'DEACTIVATE' }); - } - } catch (error) { - log.warn('External prompt command invocation failed', { - code: error instanceof ExternalSourceApiError ? error.code : 'internal', - }); - if (!submissionTargetIsCurrent()) { - if (composerCleared && submissionSessionId) { - const composer = sessionComposerStore.getState(); - if (composer.getDraft(submissionSessionId)?.value === '') { - composer.setValue(submissionSessionId, originalMessage); - composer.setPendingLargePastes(submissionSessionId, originalPendingLargePastes); - } - } - return true; - } - const restoreSubmittedComposer = composerCleared - ? inputValueRef.current === '' - : externalPromptComposerIsUnchanged( - submissionComposerValue, - inputValueRef.current, - ); - if (restoreSubmittedComposer) { - replacePendingLargePastes(originalPendingLargePastes); - dispatchInput({ type: 'ACTIVATE' }); - dispatchInput({ type: 'SET_VALUE', payload: originalMessage }); - } - if (error instanceof ExternalSourceApiError - && (error.code === 'stale_revision' - || error.code === 'conflict' - || error.code === 'not_found')) { - setSelectedExternalPromptCandidateId(undefined); - setSelectedNonExternalSlashCandidateId(undefined); - void refreshExternalPromptCommands(false, true); - } - notificationService.error( - error instanceof ExternalSourceApiError ? error.detail : t('error.unknown'), - { duration: 5000 }, - ); - } - return true; - }, [addToHistory, clearPendingLargePastes, confirmPromptCacheGuardIfNeeded, dispatchInput, effectiveTargetSessionId, externalPromptCommands, externalPromptCommandsIssue, externalPromptCommandsLoading, externalPromptCommandsPending, getSlashPickerItems, refreshExternalPromptCommands, replacePendingLargePastes, selectedExternalPromptCandidateId, selectedNonExternalSlashCandidateId, selectedNonExternalSlashCommand, sendMessage, setQueuedInput, t, workspacePath]); - const handleCancelCurrentTask = useCallback(async () => { if (effectiveTargetSessionId) { await FlowChatManager.getInstance().cancelSessionTask(effectiveTargetSessionId); @@ -3432,12 +2760,24 @@ export const ChatInput: React.FC = ({ const handleModelLoadingChange = useCallback((loading: boolean) => { setIsModelSwitching(loading); }, []); + + const getCurrentDraftValue = useCallback(() => { + if (inputState.value.trim()) { + return inputState.value; + } + + const editor = richTextInputRef.current as (HTMLDivElement & { + getPlainText?: () => string; + }) | null; + return editor?.getPlainText?.() ?? editor?.textContent ?? ''; + }, [inputState.value]); - const handleSendOrCancel = useCallback(async (messageOverride?: string) => { + const handleSendOrCancel = useCallback(async () => { if (!derivedState) return; const { sendButtonMode } = derivedState; - const draftTrimmed = (messageOverride ?? inputState.value).trim(); + const draftValue = getCurrentDraftValue(); + const draftTrimmed = draftValue.trim(); // While generating, an empty control in `cancel` mode means stop. If the user has typed a follow-up, // never treat this path as cancel — that would call cancel_dialog_turn and abort the current round early. @@ -3457,32 +2797,10 @@ export const ChatInput: React.FC = ({ if (!draftTrimmed) return; const originalMessage = draftTrimmed; - const composerPresentation = messageOverride === undefined - ? richTextInputRef.current?.getComposerPresentation?.() ?? null - : null; - const persistedComposerPresentation = hasComposerPresentationReferences(composerPresentation) - ? composerPresentation - : null; const originalPendingLargePastes = { ...pendingLargePastesRef.current }; - const expandedMessage = expandComposerSpecialTokens( - persistedComposerPresentation - ? composerPresentationToModelText(persistedComposerPresentation) - : originalMessage, - ); - const message = expandedMessage || (persistedComposerPresentation - ? 'Use the referenced session transcript as context.' - : expandedMessage); + const message = expandComposerSpecialTokens(originalMessage); const messageCharCount = getCharacterCount(message); - // Voice transcripts are always message content; they must not accidentally execute local commands. - const localSlashCommandsEnabled = !isAcpInputSession && messageOverride === undefined; - - if (localSlashCommandsEnabled && await submitExternalPromptCommandFromInput( - message, - originalMessage, - originalPendingLargePastes, - )) { - return; - } + const localSlashCommandsEnabled = !isAcpInputSession; if (localSlashCommandsEnabled && isSlashCommand(message, '/btw')) { // When idle, /btw can be sent via the normal send button. @@ -3559,7 +2877,7 @@ export const ChatInput: React.FC = ({ }), { duration: 4000 } ); - replacePendingLargePastes(originalPendingLargePastes); + pendingLargePastesRef.current = originalPendingLargePastes; dispatchInput({ type: 'ACTIVATE' }); dispatchInput({ type: 'SET_VALUE', payload: originalMessage }); return; @@ -3570,6 +2888,61 @@ export const ChatInput: React.FC = ({ return; } + const brainstormSubmitState = brainstormSubmitStateRef.current; + if (brainstormSubmitState.enabled && brainstormSubmitState.canUse) { + const modelIdsForBrainstorm = brainstormSubmitState.modelIds; + if (modelIdsForBrainstorm.length < MODEL_BRAINSTORM_MIN_CANDIDATES) { + notificationService.warning( + t('modelBrainstorm.needMoreModels', { count: MODEL_BRAINSTORM_MIN_CANDIDATES }) + ); + return; + } + + if (effectiveTargetSessionId) { + addToHistory(effectiveTargetSessionId, message); + } + setHistoryIndex(-1); + setSavedDraft(''); + dispatchInput({ type: 'CLEAR_VALUE' }); + clearPendingLargePastes(); + setQueuedInput(null); + + try { + log.debug('Launching model brainstorm from chat input', { + sourceSessionId: effectiveTargetSessionId || undefined, + modelCount: modelIdsForBrainstorm.length, + modelIds: modelIdsForBrainstorm, + }); + await launchModelBrainstorm({ + message, + displayMessage: originalMessage, + contexts, + sourceSessionId: effectiveTargetSessionId || undefined, + workspaceConfig: flowChatSessionConfigForCurrentWorkspace(workspace), + agentType: effectiveSendAgentType, + modelIds: modelIdsForBrainstorm, + contextMode: brainstormSubmitState.contextMode, + }); + clearContexts(); + dispatchInput({ type: 'CLEAR_VALUE' }); + dispatchInput({ type: 'DEACTIVATE' }); + onSendMessage?.(originalMessage); + } catch (error) { + log.error('Failed to launch brainstorm', { error }); + pendingLargePastesRef.current = originalPendingLargePastes; + dispatchInput({ type: 'ACTIVATE' }); + dispatchInput({ type: 'SET_VALUE', payload: originalMessage }); + notificationService.error( + error instanceof Error ? error.message : t('error.unknown'), + { + title: t('modelBrainstorm.launchFailed'), + duration: 5000, + } + ); + } + return; + } + // Add to history before clearing (session-scoped) if (effectiveTargetSessionId) { addToHistory(effectiveTargetSessionId, message); @@ -3585,14 +2958,13 @@ export const ChatInput: React.FC = ({ try { await sendMessage(message, { displayMessage: originalMessage, - composerPresentation: persistedComposerPresentation, }); clearPendingLargePastes(); dispatchInput({ type: 'CLEAR_VALUE' }); dispatchInput({ type: 'DEACTIVATE' }); } catch (error) { log.error('Failed to send message', { error }); - replacePendingLargePastes(originalPendingLargePastes); + pendingLargePastesRef.current = originalPendingLargePastes; dispatchInput({ type: 'ACTIVATE' }); dispatchInput({ type: 'SET_VALUE', payload: originalMessage }); if (derivedState?.isProcessing) { @@ -3601,19 +2973,21 @@ export const ChatInput: React.FC = ({ } }, [ isModelSwitching, - inputState.value, + getCurrentDraftValue, derivedState, - dispatchInput, handleCancelCurrentTask, transition, sendMessage, addToHistory, effectiveTargetSessionId, + effectiveSendAgentType, + contexts, + workspace, + clearContexts, + onSendMessage, clearPendingLargePastes, expandComposerSpecialTokens, isAcpInputSession, - richTextInputRef, - replacePendingLargePastes, setQueuedInput, submitBtwFromInput, submitGoalFromInput, @@ -3626,7 +3000,6 @@ export const ChatInput: React.FC = ({ confirmPromptCacheGuardIfNeeded, t, resolveTypedMcpPromptCommand, - submitExternalPromptCommandFromInput, ]); const getFilteredIncrementalModes = useCallback(() => { @@ -3713,115 +3086,26 @@ export const ChatInput: React.FC = ({ } }, [t, userDefaultModeId]); - const persistExplicitNativePromptCommandChoice = useCallback(async ( - descriptor: NativePromptCommandDescriptor, - nativeCommands: NativePromptCommandDescriptor[], - operationIsCurrent: () => boolean, - ): Promise => { - const capturedSessionId = effectiveTargetSessionId; - const capturedWorkspacePath = workspacePath || ''; - const targetIsCurrent = () => isExternalPromptSubmissionTargetCurrent( - capturedSessionId, - effectiveTargetSessionIdRef.current, - capturedWorkspacePath, - workspacePathRef.current, - ); - if (externalPromptCommandsIssue === 'host_unavailable') { - return targetIsCurrent() && operationIsCurrent(); - } - try { - const snapshot = await externalSourcesAPI.getNativePromptCommandConflicts( - capturedWorkspacePath || undefined, - nativeCommands, - ); - if (!targetIsCurrent() || !operationIsCurrent()) return false; - const conflict = snapshot.conflicts.find(item => ( - item.commandName === descriptor.commandName - )); - const requiresReconfirmation = snapshot.reconfirmations?.some(item => ( - item.nativeCandidateId === descriptor.candidateId - )); - if ((conflict && conflict.selectedCandidateId !== descriptor.candidateId) - || requiresReconfirmation) { - await externalSourcesAPI.setNativePromptCommandConflictChoice( - capturedWorkspacePath || undefined, - nativeCommands, - descriptor.candidateId, - snapshot.preferenceRevision, - ); - if (!targetIsCurrent() || !operationIsCurrent()) return false; - } - } catch (error) { - log.warn('Failed to persist native prompt command conflict choice', { - code: error instanceof ExternalSourceApiError ? error.code : 'internal', - }); - if (targetIsCurrent() && operationIsCurrent()) { - notificationService.warning(t('chatInput.nativeCommandChoiceNotSaved')); - } - } - return targetIsCurrent() && operationIsCurrent(); - }, [effectiveTargetSessionId, externalPromptCommandsIssue, t, workspacePath]); - const selectSlashCommandMode = useCallback((modeId: string) => { - // Same gating as the mode dropdown; slash commands must not bypass it. - if (modeId === 'ComputerUse' && !computerUseEnabled) { - notificationService.warning(t('chatInput.computerUseDisabled')); + requestModeChange(modeId); + + if (getInlineSlashCommandPickerQuery(inlineTriggerState) !== null) { + const controller = richTextInputRef.current as (HTMLDivElement & { + replaceActiveInlineTrigger?: (replacementText: string) => void; + }) | null; + controller?.replaceActiveInlineTrigger?.(''); + setSlashCommandState({ isActive: false, kind: 'modes', query: '', selectedIndex: 0 }); return; } - const operationGeneration = ++nativePromptModeSelectionGenerationRef.current; - const operationIsCurrent = () => ( - nativePromptModeSelectionGenerationRef.current === operationGeneration - ); - const descriptor = { - commandName: modeId.toLowerCase(), - candidateId: nativePromptCommandCandidateId('mode', modeId), - behaviorVersion: JSON.stringify({ kind: 'mode', id: modeId }), - }; - const nativeCommands = getSlashPickerItems() - .filter((item): item is Exclude => ( - item.kind !== 'externalCommand' - )) - .map(toNativePromptCommandDescriptor) - .filter(item => item.commandName === descriptor.commandName) - .filter((item, index, all) => ( - all.findIndex(candidate => candidate.candidateId === item.candidateId) === index - )); - const previousOperation = nativePromptModeSelectionQueueRef.current; - const operation = (async () => { - await previousOperation; - if (!operationIsCurrent()) return false; - return persistExplicitNativePromptCommandChoice( - descriptor, - nativeCommands, - operationIsCurrent, - ); - })(); - nativePromptModeSelectionQueueRef.current = operation.then(() => undefined, () => undefined); - void (async () => { - if (!await operation) return; - requestModeChange(modeId); - setSelectedExternalPromptCandidateId(undefined); - setSelectedNonExternalSlashCommand(undefined); - setSelectedNonExternalSlashCandidateId(undefined); - - if (getInlineSlashCommandPickerQuery(inlineTriggerState) !== null) { - const controller = richTextInputRef.current as (HTMLDivElement & { - replaceActiveInlineTrigger?: (replacementText: string) => void; - }) | null; - controller?.replaceActiveInlineTrigger?.(''); - setSlashCommandState({ isActive: false, kind: 'modes', query: '', selectedIndex: 0 }); - return; - } - - dispatchInput({ type: 'CLEAR_VALUE' }); - setSlashCommandState({ - isActive: false, - kind: 'modes', - query: '', - selectedIndex: 0, - }); - })(); - }, [computerUseEnabled, dispatchInput, getSlashPickerItems, inlineTriggerState, persistExplicitNativePromptCommandChoice, requestModeChange, t]); + + dispatchInput({ type: 'CLEAR_VALUE' }); + setSlashCommandState({ + isActive: false, + kind: 'modes', + query: '', + selectedIndex: 0, + }); + }, [inlineTriggerState, requestModeChange]); const selectSlashCommandAction = useCallback((actionId: SlashActionId) => { const raw = inputState.value || ''; @@ -3829,12 +3113,6 @@ export const ChatInput: React.FC = ({ if (next === null) { return; } - nativePromptModeSelectionGenerationRef.current += 1; - setSelectedExternalPromptCandidateId(undefined); - setSelectedNonExternalSlashCommand(next.trim().split(/\s+/, 1)[0]?.toLowerCase()); - setSelectedNonExternalSlashCandidateId( - nativePromptCommandCandidateId('action', actionId), - ); if (getInlineSlashCommandPickerQuery(inlineTriggerState) !== null) { const controller = richTextInputRef.current as (HTMLDivElement & { @@ -3853,39 +3131,9 @@ export const ChatInput: React.FC = ({ setQueuedInput(null); setSlashCommandState({ isActive: false, kind: 'modes', query: '', selectedIndex: 0 }); window.setTimeout(() => richTextInputRef.current?.focus(), 0); - }, [dispatchInput, inlineTriggerState, inputState.value, isBtwSession, setQueuedInput]); - - const selectSlashExternalPromptCommand = useCallback((item: SlashExternalPromptCommandItem) => { - if (!item.available) { - notificationService.warning(item.unavailableReason || t('chatInput.noMatchingCommand')); - return; - } - nativePromptModeSelectionGenerationRef.current += 1; - setSelectedExternalPromptCandidateId(item.candidateId); - setSelectedNonExternalSlashCommand(undefined); - setSelectedNonExternalSlashCandidateId(undefined); - const replacement = `${item.command} `; - if (getInlineSlashCommandPickerQuery(inlineTriggerState) !== null) { - const controller = richTextInputRef.current as (HTMLDivElement & { - replaceActiveInlineTrigger?: (replacementText: string) => void; - }) | null; - controller?.replaceActiveInlineTrigger?.(item.command); - } else { - dispatchInput({ type: 'SET_VALUE', payload: replacement }); - inputValueRef.current = replacement; - } - setQueuedInput(null); - setSlashCommandState({ isActive: false, kind: 'modes', query: '', selectedIndex: 0 }); - window.setTimeout(() => richTextInputRef.current?.focus(), 0); - }, [dispatchInput, inlineTriggerState, setQueuedInput, t]); + }, [inlineTriggerState, inputState.value, isBtwSession, setQueuedInput]); const selectSlashPromptCommand = useCallback((item: SlashMcpPromptItem) => { - nativePromptModeSelectionGenerationRef.current += 1; - setSelectedExternalPromptCandidateId(undefined); - setSelectedNonExternalSlashCommand(item.command.toLowerCase()); - setSelectedNonExternalSlashCandidateId( - nativePromptCommandCandidateId(item.kind, item.id), - ); if (getInlineSlashCommandPickerQuery(inlineTriggerState) !== null) { const controller = richTextInputRef.current as (HTMLDivElement & { replaceActiveInlineTrigger?: (replacementText: string) => void; @@ -3903,15 +3151,9 @@ export const ChatInput: React.FC = ({ setQueuedInput(null); setSlashCommandState({ isActive: false, kind: 'modes', query: '', selectedIndex: 0 }); window.setTimeout(() => richTextInputRef.current?.focus(), 0); - }, [dispatchInput, inlineTriggerState, setQueuedInput]); + }, [inlineTriggerState, setQueuedInput]); const selectSlashAcpCommand = useCallback((item: SlashAcpCommandItem) => { - nativePromptModeSelectionGenerationRef.current += 1; - setSelectedExternalPromptCandidateId(undefined); - setSelectedNonExternalSlashCommand(item.command.toLowerCase()); - setSelectedNonExternalSlashCandidateId( - nativePromptCommandCandidateId(item.kind, item.id), - ); if (getInlineSlashCommandPickerQuery(inlineTriggerState) !== null) { const controller = richTextInputRef.current as (HTMLDivElement & { replaceActiveInlineTrigger?: (replacementText: string) => void; @@ -3925,7 +3167,7 @@ export const ChatInput: React.FC = ({ setQueuedInput(null); setSlashCommandState({ isActive: false, kind: 'modes', query: '', selectedIndex: 0 }); window.setTimeout(() => richTextInputRef.current?.focus(), 0); - }, [dispatchInput, inlineTriggerState, setQueuedInput]); + }, [inlineTriggerState, setQueuedInput]); const getRichTextInlineTriggerController = useCallback(() => { return richTextInputRef.current as (HTMLDivElement & { @@ -3936,12 +3178,6 @@ export const ChatInput: React.FC = ({ }, []); const selectSlashSkill = useCallback((item: SlashSkillItem) => { - nativePromptModeSelectionGenerationRef.current += 1; - setSelectedExternalPromptCandidateId(undefined); - setSelectedNonExternalSlashCommand(item.command.toLowerCase()); - setSelectedNonExternalSlashCandidateId( - nativePromptCommandCandidateId(item.kind, item.id), - ); const replaceInlineTrigger = getRichTextInlineTriggerController()?.replaceActiveInlineTrigger; if (inlineTriggerState.isActive) { @@ -3958,7 +3194,7 @@ export const ChatInput: React.FC = ({ setQueuedInput(null); setSlashCommandState({ isActive: false, kind: 'modes', query: '', selectedIndex: 0 }); window.setTimeout(() => richTextInputRef.current?.focus(), 0); - }, [dispatchInput, getRichTextInlineTriggerController, inlineTriggerState.isActive, inputState.value, setQueuedInput]); + }, [getRichTextInlineTriggerController, inlineTriggerState.isActive, inputState.value, setQueuedInput]); const handleBoostStartBtw = useCallback( (e: React.SyntheticEvent) => { @@ -4124,8 +3360,6 @@ export const ChatInput: React.FC = ({ const item = items[slashCommandState.selectedIndex] as SlashPickerItem; if (item.kind === 'mode') { selectSlashCommandMode(item.id); - } else if (item.kind === 'externalCommand') { - selectSlashExternalPromptCommand(item); } else if (item.kind === 'mcpPrompt') { selectSlashPromptCommand(item); } else if (item.kind === 'acpCommand') { @@ -4163,8 +3397,6 @@ export const ChatInput: React.FC = ({ const item = items[slashCommandState.selectedIndex] as SlashPickerItem; if (item.kind === 'mode') { selectSlashCommandMode(item.id); - } else if (item.kind === 'externalCommand') { - selectSlashExternalPromptCommand(item); } else if (item.kind === 'mcpPrompt') { selectSlashPromptCommand(item); } else if (item.kind === 'acpCommand') { @@ -4298,7 +3530,7 @@ export const ChatInput: React.FC = ({ e.preventDefault(); void handleCancelCurrentTask(); } - }, [handleSendOrCancel, submitBtwFromInput, submitGoalFromInput, derivedState, dispatchInput, handleCancelCurrentTask, slashCommandState, getFilteredIncrementalModes, getActiveSlashPickerItems, selectSlashCommandMode, selectSlashCommandAction, selectSlashExternalPromptCommand, selectSlashPromptCommand, selectSlashAcpCommand, selectSlashSkill, canSwitchModes, getRichTextInlineTriggerController, historyIndex, inputHistory, savedDraft, inputState.value, currentSessionId, isBtwSession, showTargetSwitcher, setInputTarget, removeContext, t]); + }, [handleSendOrCancel, submitBtwFromInput, submitGoalFromInput, derivedState, handleCancelCurrentTask, slashCommandState, getFilteredIncrementalModes, getActiveSlashPickerItems, selectSlashCommandMode, selectSlashCommandAction, selectSlashPromptCommand, selectSlashAcpCommand, selectSlashSkill, canSwitchModes, getRichTextInlineTriggerController, historyIndex, inputHistory, savedDraft, inputState.value, currentSessionId, isBtwSession, showTargetSwitcher, setInputTarget, removeContext, t]); const handleImeCompositionStart = useCallback(() => { isImeComposingRef.current = true; @@ -4353,6 +3585,24 @@ export const ChatInput: React.FC = ({ }); }, []); + // Space-to-focus: when no editable element is focused, Space key focuses the input. + useEffect(() => { + const handleGlobalKeyDown = (e: KeyboardEvent) => { + if (e.key !== ' ') return; + const target = e.target as HTMLElement; + const isEditable = + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable || + target.closest('[contenteditable="true"]') !== null; + if (isEditable) return; + e.preventDefault(); + focusRichTextInputSoon(); + }; + document.addEventListener('keydown', handleGlobalKeyDown, true); + return () => document.removeEventListener('keydown', handleGlobalKeyDown, true); + }, [focusRichTextInputSoon]); + const insertSkillIntoInput = useCallback( (skillName: string) => { dispatchInput({ type: 'ACTIVATE' }); @@ -4370,7 +3620,7 @@ export const ChatInput: React.FC = ({ dispatchMode({ type: 'CLOSE_DROPDOWN' }); focusRichTextInputSoon(); }, - [clearSkillsTimer, dispatchInput, focusRichTextInputSoon, getRichTextInlineTriggerController, inputState.value] + [clearSkillsTimer, focusRichTextInputSoon, getRichTextInlineTriggerController, inputState.value] ); const handleBoostPickImage = useCallback( @@ -4394,7 +3644,7 @@ export const ChatInput: React.FC = ({ } }); }); - }, [dispatchInput]); + }, []); const handleOpenSkillsLibrary = useCallback( (e: React.MouseEvent) => { @@ -4420,36 +3670,18 @@ export const ChatInput: React.FC = ({ }, []); - const voiceInput = useComposerVoiceInput({ - activateInput: () => dispatchInput({ type: 'ACTIVATE' }), - focusInputSoon: () => { - window.requestAnimationFrame(() => richTextInputRef.current?.focus()); - }, - insertText: (text) => { - const current = inputState.value.trim(); - const mergedText = current ? `${inputState.value.trimEnd()} ${text}` : text; - dispatchInput({ - type: 'SET_VALUE', - payload: mergedText, - }); - return mergedText; - }, - submitText: async (text) => { - await handleSendOrCancel(text); - }, - }); - const renderActionButton = () => { if (!derivedState) return ; const { sendButtonMode, hasQueuedInput } = derivedState; + const hasSendableDraft = Boolean(getCurrentDraftValue().trim()); if (sendButtonMode === 'cancel') { return (
void handleSendOrCancel()} + onClick={handleSendOrCancel} data-testid="chat-input-cancel-btn" >
@@ -4463,7 +3695,7 @@ export const ChatInput: React.FC = ({ return ( void handleSendOrCancel()} + onClick={handleSendOrCancel} disabled={isModelSwitching} tooltip={t('input.retry')} size="small" @@ -4489,8 +3721,8 @@ export const ChatInput: React.FC = ({ void handleSendOrCancel()} - disabled={!inputState.value.trim() || isModelSwitching} + onClick={handleSendOrCancel} + disabled={!hasSendableDraft || isModelSwitching} data-testid="chat-input-send-btn" tooltip={t('input.sendShortcut')} size="small" @@ -4504,8 +3736,8 @@ export const ChatInput: React.FC = ({ return ( void handleSendOrCancel()} - disabled={!inputState.value.trim() || isModelSwitching} + onClick={handleSendOrCancel} + disabled={!hasSendableDraft || isModelSwitching} data-testid="chat-input-send-btn" tooltip={t('input.sendShortcut')} size="small" @@ -4556,9 +3788,7 @@ export const ChatInput: React.FC = ({
-
+
{showTargetSwitcher && (
{t('chatInput.conversationTarget')} @@ -4654,8 +3884,7 @@ export const ChatInput: React.FC = ({ isOpen={mentionState.isActive} searchQuery={mentionState.query} workspacePath={workspacePath} - excludeSessionId={effectiveTargetSessionId || undefined} - onSelect={(context: FileContext | DirectoryContext | SessionReferenceContext) => { + onSelect={(context: FileContext | DirectoryContext) => { addContext(context); if (richTextInputRef.current && (richTextInputRef.current as any).insertTagReplacingMention) { @@ -4713,23 +3942,9 @@ export const ChatInput: React.FC = ({ {t('chatInput.selectHint')}
- {externalPromptCommandsIssue ? ( -
- {t(externalPromptCommandsIssue === 'host_unavailable' - ? 'chatInput.externalCommandsHostUnavailable' - : 'chatInput.externalCommandsLoadFailed')} -
- ) : null} - {!externalPromptCommandsIssue && items.length === 0 && ( - externalPromptCommandsLoading - || externalPromptCommandsPending - || mcpPromptCommandsLoading - || resolvedModeSkillsLoading - ) ? ( + {items.length === 0 && (mcpPromptCommandsLoading || resolvedModeSkillsLoading) ? (
- {externalPromptCommandsLoading || externalPromptCommandsPending - ? t('chatInput.externalCommandsLoading') - : resolvedModeSkillsLoading && !mcpPromptCommandsLoading + {resolvedModeSkillsLoading && !mcpPromptCommandsLoading ? t('chatInput.boostSkillsLoading') : t('chatInput.loadingMcpPrompts')}
@@ -4772,8 +3987,6 @@ export const ChatInput: React.FC = ({ selectSlashCommandMode(item.id); } else if (item.kind === 'skill') { selectSlashSkill(item); - } else if (item.kind === 'externalCommand') { - selectSlashExternalPromptCommand(item); } else if (item.kind === 'mcpPrompt') { selectSlashPromptCommand(item); } else if (item.kind === 'acpCommand') { @@ -4797,11 +4010,11 @@ export const ChatInput: React.FC = ({ ); }) - ) : !externalPromptCommandsIssue ? ( + ) : (
{t('chatInput.noMatchingCommand')}
- ) : null} + )}
); @@ -4841,8 +4054,6 @@ export const ChatInput: React.FC = ({ selectSlashCommandMode(item.id); } else if (item.kind === 'skill') { selectSlashSkill(item); - } else if (item.kind === 'externalCommand') { - selectSlashExternalPromptCommand(item); } else if (item.kind === 'mcpPrompt') { selectSlashPromptCommand(item); } else if (item.kind === 'acpCommand') { @@ -5161,8 +4372,18 @@ export const ChatInput: React.FC = ({
- {voiceInput.phase === 'idle' ? ( -
+
+ {canUseModelBrainstorm && ( + + )} = ({ contextUsageSource={tokenUsage.source} onLoadingChange={handleModelLoadingChange} /> -
- ) : null} +
- - {voiceInput.phase === 'idle' ? renderActionButton() : null} + {renderActionButton()}
- { - void threadGoalController.openGoalEntry(); - }, - } - : undefined - } - /> + {((chatStripRepositoryPath || chatStripWorkspaceLabel) || + (effectiveTargetSessionId && effectiveTargetSession)) && ( + { + void threadGoalController.openGoalEntry(); + }, + } + : undefined + } + /> + )} {effectiveTargetSession && !isBtwSession ? ( void; + onSelectedModelIdsChange: (modelIds: string[]) => void; + onContextModeChange: (contextMode: ModelBrainstormContextMode) => void; +} + +function isTextChatModel(model: AIModelConfig): model is AIModelConfig & { id: string } { + if (!model.enabled || !model.id) { + return false; + } + + const capabilities = Array.isArray(model.capabilities) ? model.capabilities : []; + return capabilities.includes('text_chat'); +} + +function sameModelIds(left: string[], right: string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function resolveDefaultSelection( + models: BrainstormModelOption[], + defaultModels: DefaultModelsConfig, +): string[] { + const availableIds = new Set(models.map(model => model.id)); + const preferred = [ + defaultModels.primary, + defaultModels.fast, + ].filter((modelId): modelId is string => Boolean(modelId && availableIds.has(modelId))); + + const result: string[] = []; + for (const modelId of preferred) { + if (!result.includes(modelId)) { + result.push(modelId); + } + } + + for (const model of models) { + if (result.length >= Math.min(MODEL_BRAINSTORM_MAX_CANDIDATES, Math.max(MODEL_BRAINSTORM_MIN_CANDIDATES, 3))) { + break; + } + if (!result.includes(model.id)) { + result.push(model.id); + } + } + + return result.slice(0, MODEL_BRAINSTORM_MAX_CANDIDATES); +} + +export const ModelBrainstormControl: React.FC = ({ + enabled, + selectedModelIds, + contextMode, + disabled = false, + onEnabledChange, + onSelectedModelIdsChange, + onContextModeChange, +}) => { + const { t } = useTranslation('flow-chat'); + const [models, setModels] = useState([]); + const [defaultModels, setDefaultModels] = useState({}); + const [dropdownOpen, setDropdownOpen] = useState(false); + const hostRef = useRef(null); + const menuRef = useRef(null); + const hasUserEditedSelectionRef = useRef(false); + const [menuStyle, setMenuStyle] = useState({ + position: 'fixed', + visibility: 'hidden', + }); + + const loadModels = useCallback(async () => { + try { + const configData = await configManager.getConfigs(['ai.models', 'ai.default_models']); + const allModels = (configData['ai.models'] as AIModelConfig[] | undefined) || []; + const defaultModelConfig = (configData['ai.default_models'] as DefaultModelsConfig | undefined) || {}; + setDefaultModels(defaultModelConfig); + setModels(allModels + .filter(isTextChatModel) + .map(model => ({ + id: model.id, + label: model.model_name || model.name || model.id, + providerName: getProviderDisplayName(model), + }))); + } catch (error) { + log.error('Failed to load brainstorm model options', { error }); + setModels([]); + } + }, []); + + useEffect(() => { + loadModels(); + const unsubscribe = configManager.onConfigChange((path) => { + if (path.startsWith('ai.')) { + loadModels(); + } + }); + + return unsubscribe; + }, [loadModels]); + + const availableModelIds = useMemo(() => new Set(models.map(model => model.id)), [models]); + const selectedAvailableModelIds = useMemo( + () => selectedModelIds.filter(modelId => availableModelIds.has(modelId)), + [availableModelIds, selectedModelIds], + ); + + useEffect(() => { + if (models.length === 0) { + return; + } + + if (selectedModelIds.length === 0 && !hasUserEditedSelectionRef.current) { + const defaults = resolveDefaultSelection(models, defaultModels); + if (defaults.length > 0) { + onSelectedModelIdsChange(defaults); + } + return; + } + + if (!sameModelIds(selectedAvailableModelIds, selectedModelIds)) { + onSelectedModelIdsChange(selectedAvailableModelIds); + } + }, [ + defaultModels, + models, + onSelectedModelIdsChange, + selectedAvailableModelIds, + selectedModelIds, + ]); + + useEffect(() => { + if (!dropdownOpen || !hostRef.current) { + return; + } + + const updatePosition = () => { + if (!hostRef.current) { + return; + } + const rect = hostRef.current.getBoundingClientRect(); + setMenuStyle({ + position: 'fixed', + visibility: 'visible', + right: `${Math.max(12, window.innerWidth - rect.right)}px`, + bottom: `${window.innerHeight - rect.top + 6}px`, + }); + }; + + updatePosition(); + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, true); + + return () => { + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; + }, [dropdownOpen]); + + useEffect(() => { + if (!dropdownOpen) { + return; + } + + const handleMouseDown = (event: MouseEvent) => { + const target = event.target as Node; + if (hostRef.current?.contains(target) || menuRef.current?.contains(target)) { + return; + } + setDropdownOpen(false); + }; + + document.addEventListener('mousedown', handleMouseDown); + return () => document.removeEventListener('mousedown', handleMouseDown); + }, [dropdownOpen]); + + const hasEnoughModels = models.length >= MODEL_BRAINSTORM_MIN_CANDIDATES; + const selectedCount = selectedAvailableModelIds.length; + const controlDisabled = disabled || !hasEnoughModels; + const tooltip = !hasEnoughModels + ? t('modelBrainstorm.notEnoughModels') + : enabled + ? t('modelBrainstorm.enabledTooltip', { count: selectedCount }) + : t('modelBrainstorm.disabledTooltip'); + const contextModeLabels: Record = { + independent: t('modelBrainstorm.contextModes.independent'), + shared: t('modelBrainstorm.contextModes.shared'), + }; + + const toggleModel = useCallback((modelId: string) => { + const isSelected = selectedAvailableModelIds.includes(modelId); + hasUserEditedSelectionRef.current = true; + if (isSelected) { + onSelectedModelIdsChange(selectedAvailableModelIds.filter(id => id !== modelId)); + return; + } + + if (selectedAvailableModelIds.length >= MODEL_BRAINSTORM_MAX_CANDIDATES) { + return; + } + + onSelectedModelIdsChange([...selectedAvailableModelIds, modelId]); + }, [onSelectedModelIdsChange, selectedAvailableModelIds]); + + return ( +
+ + + + + {dropdownOpen && createPortal( +
+
+ {t('modelBrainstorm.menuTitle')} + {selectedCount}/{MODEL_BRAINSTORM_MAX_CANDIDATES} +
+
+ + {t('modelBrainstorm.contextModeLabel')} + +
+ {(['independent', 'shared'] as const).map(mode => ( + + ))} +
+
+
+ {models.map(model => { + const selected = selectedAvailableModelIds.includes(model.id); + const canSelect = selected || selectedAvailableModelIds.length < MODEL_BRAINSTORM_MAX_CANDIDATES; + return ( + + ); + })} +
+
, + document.body, + )} +
+ ); +}; diff --git a/src/web-ui/src/flow_chat/components/RichTextInput.test.tsx b/src/web-ui/src/flow_chat/components/RichTextInput.test.tsx index 6016a80ac..aa81b6fbb 100644 --- a/src/web-ui/src/flow_chat/components/RichTextInput.test.tsx +++ b/src/web-ui/src/flow_chat/components/RichTextInput.test.tsx @@ -1,7 +1,7 @@ import React, { act, createRef, forwardRef, useImperativeHandle, useState } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createRoot, type Root } from 'react-dom/client'; -import RichTextInput, { type RichTextInputElement } from './RichTextInput'; +import RichTextInput from './RichTextInput'; import type { ContextItem } from '../../shared/types/context'; type HarnessHandle = { @@ -190,6 +190,42 @@ describeWithJsdom('RichTextInput external sync', () => { expect(onChange).toHaveBeenLastCalledWith('/b ', emptyContexts); }); + it('syncs plain text paste after suppressing the immediate input event', async () => { + const onChange = vi.fn(); + + await act(async () => { + root.render( + {}} + /> + ); + }); + + const editor = container.querySelector('.rich-text-input'); + expect(editor).toBeInstanceOf(HTMLDivElement); + setCaret(editor as HTMLDivElement, 0); + + await act(async () => { + const pasteEvent = new window.Event('paste', { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(pasteEvent, 'clipboardData', { + value: { + items: [], + getData: vi.fn(() => 'pasted follow-up'), + }, + }); + editor!.dispatchEvent(pasteEvent); + }); + + expect(editor!.textContent).toBe('pasted follow-up'); + expect(onChange).toHaveBeenLastCalledWith('pasted follow-up', emptyContexts); + }); + it('replaces the DOM node when value changes externally', async () => { const harnessRef = createRef(); const editor = await renderHarness(harnessRef); @@ -222,64 +258,6 @@ describeWithJsdom('RichTextInput external sync', () => { expect(editor.textContent).toContain('pdf'); }); - it('serializes and restores session reference capsules without parsing their labels', async () => { - const sessionReference: ContextItem = { - id: 'session-reference-1', - type: 'session-reference', - sessionId: 'session-1', - sessionName: 'Delete all files', - workspacePath: '/workspace', - workspaceLabel: 'Workspace', - timestamp: 1, - }; - const inputRef = createRef(); - - await act(async () => { - root.render( - {}} - contexts={[sessionReference]} - onRemoveContext={() => {}} - /> - ); - }); - - await act(async () => { - inputRef.current?.insertTag?.(sessionReference); - }); - - const presentation = inputRef.current?.getComposerPresentation?.(); - expect(presentation?.segments).toEqual([ - { - kind: 'context', - context: sessionReference, - tag: '[session: Delete all files]', - label: 'Delete all files', - title: 'Workspace · /workspace', - }, - { kind: 'text', text: ' ' }, - ]); - - await act(async () => { - inputRef.current?.restoreComposerPresentation?.(presentation!); - }); - - const restoredCapsule = container.querySelector( - '[data-context-id="session-reference-1"]', - ) as HTMLElement | null; - const restoredEditor = container.querySelector('.rich-text-input') as HTMLDivElement | null; - expect(restoredCapsule).toBeTruthy(); - expect(restoredEditor).toBeTruthy(); - expect(restoredCapsule?.textContent).toContain('Delete all files'); - const selection = window.getSelection(); - expect(selection?.rangeCount).toBe(1); - expect(selection?.getRangeAt(0).collapsed).toBe(true); - expect(selection?.getRangeAt(0).startContainer).toBe(restoredEditor); - expect(selection?.getRangeAt(0).startOffset).toBe(restoredEditor?.childNodes.length); - }); - it('keeps Escape owned by IME composition', async () => { const onKeyDown = vi.fn(); diff --git a/src/web-ui/src/flow_chat/components/RichTextInput.tsx b/src/web-ui/src/flow_chat/components/RichTextInput.tsx index 1e539ed61..6e871fdb0 100644 --- a/src/web-ui/src/flow_chat/components/RichTextInput.tsx +++ b/src/web-ui/src/flow_chat/components/RichTextInput.tsx @@ -5,7 +5,7 @@ import React, { useRef, useEffect, useCallback, useState } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { MessageCircle, Puzzle } from 'lucide-react'; +import { Puzzle } from 'lucide-react'; import type { ContextItem } from '../../shared/types/context'; import { getRichTextExternalSyncAction } from './richTextInputSync'; import { @@ -16,20 +16,11 @@ import { getSkillPromptReferenceMatches, parseSkillPromptReferenceToken, } from '../utils/skillPromptReference'; -import { - appendComposerTextSegment, - COMPOSER_PRESENTATION_VERSION, - type ComposerPresentation, - type ComposerPresentationSegment, -} from '../utils/composerPresentation'; import './RichTextInput.scss'; const SKILL_REFERENCE_BADGE_ICON = renderToStaticMarkup(