From 5759530444fc7d3721ea0a0281a4e44668c29be4 Mon Sep 17 00:00:00 2001 From: weixin_44319083 Date: Wed, 22 Jul 2026 10:03:49 +0800 Subject: [PATCH 1/4] feat: add multi-model brainstorm mode --- .../src/flow_chat/components/ChatInput.scss | 265 +--- .../src/flow_chat/components/ChatInput.tsx | 1275 +++-------------- .../components/ModelBrainstormControl.scss | 174 +++ .../components/ModelBrainstormControl.tsx | 293 ++++ .../modern/ModelBrainstormGroup.scss | 244 ++++ .../modern/ModelBrainstormGroup.tsx | 312 ++++ .../modern/ModernFlowChatContainer.scss | 11 + .../modern/ModernFlowChatContainer.tsx | 185 +-- .../components/modern/VirtualMessageList.tsx | 247 +--- .../src/flow_chat/hooks/useMessageSender.ts | 63 +- .../src/flow_chat/services/FlowChatManager.ts | 42 +- .../services/ModelBrainstormService.ts | 210 +++ .../flow-chat-manager/SessionModule.ts | 123 +- .../src/flow_chat/store/FlowChatStore.ts | 609 +------- .../store/modelBrainstormStore.test.ts | 72 + .../flow_chat/store/modelBrainstormStore.ts | 138 ++ .../src/flow_chat/utils/messagePrompt.ts | 24 + src/web-ui/src/locales/en-US/flow-chat.json | 290 +--- src/web-ui/src/locales/zh-CN/flow-chat.json | 306 +--- src/web-ui/src/locales/zh-TW/flow-chat.json | 306 +--- 20 files changed, 2016 insertions(+), 3173 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/ModelBrainstormControl.scss create mode 100644 src/web-ui/src/flow_chat/components/ModelBrainstormControl.tsx create mode 100644 src/web-ui/src/flow_chat/components/modern/ModelBrainstormGroup.scss create mode 100644 src/web-ui/src/flow_chat/components/modern/ModelBrainstormGroup.tsx create mode 100644 src/web-ui/src/flow_chat/services/ModelBrainstormService.ts create mode 100644 src/web-ui/src/flow_chat/store/modelBrainstormStore.test.ts create mode 100644 src/web-ui/src/flow_chat/store/modelBrainstormStore.ts create mode 100644 src/web-ui/src/flow_chat/utils/messagePrompt.ts diff --git a/src/web-ui/src/flow_chat/components/ChatInput.scss b/src/web-ui/src/flow_chat/components/ChatInput.scss index a904960f97..9aabb1c5b3 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 70d9356f31..bb57499767 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,25 @@ 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 { 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 +68,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 +90,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 +112,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 +121,6 @@ const log = createLogger('ChatInput'); export interface ChatInputProps { className?: string; onSendMessage?: (message: string) => void; - isSceneActive?: boolean; } type SlashActionItem = { @@ -215,61 +167,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 +270,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 +288,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 +322,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 +646,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 +665,8 @@ export const ChatInput: React.FC = ({ getSessionContextUsageDisplay() ); const [isModelSwitching, setIsModelSwitching] = useState(false); + const [brainstormEnabled, setBrainstormEnabled] = useState(false); + const [brainstormModelIds, setBrainstormModelIds] = useState([]); const isAssistantWorkspace = useMemo( () => resolveSessionAssistantWorkspace({ currentWorkspace: workspace, @@ -818,13 +684,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 +729,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 +915,13 @@ export const ChatInput: React.FC = ({ composerMode: currentMode, }); const targetWorkspacePath = (workspacePath || effectiveTargetSession?.workspacePath || '').trim(); + const canUseModelBrainstorm = !isAcpInputSession && !isBtwSession && !isSubagentInputTarget; + + useEffect(() => { + if (!canUseModelBrainstorm && brainstormEnabled) { + setBrainstormEnabled(false); + } + }, [brainstormEnabled, canUseModelBrainstorm]); useEffect(() => { if (!isSubagentInputTarget) { @@ -1183,98 +1066,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 +1151,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 +1221,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 +1240,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 +1254,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 +1300,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 +1327,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 +1359,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 +1374,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 +1467,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 +1502,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 +1665,6 @@ export const ChatInput: React.FC = ({ derivedState?.queuedInput, effectiveTargetSessionId, clearPendingLargePastes, - dispatchInput, ]); React.useEffect(() => { @@ -2191,7 +1787,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 +1915,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 +1990,6 @@ export const ChatInput: React.FC = ({ } const actions = getFilteredActions(); - const externalCommands = getFilteredExternalPromptCommands(); const mcpPrompts = getFilteredMcpPromptCommands(); const skills = getFilteredSkills(); let modeList = incrementalCodeModes; @@ -2426,8 +2006,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 +2039,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 +2117,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 +2156,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 +2193,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 +2249,6 @@ export const ChatInput: React.FC = ({ } }, [ derivedState?.isProcessing, - dispatchInput, effectiveTargetSession, effectiveTargetSessionId, inputState.value, @@ -2722,7 +2287,6 @@ export const ChatInput: React.FC = ({ } }, [ derivedState?.isProcessing, - dispatchInput, effectiveTargetSession, effectiveTargetSessionId, t, @@ -2755,7 +2319,6 @@ export const ChatInput: React.FC = ({ dispatchInput({ type: 'SET_VALUE', payload: message }); } }, [ - dispatchInput, effectiveTargetSession, effectiveTargetSessionId, inputState.value, @@ -2829,7 +2392,6 @@ export const ChatInput: React.FC = ({ } }, [ derivedState?.isProcessing, - dispatchInput, effectiveTargetSession, effectiveTargetSessionId, inputState.value, @@ -2877,7 +2439,6 @@ export const ChatInput: React.FC = ({ dispatchInput({ type: 'DEACTIVATE' }); }, [ - dispatchInput, effectiveTargetSession, effectiveTargetSessionId, inputState.value, @@ -2926,7 +2487,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 +2577,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 +2596,11 @@ export const ChatInput: React.FC = ({ clearPendingLargePastes, confirmDeepReviewLaunch, currentReviewActivity, - dispatchInput, effectiveTargetSession, effectiveTargetSessionId, flowChatState, inputState.value, isBtwSession, - replacePendingLargePastes, setQueuedInput, t, ]); @@ -3123,7 +2682,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 +2697,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); @@ -3433,11 +2718,11 @@ export const ChatInput: React.FC = ({ setIsModelSwitching(loading); }, []); - const handleSendOrCancel = useCallback(async (messageOverride?: string) => { + const handleSendOrCancel = useCallback(async () => { if (!derivedState) return; const { sendButtonMode } = derivedState; - const draftTrimmed = (messageOverride ?? inputState.value).trim(); + const draftTrimmed = inputState.value.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 +2742,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 +2822,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 +2833,53 @@ export const ChatInput: React.FC = ({ return; } + if (brainstormEnabled && canUseModelBrainstorm) { + if (brainstormModelIds.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 { + await launchModelBrainstorm({ + message, + displayMessage: originalMessage, + contexts, + sourceSessionId: effectiveTargetSessionId || undefined, + workspaceConfig: flowChatSessionConfigForCurrentWorkspace(workspace), + agentType: effectiveSendAgentType, + modelIds: brainstormModelIds, + }); + 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 +2895,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 +2910,24 @@ export const ChatInput: React.FC = ({ } }, [ isModelSwitching, + brainstormEnabled, + brainstormModelIds, + canUseModelBrainstorm, inputState.value, derivedState, - dispatchInput, handleCancelCurrentTask, transition, sendMessage, addToHistory, effectiveTargetSessionId, + effectiveSendAgentType, + contexts, + workspace, + clearContexts, + onSendMessage, clearPendingLargePastes, expandComposerSpecialTokens, isAcpInputSession, - richTextInputRef, - replacePendingLargePastes, setQueuedInput, submitBtwFromInput, submitGoalFromInput, @@ -3626,7 +2940,6 @@ export const ChatInput: React.FC = ({ confirmPromptCacheGuardIfNeeded, t, resolveTypedMcpPromptCommand, - submitExternalPromptCommandFromInput, ]); const getFilteredIncrementalModes = useCallback(() => { @@ -3713,115 +3026,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 +3053,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 +3071,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 +3091,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 +3107,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 +3118,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 +3134,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 +3300,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 +3337,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 +3470,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 +3525,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 +3560,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 +3584,7 @@ export const ChatInput: React.FC = ({ } }); }); - }, [dispatchInput]); + }, []); const handleOpenSkillsLibrary = useCallback( (e: React.MouseEvent) => { @@ -4420,25 +3610,6 @@ 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 ; @@ -4449,7 +3620,7 @@ export const ChatInput: React.FC = ({
void handleSendOrCancel()} + onClick={handleSendOrCancel} data-testid="chat-input-cancel-btn" >
@@ -4463,7 +3634,7 @@ export const ChatInput: React.FC = ({ return ( void handleSendOrCancel()} + onClick={handleSendOrCancel} disabled={isModelSwitching} tooltip={t('input.retry')} size="small" @@ -4489,7 +3660,7 @@ export const ChatInput: React.FC = ({ void handleSendOrCancel()} + onClick={handleSendOrCancel} disabled={!inputState.value.trim() || isModelSwitching} data-testid="chat-input-send-btn" tooltip={t('input.sendShortcut')} @@ -4504,7 +3675,7 @@ export const ChatInput: React.FC = ({ return ( void handleSendOrCancel()} + onClick={handleSendOrCancel} disabled={!inputState.value.trim() || isModelSwitching} data-testid="chat-input-send-btn" tooltip={t('input.sendShortcut')} @@ -4556,9 +3727,7 @@ export const ChatInput: React.FC = ({
-
+
{showTargetSwitcher && (
{t('chatInput.conversationTarget')} @@ -4654,8 +3823,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 +3881,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 +3926,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 +3949,11 @@ export const ChatInput: React.FC = ({ ); }) - ) : !externalPromptCommandsIssue ? ( + ) : (
{t('chatInput.noMatchingCommand')}
- ) : null} + )}
); @@ -4841,8 +3993,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 +4311,16 @@ 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; +} + +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, + disabled = false, + onEnabledChange, + onSelectedModelIdsChange, +}) => { + 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 [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) { + 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 toggleModel = useCallback((modelId: string) => { + const isSelected = selectedAvailableModelIds.includes(modelId); + 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} +
+
+ {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/modern/ModelBrainstormGroup.scss b/src/web-ui/src/flow_chat/components/modern/ModelBrainstormGroup.scss new file mode 100644 index 0000000000..15bda23160 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/ModelBrainstormGroup.scss @@ -0,0 +1,244 @@ +.model-brainstorm-group { + width: min(100%, 1280px); + margin: 24px auto; + padding: 0 24px; + + &__header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 18px; + margin-bottom: 12px; + } + + &__title-block { + min-width: 0; + } + + &__eyebrow { + display: block; + margin-bottom: 5px; + color: var(--color-text-muted); + font-size: var(--flowchat-font-size-2xs); + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; + } + + &__title { + display: -webkit-box; + max-width: 900px; + margin: 0; + overflow: hidden; + color: var(--color-text-primary); + font-size: var(--flowchat-font-size-sm); + font-weight: 650; + line-height: 1.45; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + + &__progress { + flex-shrink: 0; + color: var(--color-text-muted); + font-size: var(--flowchat-font-size-xs); + } + + &__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); + gap: 12px; + align-items: start; + } + + &__candidate { + display: flex; + min-height: 280px; + max-height: min(720px, calc(100vh - 220px)); + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border-subtle); + border-radius: 8px; + background: var(--color-bg-elevated); + box-shadow: 0 1px 0 var(--color-overlay-white-08) inset; + + &--selected { + border-color: color-mix(in srgb, var(--color-success) 50%, var(--border-subtle)); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--color-success) 20%, transparent), + 0 1px 0 var(--color-overlay-white-08) inset; + } + } + + &__candidate-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 12px 12px 8px; + border-bottom: 1px solid var(--border-subtle); + } + + &__model { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; + } + + &__model-name, + &__model-provider { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__model-name { + color: var(--color-text-primary); + font-size: var(--flowchat-font-size-sm); + font-weight: 700; + } + + &__model-provider { + color: var(--color-text-muted); + font-size: var(--flowchat-font-size-2xs); + } + + &__status { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + padding: 2px 6px; + border-radius: 5px; + background: var(--element-bg-subtle); + color: var(--color-text-muted); + font-size: var(--flowchat-font-size-2xs); + font-weight: 650; + + &--completed { + background: color-mix(in srgb, var(--color-success) 12%, transparent); + color: color-mix(in srgb, var(--color-success) 82%, var(--color-text-primary)); + } + + &--failed, + &--error, + &--cancelled { + background: color-mix(in srgb, var(--color-error) 12%, transparent); + color: color-mix(in srgb, var(--color-error) 82%, var(--color-text-primary)); + } + } + + &__status-spinner, + &__empty-spinner { + animation: model-brainstorm-spin 0.9s linear infinite; + } + + &__metrics { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 12px; + border-bottom: 1px solid var(--border-subtle); + } + + &__metric { + display: inline-flex; + align-items: center; + gap: 4px; + min-width: 0; + padding: 2px 6px; + border-radius: 5px; + background: var(--element-bg-subtle); + color: var(--color-text-secondary); + font-size: var(--flowchat-font-size-2xs); + } + + &__body { + flex: 1; + min-height: 0; + overflow: auto; + padding: 4px 0 10px; + + .model-round-item { + width: 100%; + padding: 10px 14px; + } + + .model-round-item__footer { + padding-right: 0; + } + } + + &__empty, + &__error { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 160px; + padding: 18px; + color: var(--color-text-muted); + font-size: var(--flowchat-font-size-xs); + text-align: center; + } + + &__error { + color: color-mix(in srgb, var(--color-error) 82%, var(--color-text-primary)); + } + + &__select { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 34px; + margin: 0; + padding: 0 12px; + border: 0; + border-top: 1px solid var(--border-subtle); + background: transparent; + color: var(--color-text-secondary); + font-size: var(--flowchat-font-size-xs); + font-weight: 700; + cursor: pointer; + + &:hover:not(:disabled) { + background: var(--element-bg-subtle); + color: var(--color-text-primary); + } + + &:disabled { + cursor: default; + opacity: 0.45; + } + } + + @media (max-width: 760px) { + padding: 0 12px; + + &__header { + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + + &__progress { + flex-shrink: 1; + } + + &__candidate { + max-height: none; + } + } +} + +@keyframes model-brainstorm-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} diff --git a/src/web-ui/src/flow_chat/components/modern/ModelBrainstormGroup.tsx b/src/web-ui/src/flow_chat/components/modern/ModelBrainstormGroup.tsx new file mode 100644 index 0000000000..9c587e8f31 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/ModelBrainstormGroup.tsx @@ -0,0 +1,312 @@ +import React, { useEffect, useMemo, useReducer } from 'react'; +import { Check, Loader2, Wrench, Brain } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { DialogTurn, FlowThinkingItem, FlowToolItem, ModelRound, Session } from '../../types/flow-chat'; +import { FlowChatManager } from '../../services/FlowChatManager'; +import { FlowChatStore } from '../../store/FlowChatStore'; +import { + useModelBrainstormStore, + type ModelBrainstormBatch, + type ModelBrainstormCandidate, +} from '../../store/modelBrainstormStore'; +import { getEffectiveToolName } from '../../utils/toolInvocationIdentity'; +import { Tooltip } from '@/component-library'; +import { FlowChatContext, useFlowChatContext } from './FlowChatContext'; +import { ModelRoundItem } from './ModelRoundItem'; +import './ModelBrainstormGroup.scss'; + +const READONLY_TOOL_NAMES = new Set([ + 'Read', + 'LS', + 'Grep', + 'Glob', + 'WebSearch', + 'WebFetch', + 'GetFileDiff', + 'GetToolSpec', + 'ListModels', + 'Skill', + 'view_image', + 'analyze_image', + 'ReadCanvas', + 'SessionHistory', +]); + +interface ModelBrainstormGroupProps { + batch: ModelBrainstormBatch; +} + +interface CandidateProjection { + candidate: ModelBrainstormCandidate; + session?: Session; + turn?: DialogTurn; + status: 'pending' | 'starting' | 'running' | 'completed' | 'failed' | 'cancelled' | 'error'; + readonlyToolNames: string[]; + readonlyToolCount: number; + thinkingChars: number; +} + +function getCandidateSessionSignature(batch: ModelBrainstormBatch): string { + const store = FlowChatStore.getInstance(); + const state = store.getState(); + return batch.candidates + .map(candidate => { + const session = candidate.sessionId ? state.sessions.get(candidate.sessionId) : undefined; + const turn = session?.dialogTurns[session.dialogTurns.length - 1]; + const rounds = turn?.modelRounds ?? []; + const itemCounts = rounds.map(round => `${round.id}:${round.status}:${round.items.length}:${round.isStreaming ? '1' : '0'}`).join(','); + return [ + candidate.id, + candidate.status, + candidate.error ?? '', + candidate.sessionId ?? '', + turn?.id ?? '', + turn?.status ?? '', + itemCounts, + ].join(':'); + }) + .join('|'); +} + +function useCandidateSessionUpdates(batch: ModelBrainstormBatch): void { + const [, forceRender] = useReducer((value: number) => value + 1, 0); + const signature = useMemo( + () => batch.candidates.map(candidate => candidate.id).join('|'), + [batch.candidates], + ); + + useEffect(() => { + forceRender(); + return FlowChatStore.getInstance().subscribeSelector( + () => getCandidateSessionSignature(batch), + () => forceRender(), + { isEqual: Object.is }, + ); + }, [batch, signature]); +} + +function flattenRoundItems(rounds: ModelRound[]) { + return rounds.flatMap(round => round.items ?? []); +} + +function projectCandidate(batch: ModelBrainstormBatch, candidate: ModelBrainstormCandidate): CandidateProjection { + const session = candidate.sessionId + ? FlowChatStore.getInstance().getState().sessions.get(candidate.sessionId) + : undefined; + const turn = session?.dialogTurns.find(dialogTurn => + dialogTurn.userMessage?.metadata?.brainstormBatchId === batch.id && + dialogTurn.userMessage?.metadata?.brainstormCandidateId === candidate.id + ) ?? session?.dialogTurns[session.dialogTurns.length - 1]; + const items = flattenRoundItems(turn?.modelRounds ?? []); + const readonlyTools = items + .filter((item): item is FlowToolItem => item.type === 'tool') + .map(item => getEffectiveToolName(item)) + .filter(toolName => READONLY_TOOL_NAMES.has(toolName)); + const readonlyToolNames = Array.from(new Set(readonlyTools)); + const thinkingChars = items + .filter((item): item is FlowThinkingItem => item.type === 'thinking') + .reduce((total, item) => total + (item.content?.length ?? 0), 0); + + const status = (() => { + if (candidate.status === 'failed') { + return 'failed'; + } + if (!turn) { + return candidate.status; + } + if (turn.status === 'completed') { + return 'completed'; + } + if (turn.status === 'cancelled') { + return 'cancelled'; + } + if (turn.status === 'error') { + return 'error'; + } + return 'running'; + })(); + + return { + candidate, + session, + turn, + status, + readonlyToolNames, + readonlyToolCount: readonlyTools.length, + thinkingChars, + }; +} + +function formatThinkingChars(chars: number): string { + if (chars >= 1000) { + return `${(chars / 1000).toFixed(chars >= 10_000 ? 0 : 1)}k`; + } + return String(chars); +} + +export const ModelBrainstormGroup: React.FC = ({ batch }) => { + const { t } = useTranslation('flow-chat'); + const parentContext = useFlowChatContext(); + useCandidateSessionUpdates(batch); + + const projections = batch.candidates.map(candidate => projectCandidate(batch, candidate)); + const completedCount = projections.filter(projection => projection.status === 'completed').length; + const selectedCandidateId = batch.selectedCandidateId; + const statusLabels: Record = { + pending: t('shared:statuses.loading'), + starting: t('shared:statuses.loading'), + running: t('shared:statuses.running'), + completed: t('shared:statuses.done'), + failed: t('shared:statuses.failed'), + cancelled: t('shared:statuses.cancelled'), + error: t('shared:statuses.failed'), + }; + + const handleSelect = async (projection: CandidateProjection) => { + if (!projection.candidate.sessionId) { + return; + } + + useModelBrainstormStore.getState().selectCandidate(batch.id, projection.candidate.id); + await FlowChatManager.getInstance().switchChatSession(projection.candidate.sessionId); + }; + + return ( +
+
+
+ {t('modelBrainstorm.groupEyebrow')} +

{batch.displayQuestion}

+
+
+ {t('modelBrainstorm.progress', { + completed: completedCount, + total: projections.length, + })} +
+
+ +
+ {projections.map(projection => { + const selected = selectedCandidateId === projection.candidate.id; + const statusLabel = statusLabels[projection.status]; + const readonlyTooltip = projection.readonlyToolNames.length > 0 + ? projection.readonlyToolNames.join(', ') + : t('modelBrainstorm.noReadonlyTools'); + const canSelect = Boolean(projection.candidate.sessionId); + + return ( +
+
+
+ + {projection.candidate.modelLabel} + + {projection.candidate.providerName && ( + + {projection.candidate.providerName} + + )} +
+ + {(projection.status === 'running' || projection.status === 'starting' || projection.status === 'pending') && ( + + )} + {statusLabel} + +
+ +
+ + + + {t('modelBrainstorm.readonlyToolMetric', { + count: projection.readonlyToolCount, + })} + + + + + {t('modelBrainstorm.thinkingMetric', { + value: formatThinkingChars(projection.thinkingChars), + })} + +
+ +
+ {projection.candidate.error ? ( +
+ {projection.candidate.error} +
+ ) : projection.turn && projection.turn.modelRounds.length > 0 && projection.session ? ( + + {projection.turn.modelRounds.map((round, index) => ( + + ))} + + ) : ( +
+ + {statusLabel} +
+ )} +
+ + +
+ ); + })} +
+
+ ); +}; diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss index 2f830cf349..02ed49a419 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss @@ -38,6 +38,17 @@ background: transparent; } + &__brainstorm-only { + width: 100%; + height: 100%; + overflow-y: auto; + } + + &__brainstorm-groups { + display: grid; + gap: 18px; + } + &__messages[data-history-state="ready"][data-is-partial="true"] { .user-message-item, .model-round-item { diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index 7d0a60d13d..387cfdb7cd 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -22,12 +22,7 @@ import { import { BackgroundCommandInputDialog } from '../background-command/BackgroundCommandInputDialog'; import { WelcomePanel } from '../WelcomePanel'; import { HistorySessionPlaceholder } from './HistorySessionPlaceholder'; -import { - FlowChatContext, - FlowChatContextValue, - FlowChatVolatileContext, - FlowChatVolatileContextValue, -} from './FlowChatContext'; +import { FlowChatContext, FlowChatContextValue } from './FlowChatContext'; import { useExploreGroupState } from './useExploreGroupState'; import { useFlowChatFileActions } from './useFlowChatFileActions'; import { useFlowChatNavigation } from './useFlowChatNavigation'; @@ -36,6 +31,7 @@ import { useFlowChatSync } from './useFlowChatSync'; import { useFlowChatToolActions } from './useFlowChatToolActions'; import { useFlowChatSearch } from './useFlowChatSearch'; import { useVirtualItems, useActiveSession, useVisibleTurnInfo, type VisibleTurnInfo } from '../../store/modernFlowChatStore'; +import { useModelBrainstormBatchesForSession } from '../../store/modelBrainstormStore'; import type { FlowChatConfig, DialogTurn } from '../../types/flow-chat'; import { useBackgroundCommandActivityStore, @@ -66,6 +62,7 @@ import { isRemoteTraceContext, startupTrace } from '@/shared/utils/startupTrace' import { scheduleAfterStartupPaint } from '@/shared/utils/startupTaskScheduling'; import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; import { notificationService } from '@/shared/notification-system'; +import { ModelBrainstormGroup } from './ModelBrainstormGroup'; import { clearHistorySessionOpenTransition, getHistorySessionOpenTransitionSnapshot, @@ -81,16 +78,12 @@ import { type BackgroundSubagentActivityItem, } from '../../utils/backgroundSubagentActivity'; import './ModernFlowChatContainer.scss'; -import { PermissionRequestPanel } from './PermissionRequestPanel'; -import { pendingPermissionToolCallIdsForSession } from './permissionRequestRouting'; -import { usePermissionRequests } from './usePermissionRequests'; const log = createLogger('ModernFlowChatContainer'); interface ModernFlowChatContainerProps { className?: string; config?: Partial; - permissionPanelAboveChatInput?: boolean; // Callbacks compatible with the legacy version. onFileViewRequest?: (filePath: string, fileName: string, lineRange?: LineRange) => void; @@ -126,7 +119,7 @@ const MOCK_BACKGROUND_SUBAGENTS: BackgroundSubagentSummary[] = [ sessionId: 'mock-background-subagent-review', parentSessionId: 'mock-parent-session', title: 'Reviewing auth boundary changes', - agentType: 'ReviewWorker', + agentType: 'ReviewSecurity', status: 'processing', createdAt: Date.now() - 36_000, updatedAt: Date.now() - 4_000, @@ -229,7 +222,6 @@ function backgroundCommandSummaryFromActivity(activity: BackgroundCommandActivit export const ModernFlowChatContainer: React.FC = ({ className = '', config, - permissionPanelAboveChatInput = false, onFileViewRequest, onTabOpen, onOpenVisualization, @@ -238,14 +230,7 @@ export const ModernFlowChatContainer: React.FC = ( const { t } = useTranslation('flow-chat'); const virtualItems = useVirtualItems(); const activeSession = useActiveSession(); - const { - requests: permissionRequests, - activeBatch: activePermissionBatch, - respond: respondPermission, - respondBatch: respondPermissionBatch, - } = usePermissionRequests( - activeSession?.sessionId, - ); + const modelBrainstormBatches = useModelBrainstormBatchesForSession(activeSession?.sessionId); const visibleTurnInfo = useVisibleTurnInfo(); const [pendingHeaderTurnId, setPendingHeaderTurnId] = useState(null); const [queuedHeaderTurnPinId, setQueuedHeaderTurnPinId] = useState(null); @@ -300,6 +285,15 @@ export const ModernFlowChatContainer: React.FC = ( onCollapseGroup: handleCollapseGroup, } = useExploreGroupState(virtualItems); const { handleToolConfirm, handleToolReject } = useFlowChatToolActions(); + const modelBrainstormFooter = useMemo(() => ( + modelBrainstormBatches.length > 0 ? ( +
+ {modelBrainstormBatches.map(batch => ( + + ))} +
+ ) : null + ), [modelBrainstormBatches]); const { handleFileViewRequest } = useFlowChatFileActions({ workspacePath, @@ -330,12 +324,6 @@ export const ModernFlowChatContainer: React.FC = ( goToPrev: handleSearchPrev, clearSearch, } = useFlowChatSearch(virtualItems); - const searchCurrentMatch = searchMatches[searchCurrentMatchIndex]; - const searchCurrentMatchFlowItemId = searchCurrentMatch?.flowItemId; - const searchCurrentMatchTurnId = searchCurrentMatch?.turnId; - const searchCurrentMatchVirtualItemIndex = searchCurrentMatch?.virtualItemIndex ?? -1; - const searchCurrentMatchOccurrenceIndex = searchCurrentMatch?.occurrenceIndex ?? 0; - const searchCurrentMatchExpandableKey = searchCurrentMatch?.expandableIds?.join('\u0000') ?? ''; useFlowChatSync(); useFlowChatCopyDialog(); @@ -409,42 +397,6 @@ export const ModernFlowChatContainer: React.FC = ( showHistoryPlaceholder, ]); - // Scalar session facts for the stable context. Depending on scalars (not the - // session object) keeps the context value referentially stable across - // streaming flushes, which produce a new session object ~30x/second. - const activeSessionId = activeSession?.sessionId; - const activeSessionWorkspacePath = activeSession?.workspacePath - || activeSession?.config?.workspacePath; - const activeSessionRemoteConnectionId = activeSession?.remoteConnectionId - || activeSession?.config?.remoteConnectionId; - const activeSessionIsHistorical = activeSession?.isHistorical === true; - const activeSessionContextRestoreState = activeSession?.contextRestoreState; - - // Reuse the previous Set when the pending permission tool-call ids are - // content-equal so consumers do not re-render on identity-only changes. - const pendingPermissionToolCallIdsRef = useRef>(new Set()); - const pendingPermissionToolCallIds = useMemo(() => { - const next = pendingPermissionToolCallIdsForSession( - permissionRequests, - activeSessionId, - ); - const previous = pendingPermissionToolCallIdsRef.current; - if (previous.size === next.size) { - let contentEqual = true; - for (const id of next) { - if (!previous.has(id)) { - contentEqual = false; - break; - } - } - if (contentEqual) { - return previous; - } - } - pendingPermissionToolCallIdsRef.current = next; - return next; - }, [permissionRequests, activeSessionId]); - const contextValue: FlowChatContextValue = useMemo(() => ({ onFileViewRequest: handleFileViewRequest, onTabOpen, @@ -453,11 +405,8 @@ export const ModernFlowChatContainer: React.FC = ( onSwitchToChatPanel, onToolConfirm: handleToolConfirm, onToolReject: handleToolReject, - sessionId: activeSessionId, - workspacePath: activeSessionWorkspacePath, - remoteConnectionId: activeSessionRemoteConnectionId, - isHistoricalSession: activeSessionIsHistorical, - contextRestoreState: activeSessionContextRestoreState, + sessionId: activeSession?.sessionId, + activeSessionOverride: activeSession, allowUserMessageRollback, config: { enableMarkdown: true, @@ -468,10 +417,14 @@ export const ModernFlowChatContainer: React.FC = ( theme: 'dark', ...config, }, + exploreGroupStates, onExploreGroupToggle: handleExploreGroupToggle, onExpandGroup: handleExpandGroup, onExpandAllInTurn: handleExpandAllInTurn, onCollapseGroup: handleCollapseGroup, + searchQuery, + searchMatchIndices, + searchCurrentMatchVirtualIndex, }), [ handleFileViewRequest, onTabOpen, @@ -480,28 +433,14 @@ export const ModernFlowChatContainer: React.FC = ( onSwitchToChatPanel, handleToolConfirm, handleToolReject, - activeSessionId, - activeSessionWorkspacePath, - activeSessionRemoteConnectionId, - activeSessionIsHistorical, - activeSessionContextRestoreState, + activeSession, allowUserMessageRollback, config, + exploreGroupStates, handleExploreGroupToggle, handleExpandGroup, handleExpandAllInTurn, handleCollapseGroup, - ]); - - const volatileContextValue: FlowChatVolatileContextValue = useMemo(() => ({ - pendingPermissionToolCallIds, - exploreGroupStates, - searchQuery, - searchMatchIndices, - searchCurrentMatchVirtualIndex, - }), [ - pendingPermissionToolCallIds, - exploreGroupStates, searchQuery, searchMatchIndices, searchCurrentMatchVirtualIndex, @@ -1076,33 +1015,14 @@ export const ModernFlowChatContainer: React.FC = ( ]); useEffect(() => { - if (searchCurrentMatchVirtualItemIndex < 0 || !searchQuery.trim()) { - virtualListRef.current?.clearSearchMatch(); - return; - } - + if (searchCurrentMatchVirtualIndex < 0) return; const frameId = requestAnimationFrame(() => { - virtualListRef.current?.scrollToSearchMatch({ - virtualItemIndex: searchCurrentMatchVirtualItemIndex, - query: searchQuery, - flowItemId: searchCurrentMatchFlowItemId, - occurrenceIndex: searchCurrentMatchOccurrenceIndex, - expandableIds: searchCurrentMatchExpandableKey - ? searchCurrentMatchExpandableKey.split('\u0000') - : undefined, - }); + virtualListRef.current?.scrollToIndex(searchCurrentMatchVirtualIndex); }); return () => { cancelAnimationFrame(frameId); }; - }, [ - searchCurrentMatchFlowItemId, - searchCurrentMatchTurnId, - searchCurrentMatchExpandableKey, - searchCurrentMatchVirtualItemIndex, - searchCurrentMatchOccurrenceIndex, - searchQuery, - ]); + }, [searchCurrentMatchVirtualIndex]); const handleJumpToTurn = useCallback((turnId: string) => { if (!turnId) return false; @@ -1316,18 +1236,7 @@ export const ModernFlowChatContainer: React.FC = ( } try { - const result = await agentAPI.cancelSession(subagent.sessionId); - if (!result.cancelled) { - setStoppingBackgroundSubagentIds((previous) => { - const next = new Set(previous); - next.delete(subagent.sessionId); - return next; - }); - notificationService.error( - t('flowChatHeader.backgroundSubagentStopFailed'), - { duration: 5000 }, - ); - } + await agentAPI.cancelSession(subagent.sessionId); } catch (_error) { setStoppingBackgroundSubagentIds((previous) => { const next = new Set(previous); @@ -1537,7 +1446,6 @@ export const ModernFlowChatContainer: React.FC = ( return ( -
= ( onSend={handleSendBackgroundCommandInput} /> - {activePermissionBatch && ( - - )} -
= ( /> ) : virtualItems.length === 0 ? ( showHistoryPlaceholder || showHistoryOpenIntentOverlay ? null : ( - { - window.dispatchEvent(new CustomEvent('fill-chat-input', { - detail: { message: command } - })); - }} - /> + modelBrainstormFooter ? ( +
+
+ {modelBrainstormFooter} +
+
+ ) : ( + { + window.dispatchEvent(new CustomEvent('fill-chat-input', { + detail: { message: command } + })); + }} + /> + ) ) ) : ( <> )} @@ -1678,7 +1584,6 @@ export const ModernFlowChatContainer: React.FC = (
- ); }; diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx index ba1389bd98..e91aac7c0b 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -53,12 +53,6 @@ import { activeSessionHistoryProjectionHandoff, type HistoryProjectionHandoffSnapshot, } from './historyProjectionHandoff'; -import { - findElementWithDataValue, - findFlowChatSearchTextRanges, - getFlowChatSearchTextRoot, - setFlowChatSearchHighlight, -} from './flowChatSearchDom'; import './VirtualMessageList.scss'; const COMPENSATION_EPSILON_PX = 0.5; @@ -79,7 +73,6 @@ const PARTIAL_HISTORY_FULL_PROJECTION_TOP_THRESHOLD_PX = 1200; const HISTORY_PROJECTION_HANDOFF_MAX_DURATION_MS = 5000; const SESSION_OPEN_HANDOFF_ITEM_BUDGET = 24; const PREVIOUS_HISTORY_BOUNDARY_STATUS_DURATION_MS = 2500; -const SEARCH_NAVIGATION_MAX_ATTEMPTS = 24; type LatestEndAnchorResolveReason = | 'raf' @@ -105,15 +98,6 @@ export type FlowChatTurnPinRequestStatus = 'rejected' | 'pending' | 'settled'; export interface VirtualMessageListRef { scrollToTurn: (turnIndex: number) => void; scrollToIndex: (index: number) => void; - // Materializes a virtual item, reveals collapsed search content, and centers the exact text range. - scrollToSearchMatch: (target: { - virtualItemIndex: number; - query: string; - flowItemId?: string; - occurrenceIndex?: number; - expandableIds?: readonly string[]; - }) => void; - clearSearchMatch: () => void; // Clears pin reservation first, then scrolls to the physical bottom. scrollToPhysicalBottomAndClearPin: () => void; // Clears pin reservation first, then keeps the target turn visible near the natural tail. @@ -132,6 +116,7 @@ export interface VirtualMessageListRef { export interface VirtualMessageListProps { onUserScrollIntent?: () => void; + extraFooter?: React.ReactNode; } interface ScrollAnchorLockState { @@ -339,8 +324,6 @@ function getVirtualItemStableKey(item: VirtualItem): string { return `${item.type}:${item.turnId}:${item.data.groupId}`; case 'turn-completion-notice': return `${item.type}:${item.turnId}:${item.data.reasonCode}`; - case 'turn-failure-notice': - return `${item.type}:${item.turnId}`; case 'image-analyzing': return `${item.type}:${item.turnId}`; } @@ -371,6 +354,7 @@ function getReservationConsumablePx(reservation: BottomReservationBase): number const VirtualMessageListSession = forwardRef(({ onUserScrollIntent, + extraFooter, }, ref) => { const { t } = useTranslation('flow-chat'); const virtuosoRef = useRef(null); @@ -451,7 +435,6 @@ const VirtualMessageListSession = forwardRef(null); const turnPinStabilizationFrameRef = useRef(null); const latestEndAnchorStabilizationFrameRef = useRef(null); - const searchNavigationRequestIdRef = useRef(0); const staticInitialHistoryBottomGuardFrameRef = useRef(null); const staticInitialHistoryBottomGuardUntilMsRef = useRef(0); const latestEndAnchorRequestRef = useRef(null); @@ -3598,225 +3581,6 @@ const VirtualMessageListSession = forwardRef { - searchNavigationRequestIdRef.current += 1; - setFlowChatSearchHighlight(null); - }, []); - - const scrollToSearchMatch = useCallback((target: { - virtualItemIndex: number; - query: string; - flowItemId?: string; - occurrenceIndex?: number; - expandableIds?: readonly string[]; - }) => { - const query = target.query.trim(); - if ( - !query || - target.virtualItemIndex < 0 || - target.virtualItemIndex >= virtualItems.length - ) { - clearSearchMatch(); - return; - } - - const requestId = searchNavigationRequestIdRef.current + 1; - searchNavigationRequestIdRef.current = requestId; - setFlowChatSearchHighlight(null); - exitFollowOutput('scroll-to-index'); - clearPinReservationForUserNavigation(); - - const virtuoso = virtuosoRef.current; - if (virtuoso) { - if (target.virtualItemIndex === 0) { - virtuoso.scrollTo({ top: 0, behavior: 'auto' }); - } else { - virtuoso.scrollToIndex({ - index: toVirtuosoIndex(target.virtualItemIndex), - align: 'center', - behavior: 'auto', - }); - } - } - - let attempts = 0; - let revealedCollapsedContent = false; - // Scroll position the previous successful pass settled on. Expanding - // collapsed content reflows everything below it, so one more pass is needed - // after the expansion — but only until the answer stops moving. Re-arming - // unconditionally kept re-running the full tree-walk, re-highlight and - // ancestor `getComputedStyle` scan for all 24 attempts, roughly 22 of them - // recomputing a position that had already converged. - let lastResolvedScrollTop: number | null = null; - const resolveExactTextPosition = () => { - if (searchNavigationRequestIdRef.current !== requestId) { - return; - } - - attempts += 1; - const scroller = scrollerElementRef.current; - const wrapper = getRenderedVirtualItemElement(target.virtualItemIndex); - if (!scroller || !wrapper) { - const targetTurnId = virtualItems[target.virtualItemIndex]?.turnId; - if ( - scroller && - !wrapper && - !virtuosoRef.current && - useStaticInitialHistoryListRef.current && - targetTurnId - ) { - setStaticAnchorWindowTurnId(currentTurnId => ( - currentTurnId === targetTurnId ? currentTurnId : targetTurnId - )); - } - if (attempts < SEARCH_NAVIGATION_MAX_ATTEMPTS) { - requestAnimationFrame(resolveExactTextPosition); - } - return; - } - - for (const expandableId of target.expandableIds ?? []) { - const expandable = findElementWithDataValue( - wrapper, - 'data-tool-card-id', - expandableId, - ); - if (!expandable) { - if (attempts < SEARCH_NAVIGATION_MAX_ATTEMPTS) { - requestAnimationFrame(resolveExactTextPosition); - } - return; - } - - if (expandable.dataset.expanded === 'false') { - const toggle = expandable.querySelector( - '[data-testid="chat-explore-group-toggle"], [data-testid="chat-thinking-toggle"]', - ); - if (toggle) { - revealedCollapsedContent = true; - toggle.click(); - } - if (attempts < SEARCH_NAVIGATION_MAX_ATTEMPTS) { - requestAnimationFrame(resolveExactTextPosition); - } - return; - } - } - - if (target.flowItemId) { - const flowItem = findElementWithDataValue( - wrapper, - 'data-flow-item-id', - target.flowItemId, - ); - const thinkingItem = findElementWithDataValue( - wrapper, - 'data-tool-card-id', - target.flowItemId, - ); - if (!flowItem && !thinkingItem) { - if (attempts < SEARCH_NAVIGATION_MAX_ATTEMPTS) { - requestAnimationFrame(resolveExactTextPosition); - } - return; - } - } - - const textRoot = getFlowChatSearchTextRoot(wrapper, target.flowItemId); - const userMessage = textRoot.closest('.user-message-item'); - if ( - userMessage && - !userMessage.classList.contains('user-message-item--expanded') && - textRoot.scrollHeight > textRoot.clientHeight + 1 - ) { - revealedCollapsedContent = true; - textRoot.click(); - if (attempts < SEARCH_NAVIGATION_MAX_ATTEMPTS) { - requestAnimationFrame(resolveExactTextPosition); - } - return; - } - - const ranges = findFlowChatSearchTextRanges(textRoot, query); - if (ranges.length === 0) { - if (attempts < SEARCH_NAVIGATION_MAX_ATTEMPTS) { - requestAnimationFrame(resolveExactTextPosition); - } - return; - } - - // Markdown syntax can make the raw-content occurrence count exceed the - // rendered one; clamping still lands navigation on a real occurrence. - const rangeIndex = Math.min(Math.max(target.occurrenceIndex ?? 0, 0), ranges.length - 1); - const range = ranges[rangeIndex]; - setFlowChatSearchHighlight(range, ranges.filter((_, index) => index !== rangeIndex)); - - let ancestor = range.startContainer.parentElement; - while (ancestor && ancestor !== scroller && wrapper.contains(ancestor)) { - const styles = ancestor.ownerDocument.defaultView?.getComputedStyle(ancestor); - const canScrollVertically = ( - ancestor.scrollHeight > ancestor.clientHeight + 1 && - (styles?.overflowY === 'auto' || styles?.overflowY === 'scroll') - ); - if (canScrollVertically) { - const rangeRect = range.getBoundingClientRect(); - const ancestorRect = ancestor.getBoundingClientRect(); - const nextScrollTop = ancestor.scrollTop + - rangeRect.top - - ancestorRect.top - - Math.max(0, (ancestor.clientHeight - rangeRect.height) / 2); - ancestor.scrollTop = Math.max( - 0, - Math.min(nextScrollTop, ancestor.scrollHeight - ancestor.clientHeight), - ); - } - ancestor = ancestor.parentElement; - } - - const rangeRect = range.getBoundingClientRect(); - const scrollerRect = scroller.getBoundingClientRect(); - const nextScrollTop = scroller.scrollTop + - rangeRect.top - - scrollerRect.top - - Math.max(0, (scroller.clientHeight - rangeRect.height) / 2); - scroller.scrollTop = Math.max( - 0, - Math.min(nextScrollTop, scroller.scrollHeight - scroller.clientHeight), - ); - previousScrollTopRef.current = scroller.scrollTop; - recordScrollerGeometry(scroller); - scheduleVisibleTurnMeasure(2); - - const settled = - lastResolvedScrollTop !== null - && Math.abs(scroller.scrollTop - lastResolvedScrollTop) <= 1; - lastResolvedScrollTop = scroller.scrollTop; - if ( - revealedCollapsedContent - && !settled - && attempts < SEARCH_NAVIGATION_MAX_ATTEMPTS - ) { - requestAnimationFrame(resolveExactTextPosition); - } - }; - - requestAnimationFrame(resolveExactTextPosition); - }, [ - clearPinReservationForUserNavigation, - clearSearchMatch, - exitFollowOutput, - getRenderedVirtualItemElement, - recordScrollerGeometry, - scheduleVisibleTurnMeasure, - toVirtuosoIndex, - virtualItems, - ]); - - useEffect(() => () => { - searchNavigationRequestIdRef.current += 1; - setFlowChatSearchHighlight(null); - }, []); - const pinTurnToTopWithStatus = useCallback((turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: FlowChatPinTurnToTopMode }): FlowChatTurnPinRequestStatus => { const shouldExitFollowOutput = !( options?.pinMode === 'sticky-latest' && @@ -4054,8 +3818,6 @@ const VirtualMessageListSession = forwardRef ({ scrollToTurn, scrollToIndex, - scrollToSearchMatch, - clearSearchMatch, scrollToPhysicalBottomAndClearPin, scrollToTurnEndAndClearPin, isTurnRenderedInViewport, @@ -4068,10 +3830,8 @@ const VirtualMessageListSession = forwardRef ) : null} + {extraFooter}
( <> + {extraFooter}
Promise; /** Whether a send is in progress */ @@ -69,7 +60,6 @@ export function useMessageSender(props: UseMessageSenderProps): UseMessageSender message: string, options?: { displayMessage?: string; - composerPresentation?: ComposerPresentation | null; } ) => { if (!message.trim()) { @@ -83,13 +73,7 @@ export function useMessageSender(props: UseMessageSenderProps): UseMessageSender // below. Leaving the placeholder in the prompt misleads the model into // looking up a non-existent file. The display message keeps the tag so // the UI can still render the inline pill. - const stripImageTags = (text: string): string => - text - .replace(/#img:[^\s\n]+\s?/g, '') - .replace(/[ \t]+\n/g, '\n') - .replace(/\n{3,}/g, '\n\n') - .trim(); - const aiTrimmedMessage = stripImageTags(trimmedMessage); + const aiTrimmedMessage = stripInlineImageTags(trimmedMessage); let sessionId = currentSessionId; log.debug('Send message initiated', { textLength: trimmedMessage.length, @@ -118,28 +102,6 @@ export function useMessageSender(props: UseMessageSenderProps): UseMessageSender } const imageContexts = contexts.filter(ctx => ctx.type === 'image') as ImageContext[]; - const presentationSessionReferences = options?.composerPresentation - ? composerPresentationSessionReferences(options.composerPresentation) - : []; - const sessionReferenceContexts = presentationSessionReferences.length > 0 - ? presentationSessionReferences - : contexts - .filter((context): context is SessionReferenceContext => context.type === 'session-reference') - const sessionReferences = sessionReferenceContexts.map((context) => ({ - sessionId: context.sessionId, - workspacePath: context.workspacePath, - remoteConnectionId: context.remoteConnectionId, - remoteSshHost: context.remoteSshHost, - })); - const userMessageMetadata = - options?.composerPresentation || sessionReferences.length > 0 - ? { - ...(options?.composerPresentation - ? { composerPresentation: options.composerPresentation } - : {}), - ...(sessionReferences.length > 0 ? { sessionReferences } : {}), - } - : undefined; let imagePayload: Awaited>; try { imagePayload = await buildImagePayload(imageContexts); @@ -157,20 +119,8 @@ export function useMessageSender(props: UseMessageSenderProps): UseMessageSender throw error; } - let fullMessage = aiTrimmedMessage; const displayMessage = options?.displayMessage?.trim() || trimmedMessage; - - if (contexts.length > 0) { - const fullContextSection = contexts - .filter(context => context.type !== 'session-reference') - .map(formatContextForPrompt) - .filter(Boolean) - .join('\n'); - - fullMessage = fullContextSection - ? `${fullContextSection}\n\n${aiTrimmedMessage}` - : aiTrimmedMessage; - } + const fullMessage = buildPromptMessage(aiTrimmedMessage, contexts); // Always pass imageContexts to the backend; the coordinator decides // whether to pre-analyse via a vision model or attach directly. @@ -180,10 +130,7 @@ export function useMessageSender(props: UseMessageSenderProps): UseMessageSender displayMessage, agentTypeForSend, undefined, - { - ...(imagePayload ?? {}), - ...(userMessageMetadata ? { userMessageMetadata } : {}), - } + imagePayload ); onClearContexts(); diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 0ca9c4f200..ecdf050541 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -23,17 +23,11 @@ import { } from '../utils/sessionOrdering'; import { resolveSessionRelationship } from '../utils/sessionMetadata'; -import type { - FlowChatContext, - SessionConfig, - DialogTurn, - SessionHistoryHydrationLocation, -} from './flow-chat-manager/types'; +import type { FlowChatContext, SessionConfig, DialogTurn } from './flow-chat-manager/types'; import { saveAllInProgressTurns, immediateSaveDialogTurn, createChatSession as createChatSessionModule, - hydrateSessionHistoryForDetail as hydrateSessionHistoryForDetailModule, preloadHistoricalSessionForOpen as preloadHistoricalSessionForOpenModule, switchChatSession as switchChatSessionModule, deleteChatSession as deleteChatSessionModule, @@ -55,8 +49,6 @@ import { updateImageAnalysisItem as updateImageAnalysisItemModule, updateSessionMetadata, } from './flow-chat-manager'; -import { ensureBackendSession } from './flow-chat-manager/SessionModule'; -import { installPeerSessionRefresh } from './flow-chat-manager/PeerSessionRefreshModule'; const log = createLogger('FlowChatManager'); @@ -69,7 +61,6 @@ export class FlowChatManager { private eventListenerCleanup: (() => void) | null = null; private initializationRequests = new Map>(); private latestInitializationRequestKey: string | null = null; - private peerSessionRefreshCleanup: (() => void) | null = null; private disposed = false; private constructor() { @@ -81,7 +72,6 @@ export class FlowChatManager { }), pendingTurnCompletions: new Map(), pendingHistoryLoads: new Map(), - pendingHistoryLoadCapabilities: new Map(), pendingContextRestores: new Map(), contentBuffers: new Map(), activeTextItems: new Map(), @@ -98,7 +88,6 @@ export class FlowChatManager { this.agentService = AgentService.getInstance(); installPendingQueueDrainListener(this.context); - this.peerSessionRefreshCleanup = installPeerSessionRefresh(this.context); } /** Public hook used by the queue panel "send now" fallback to drain head item. */ @@ -425,8 +414,6 @@ export class FlowChatManager { this.initializationRequests.clear(); this.latestInitializationRequestKey = null; this.cleanupEventListeners(); - this.peerSessionRefreshCleanup?.(); - this.peerSessionRefreshCleanup = null; this.context.eventBatcher.destroy(); } @@ -442,8 +429,15 @@ export class FlowChatManager { ); } - async createChatSession(config: SessionConfig, mode?: string): Promise { - return createChatSessionModule(this.context, config, mode); + async createChatSession( + config: SessionConfig, + mode?: string, + options?: { + activate?: boolean; + title?: string; + } + ): Promise { + return createChatSessionModule(this.context, config, mode, options); } async createAcpChatSession(clientId: string, config: SessionConfig = {}): Promise { @@ -458,7 +452,6 @@ export class FlowChatManager { detail: { phase: 'start', clientId, action: 'create' }, })); - let succeeded = false; try { const response = await ACPClientAPI.createFlowSession({ clientId, @@ -484,11 +477,10 @@ export class FlowChatManager { config.remoteSshHost, ); - succeeded = true; return response.sessionId; } finally { window.dispatchEvent(new CustomEvent('bitfun:acp-session-creation', { - detail: { phase: 'finish', clientId, action: 'create', succeeded }, + detail: { phase: 'finish', clientId, action: 'create' }, })); } } @@ -501,13 +493,6 @@ export class FlowChatManager { preloadHistoricalSessionForOpenModule(this.context, sessionId); } - async hydrateSessionHistoryForDetail( - sessionId: string, - location?: SessionHistoryHydrationLocation, - ): Promise { - await hydrateSessionHistoryForDetailModule(this.context, sessionId, location); - } - async deleteChatSession(sessionId: string): Promise { return deleteChatSessionModule(this.context, sessionId); } @@ -527,11 +512,6 @@ export class FlowChatManager { return removedSessionIds; } - /** Restores a persisted session into the coordinator before a non-message workflow uses it. */ - public async ensureBackendSession(sessionId: string): Promise { - await ensureBackendSession(this.context, sessionId); - } - public discardLocalSessionsForWorkspace( workspace: Pick ): string[] { diff --git a/src/web-ui/src/flow_chat/services/ModelBrainstormService.ts b/src/web-ui/src/flow_chat/services/ModelBrainstormService.ts new file mode 100644 index 0000000000..3863d8eb1b --- /dev/null +++ b/src/web-ui/src/flow_chat/services/ModelBrainstormService.ts @@ -0,0 +1,210 @@ +import { configManager } from '@/infrastructure/config/services/ConfigManager'; +import { getProviderDisplayName } from '@/infrastructure/config/services/modelConfigs'; +import type { AIModelConfig } from '@/infrastructure/config/types'; +import { createLogger } from '@/shared/utils/logger'; +import type { ContextItem, ImageContext } from '@/shared/types/context'; +import type { SessionConfig } from '../types/flow-chat'; +import { FlowChatManager } from './FlowChatManager'; +import { useModelBrainstormStore, type ModelBrainstormCandidate } from '../store/modelBrainstormStore'; +import { buildImagePayload } from '../utils/imagePayload'; +import { buildPromptMessage, stripInlineImageTags } from '../utils/messagePrompt'; + +const log = createLogger('ModelBrainstormService'); + +export const MODEL_BRAINSTORM_MAX_CANDIDATES = 4; +export const MODEL_BRAINSTORM_MIN_CANDIDATES = 2; + +interface BrainstormModelInfo { + id: string; + label: string; + providerName: string; +} + +export interface LaunchModelBrainstormRequest { + message: string; + displayMessage?: string; + contexts: ContextItem[]; + sourceSessionId?: string; + workspaceConfig: SessionConfig; + agentType: string; + modelIds: string[]; +} + +export interface LaunchModelBrainstormResult { + batchId: string; + sourceSessionId: string; +} + +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 uniqueModelIds(modelIds: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const rawModelId of modelIds) { + const modelId = rawModelId.trim(); + if (!modelId || seen.has(modelId)) { + continue; + } + seen.add(modelId); + result.push(modelId); + } + return result.slice(0, MODEL_BRAINSTORM_MAX_CANDIDATES); +} + +async function resolveSelectedModels(modelIds: string[]): Promise { + const configData = await configManager.getConfigs(['ai.models']); + const allModels = (configData['ai.models'] as AIModelConfig[] | undefined) || []; + const availableModels = allModels.filter(isTextChatModel); + const byId = new Map(availableModels.map(model => [model.id, model])); + + return uniqueModelIds(modelIds) + .map(modelId => byId.get(modelId)) + .filter((model): model is AIModelConfig & { id: string } => Boolean(model)) + .map(model => ({ + id: model.id, + label: model.model_name || model.name || model.id, + providerName: getProviderDisplayName(model), + })); +} + +function buildCandidateId(batchId: string, modelId: string, index: number): string { + const normalizedModelId = modelId.replace(/[^a-zA-Z0-9_-]/g, '-'); + return `${batchId}_${index}_${normalizedModelId}`; +} + +function buildBatchId(): string { + return `brainstorm_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +} + +function buildCandidateSessionTitle(model: BrainstormModelInfo): string { + return `Brainstorm · ${model.label}`; +} + +async function ensureSourceSession( + manager: FlowChatManager, + sourceSessionId: string | undefined, + workspaceConfig: SessionConfig, + agentType: string, +): Promise { + if (sourceSessionId) { + return sourceSessionId; + } + + return manager.createChatSession(workspaceConfig, agentType, { + title: 'Multi-model Brainstorm', + }); +} + +export async function launchModelBrainstorm( + request: LaunchModelBrainstormRequest, +): Promise { + const displayMessage = request.displayMessage?.trim() || request.message.trim(); + const promptMessage = buildPromptMessage(stripInlineImageTags(request.message), request.contexts); + const selectedModels = await resolveSelectedModels(request.modelIds); + + if (selectedModels.length < MODEL_BRAINSTORM_MIN_CANDIDATES) { + throw new Error('Select at least two enabled chat models for brainstorm mode.'); + } + + const imageContexts = request.contexts.filter((ctx): ctx is ImageContext => ctx.type === 'image'); + const imagePayload = await buildImagePayload(imageContexts); + const manager = FlowChatManager.getInstance(); + const sourceSessionId = await ensureSourceSession( + manager, + request.sourceSessionId, + request.workspaceConfig, + request.agentType, + ); + const batchId = buildBatchId(); + const candidates: ModelBrainstormCandidate[] = selectedModels.map((model, index) => ({ + id: buildCandidateId(batchId, model.id, index), + modelId: model.id, + modelLabel: model.label, + providerName: model.providerName, + status: 'pending', + })); + + useModelBrainstormStore.getState().createBatch({ + id: batchId, + sourceSessionId, + question: promptMessage, + displayQuestion: displayMessage, + createdAt: Date.now(), + candidates, + }); + + const launches = candidates.map(async (candidate) => { + const updateCandidate = (updates: Partial) => { + useModelBrainstormStore.getState().updateCandidate(batchId, candidate.id, updates); + }; + + try { + updateCandidate({ status: 'starting' }); + const sessionId = await manager.createChatSession( + { + ...request.workspaceConfig, + modelName: candidate.modelId, + }, + request.agentType, + { + activate: false, + title: buildCandidateSessionTitle({ + id: candidate.modelId, + label: candidate.modelLabel, + providerName: candidate.providerName || '', + }), + }, + ); + + updateCandidate({ sessionId, status: 'running' }); + await manager.sendMessage( + promptMessage, + sessionId, + displayMessage, + request.agentType, + undefined, + { + imageContexts: imagePayload?.imageContexts, + imageDisplayData: imagePayload?.imageDisplayData, + userMessageMetadata: { + brainstormBatchId: batchId, + brainstormCandidateId: candidate.id, + brainstormModelId: candidate.modelId, + }, + }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to launch candidate'; + log.error('Failed to launch model brainstorm candidate', { + batchId, + candidateId: candidate.id, + modelId: candidate.modelId, + error, + }); + updateCandidate({ + status: 'failed', + error: message, + }); + } + }); + + await Promise.all(launches); + + const latestBatch = useModelBrainstormStore.getState().batches[batchId]; + const launchedCount = latestBatch?.candidates.filter(candidate => candidate.sessionId).length ?? 0; + if (launchedCount === 0) { + throw new Error('Failed to launch any brainstorm candidates.'); + } + + return { + batchId, + sourceSessionId, + }; +} diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index 0f4cd7d7d4..25d89d6822 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -16,11 +16,7 @@ import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFla import { normalizeRemoteWorkspacePath } from '@/shared/utils/pathUtils'; import { WorkspaceKind, type WorkspaceInfo } from '@/shared/types'; import type { AIModelConfig, AgentModelDefaultsConfig, DefaultModelsConfig } from '@/infrastructure/config/types'; -import type { - FlowChatContext, - SessionConfig, - SessionHistoryHydrationLocation, -} from './types'; +import type { FlowChatContext, SessionConfig } from './types'; import type { Session } from '../../types/flow-chat'; import { touchSessionActivity, cleanupSaveState } from './PersistenceModule'; import { cleanupSessionBuffers } from './TextChunkModule'; @@ -47,16 +43,6 @@ import { const log = createLogger('SessionModule'); const pendingSessionCreations = new Map>(); - -const getHydrationLocationKey = ( - location: SessionHistoryHydrationLocation | undefined, -): string => location?.workspacePath - ? JSON.stringify([ - location.workspacePath, - location.remoteConnectionId ?? '', - location.remoteSshHost ?? '', - ]) - : ''; export const SESSION_ACTIVITY_TOUCH_DELAY_MS = 350; let latestSwitchRequestId = 0; let pendingActivityTouchTimer: ReturnType | null = null; @@ -157,47 +143,16 @@ async function hydrateHistoricalSession( options?: { isRetryStillRelevant?: () => boolean; retryActiveStaleReuse?: boolean; - allowNonHistorical?: boolean; - includeInternal?: boolean; - deferFullHistoryUntilActive?: boolean; - location?: SessionHistoryHydrationLocation; }, ): Promise { const existing = context.pendingHistoryLoads.get(sessionId); if (existing) { - const existingCapabilities = context.pendingHistoryLoadCapabilities?.get(sessionId); - const requestedLocationKey = getHydrationLocationKey(options?.location); - const requiresStrongerHydrate = - (options?.includeInternal === true && existingCapabilities?.includeInternal !== true) || - (options?.deferFullHistoryUntilActive === false && - existingCapabilities?.deferFullHistoryUntilActive !== false) || - (Boolean(requestedLocationKey) && existingCapabilities?.locationKey !== requestedLocationKey); startupTrace.markPhase('historical_session_hydrate_reused'); recordHistorySessionDiagnosticEvent(sessionId, 'hydrate_reused_pending', { notifyOnError, retryActiveStaleReuse: options?.retryActiveStaleReuse === true, }); - let existingFailed = false; - let existingError: unknown; - try { - await existing; - } catch (error) { - existingFailed = true; - existingError = error; - } - if (requiresStrongerHydrate) { - if (context.pendingHistoryLoads.get(sessionId) === existing) { - context.pendingHistoryLoads.delete(sessionId); - } - if (context.pendingHistoryLoadCapabilities?.get(sessionId)?.promise === existing) { - context.pendingHistoryLoadCapabilities.delete(sessionId); - } - await hydrateHistoricalSession(context, sessionId, notifyOnError, options); - return; - } - if (existingFailed) { - throw existingError; - } + await existing; const retryStillRelevant = options?.isRetryStillRelevant?.() !== false; const shouldRetryActiveStale = shouldRetryActiveStaleHydrate(context, sessionId); recordHistorySessionDiagnosticEvent(sessionId, 'hydrate_reused_settled', { @@ -229,25 +184,19 @@ async function hydrateHistoricalSession( const loadPromise = (async () => { const session = context.flowChatStore.getState().sessions.get(sessionId); - if (!session || (!session.isHistorical && options?.allowNonHistorical !== true)) { + if (!session?.isHistorical) { recordHistorySessionDiagnosticEvent(sessionId, 'hydrate_request_skipped', { reason: session ? 'not_historical' : 'missing_session', }); return; } - const workspacePath = requireSessionWorkspacePath( - session.workspacePath || options?.location?.workspacePath, - sessionId, - ); - const storedConnectionId = options?.location?.remoteConnectionId || session.remoteConnectionId; - const storedSshHost = options?.location?.remoteSshHost || session.remoteSshHost; - const remote = isRemoteTraceContext(storedConnectionId, storedSshHost); - const deferFullHistoryUntilActive = options?.deferFullHistoryUntilActive ?? true; + const workspacePath = requireSessionWorkspacePath(session.workspacePath, sessionId); + const remote = isRemoteTraceContext(session.remoteConnectionId, session.remoteSshHost); markHistorySessionHydratePending(sessionId, { notifyOnError, remote, - deferFullHistoryUntilActive, + deferFullHistoryUntilActive: true, }); startupTrace.markPhase('historical_session_hydrate_request', { remote }); recordHistorySessionDiagnosticEvent(sessionId, 'hydrate_request_started', { @@ -261,13 +210,13 @@ async function hydrateHistoricalSession( // remoteConnectionId becomes stale; the active workspace always // carries the up-to-date connection_id. const effectiveConnectionId = resolveEffectiveConnectionId( - storedConnectionId, - storedSshHost, + session.remoteConnectionId, + session.remoteSshHost, workspacePath ); const effectiveSshHost = resolveEffectiveSshHost( - storedSshHost, - storedConnectionId, + session.remoteSshHost, + session.remoteConnectionId, workspacePath ); @@ -277,22 +226,11 @@ async function hydrateHistoricalSession( undefined, effectiveConnectionId, effectiveSshHost, - { - includeInternal: options?.includeInternal, - deferFullHistoryUntilActive, - }, + { deferFullHistoryUntilActive: true }, ); })(); context.pendingHistoryLoads.set(sessionId, loadPromise); - const pendingHistoryLoadCapabilities = - context.pendingHistoryLoadCapabilities ??= new Map(); - pendingHistoryLoadCapabilities.set(sessionId, { - promise: loadPromise, - includeInternal: options?.includeInternal === true, - deferFullHistoryUntilActive: options?.deferFullHistoryUntilActive ?? true, - locationKey: getHydrationLocationKey(options?.location), - }); try { await loadPromise; @@ -319,26 +257,9 @@ async function hydrateHistoricalSession( if (context.pendingHistoryLoads.get(sessionId) === loadPromise) { context.pendingHistoryLoads.delete(sessionId); } - if (context.pendingHistoryLoadCapabilities?.get(sessionId)?.promise === loadPromise) { - context.pendingHistoryLoadCapabilities.delete(sessionId); - } } } -export async function hydrateSessionHistoryForDetail( - context: FlowChatContext, - sessionId: string, - location?: SessionHistoryHydrationLocation, -): Promise { - const session = context.flowChatStore.getState().sessions.get(sessionId); - await hydrateHistoricalSession(context, sessionId, false, { - allowNonHistorical: true, - includeInternal: session?.sessionKind === 'subagent', - deferFullHistoryUntilActive: false, - location, - }); -} - function shouldHydrateHistoricalSessionBeforeSwitch(session: Session | undefined): session is Session { if (session?.isHistorical !== true) { return false; @@ -631,7 +552,11 @@ async function resolveModelForSessionCreation(modelName?: string): Promise { try { const workspacePath = resolveSessionWorkspacePath(context, config); @@ -654,7 +579,8 @@ export async function createChatSession( : remoteConnectionId != null && remoteConnectionId !== '' ? `${remoteConnectionId}\n${workspacePath}` : workspacePath; - const creationKey = JSON.stringify([workspaceCreationKey, agentType]); + const explicitModelCreationKey = config.modelName?.trim() || ''; + const creationKey = JSON.stringify([workspaceCreationKey, agentType, explicitModelCreationKey]); const pendingCreation = pendingSessionCreations.get(creationKey); if (pendingCreation) { @@ -674,11 +600,13 @@ export async function createChatSession( remoteSshHost, }, ); - const titleDescriptor = createDefaultSessionTitleDescriptor( - sessionMode, - sameModeCount, - (key, options) => i18nService.t(key, options), - ); + const titleDescriptor = options?.title + ? createTextSessionTitleDescriptor(options.title) + : createDefaultSessionTitleDescriptor( + sessionMode, + sameModeCount, + (key, options) => i18nService.t(key, options), + ); const sessionName = titleDescriptor.text; const sessionModelName = await resolveModelForSessionCreation(config.modelName); @@ -720,6 +648,7 @@ export async function createChatSession( remoteConnectionId, remoteSshHost, titleDescriptor, + { activate: options?.activate }, ); return response.sessionId; diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 6484ffe50a..9896349bf9 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -9,7 +9,6 @@ import { DialogTurn, ModelRound, ModelRoundAttempt, - ModelRoundAttemptDiagnostic, FlowItem, FlowToolItem, FlowImageAnalysisItem, @@ -40,6 +39,7 @@ import type { SessionMetadataPage } from '@/infrastructure/api/service-api/Sessi import { deriveLastFinishedAtFromMetadata, deriveSessionRelationshipFromMetadata, + isLegacyPersistedBtwSession, normalizeSessionRelationship, } from '../utils/sessionMetadata'; import type { SessionTitleDescriptor } from '../utils/sessionTitle'; @@ -54,7 +54,6 @@ import { normalizeRecoveredTextStatus, normalizeRecoveredThinkingStatus, normalizeRecoveredToolStatus, - normalizeRecoveredTurnFinishReason, normalizeRecoveredTurnStatus, settleInterruptedDialogTurn, } from '../utils/dialogTurnStability'; @@ -62,9 +61,7 @@ import type { WorkspaceInfo } from '@/shared/types'; import { sessionBelongsToWorkspaceNavRow } from '../utils/sessionOrdering'; import { sessionMatchesWorkspace } from '../utils/workspaceScope'; import { resolveThreadGoalUserMessageDisplay } from '../utils/threadGoalDisplay'; -import { cleanRemoteUserInput } from '../utils/userInputText'; import { useBackgroundSubagentActivityStore } from './backgroundSubagentActivityStore'; -import { sessionComposerStore } from './sessionComposerStore'; import { recordHistorySessionDiagnosticEvent } from '../services/historySessionDiagnostics'; const log = createLogger('FlowChatStore'); @@ -83,7 +80,6 @@ const HISTORICAL_SESSION_INITIAL_REMOTE_TAIL_TURN_COUNT = 3; const HISTORICAL_SESSION_INITIAL_LOCAL_TAIL_TURN_COUNT = 3; const HISTORICAL_SESSION_FULL_HISTORY_IDLE_TIMEOUT_MS = 1500; const HISTORICAL_SESSION_PREVIOUS_WINDOW_TURN_COUNT = 12; -const PEER_SESSION_REFRESH_TAIL_TURN_COUNT = 3; type RemoveSessionOptions = { nextActiveSessionId?: string | null; @@ -91,262 +87,6 @@ type RemoveSessionOptions = { const HISTORICAL_SESSION_FULL_HISTORY_FIRST_PAINT_TIMEOUT_MS = 2500; const MAX_DEFERRED_FULL_HISTORY_PROJECTIONS = 3; -export interface PeerSessionSnapshotRefreshResult { - applied: boolean; - backendState: string; - latestTurnId?: string; - latestTurnStatus?: DialogTurn['status']; -} - -export function isBackendSessionActivelyProcessing(state: unknown): boolean { - if (typeof state !== 'string') { - return false; - } - - const normalized = state.trim().toLowerCase(); - return normalized === 'processing' || - normalized.startsWith('processing ') || - normalized.startsWith('processing {') || - normalized === 'waitingfortoolresponse' || - normalized === 'paused'; -} - -function normalizeLiveTurnStatus(status: unknown): DialogTurn['status'] { - const normalized = typeof status === 'string' ? status.trim().toLowerCase() : ''; - switch (normalized) { - case 'pending': - return 'pending'; - case 'image_analyzing': - return 'image_analyzing'; - case 'finishing': - return 'finishing'; - case 'cancelling': - return 'cancelling'; - case 'completed': - return 'completed'; - case 'cancelled': - return 'cancelled'; - case 'error': - return 'error'; - case 'inprogress': - case 'processing': - default: - return 'processing'; - } -} - -function normalizeLiveRoundStatus( - status: unknown, - parentTurnStatus: DialogTurn['status'], -): ModelRound['status'] { - const normalized = typeof status === 'string' ? status.trim().toLowerCase() : ''; - switch (normalized) { - case 'pending': - return 'pending'; - case 'streaming': - case 'inprogress': - case 'running': - return 'streaming'; - case 'pending_confirmation': - return 'pending_confirmation'; - case 'completed': - return 'completed'; - case 'cancelled': - return 'cancelled'; - case 'rejected': - return 'rejected'; - case 'error': - return 'error'; - default: - return parentTurnStatus === 'processing' || parentTurnStatus === 'pending' - ? 'streaming' - : normalizeRecoveredRoundStatus(status, parentTurnStatus); - } -} - -function normalizeLiveItemStatus( - status: unknown, - fallback: AnyFlowItem['status'], -): AnyFlowItem['status'] { - const normalized = typeof status === 'string' ? status.trim().toLowerCase() : ''; - switch (normalized) { - case 'pending': - case 'queued': - case 'waiting': - case 'preparing': - case 'running': - case 'streaming': - case 'receiving': - case 'completed': - case 'cancelled': - case 'rejected': - case 'error': - case 'analyzing': - case 'pending_confirmation': - case 'confirmed': - return normalized; - case 'starting': - return 'preparing'; - default: - return fallback; - } -} - -function compareDialogTurnOrder(left: DialogTurn, right: DialogTurn): number { - if ( - typeof left.backendTurnIndex === 'number' && - typeof right.backendTurnIndex === 'number' && - left.backendTurnIndex !== right.backendTurnIndex - ) { - return left.backendTurnIndex - right.backendTurnIndex; - } - return left.startTime - right.startTime; -} - -function runningStatusRank(status: string | undefined): number { - switch (status) { - case 'pending': - case 'queued': - case 'waiting': - case 'preparing': - return 0; - case 'processing': - case 'running': - case 'streaming': - case 'receiving': - case 'analyzing': - return 1; - case 'pending_confirmation': - case 'confirmed': - case 'finishing': - case 'cancelling': - return 2; - case 'completed': - case 'cancelled': - case 'rejected': - case 'error': - return 3; - default: - return 0; - } -} - -function substantiveRoundItems(round: ModelRound): AnyFlowItem[] { - return round.items.filter(item => ( - item.type !== 'text' || - !(item as AnyFlowItem & { runtimeStatus?: unknown }).runtimeStatus - )); -} - -type RunningStreamItem = AnyFlowItem & { - type: 'text' | 'thinking'; - content: string; -}; - -function streamProgressEntries(round: ModelRound): Map { - const entries = new Map(); - const ordinals: Record<'text' | 'thinking', number> = { - text: 0, - thinking: 0, - }; - - for (const item of substantiveRoundItems(round)) { - if (item.type !== 'text' && item.type !== 'thinking') { - continue; - } - const ordinal = ordinals[item.type]++; - const attempt = - item.attemptId || - (typeof item.attemptIndex === 'number' ? `index:${item.attemptIndex}` : 'default'); - entries.set(`${item.type}:${attempt}:${ordinal}`, item as RunningStreamItem); - } - - return entries; -} - -function toolProgressEntries(round: ModelRound): Map { - const entries = new Map(); - for (const item of substantiveRoundItems(round)) { - if (item.type !== 'tool') { - continue; - } - const tool = item as FlowToolItem; - entries.set(tool.toolCall?.id || tool.id, tool); - } - return entries; -} - -/** - * Accept an active persisted snapshot only when it is provably ahead of the - * controller projection. Peer text-item ids are generated independently on - * each device, so progress is compared by round/type/attempt/ordinal and - * content prefix instead of item id. - */ -function isRunningSnapshotForwardProgress( - current: DialogTurn, - snapshot: DialogTurn, -): boolean { - if (current.id !== snapshot.id) { - return false; - } - - let advanced = - runningStatusRank(snapshot.status) > runningStatusRank(current.status) || - snapshot.modelRounds.length > current.modelRounds.length; - - for (const currentRound of current.modelRounds) { - const snapshotRound = snapshot.modelRounds.find(round => round.id === currentRound.id); - if (!snapshotRound) { - return false; - } - if (runningStatusRank(snapshotRound.status) < runningStatusRank(currentRound.status)) { - return false; - } - if (runningStatusRank(snapshotRound.status) > runningStatusRank(currentRound.status)) { - advanced = true; - } - - const currentStreams = streamProgressEntries(currentRound); - const snapshotStreams = streamProgressEntries(snapshotRound); - for (const [key, currentItem] of currentStreams) { - const snapshotItem = snapshotStreams.get(key); - if (!snapshotItem || !snapshotItem.content.startsWith(currentItem.content)) { - return false; - } - if (snapshotItem.content.length > currentItem.content.length) { - advanced = true; - } - } - if (snapshotStreams.size > currentStreams.size) { - advanced = true; - } - - const currentTools = toolProgressEntries(currentRound); - const snapshotTools = toolProgressEntries(snapshotRound); - for (const [key, currentTool] of currentTools) { - const snapshotTool = snapshotTools.get(key); - if ( - !snapshotTool || - runningStatusRank(snapshotTool.status) < runningStatusRank(currentTool.status) || - (currentTool.toolResult && !snapshotTool.toolResult) - ) { - return false; - } - if ( - runningStatusRank(snapshotTool.status) > runningStatusRank(currentTool.status) || - (!currentTool.toolResult && Boolean(snapshotTool.toolResult)) - ) { - advanced = true; - } - } - if (snapshotTools.size > currentTools.size) { - advanced = true; - } - } - - return advanced; -} - function itemMatchesIdentity(item: AnyFlowItem, itemId: string): boolean { if (item.id === itemId) { return true; @@ -595,66 +335,6 @@ function synchronizeRoundAttempts(round: ModelRound): ModelRound { }; } -export function mergeModelRoundAttemptDiagnostics( - round: ModelRound, - diagnostics: ModelRoundAttemptDiagnostic[] | undefined, - options: { supersedeMatchingAttempts?: boolean } = {}, -): ModelRound { - if (!diagnostics || diagnostics.length === 0) { - return round; - } - - const attempts = round.attempts ?? deriveRoundAttemptsFromItems(round.items) ?? []; - const diagnosticByKey = new Map(); - for (const diagnostic of round.attemptDiagnostics ?? []) { - diagnosticByKey.set(`${diagnostic.attemptId}::${diagnostic.attemptIndex}`, diagnostic); - } - for (const attempt of attempts) { - if (attempt.diagnostic) { - diagnosticByKey.set(`${attempt.diagnostic.attemptId}::${attempt.diagnostic.attemptIndex}`, attempt.diagnostic); - } - } - for (const diagnostic of diagnostics) { - diagnosticByKey.set(`${diagnostic.attemptId}::${diagnostic.attemptIndex}`, diagnostic); - } - - const sortedDiagnostics = [...diagnosticByKey.values()].sort((left, right) => ( - left.attemptIndex - right.attemptIndex || left.attemptId.localeCompare(right.attemptId) - )); - const supersededKeys = new Set( - options.supersedeMatchingAttempts - ? diagnostics.map(diagnostic => `${diagnostic.attemptId}::${diagnostic.attemptIndex}`) - : [], - ); - const nextAttempts = attempts.map(attempt => { - const key = `${attempt.id}::${attempt.index}`; - const diagnostic = diagnosticByKey.get(key) ?? attempt.diagnostic; - return supersededKeys.has(key) - ? { ...attempt, status: 'superseded' as const, diagnostic } - : diagnostic ? { ...attempt, diagnostic } : attempt; - }); - const knownKeys = new Set(nextAttempts.map(attempt => `${attempt.id}::${attempt.index}`)); - - for (const diagnostic of sortedDiagnostics) { - const key = `${diagnostic.attemptId}::${diagnostic.attemptIndex}`; - if (!knownKeys.has(key)) { - nextAttempts.push({ - id: diagnostic.attemptId, - index: diagnostic.attemptIndex, - status: 'superseded', - items: [], - diagnostic, - }); - } - } - - return { - ...round, - attemptDiagnostics: sortedDiagnostics, - attempts: sortAttemptEntries(nextAttempts), - }; -} - interface FullHistoryHydrationReleaseOptions { immediate?: boolean; reason?: string; @@ -1519,10 +1199,7 @@ export class FlowChatStore { } const convertStartedAt = nowMs(); - const activeTurnId = isBackendSessionActivelyProcessing(restored.session.state) - ? restored.turns[restored.turns.length - 1]?.turnId - : undefined; - const dialogTurns = this.convertToDialogTurns(restored.turns, { activeTurnId }); + const dialogTurns = this.convertToDialogTurns(restored.turns); const restoredLastUserDialogMode = restored.session.lastUserDialogAgentType || this.deriveLastUserDialogMode(dialogTurns); const contextRestoreState: SessionContextRestoreState = @@ -1768,6 +1445,9 @@ export class FlowChatStore { remoteConnectionId?: string, remoteSshHost?: string, titleDescriptor?: SessionTitleDescriptor, + options?: { + activate?: boolean; + }, ): void { import('../state-machine').then(({ stateMachineManager }) => { stateMachineManager.getOrCreate(sessionId); @@ -1817,7 +1497,7 @@ export class FlowChatStore { return { ...prev, sessions: newSessions, - activeSessionId: sessionId + activeSessionId: options?.activate === false ? prev.activeSessionId : sessionId }; }); } @@ -1840,7 +1520,6 @@ export class FlowChatStore { isTransient?: boolean; agentBackedTransient?: boolean; deepReviewRunManifest?: Session['deepReviewRunManifest']; - focusedReviewDisplayLabel?: Session['focusedReviewDisplayLabel']; reviewTargetEvidence?: Session['reviewTargetEvidence']; reviewTargetFilePaths?: Session['reviewTargetFilePaths']; }, @@ -1887,7 +1566,6 @@ export class FlowChatStore { btwThreads: [], btwOrigin: relationship.btwOrigin, deepReviewRunManifest: meta?.deepReviewRunManifest, - focusedReviewDisplayLabel: meta?.focusedReviewDisplayLabel, reviewTargetEvidence: meta?.reviewTargetEvidence, reviewTargetFilePaths: meta?.reviewTargetFilePaths, isTransient: meta?.isTransient ?? false, @@ -2100,21 +1778,6 @@ export class FlowChatStore { }); } - public updateSessionFocusedReviewDisplayLabel( - sessionId: string, - focusedReviewDisplayLabel: Session['focusedReviewDisplayLabel'], - ): void { - if (!focusedReviewDisplayLabel) return; - this.setState(prev => { - const session = prev.sessions.get(sessionId); - if (!session || session.focusedReviewDisplayLabel === focusedReviewDisplayLabel) return prev; - - const newSessions = new Map(prev.sessions); - newSessions.set(sessionId, { ...session, focusedReviewDisplayLabel }); - return { ...prev, sessions: newSessions }; - }); - } - /** * Update session relationship metadata (parent/child grouping, kind, etc.). * This is UI-only and does not affect backend behavior directly. @@ -2330,8 +1993,7 @@ export class FlowChatStore { log.error('Failed to delete session on backend', { sessionId, error }); } - const removedSessionIds = this.removeSession(sessionId, options); - sessionComposerStore.getState().removeDrafts(removedSessionIds); + this.removeSession(sessionId, options); this.pendingRemoveSessionOptions.delete(sessionId); } @@ -3533,6 +3195,7 @@ export class FlowChatStore { preflightMs: (item as any).preflightMs, confirmationWaitMs: (item as any).confirmationWaitMs, executionMs: (item as any).executionMs, + confirmationTimeoutAt: (item as any).confirmationTimeoutAt, attemptId: item.attemptId, attemptIndex: item.attemptIndex, })); @@ -3570,7 +3233,6 @@ export class FlowChatStore { firstVisibleOutputMs: round.firstVisibleOutputMs, streamDurationMs: round.streamDurationMs, attemptCount: round.attemptCount, - attemptDiagnostics: round.attemptDiagnostics, failureCategory: round.failureCategory, tokenDetails: round.tokenDetails, status: round.status @@ -3735,6 +3397,9 @@ export class FlowChatStore { if (existingSession) { return; } + if (isLegacyPersistedBtwSession(metadata)) { + return; + } // Skip archived sessions - they are managed in the settings page. if (metadata.status === 'archived') { return; @@ -4103,6 +3768,9 @@ export class FlowChatStore { if (existingSession) { return; } + if (isLegacyPersistedBtwSession(metadata)) { + return; + } // Skip archived sessions - they are managed in the settings page if (metadata.status === 'archived') { return; @@ -4269,134 +3937,6 @@ export class FlowChatStore { }); } - /** - * Reconcile the active Peer Device session with a small authoritative - * snapshot from the host. - * - * Peer agentic events remain the primary low-latency path. This snapshot is - * the recovery path for a controller that attached after lifecycle events, - * or for a DeviceEvent gap (the relay stream has no ACK/replay contract). - */ - public async refreshPeerSessionSnapshot( - sessionId: string, - workspacePath: string, - options?: { - replaceRunningSnapshot?: boolean; - requireActiveSession?: boolean; - shouldApply?: () => boolean; - }, - ): Promise { - const initialSession = this.state.sessions.get(sessionId); - if (!initialSession) { - return { - applied: false, - backendState: 'Unknown', - }; - } - - const restored = await agentAPI.restoreSessionView( - sessionId, - workspacePath, - initialSession.remoteConnectionId, - initialSession.remoteSshHost, - `peer-refresh-${sessionId.slice(0, 8)}`, - undefined, - PEER_SESSION_REFRESH_TAIL_TURN_COUNT, - ); - const backendActive = isBackendSessionActivelyProcessing(restored.session.state); - const activeTurnId = backendActive - ? restored.turns[restored.turns.length - 1]?.turnId - : undefined; - const snapshotTurns = this.convertToDialogTurns(restored.turns, { activeTurnId }); - const replaceExistingTurns = - !backendActive || options?.replaceRunningSnapshot === true; - let applied = false; - - this.setState(prev => { - if ( - options?.shouldApply?.() === false || - ( - options?.requireActiveSession !== false && - prev.activeSessionId !== sessionId - ) - ) { - return prev; - } - - const session = prev.sessions.get(sessionId); - // A live event or another refresh won the race while HostInvoke was in - // flight. Do not overwrite that newer local projection. - if (!session || session !== initialSession) { - return prev; - } - - const mergedTurns = [...session.dialogTurns]; - let turnsChanged = false; - for (const snapshotTurn of snapshotTurns) { - const existingIndex = mergedTurns.findIndex(turn => turn.id === snapshotTurn.id); - if (existingIndex === -1) { - mergedTurns.push(snapshotTurn); - turnsChanged = true; - } else if ( - replaceExistingTurns || - isRunningSnapshotForwardProgress(mergedTurns[existingIndex], snapshotTurn) - ) { - mergedTurns[existingIndex] = snapshotTurn; - turnsChanged = true; - } - } - - if (!turnsChanged) { - return prev; - } - - mergedTurns.sort(compareDialogTurnOrder); - const newSessions = new Map(prev.sessions); - newSessions.set(sessionId, { - ...session, - dialogTurns: mergedTurns, - isHistorical: false, - historyState: 'ready', - contextRestoreState: - session.contextRestoreState === 'ready' - ? 'ready' - : restored.contextRestoreState, - isPartial: session.isPartial === true, - loadedTurnCount: Math.max(session.loadedTurnCount ?? 0, mergedTurns.length), - totalTurnCount: Math.max( - session.totalTurnCount ?? 0, - restored.totalTurnCount ?? restored.session.turnCount, - mergedTurns.length, - ), - config: { - ...session.config, - ...(restored.session.modelName - ? { modelName: restored.session.modelName } - : {}), - }, - mode: restored.session.agentType || session.mode, - lastUserDialogMode: - restored.session.lastUserDialogAgentType || session.lastUserDialogMode, - lastSubmittedMode: - restored.session.lastSubmittedAgentType ?? session.lastSubmittedMode, - }); - applied = true; - - return { - ...prev, - sessions: newSessions, - }; - }); - - const latestTurn = snapshotTurns[snapshotTurns.length - 1]; - return { - applied, - backendState: restored.session.state, - latestTurnId: latestTurn?.id, - latestTurnStatus: latestTurn?.status, - }; - } - /** * Lazy load session history (convert historical data to FlowChat format) */ @@ -4655,7 +4195,7 @@ export class FlowChatStore { durationMs: elapsedMs(turnsLoadStartedAt), }); } - const { stateMachineManager, SessionExecutionEvent } = await stateMachineManagerPromise; + const { stateMachineManager } = await stateMachineManagerPromise; stateMachineManager.getOrCreate(sessionId); startupTrace.markPhase('historical_session_turns_loaded', { remote, @@ -4716,10 +4256,7 @@ export class FlowChatStore { } const convertStartedAt = nowMs(); - const activeTurnId = isBackendSessionActivelyProcessing(restoredSessionInfo?.state) - ? turns[turns.length - 1]?.turnId - : undefined; - const dialogTurns = this.convertToDialogTurns(turns, { activeTurnId }); + const dialogTurns = this.convertToDialogTurns(turns); const restoredLastUserDialogMode = restoredSessionInfo?.lastUserDialogAgentType || this.deriveLastUserDialogMode(dialogTurns); startupTrace.markPhase('historical_session_convert_end', { @@ -4792,17 +4329,9 @@ export class FlowChatStore { frameCount: 2, }); - // Historical views normally settle to IDLE. When the same process still - // owns a live turn (notably a Peer Host), keep the controller state - // machine aligned so subsequent streamed chunks are accepted even though - // their DialogTurnStarted event happened before the controller attached. + // Reset state machine to IDLE after loading history + // This handles the case where restoreSession triggered events that left the state machine in PROCESSING stateMachineManager.reset(sessionId); - if (activeTurnId) { - await stateMachineManager.transition(sessionId, SessionExecutionEvent.START, { - taskId: sessionId, - dialogTurnId: activeTurnId, - }); - } startupTrace.markPhase('historical_session_hydrate_end', { remote, sessionId, @@ -4870,15 +4399,26 @@ export class FlowChatStore { } } + /** + * Strip agent-internal XML wrapper tags from persisted user inputs. + */ + private cleanRemoteUserInput(raw: string): string { + const s = raw.trim(); + const userQueryMatch = s.match(/\s*([\s\S]*?)\s*<\/user_query>/); + if (userQueryMatch) { + return userQueryMatch[1].trim(); + } + + return s + .replace(/[\s\S]*?<\/system(?:_|-)reminder>/g, '') + .trim(); + } + /** * Convert DialogTurnData to FlowChat DialogTurn format */ - private convertToDialogTurns( - turns: any[], - options?: { activeTurnId?: string }, - ): DialogTurn[] { + private convertToDialogTurns(turns: any[]): DialogTurn[] { return turns.map(turn => { - const isLiveTurn = options?.activeTurnId === turn.turnId; const metadata = turn.userMessage.metadata; const metaImages = metadata?.images; const hasImages = Array.isArray(metaImages) && metaImages.length > 0; @@ -4898,20 +4438,12 @@ export class FlowChatStore { || metadata?.threadGoalObjectiveUpdated || metadata?.threadGoalContinuation ? turn.userMessage.content - : metadata?.original_text || cleanRemoteUserInput(turn.userMessage.content); + : metadata?.original_text || this.cleanRemoteUserInput(turn.userMessage.content); const displayContent = resolveThreadGoalUserMessageDisplay( rawDisplay, metadata as Record | undefined ); - const normalizedTurnStatus = isLiveTurn - ? normalizeLiveTurnStatus(turn.status) - : normalizeRecoveredTurnStatus(turn.status, { error: undefined }); - const persistedFinishReason = - typeof turn.finishReason === 'string' - ? turn.finishReason - : typeof turn.finish_reason === 'string' - ? turn.finish_reason - : undefined; + const normalizedTurnStatus = normalizeRecoveredTurnStatus(turn.status, { error: undefined }); const rawTokenUsage = turn.tokenUsage ?? turn.token_usage; return { @@ -4929,23 +4461,16 @@ export class FlowChatStore { images, }, modelRounds: turn.modelRounds.map((round: any) => { - const normalizedRoundStatus = isLiveTurn - ? normalizeLiveRoundStatus(round.status, normalizedTurnStatus) - : normalizeRecoveredRoundStatus(round.status, normalizedTurnStatus); + const normalizedRoundStatus = normalizeRecoveredRoundStatus(round.status, normalizedTurnStatus); const flatItems = [ ...round.textItems.map((text: any) => ({ id: text.id, type: 'text' as const, content: text.content, - isStreaming: isLiveTurn ? text.isStreaming === true : false, + isStreaming: false, isMarkdown: text.isMarkdown !== undefined ? text.isMarkdown : true, timestamp: text.timestamp, - status: isLiveTurn - ? normalizeLiveItemStatus( - text.status, - text.isStreaming === true ? 'streaming' : 'completed', - ) - : normalizeRecoveredTextStatus(text.status, normalizedTurnStatus), + status: normalizeRecoveredTextStatus(text.status, normalizedTurnStatus), orderIndex: text.orderIndex, subagentSessionId: text.subagentSessionId, attemptId: text.attemptId, @@ -4966,6 +4491,7 @@ export class FlowChatStore { userConfirmed: tool.userConfirmed, acpPermission: tool.acpPermission, startTime: tool.startTime, + confirmationTimeoutAt: tool.confirmationTimeoutAt, endTime: tool.endTime, durationMs: tool.durationMs, queueWaitMs: tool.queueWaitMs, @@ -4973,16 +4499,11 @@ export class FlowChatStore { confirmationWaitMs: tool.confirmationWaitMs, executionMs: tool.executionMs, timestamp: tool.startTime, - status: isLiveTurn - ? normalizeLiveItemStatus( - tool.status, - tool.toolResult ? (tool.toolResult.success ? 'completed' : 'error') : 'running', - ) - : normalizeRecoveredToolStatus( - tool.status, - normalizedTurnStatus, - tool.toolResult, - ), + status: normalizeRecoveredToolStatus( + tool.status, + normalizedTurnStatus, + tool.toolResult, + ), orderIndex: tool.orderIndex, subagentSessionId: tool.subagentSessionId, subagentDialogTurnId: tool.subagentDialogTurnId, @@ -4995,17 +4516,10 @@ export class FlowChatStore { id: thinking.id, type: 'thinking' as const, content: thinking.content, - isStreaming: isLiveTurn ? thinking.isStreaming === true : false, - isCollapsed: isLiveTurn - ? (thinking.isCollapsed ?? thinking.isStreaming !== true) - : (thinking.isCollapsed ?? true), + isStreaming: false, + isCollapsed: thinking.isCollapsed ?? true, timestamp: thinking.timestamp, - status: isLiveTurn - ? normalizeLiveItemStatus( - thinking.status, - thinking.isStreaming === true ? 'streaming' : 'completed', - ) - : normalizeRecoveredThinkingStatus(thinking.status, normalizedTurnStatus), + status: normalizeRecoveredThinkingStatus(thinking.status, normalizedTurnStatus), orderIndex: thinking.orderIndex, subagentSessionId: thinking.subagentSessionId, attemptId: thinking.attemptId, @@ -5018,21 +4532,14 @@ export class FlowChatStore { return aIndex - bIndex; }); - const hydratedRound = mergeModelRoundAttemptDiagnostics(synchronizeRoundAttempts({ + const hydratedRound = synchronizeRoundAttempts({ id: round.id, index: round.roundIndex ?? 0, roundGroupId: round.roundGroupId, renderHints: round.renderHints, items: flatItems, - isStreaming: - isLiveTurn && - (normalizedRoundStatus === 'pending' || - normalizedRoundStatus === 'streaming' || - normalizedRoundStatus === 'pending_confirmation'), - isComplete: - normalizedRoundStatus !== 'pending' && - normalizedRoundStatus !== 'streaming' && - normalizedRoundStatus !== 'pending_confirmation', + isStreaming: false, + isComplete: normalizedRoundStatus !== 'pending' && normalizedRoundStatus !== 'streaming', status: normalizedRoundStatus, startTime: round.startTime ?? round.timestamp, endTime: round.endTime, @@ -5044,26 +4551,26 @@ export class FlowChatStore { firstVisibleOutputMs: round.firstVisibleOutputMs, streamDurationMs: round.streamDurationMs, attemptCount: round.attemptCount, - attemptDiagnostics: round.attemptDiagnostics, failureCategory: round.failureCategory, tokenDetails: round.tokenDetails, - }), round.attemptDiagnostics); + }); return hydratedRound; }), timestamp: turn.timestamp, status: normalizedTurnStatus, - finishReason: isLiveTurn - ? persistedFinishReason - : normalizeRecoveredTurnFinishReason(turn.status, persistedFinishReason), + finishReason: + typeof turn.finishReason === 'string' + ? turn.finishReason + : typeof turn.finish_reason === 'string' + ? turn.finish_reason + : undefined, hasFinalResponse: typeof turn.hasFinalResponse === 'boolean' ? turn.hasFinalResponse : typeof turn.has_final_response === 'boolean' ? turn.has_final_response : undefined, - error: typeof turn.error === 'string' ? turn.error : undefined, - errorDetail: turn.errorDetail ?? turn.error_detail, startTime: turn.startTime, endTime: turn.endTime, tokenUsage: rawTokenUsage diff --git a/src/web-ui/src/flow_chat/store/modelBrainstormStore.test.ts b/src/web-ui/src/flow_chat/store/modelBrainstormStore.test.ts new file mode 100644 index 0000000000..2fa9d36ac9 --- /dev/null +++ b/src/web-ui/src/flow_chat/store/modelBrainstormStore.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { useModelBrainstormStore, type ModelBrainstormBatch } from './modelBrainstormStore'; + +function makeBatch(): ModelBrainstormBatch { + return { + id: 'batch-1', + sourceSessionId: 'source-1', + question: 'Question with context', + displayQuestion: 'Question', + createdAt: 100, + candidates: [ + { + id: 'candidate-a', + modelId: 'model-a', + modelLabel: 'Model A', + status: 'pending', + }, + { + id: 'candidate-b', + modelId: 'model-b', + modelLabel: 'Model B', + status: 'pending', + }, + ], + }; +} + +describe('modelBrainstormStore', () => { + beforeEach(() => { + useModelBrainstormStore.getState().reset(); + }); + + it('creates a batch and updates candidate launch state', () => { + useModelBrainstormStore.getState().createBatch(makeBatch()); + useModelBrainstormStore.getState().updateCandidate('batch-1', 'candidate-a', { + sessionId: 'session-a', + status: 'running', + }); + + const batch = useModelBrainstormStore.getState().batches['batch-1']; + expect(batch.candidates[0]).toMatchObject({ + id: 'candidate-a', + sessionId: 'session-a', + status: 'running', + }); + expect(useModelBrainstormStore.getState().order).toEqual(['batch-1']); + }); + + it('selects only existing candidates', () => { + useModelBrainstormStore.getState().createBatch(makeBatch()); + useModelBrainstormStore.getState().selectCandidate('batch-1', 'missing'); + expect(useModelBrainstormStore.getState().batches['batch-1'].selectedCandidateId).toBeUndefined(); + + useModelBrainstormStore.getState().selectCandidate('batch-1', 'candidate-b'); + expect(useModelBrainstormStore.getState().batches['batch-1'].selectedCandidateId).toBe('candidate-b'); + }); + + it('removes batches for a source session', () => { + useModelBrainstormStore.getState().createBatch(makeBatch()); + useModelBrainstormStore.getState().createBatch({ + ...makeBatch(), + id: 'batch-2', + sourceSessionId: 'source-2', + }); + + useModelBrainstormStore.getState().removeBatchesForSession('source-1'); + + expect(useModelBrainstormStore.getState().batches['batch-1']).toBeUndefined(); + expect(useModelBrainstormStore.getState().batches['batch-2']).toBeDefined(); + expect(useModelBrainstormStore.getState().order).toEqual(['batch-2']); + }); +}); diff --git a/src/web-ui/src/flow_chat/store/modelBrainstormStore.ts b/src/web-ui/src/flow_chat/store/modelBrainstormStore.ts new file mode 100644 index 0000000000..a6d7726afd --- /dev/null +++ b/src/web-ui/src/flow_chat/store/modelBrainstormStore.ts @@ -0,0 +1,138 @@ +import { create } from 'zustand'; +import { useShallow } from 'zustand/react/shallow'; + +export type ModelBrainstormCandidateStatus = 'pending' | 'starting' | 'running' | 'failed'; + +export interface ModelBrainstormCandidate { + id: string; + modelId: string; + modelLabel: string; + providerName?: string; + sessionId?: string; + status: ModelBrainstormCandidateStatus; + error?: string; +} + +export interface ModelBrainstormBatch { + id: string; + sourceSessionId: string; + question: string; + displayQuestion: string; + createdAt: number; + selectedCandidateId?: string; + candidates: ModelBrainstormCandidate[]; +} + +interface ModelBrainstormState { + batches: Record; + order: string[]; + createBatch: (batch: ModelBrainstormBatch) => void; + updateCandidate: ( + batchId: string, + candidateId: string, + updates: Partial, + ) => void; + selectCandidate: (batchId: string, candidateId: string) => void; + removeBatchesForSession: (sessionId: string) => void; + reset: () => void; +} + +export const useModelBrainstormStore = create((set) => ({ + batches: {}, + order: [], + + createBatch: (batch) => set((state) => ({ + batches: { + ...state.batches, + [batch.id]: batch, + }, + order: state.order.includes(batch.id) + ? state.order + : [...state.order, batch.id], + })), + + updateCandidate: (batchId, candidateId, updates) => set((state) => { + const batch = state.batches[batchId]; + if (!batch) { + return state; + } + + let changed = false; + const candidates = batch.candidates.map(candidate => { + if (candidate.id !== candidateId) { + return candidate; + } + changed = true; + return { + ...candidate, + ...updates, + }; + }); + + if (!changed) { + return state; + } + + return { + batches: { + ...state.batches, + [batchId]: { + ...batch, + candidates, + }, + }, + }; + }), + + selectCandidate: (batchId, candidateId) => set((state) => { + const batch = state.batches[batchId]; + if (!batch || !batch.candidates.some(candidate => candidate.id === candidateId)) { + return state; + } + + return { + batches: { + ...state.batches, + [batchId]: { + ...batch, + selectedCandidateId: candidateId, + }, + }, + }; + }), + + removeBatchesForSession: (sessionId) => set((state) => { + const nextBatches = { ...state.batches }; + const nextOrder = state.order.filter(batchId => { + const batch = state.batches[batchId]; + if (batch?.sourceSessionId !== sessionId) { + return true; + } + delete nextBatches[batchId]; + return false; + }); + + if (nextOrder.length === state.order.length) { + return state; + } + + return { + batches: nextBatches, + order: nextOrder, + }; + }), + + reset: () => set({ batches: {}, order: [] }), +})); + +export function useModelBrainstormBatchesForSession(sessionId: string | undefined | null): ModelBrainstormBatch[] { + return useModelBrainstormStore(useShallow((state) => { + if (!sessionId) { + return []; + } + + return state.order + .map(batchId => state.batches[batchId]) + .filter((batch): batch is ModelBrainstormBatch => batch?.sourceSessionId === sessionId); + })); +} diff --git a/src/web-ui/src/flow_chat/utils/messagePrompt.ts b/src/web-ui/src/flow_chat/utils/messagePrompt.ts new file mode 100644 index 0000000000..cdfca58ae0 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/messagePrompt.ts @@ -0,0 +1,24 @@ +import type { ContextItem } from '@/shared/types/context'; +import { formatContextForPrompt } from '@/shared/utils/contextPrompt'; + +export function stripInlineImageTags(text: string): string { + return text + .replace(/#img:[^\s\n]+\s?/g, '') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +export function buildPromptMessage(message: string, contexts: ContextItem[]): string { + const aiTrimmedMessage = stripInlineImageTags(message.trim()); + if (contexts.length === 0) { + return aiTrimmedMessage; + } + + const fullContextSection = contexts + .map(formatContextForPrompt) + .filter(Boolean) + .join('\n'); + + return `${fullContextSection}\n\n${aiTrimmedMessage}`; +} diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 4efd58db2c..8beab725a5 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -1,51 +1,4 @@ { - "permission": { - "title": "Permission required", - "subagentOwner": "{{subagent}} subagent", - "feedbackLabel": "Rejection feedback", - "feedbackPlaceholder": "Optional guidance when rejecting", - "responseFailed": "The permission response could not be delivered. Try again.", - "allowOnce": "Allow once", - "allowAlways": "Always allow", - "allowAlwaysTooltip": "Always allow saves matching access for {{projectPath}}.", - "allowAlwaysTooltipCurrentProject": "Always allow saves matching access for the current project.", - "allowAlwaysTooltipNoGrant": "This request has no savable access scope; always allow applies only to this request.", - "risks": { - "pageSave": "Save a new immutable version of “{{slug}}” with {{visibility}} visibility without changing production.", - "pagePublish": "Save “{{slug}}” with {{visibility}} visibility and switch its production version.", - "pageDeploy": "Switch production traffic for “{{slug}}” to immutable version “{{version}}”." - }, - "visibility": { - "private": "private", - "relay": "signed-in users", - "public": "public" - }, - "reject": "Reject", - "batchCount": "{{count}} permission requests", - "collapsePanel": "Collapse permission requests", - "expandPanel": "Expand {{count}} pending permission requests", - "current": "Current", - "pending": "Pending", - "actions": { - "read": "Read files", - "edit": "Edit files", - "bash": "Run command", - "git": "Git action", - "computerUse": "Control device", - "webSearch": "Search web", - "webFetch": "Access web", - "mcp": "MCP tool", - "task": "Run task", - "skill": "Run skill", - "pagePublish": "Save page version", - "pageDeploy": "Deploy page version", - "customTool": "External tool", - "externalDirectory": "Access external directory", - "other": "Other action" - }, - "allowCurrentAndFollowing": "Allow all", - "rejectCurrentAndFollowing": "Reject all" - }, "emptyState": { "welcomeBack": "Welcome back!", "workingIn": "You are working in {{workspace}}", @@ -411,17 +364,6 @@ "completed": "Completed", "failed": "Failed", "interrupted": "Interrupted" - }, - "menu": { - "closePet": "Close pet", - "closeBubble": "Close this bubble" - }, - "composer": { - "openTitle": "Send a message to this session", - "ariaLabel": "Send a message to this session", - "placeholder": "Type a message, Enter to send", - "cancel": "Cancel", - "send": "Send" } }, "session": { @@ -508,10 +450,9 @@ "copySelection": "Copy Selection", "copyFullContent": "Copy Full Content", "copyContent": "Copy Content", + "copyDialog": "Copy Entire Dialog", "copyToolInput": "Copy Tool Input", - "copyToolOutput": "Copy Tool Output", - "copyDialogFull": "Copy Entire Dialog (Full Process)", - "copyDialogResult": "Copy Entire Dialog (Result Only)" + "copyToolOutput": "Copy Tool Output" }, "widgetContextMenu": { "addToInput": "Add selected element to input", @@ -546,24 +487,7 @@ "openWorkspaceFolderFailed": "Failed to open workspace folder: {{error}}", "spaceToActivate": "Press Space to type", "assistantPlaceholder": "Message {{name}}...", - "sendHint": "Enter to send / Ctrl+Enter or Shift+Enter for new line", - "voiceInput": { - "start": "Start voice input", - "stop": "Stop recording", - "preparing": "Preparing microphone", - "transcribing": "Transcribing", - "disabled": "Voice input is disabled", - "unsupported": "Microphone capture is unavailable", - "modelMissing": "Download the local speech model first", - "cloudPending": "Cloud transcription is configured but not connected yet", - "permissionDenied": "Microphone permission was denied", - "deviceDisconnected": "The selected microphone was disconnected", - "lowVolume": "No voice detected. Check the selected microphone.", - "failed": "Voice input failed", - "empty": "No speech was recognized", - "transcribeOnly": "Insert transcription", - "transcribeAndSend": "Transcribe and send" - } + "sendHint": "Enter to send / Ctrl+Enter or Shift+Enter for new line" }, "workspaceStrip": { "branchTooltipUnavailable": "Not a git repository or no current branch" @@ -627,34 +551,6 @@ }, "chatInput": { "addBoostTooltip": "Agent modes, image, or skills", - "permissionMode": { - "menuLabel": "Permission mode", - "globalScope": "Global", - "current": "Permissions: {{mode}}", - "changeFailed": "Failed to change the permission mode.", - "hideControl": "Hide permission mode selector", - "hideControlFailed": "Failed to hide the permission mode selector.", - "ask": { - "label": "Ask", - "description": "External access, file changes, and command execution require confirmation." - }, - "auto": { - "label": "Auto approve", - "description": "Automatically approve requests that require confirmation." - }, - "fullAccess": { - "label": "Full access", - "description": "Tools are allowed by default without confirmation." - }, - "acp": { - "label": "ACP controlled", - "tooltip": "Permissions for this session are controlled by the ACP client." - }, - "fullAccessWarningTitle": "Enable full access?", - "fullAccessWarningMessage": "Full access allows tools by default without asking each time.", - "fullAccessConfirm": "Enable full access", - "cancel": "Cancel" - }, "addModeTooltip": "Add Plan or Debug", "boostSectionAgent": "Agent", "boostSectionContext": "Context", @@ -735,8 +631,6 @@ "currentMode": "Current mode: {{mode}}", "noMatchingMode": "No matching mode", "noMatchingCommand": "No matching command", - "nativeCommandChoiceNotSaved": "This command will run once, but your conflict choice could not be saved.", - "nativeCommandReconfirmationRequired": "The external command you previously chose is no longer available. Choose the BitFun command from the slash menu to run it.", "selectHint": "↑↓ Select · Enter Confirm · Esc Cancel", "current": "Current", "computerUseDisabled": "Computer Use is disabled in Settings → Session permissions", @@ -776,10 +670,7 @@ "switchWorkspaceFailed": "Failed to switch workspace: {{error}}", "mcpPromptMissingArgs": "This MCP prompt requires arguments: {{args}}", "mcpPromptFailed": "MCP prompt failed", - "loadingMcpPrompts": "Loading MCP prompts...", - "externalCommandsLoading": "Loading compatible commands...", - "externalCommandsHostUnavailable": "External commands are not available on this host.", - "externalCommandsLoadFailed": "External commands could not be loaded. Reopen the command picker to retry." + "loadingMcpPrompts": "Loading MCP prompts..." }, "planner": { "title": "Task Planning", @@ -821,30 +712,6 @@ "toolCall": "🔧 Tool call: {{name}}", "unknownTool": "Unknown tool" }, - "transcriptExport": { - "copyFull": "Copy full process", - "copyResult": "Copy result", - "copyEmpty": "Nothing to copy in this dialog", - "exportEmpty": "This session has nothing to export", - "exportSuccess": "Session exported: {{filePath}}", - "exportFailed": "Failed to export the session", - "saveDialogTitle": "Export session as Markdown", - "scopeFull": "Full process", - "scopeResult": "Result only", - "metaSessionId": "Session ID", - "metaExportedAt": "Exported at", - "metaTurnCount": "Turns", - "metaScope": "Scope", - "turnHeading": "Turn {{index}}", - "userHeading": "👤 User", - "assistantHeading": "🤖 Assistant", - "toolHeading": "Tool · {{name}}", - "thinking": "Thinking", - "toolInput": "Input", - "toolResult": "Result", - "toolError": "Error", - "empty": "(empty)" - }, "smartRecommendations": { "title": "💡 Smart Recommendations", "close": "Close" @@ -874,21 +741,7 @@ "childSession": { "stopReview": "Stop review", "stoppingReview": "Stopping review...", - "stopReviewFailed": "Could not confirm that Review stopped. It may still be running.", - "backToReview": "Back to Review", - "reviewDetail": { - "loading": "Loading saved details...", - "loadFailed": "Details could not be loaded.", - "unavailable": "No saved details are available for this check.", - "retryLoad": "Try loading again", - "preparing": "{{label}} is preparing...", - "completedEmpty": "{{label}} finished without additional details.", - "partialTimedOut": "This check timed out after returning partial details.", - "stopped": "{{label}} stopped before producing details.", - "interrupted": "{{label}} was interrupted. Return to Review to continue.", - "timedOut": "{{label}} timed out before producing details.", - "failed": "{{label}} could not be completed." - }, + "stopReviewFailed": "Failed to stop the review session.", "kinds": { "btw": { "short": "Side", @@ -1115,37 +968,39 @@ "deepReviewConsent": { "windowTitle": "Review plan", "eyebrow": "Review plan", - "title": "Start this review?", - "body": "BitFun reviews this change directly and adds additional checks only when the evidence needs them. Confirm the target, expected time, and read-only boundary before continuing.", + "title": "Add broader review coverage?", + "body": "BitFun selected additional independent checks for this change. Review the target scope, planned checks, runtime tendency, and read-only boundary before continuing.", "readonlyLabel": "Read-only", - "readonly": "The review does not modify files.", + "readonly": "Reviewers do not modify files.", "sessionConcurrencyTitle": "Active session is busy", "sessionConcurrencyBody": "The target session already has {{count}} review tasks running. Continuing will share the available review capacity.", - "costLabel": "Review depth", - "cost": "This review investigates the target more thoroughly and adds additional checks only when they are useful.", + "costLabel": "Coverage", + "cost": "Planned review agent runs; model requests and token use are not estimated here.", "timeLabel": "Time", "time": "Runs in background and may take longer.", "cancel": "Cancel", "confirm": "Start review", + "initialCalls": "Independent checks: {{planned}} planned review agent run", + "parallelCalls": "Up to {{count}} review agent runs may occur without another confirmation.", "runStrategy": "Run strategy: {{strategy}}", "strategyLabels": { - "quick": "Quick", + "quick": "Focused", "normal": "Standard", "deep": "Extensive" }, "strategySummaries": { - "quick": "Quick review checks the most relevant risks for the selected target.", - "normal": "Standard review examines the selected target in more depth and adds additional checks only when useful.", - "deep": "Extensive review examines all applicable high-risk areas and adds independent validation only when the evidence needs it." + "quick": "Focused review checks the most relevant risks for the selected target.", + "normal": "Standard adds independent coverage while keeping cost practical.", + "deep": "Extensive review adds the broadest applicable coverage for high-risk changes." }, "summaryTitle": "Review plan", "targetFiles": "{{count}} files", "targetFiles_one": "{{count}} file", "targetFiles_other": "{{count}} files", - "skippedReviewers": "{{count}} optional review work items not run", - "skippedReviewers_one": "{{count}} optional review work item not run", - "skippedReviewers_other": "{{count}} optional review work items not run", - "skippedGroupTitle": "Review work not run", + "skippedReviewers": "{{count}} optional checks not needed", + "skippedReviewers_one": "{{count}} optional check not needed", + "skippedReviewers_other": "{{count}} optional checks not needed", + "skippedGroupTitle": "Review scope selected", "targetSource": { "manualPrompt": "Provided context", "workspaceDiff": "Workspace changes", @@ -1153,9 +1008,9 @@ "selectedContext": "Selected context", "reviewTarget": "Review target" }, - "skippedSummary": "{{count}} optional review work items were not run. This result reflects only the completed review scope.", - "skippedSummary_one": "{{count}} optional review work item was not run. This result reflects only the completed review scope.", - "skippedSummary_other": "{{count}} optional review work items were not run. This result reflects only the completed review scope." + "skippedSummary": "BitFun selected the most relevant checks for this target. {{count}} optional checks were outside this run because of applicability, configuration, or budget.", + "skippedSummary_one": "BitFun selected the most relevant checks for this target. {{count}} optional check was outside this run because of applicability, configuration, or budget.", + "skippedSummary_other": "BitFun selected the most relevant checks for this target. {{count}} optional checks were outside this run because of applicability, configuration, or budget." }, "flowChatHeader": { "turnList": "Turn list", @@ -1308,17 +1163,6 @@ "exporting": "Exporting...", "exportToImage": "Export as Image" }, - "turnFailure": { - "showDetails": "Show technical details", - "hideDetails": "Hide technical details", - "provider": "Provider", - "errorCode": "Error code", - "httpStatus": "HTTP status", - "requestId": "Request ID", - "providerError": "Provider error", - "copy": "Copy", - "copied": "Copied" - }, "modelRound": { "copyDialog": "Copy dialog", "copiedDialog": "Copied!", @@ -1331,6 +1175,7 @@ "feedbackDevVersion": "Feedback is not available in dev version", "userLabel": "👤 User:", "toolCallLabel": "🔧 Tool call: {{name}}", + "loadingMoreHistory": "Loading more history...", "roundHistoryShow": "Show {{count}} previous retry rounds", "roundHistoryHide": "Hide previous retry rounds", "roundRetryLabel": "Retry {{index}}", @@ -1338,26 +1183,6 @@ "retryHistoryHide": "Hide retry history", "attemptLabel": "Attempt {{index}}", "attemptSuperseded": "superseded", - "attemptDiagnostics": { - "show": "Show retry diagnostic details", - "hide": "Hide retry diagnostic details", - "copy": "Copy", - "copied": "Copied", - "providerError": "Provider error", - "toolArguments": "Tool arguments: {{name}}", - "rawArguments": "Raw arguments", - "validationError": "Parser or validation error", - "unknownTool": "Unknown tool", - "categories": { - "transientRequestError": "Request failed before the provider responded", - "interruptedToolArguments": "Tool arguments were interrupted", - "partialStreamError": "Stream ended with a recoverable error", - "invalidToolArguments": "Provider returned invalid tool arguments", - "noEffectiveOutput": "Provider returned no usable output", - "transientStreamError": "Stream failed before usable output", - "unknown": "Retry diagnostic: {{category}}" - } - }, "meta": { "label": "Response metadata", "completed": "Completed", @@ -1507,7 +1332,6 @@ "deployingShort": "…", "untitled": "(unnamed)", "deployFailed": "Could not deploy BitFun Page version", - "openFailed": "Could not create a secure link for this Page.", "labelSlug": "slug", "labelVersion": "version", "labelPath": "url_path", @@ -1518,7 +1342,6 @@ "publishingShort": "…", "untitled": "(unnamed)", "publishFailed": "Could not publish BitFun Page", - "openFailed": "Could not create a secure link for this Page.", "labelSlug": "slug", "labelVersion": "version", "labelPath": "url_path", @@ -1605,11 +1428,6 @@ "loadingPlan": "Loading plan...", "generating": "Generating...", "clickToOpenPlan": "Click to open plan file", - "saveToProject": "Save to Project", - "savingToProject": "Saving plan to project...", - "saveToProjectUnavailable": "A workspace is required to save the plan", - "saveToProjectSuccess": "Plan saved to .bitfun/plans", - "saveToProjectFailed": "Failed to save plan to project", "revealPlanInExplorer": "Reveal plan file in file manager", "revealPlanUnavailable": "Plan files can only be revealed for local workspaces", "remainingTodos": "{{count}} Remaining To-dos", @@ -1684,10 +1502,6 @@ "openSettings": "Open System Settings", "openSettingsFailed": "Could not open System Settings" }, - "viewImage": { - "viewing": "Viewing image...", - "viewedImages": "Viewed {{count}} image" - }, "webFetch": { "parsingUrl": "Parsing URL...", "readLabel": "Read Webpage:", @@ -1758,13 +1572,8 @@ "collapseDetails": "Collapse details", "expandDetails": "Expand details", "openInPanel": "Open details in panel", - "reviewCoverageLabel": "Additional check", - "reviewCoverageDescription": "Checking additional review evidence", - "reviewFocusedDescription": "Checking a specific concern", - "reviewCheckUnavailable": "This additional check could not be completed. The main review can continue.", - "reviewPartialTimeout": "Timed out after returning partial details", - "reviewTimedOut": "Timed out", - "reviewStopped": "Stopped" + "reviewCoverageLabel": "Review check", + "reviewCoverageDescription": "Checking review coverage" }, "taskDetailPanel": { "untitled": "Untitled Task", @@ -1790,8 +1599,6 @@ "loadingMore": "Loading more output...", "loading": "Loading task details...", "stopReviewWork": "Stop this check", - "stoppingReviewWork": "Stopping this check...", - "stopReviewWorkFailed": "Could not confirm that this check stopped. It may still be running.", "stopReviewWorkHint": "Cancels only this check. The overall Review can keep going and still produce a summary." }, "timeout": { @@ -2060,12 +1867,12 @@ }, "target": "Target", "budget": "Budget", - "estimatedCalls": "Estimated review work items", - "activeGroupTitle": "Completed review work", - "skippedGroupTitle": "Review work not run", - "reducedCoverageSummary": "{{count}} optional review work items were not run. This result reflects only the completed review scope.", - "reducedCoverageSummary_one": "{{count}} optional review work item was not run. This result reflects only the completed review scope.", - "reducedCoverageSummary_other": "{{count}} optional review work items were not run. This result reflects only the completed review scope." + "estimatedCalls": "Estimated review checks", + "activeGroupTitle": "Coverage included", + "skippedGroupTitle": "Review scope selected", + "reducedCoverageSummary": "BitFun selected the most relevant checks for this target. {{count}} optional checks were outside this run because of applicability, configuration, or budget.", + "reducedCoverageSummary_one": "BitFun selected the most relevant checks for this target. {{count}} optional check was outside this run because of applicability, configuration, or budget.", + "reducedCoverageSummary_other": "BitFun selected the most relevant checks for this target. {{count}} optional checks were outside this run because of applicability, configuration, or budget." }, "sectionItemCount": "{{count}} items", "remediationPlan": "Remediation Plan", @@ -2073,7 +1880,7 @@ "title": "Review status", "context_pressure": { "label": "Context pressure rising", - "detail": "A large or constrained target has {{count}} planned review work items." + "detail": "{{count}} review checks planned for a large or constrained target." }, "compression_preserved": { "label": "Compression preserved key facts", @@ -2096,8 +1903,8 @@ "detail": "{{count}} review result is partial; confidence is limited." }, "reduced_scope": { - "label": "Limited review scope", - "detail": "This review used a limited scope." + "label": "Focused review scope", + "detail": "This review used a focused scope profile." }, "target_evidence_limited": { "label": "Target evidence limited", @@ -2109,11 +1916,11 @@ }, "skipped_reviewers": { "label": "Review scope tailored", - "detail": "Optional review work not run: {{count}} (applicability, configuration, or budget)." + "detail": "{{count}} optional check was outside this run because of applicability, configuration, or budget." }, "token_budget_limited": { "label": "Token budget limited review coverage", - "detail": "Optional review work not run due to token budget: {{count}}." + "detail": "Token budget mode kept {{count}} optional check outside this run." }, "user_decision": { "label": "User decision needed", @@ -2147,7 +1954,6 @@ "security": "Security coverage", "architecture": "Architecture coverage", "frontend": "Frontend coverage", - "focusedCheck": "Additional check", "qualityGate": "Quality check" }, "report": { @@ -2290,8 +2096,6 @@ "autoModelDesc": "Automatically Routing", "primaryModel": "Primary Model", "fastModel": "Fast Model", - "fastMode": "Fast mode", - "fastModeDescription": "1.5x speed with higher credit usage", "modelNotConfigured": "Not Configured", "contextUsage": { "agentPrompt": "Last request prompt: {{usage}}", @@ -2299,6 +2103,21 @@ "toolNote": "Tool outputs may be summarized or truncated before later requests." } }, + "modelBrainstorm": { + "menuTitle": "Brainstorm models", + "disabledTooltip": "Multi-model brainstorm", + "enabledTooltip": "Brainstorm with {{count}} models", + "notEnoughModels": "Configure at least two enabled chat models", + "needMoreModels": "Select at least {{count}} models for brainstorm mode", + "launchFailed": "Brainstorm failed", + "groupEyebrow": "Multi-model brainstorm", + "progress": "{{completed}}/{{total}} complete", + "readonlyToolMetric": "{{count}} read-only tools", + "thinkingMetric": "{{value}} thinking chars", + "noReadonlyTools": "No read-only tools used", + "selectCandidate": "Continue with this model", + "selectedCandidate": "Selected" + }, "reasoningEffort": { "title": "Reasoning Effort", "tooltip": "Adjust model reasoning depth (Responses API only)", @@ -2453,10 +2272,5 @@ }, "actions": { "send": "Send" - }, - "miniAppComposer": { - "placeholder": "Send a request to {{app}}…", - "send": "Send", - "hint": "Messages are handled by {{app}}" } } diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 40669ed696..7acf52af9d 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -1,51 +1,4 @@ { - "permission": { - "title": "需要授权", - "subagentOwner": "{{subagent}} 子 Agent", - "feedbackLabel": "拒绝反馈", - "feedbackPlaceholder": "拒绝时可选填后续建议", - "responseFailed": "权限回复未能送达,请重试。", - "allowOnce": "允许一次", - "allowAlways": "始终允许", - "allowAlwaysTooltip": "始终允许会为 {{projectPath}} 保存匹配的访问权限。", - "allowAlwaysTooltipCurrentProject": "始终允许会为当前项目保存匹配的访问权限。", - "allowAlwaysTooltipNoGrant": "此请求没有可保存的访问范围;始终允许仅应用于本次请求。", - "risks": { - "pageSave": "以{{visibility}}可见性保存“{{slug}}”的新不可变版本,不改变当前生产版本。", - "pagePublish": "以{{visibility}}可见性保存“{{slug}}”,并切换其生产版本。", - "pageDeploy": "将“{{slug}}”的生产流量切换到不可变版本“{{version}}”。" - }, - "visibility": { - "private": "仅自己可见", - "relay": "登录用户可见", - "public": "公开" - }, - "reject": "拒绝", - "batchCount": "{{count}} 个权限请求", - "collapsePanel": "收起权限请求", - "expandPanel": "展开 {{count}} 个待处理的权限请求", - "current": "当前", - "pending": "等待中", - "actions": { - "read": "读取文件", - "edit": "编辑文件", - "bash": "执行命令", - "git": "Git 操作", - "computerUse": "控制设备", - "webSearch": "搜索网络", - "webFetch": "访问网页", - "mcp": "MCP 调用", - "task": "运行任务", - "skill": "运行技能", - "pagePublish": "保存页面版本", - "pageDeploy": "部署页面版本", - "customTool": "外部工具", - "externalDirectory": "访问外部目录", - "other": "其他操作" - }, - "allowCurrentAndFollowing": "允许全部", - "rejectCurrentAndFollowing": "拒绝全部" - }, "emptyState": { "welcomeBack": "欢迎回来!", "workingIn": "您正在 {{workspace}} 中", @@ -411,17 +364,6 @@ "completed": "已完成", "failed": "执行失败", "interrupted": "已中断" - }, - "menu": { - "closePet": "关闭宠物", - "closeBubble": "关闭该气泡" - }, - "composer": { - "openTitle": "发送消息到该会话", - "ariaLabel": "发送消息到该会话", - "placeholder": "输入消息,回车发送", - "cancel": "取消", - "send": "发送" } }, "session": { @@ -508,10 +450,9 @@ "copySelection": "复制选中内容", "copyFullContent": "复制完整内容", "copyContent": "复制内容", + "copyDialog": "复制整个对话", "copyToolInput": "复制工具输入", - "copyToolOutput": "复制工具输出", - "copyDialogFull": "复制整个对话(完整过程)", - "copyDialogResult": "复制整个对话(仅结果)" + "copyToolOutput": "复制工具输出" }, "widgetContextMenu": { "addToInput": "把选中元素加入输入框", @@ -546,24 +487,7 @@ "openWorkspaceFolderFailed": "打开工作区文件夹失败:{{error}}", "spaceToActivate": "按空格键快速键入", "assistantPlaceholder": "给 {{name}} 发送消息...", - "sendHint": "Enter 发送 / Ctrl+Enter 或 Shift+Enter 换行", - "voiceInput": { - "start": "开始语音输入", - "stop": "停止录音", - "preparing": "正在准备麦克风", - "transcribing": "正在转写", - "disabled": "语音输入已关闭", - "unsupported": "当前无法使用麦克风", - "modelMissing": "请先下载本地语音模型", - "cloudPending": "云端转写已配置,但转写通路尚未接入", - "permissionDenied": "麦克风权限被拒绝", - "deviceDisconnected": "所选麦克风已断开连接", - "lowVolume": "没有检测到声音,请检查所选麦克风。", - "failed": "语音输入失败", - "empty": "没有识别到语音", - "transcribeOnly": "插入转写文本", - "transcribeAndSend": "转写并发送" - } + "sendHint": "Enter 发送 / Ctrl+Enter 或 Shift+Enter 换行" }, "workspaceStrip": { "branchTooltipUnavailable": "非 Git 仓库或当前无分支" @@ -627,34 +551,6 @@ }, "chatInput": { "addBoostTooltip": "智能体模式、图片或 Skill", - "permissionMode": { - "menuLabel": "权限模式", - "globalScope": "全局", - "current": "权限:{{mode}}", - "changeFailed": "切换权限模式失败。", - "hideControl": "隐藏权限模式选择器", - "hideControlFailed": "隐藏权限模式选择器失败。", - "ask": { - "label": "需要确认", - "description": "外部访问,修改文件和执行命令需要确认。" - }, - "auto": { - "label": "自动批准", - "description": "自动批准需要确认的请求。" - }, - "fullAccess": { - "label": "完全访问", - "description": "默认允许工具执行,无需确认。" - }, - "acp": { - "label": "ACP 控制", - "tooltip": "此会话的权限由 ACP 客户端控制。" - }, - "fullAccessWarningTitle": "启用完全访问?", - "fullAccessWarningMessage": "完全访问会默认允许工具执行,不再逐次确认。", - "fullAccessConfirm": "启用完全访问", - "cancel": "取消" - }, "addModeTooltip": "附加 Plan 或 Debug", "boostSectionAgent": "智能体", "boostSectionContext": "上下文", @@ -729,8 +625,6 @@ "currentMode": "当前模式: {{mode}}", "noMatchingMode": "没有匹配的模式", "noMatchingCommand": "没有匹配的命令", - "nativeCommandChoiceNotSaved": "本次仍会执行该命令,但未能保存这次冲突选择。", - "nativeCommandReconfirmationRequired": "之前选择的外部命令已不可用。请从斜杠菜单中选择 BitFun 命令后再执行。", "selectHint": "↑↓ 选择 · Enter 确认 · Esc 取消", "current": "当前", "computerUseDisabled": "已在「设置 → 会话权限」中禁用 Computer Use", @@ -776,10 +670,7 @@ "usageFailed": "用量报告生成失败", "mcpPromptMissingArgs": "此 MCP 提示词需要参数:{{args}}", "mcpPromptFailed": "MCP 提示词执行失败", - "loadingMcpPrompts": "正在加载 MCP 提示词...", - "externalCommandsLoading": "正在加载兼容命令...", - "externalCommandsHostUnavailable": "当前主机不支持外部命令。", - "externalCommandsLoadFailed": "外部命令加载失败,请重新打开命令选择器重试。" + "loadingMcpPrompts": "正在加载 MCP 提示词..." }, "planner": { "title": "任务规划", @@ -821,30 +712,6 @@ "toolCall": "🔧 工具调用: {{name}}", "unknownTool": "未知工具" }, - "transcriptExport": { - "copyFull": "拷贝完整过程", - "copyResult": "拷贝结果", - "copyEmpty": "该对话没有可复制的内容", - "exportEmpty": "该会话没有可导出的内容", - "exportSuccess": "会话已导出:{{filePath}}", - "exportFailed": "导出会话失败", - "saveDialogTitle": "导出会话为 Markdown", - "scopeFull": "完整过程", - "scopeResult": "仅结果", - "metaSessionId": "会话 ID", - "metaExportedAt": "导出时间", - "metaTurnCount": "对话轮数", - "metaScope": "导出范围", - "turnHeading": "第 {{index}} 轮", - "userHeading": "👤 用户", - "assistantHeading": "🤖 助手", - "toolHeading": "工具 · {{name}}", - "thinking": "思考", - "toolInput": "输入", - "toolResult": "结果", - "toolError": "错误", - "empty": "(空)" - }, "smartRecommendations": { "title": "💡 智能推荐", "close": "关闭" @@ -874,8 +741,7 @@ "childSession": { "stopReview": "中止审核", "stoppingReview": "正在中止审核...", - "stopReviewFailed": "无法确认审核是否已停止,它可能仍在运行。", - "backToReview": "返回审核", + "stopReviewFailed": "中止审核会话失败。", "kinds": { "btw": { "short": "侧问", @@ -902,19 +768,6 @@ "title": "子智能体会话", "origin": "子智能体" } - }, - "reviewDetail": { - "loading": "正在加载已保存的详情...", - "loadFailed": "无法加载详情。", - "unavailable": "没有可用于这项检查的已保存详情。", - "retryLoad": "重新加载", - "preparing": "{{label}}正在准备...", - "completedEmpty": "{{label}}已结束,没有更多详情。", - "partialTimedOut": "这项检查已超时,但返回了部分详情。", - "stopped": "{{label}}已停止,尚未产生可查看的详情。", - "interrupted": "{{label}}已中断。可返回审核继续。", - "timedOut": "{{label}}已超时,尚未产生可查看的详情。", - "failed": "{{label}}未能完成。" } }, "deepReviewActionBar": { @@ -1002,7 +855,7 @@ "copyDiagnostics": "复制排障摘要", "diagnosticsCopied": "已复制排障摘要", "diagnosticsCopyFailed": "复制排障摘要失败", - "diagnosticsTitle": "严格审核排障摘要", + "diagnosticsTitle": "严格 Review 排障摘要", "diagnosticsErrorType": "错误类型", "diagnosticsDescription": "错误描述", "diagnosticsSuggestedActions": "建议操作", @@ -1031,9 +884,9 @@ }, "resultRecovery": { "title": "严格审核需要补全结果", - "missingSubmitCodeReview": "审核已结束,但 BitFun 没有收到可确认的结构化结果。继续会保留已完成的审核工作,并补交缺失的审核报告。", - "invalidSubmitCodeReview": "审核返回了 BitFun 无法读取的结果。继续会保留已完成的审核工作,并修复报告。", - "wrongReviewMode": "审核返回了标准审核结果。继续会保留已完成的审核工作,并提交严格审核报告。" + "missingSubmitCodeReview": "审核已结束,但 BitFun 没有收到可确认的结构化结果。继续会保留已完成的审核工作,并要求 agent 补交缺失的审核报告。", + "invalidSubmitCodeReview": "审核返回了 BitFun 无法读取的结果。继续会保留已完成的审核工作,并要求 agent 修复报告。", + "wrongReviewMode": "审核返回了标准审核结果。继续会保留已完成的审核工作,并要求 agent 提交严格审核报告。" }, "capacityQueue": { "title": "审核正在等待容量", @@ -1075,7 +928,7 @@ "cancelQueued": "取消等待中的审核", "skipOptionalQueued": "保留核心检查", "openReviewSettings": "打开审核设置", - "controlFailed": "当前无法控制这个审核项的容量。可停止本次审核,或等待容量状态刷新。", + "controlFailed": "当前无法控制这个 Review 项的容量。可使用停止来中断 Review,或等待容量状态刷新。", "controlFailedWithReason": "容量控制失败:{{reason}}。请重试,或在审核卡住时使用停止来中断。", "controlPartiallyFailedWithReason": "容量控制已部分应用;{{total}} 个审核项中有 {{failed}} 个失败:{{reason}}。请等待容量状态刷新后重试,或在卡住时使用停止。" }, @@ -1115,37 +968,39 @@ "deepReviewConsent": { "windowTitle": "审核方案", "eyebrow": "审核方案", - "title": "开始本次审核?", - "body": "BitFun 会先直接审核本次变更,仅在证据确实需要时增加补充检查。继续前可确认目标范围、预计耗时和只读边界。", + "title": "增加更广的审核覆盖?", + "body": "BitFun 为本次变更选择了额外的独立检查。继续前可确认目标范围、计划检查、耗时倾向和只读边界。", "readonlyLabel": "只读", - "readonly": "审核不会修改文件。", + "readonly": "审核者不会修改文件。", "sessionConcurrencyTitle": "当前会话较忙", "sessionConcurrencyBody": "目标会话已有 {{count}} 个审核工作在运行,继续后将共享当前可用的审核容量。", - "costLabel": "审核深度", - "cost": "本次审核会更深入地调查目标,只在确有帮助时增加补充检查。", + "costLabel": "覆盖", + "cost": "计划的审查代理执行;此处不估算底层模型请求或 Token。", "timeLabel": "耗时", "time": "后台运行,可能需要更久。", "cancel": "取消", "confirm": "开始审核", + "initialCalls": "独立检查:计划 {{planned}} 次审查代理执行", + "parallelCalls": "无需再次确认时,最多可能执行 {{count}} 次审查代理。", "runStrategy": "运行策略:{{strategy}}", "strategyLabels": { - "quick": "快速", + "quick": "聚焦", "normal": "标准", "deep": "全面" }, "strategySummaries": { - "quick": "快速审核会检查当前目标最相关的风险。", - "normal": "标准审核会更深入地检查当前目标,只在确有帮助时增加补充检查。", - "deep": "全面审核会检查所有适用的高风险领域,只在证据需要时增加独立验证。" + "quick": "聚焦审核会检查当前目标最相关的风险。", + "normal": "标准审核会增加独立覆盖,同时保持成本合理。", + "deep": "全面审核会为高风险变更增加适用范围内最广的覆盖。" }, "summaryTitle": "审核方案", "targetFiles": "{{count}} 个文件", "targetFiles_one": "{{count}} 个文件", "targetFiles_other": "{{count}} 个文件", - "skippedReviewers": "{{count}} 项可选审核工作未运行", - "skippedReviewers_one": "{{count}} 项可选审核工作未运行", - "skippedReviewers_other": "{{count}} 项可选审核工作未运行", - "skippedGroupTitle": "未运行的审核工作", + "skippedReviewers": "{{count}} 个可选检查未纳入", + "skippedReviewers_one": "{{count}} 个可选检查未纳入", + "skippedReviewers_other": "{{count}} 个可选检查未纳入", + "skippedGroupTitle": "已选择 Review 范围", "targetSource": { "manualPrompt": "给定内容", "workspaceDiff": "工作区改动", @@ -1153,9 +1008,9 @@ "selectedContext": "已选上下文", "reviewTarget": "审核目标" }, - "skippedSummary": "{{count}} 项可选审核工作未运行;本次结果仅反映已完成的审核范围。", - "skippedSummary_one": "{{count}} 项可选审核工作未运行;本次结果仅反映已完成的审核范围。", - "skippedSummary_other": "{{count}} 项可选审核工作未运行;本次结果仅反映已完成的审核范围。" + "skippedSummary": "BitFun 已为此目标选择最相关的检查。{{count}} 个可选检查因适用性、配置或预算未纳入本次运行。", + "skippedSummary_one": "BitFun 已为此目标选择最相关的检查。{{count}} 个可选检查因适用性、配置或预算未纳入本次运行。", + "skippedSummary_other": "BitFun 已为此目标选择最相关的检查。{{count}} 个可选检查因适用性、配置或预算未纳入本次运行。" }, "flowChatHeader": { "turnList": "轮次列表", @@ -1308,17 +1163,6 @@ "exporting": "正在导出...", "exportToImage": "导出为图片" }, - "turnFailure": { - "showDetails": "显示技术详情", - "hideDetails": "隐藏技术详情", - "provider": "服务商", - "errorCode": "错误码", - "httpStatus": "HTTP 状态", - "requestId": "请求 ID", - "providerError": "服务商错误", - "copy": "复制", - "copied": "已复制" - }, "modelRound": { "copyDialog": "复制对话", "copiedDialog": "已复制!", @@ -1331,6 +1175,7 @@ "feedbackDevVersion": "开发版本暂不提供反馈功能", "userLabel": "👤 用户:", "toolCallLabel": "🔧 工具调用: {{name}}", + "loadingMoreHistory": "正在加载更多历史...", "roundHistoryShow": "显示 {{count}} 次历史重试轮次", "roundHistoryHide": "隐藏历史重试轮次", "roundRetryLabel": "重试轮次 {{index}}", @@ -1338,26 +1183,6 @@ "retryHistoryHide": "隐藏重试历史", "attemptLabel": "第 {{index}} 次尝试", "attemptSuperseded": "已被重试接替", - "attemptDiagnostics": { - "show": "显示本次重试诊断详情", - "hide": "隐藏本次重试诊断详情", - "copy": "复制", - "copied": "已复制", - "providerError": "服务商错误", - "toolArguments": "工具参数:{{name}}", - "rawArguments": "原始参数", - "validationError": "解析或校验错误", - "unknownTool": "未知工具", - "categories": { - "transientRequestError": "请求在服务商响应前失败", - "interruptedToolArguments": "工具参数输出被中断", - "partialStreamError": "流式输出以可恢复错误结束", - "invalidToolArguments": "服务商返回了不合法的工具参数", - "noEffectiveOutput": "服务商未返回可用输出", - "transientStreamError": "流式输出在产生可用内容前失败", - "unknown": "重试诊断:{{category}}" - } - }, "meta": { "label": "回应元信息", "completed": "完成", @@ -1507,7 +1332,6 @@ "deployingShort": "…", "untitled": "(未命名)", "deployFailed": "无法部署 BitFun Page 版本", - "openFailed": "无法为该页面创建安全访问链接。", "labelSlug": "slug", "labelVersion": "version", "labelPath": "url_path", @@ -1518,7 +1342,6 @@ "publishingShort": "…", "untitled": "(未命名)", "publishFailed": "无法发布 BitFun Page", - "openFailed": "无法为该页面创建安全访问链接。", "labelSlug": "slug", "labelVersion": "version", "labelPath": "url_path", @@ -1605,11 +1428,6 @@ "loadingPlan": "正在加载计划...", "generating": "生成中...", "clickToOpenPlan": "点击打开计划文件", - "saveToProject": "保存到项目", - "savingToProject": "正在保存到项目...", - "saveToProjectUnavailable": "需要工作区才能保存计划", - "saveToProjectSuccess": "计划已保存到 .bitfun/plans", - "saveToProjectFailed": "保存计划到项目失败", "revealPlanInExplorer": "在文件管理器中定位计划文件", "revealPlanUnavailable": "仅本地工作区支持在文件管理器中定位计划文件", "remainingTodos": "剩余 {{count}} 个待办", @@ -1684,10 +1502,6 @@ "openSettings": "打开系统设置", "openSettingsFailed": "无法打开系统设置" }, - "viewImage": { - "viewing": "正在查看图像...", - "viewedImages": "已查看 {{count}} 张图像" - }, "webFetch": { "parsingUrl": "解析链接中...", "readLabel": "读取网页:", @@ -1758,13 +1572,8 @@ "collapseDetails": "收起详情", "expandDetails": "展开详情", "openInPanel": "在面板中打开详情", - "reviewCoverageLabel": "补充检查", - "reviewCoverageDescription": "正在补充审核证据", - "reviewFocusedDescription": "正在核查一个具体问题", - "reviewCheckUnavailable": "这项补充检查未能完成,主审核仍可继续。", - "reviewPartialTimeout": "已超时,但返回了部分详情", - "reviewTimedOut": "已超时", - "reviewStopped": "已停止" + "reviewCoverageLabel": "Review 检查", + "reviewCoverageDescription": "正在检查 Review 覆盖" }, "taskDetailPanel": { "untitled": "未命名任务", @@ -1790,9 +1599,7 @@ "loadingMore": "正在加载更多输出...", "loading": "正在加载任务详情...", "stopReviewWork": "停止此检查", - "stoppingReviewWork": "正在停止此检查...", - "stopReviewWorkFailed": "无法确认这项检查是否已停止,它可能仍在运行。", - "stopReviewWorkHint": "仅取消这个检查项,整体审核仍可继续并生成摘要。" + "stopReviewWorkHint": "仅取消这个检查项,整体 Review 仍可继续并生成摘要。" }, "timeout": { "disableTooltip": "关闭超时限制", @@ -2052,7 +1859,7 @@ "runManifest": { "recommendedStrategy": "推荐策略", "riskRecommendationTitle": "风险推荐", - "reviewDepth": "审核范围", + "reviewDepth": "Review 范围", "reviewDepthLabels": { "high_risk_only": "聚焦", "risk_expanded": "扩展", @@ -2060,12 +1867,12 @@ }, "target": "目标", "budget": "预算", - "estimatedCalls": "预计审核工作项", - "activeGroupTitle": "已完成的审核工作", - "skippedGroupTitle": "未运行的审核工作", - "reducedCoverageSummary": "{{count}} 项可选审核工作未运行;本次结果仅反映已完成的审核范围。", - "reducedCoverageSummary_one": "{{count}} 项可选审核工作未运行;本次结果仅反映已完成的审核范围。", - "reducedCoverageSummary_other": "{{count}} 项可选审核工作未运行;本次结果仅反映已完成的审核范围。" + "estimatedCalls": "预计审核检查", + "activeGroupTitle": "已覆盖", + "skippedGroupTitle": "已选择 Review 范围", + "reducedCoverageSummary": "BitFun 已为此目标选择最相关的检查。{{count}} 个可选检查因适用性、配置或预算未纳入本次运行。", + "reducedCoverageSummary_one": "BitFun 已为此目标选择最相关的检查。{{count}} 个可选检查因适用性、配置或预算未纳入本次运行。", + "reducedCoverageSummary_other": "BitFun 已为此目标选择最相关的检查。{{count}} 个可选检查因适用性、配置或预算未纳入本次运行。" }, "sectionItemCount": "{{count}} 项", "remediationPlan": "修复计划", @@ -2073,7 +1880,7 @@ "title": "审核状态", "context_pressure": { "label": "上下文压力上升", - "detail": "大型或受限目标已规划 {{count}} 项审核工作。" + "detail": "大型或受限目标预计需要 {{count}} 项审核检查。" }, "compression_preserved": { "label": "压缩已保留关键事实", @@ -2096,8 +1903,8 @@ "detail": "{{count}} 个审核结果是部分结果,可信度有限。" }, "reduced_scope": { - "label": "审核范围受限", - "detail": "本次审核只覆盖有限范围。" + "label": "聚焦 Review 范围", + "detail": "本次 Review 使用了聚焦范围配置。" }, "target_evidence_limited": { "label": "目标证据受限", @@ -2108,12 +1915,12 @@ "detail": "{{count}} 条重试指引用于补足部分审核覆盖。" }, "skipped_reviewers": { - "label": "审核范围已匹配", - "detail": "未运行的可选审核工作:{{count}} 项(适用性、配置或预算限制)。" + "label": "Review 范围已匹配", + "detail": "{{count}} 个可选检查因适用性、配置或预算未纳入本次运行。" }, "token_budget_limited": { "label": "Token 预算限制了审核覆盖", - "detail": "因 Token 预算未运行的可选审核工作:{{count}} 项。" + "detail": "Token 预算模式将 {{count}} 个可选检查保留在本次范围之外。" }, "user_decision": { "label": "需要用户决策", @@ -2147,7 +1954,6 @@ "security": "安全覆盖", "architecture": "架构覆盖", "frontend": "前端覆盖", - "focusedCheck": "补充检查", "qualityGate": "质量检查" }, "report": { @@ -2290,8 +2096,6 @@ "autoModelDesc": "自动路由最优模型", "primaryModel": "Primary 模型", "fastModel": "Fast 模型", - "fastMode": "Fast 模式", - "fastModeDescription": "1.5 倍速度,消耗更多额度", "modelNotConfigured": "未配置", "contextUsage": { "agentPrompt": "上次请求输入上下文: {{usage}}", @@ -2299,6 +2103,21 @@ "toolNote": "工具输出在后续请求中可能会被摘要或截断。" } }, + "modelBrainstorm": { + "menuTitle": "头脑风暴模型", + "disabledTooltip": "多模型头脑风暴", + "enabledTooltip": "使用 {{count}} 个模型头脑风暴", + "notEnoughModels": "至少需要配置两个已启用的聊天模型", + "needMoreModels": "头脑风暴模式至少选择 {{count}} 个模型", + "launchFailed": "头脑风暴启动失败", + "groupEyebrow": "多模型头脑风暴", + "progress": "{{completed}}/{{total}} 已完成", + "readonlyToolMetric": "{{count}} 个只读工具", + "thinkingMetric": "{{value}} 推理字符", + "noReadonlyTools": "未使用只读工具", + "selectCandidate": "沿此模型继续", + "selectedCandidate": "已选择" + }, "reasoningEffort": { "title": "推理深度", "tooltip": "调整模型推理深度(仅 Responses API)", @@ -2453,10 +2272,5 @@ }, "actions": { "send": "发送" - }, - "miniAppComposer": { - "placeholder": "把需求发给 {{app}}…", - "send": "发送", - "hint": "消息将由 {{app}} 处理" } } diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index c4da9e8ea3..747a2b3a64 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -1,51 +1,4 @@ { - "permission": { - "title": "需要授權", - "subagentOwner": "{{subagent}} 子 Agent", - "feedbackLabel": "拒絕回饋", - "feedbackPlaceholder": "拒絕時可選填後續建議", - "responseFailed": "權限回覆未能送達,請重試。", - "allowOnce": "允許一次", - "allowAlways": "始終允許", - "allowAlwaysTooltip": "始終允許會為 {{projectPath}} 儲存相符的存取權限。", - "allowAlwaysTooltipCurrentProject": "始終允許會為目前項目儲存相符的存取權限。", - "allowAlwaysTooltipNoGrant": "此請求沒有可儲存的存取範圍;始終允許僅套用於這次請求。", - "risks": { - "pageSave": "以{{visibility}}可見性儲存「{{slug}}」的新不可變版本,不變更目前正式版本。", - "pagePublish": "以{{visibility}}可見性儲存「{{slug}}」,並切換其正式版本。", - "pageDeploy": "將「{{slug}}」的正式流量切換到不可變版本「{{version}}」。" - }, - "visibility": { - "private": "僅自己可見", - "relay": "登入使用者可見", - "public": "公開" - }, - "reject": "拒絕", - "batchCount": "{{count}} 個授權請求", - "collapsePanel": "收起授權請求", - "expandPanel": "展開 {{count}} 個待處理的授權請求", - "current": "目前", - "pending": "等待中", - "actions": { - "read": "讀取檔案", - "edit": "編輯檔案", - "bash": "執行命令", - "git": "Git 操作", - "computerUse": "控制裝置", - "webSearch": "搜尋網路", - "webFetch": "存取網頁", - "mcp": "MCP 呼叫", - "task": "執行任務", - "skill": "執行技能", - "pagePublish": "儲存頁面版本", - "pageDeploy": "部署頁面版本", - "customTool": "外部工具", - "externalDirectory": "存取外部目錄", - "other": "其他操作" - }, - "allowCurrentAndFollowing": "允許全部", - "rejectCurrentAndFollowing": "拒絕全部" - }, "emptyState": { "welcomeBack": "歡迎回來!", "workingIn": "您正在 {{workspace}} 中", @@ -411,17 +364,6 @@ "completed": "已完成", "failed": "執行失敗", "interrupted": "已中斷" - }, - "menu": { - "closePet": "關閉寵物", - "closeBubble": "關閉該氣泡" - }, - "composer": { - "openTitle": "傳送訊息到該工作階段", - "ariaLabel": "傳送訊息到該工作階段", - "placeholder": "輸入訊息,Enter 送出", - "cancel": "取消", - "send": "傳送" } }, "session": { @@ -508,10 +450,9 @@ "copySelection": "複製選中內容", "copyFullContent": "複製完整內容", "copyContent": "複製內容", + "copyDialog": "複製整個對話", "copyToolInput": "複製工具輸入", - "copyToolOutput": "複製工具輸出", - "copyDialogFull": "複製整個對話(完整過程)", - "copyDialogResult": "複製整個對話(僅結果)" + "copyToolOutput": "複製工具輸出" }, "widgetContextMenu": { "addToInput": "把選中元素加入輸入框", @@ -546,24 +487,7 @@ "openWorkspaceFolderFailed": "開啟工作區資料夾失敗:{{error}}", "spaceToActivate": "按空格鍵快速鍵入", "assistantPlaceholder": "傳訊息給 {{name}}...", - "sendHint": "Enter 傳送 / Ctrl+Enter 或 Shift+Enter 換行", - "voiceInput": { - "start": "開始語音輸入", - "stop": "停止錄音", - "preparing": "正在準備麥克風", - "transcribing": "正在轉寫", - "disabled": "語音輸入已關閉", - "unsupported": "目前無法使用麥克風", - "modelMissing": "請先下載本地語音模型", - "cloudPending": "雲端轉寫已設定,但轉寫通路尚未接入", - "permissionDenied": "麥克風權限被拒絕", - "deviceDisconnected": "所選麥克風已中斷連線", - "lowVolume": "沒有偵測到聲音,請檢查所選麥克風。", - "failed": "語音輸入失敗", - "empty": "沒有辨識到語音", - "transcribeOnly": "插入轉寫文字", - "transcribeAndSend": "轉寫並傳送" - } + "sendHint": "Enter 傳送 / Ctrl+Enter 或 Shift+Enter 換行" }, "workspaceStrip": { "branchTooltipUnavailable": "非 Git 存放庫或目前無分支" @@ -627,34 +551,6 @@ }, "chatInput": { "addBoostTooltip": "智能體模式、圖片或 Skill", - "permissionMode": { - "menuLabel": "權限模式", - "globalScope": "全域", - "current": "權限:{{mode}}", - "changeFailed": "切換權限模式失敗。", - "hideControl": "隱藏權限模式選擇器", - "hideControlFailed": "隱藏權限模式選擇器失敗。", - "ask": { - "label": "需要確認", - "description": "外部存取、修改檔案和執行命令需要確認。" - }, - "auto": { - "label": "自動批准", - "description": "自動批准需要確認的請求。" - }, - "fullAccess": { - "label": "完全存取", - "description": "預設允許工具執行,無需確認。" - }, - "acp": { - "label": "ACP 控制", - "tooltip": "此工作階段的權限由 ACP 用戶端控制。" - }, - "fullAccessWarningTitle": "啟用完全存取?", - "fullAccessWarningMessage": "完全存取會預設允許工具執行,不再逐次確認。", - "fullAccessConfirm": "啟用完全存取", - "cancel": "取消" - }, "addModeTooltip": "附加 Plan 或 Debug", "boostSectionAgent": "智能體", "boostSectionContext": "上下文", @@ -729,8 +625,6 @@ "currentMode": "目前模式: {{mode}}", "noMatchingMode": "沒有匹配的模式", "noMatchingCommand": "沒有匹配的命令", - "nativeCommandChoiceNotSaved": "本次仍會執行該命令,但未能儲存這次衝突選擇。", - "nativeCommandReconfirmationRequired": "先前選擇的外部命令已無法使用。請從斜線選單中選擇 BitFun 命令後再執行。", "selectHint": "↑↓ 選擇 · Enter 確認 · Esc 取消", "current": "目前", "computerUseDisabled": "已在「設定 → 工作階段權限」中停用 Computer Use", @@ -776,10 +670,7 @@ "usageFailed": "用量報告產生失敗", "mcpPromptMissingArgs": "此 MCP 提示詞需要參數:{{args}}", "mcpPromptFailed": "MCP 提示詞執行失敗", - "loadingMcpPrompts": "正在載入 MCP 提示詞...", - "externalCommandsLoading": "正在載入相容命令...", - "externalCommandsHostUnavailable": "目前主機不支援外部命令。", - "externalCommandsLoadFailed": "外部命令載入失敗,請重新開啟命令選擇器重試。" + "loadingMcpPrompts": "正在載入 MCP 提示詞..." }, "planner": { "title": "任務規劃", @@ -821,30 +712,6 @@ "toolCall": "🔧 工具調用: {{name}}", "unknownTool": "未知工具" }, - "transcriptExport": { - "copyFull": "拷貝完整過程", - "copyResult": "拷貝結果", - "copyEmpty": "該對話沒有可複製的內容", - "exportEmpty": "該工作階段沒有可匯出的內容", - "exportSuccess": "工作階段已匯出:{{filePath}}", - "exportFailed": "匯出工作階段失敗", - "saveDialogTitle": "將工作階段匯出為 Markdown", - "scopeFull": "完整過程", - "scopeResult": "僅結果", - "metaSessionId": "工作階段 ID", - "metaExportedAt": "匯出時間", - "metaTurnCount": "對話輪數", - "metaScope": "匯出範圍", - "turnHeading": "第 {{index}} 輪", - "userHeading": "👤 使用者", - "assistantHeading": "🤖 助理", - "toolHeading": "工具 · {{name}}", - "thinking": "思考", - "toolInput": "輸入", - "toolResult": "結果", - "toolError": "錯誤", - "empty": "(空)" - }, "smartRecommendations": { "title": "💡 智能推薦", "close": "關閉" @@ -874,8 +741,7 @@ "childSession": { "stopReview": "中止審核", "stoppingReview": "正在中止審核...", - "stopReviewFailed": "無法確認審核是否已停止,它可能仍在執行。", - "backToReview": "返回審核", + "stopReviewFailed": "中止審核會話失敗。", "kinds": { "btw": { "short": "側問", @@ -902,19 +768,6 @@ "title": "子智能體會話", "origin": "子智能體" } - }, - "reviewDetail": { - "loading": "正在載入已儲存的詳情...", - "loadFailed": "無法載入詳情。", - "unavailable": "沒有可用於這項檢查的已儲存詳情。", - "retryLoad": "重新載入", - "preparing": "{{label}}正在準備...", - "completedEmpty": "{{label}}已結束,沒有更多詳情。", - "partialTimedOut": "這項檢查已逾時,但傳回了部分詳情。", - "stopped": "{{label}}已停止,尚未產生可查看的詳情。", - "interrupted": "{{label}}已中斷。可返回審核繼續。", - "timedOut": "{{label}}已逾時,尚未產生可查看的詳情。", - "failed": "{{label}}未能完成。" } }, "deepReviewActionBar": { @@ -1002,7 +855,7 @@ "copyDiagnostics": "複製排障摘要", "diagnosticsCopied": "已複製排障摘要", "diagnosticsCopyFailed": "複製排障摘要失敗", - "diagnosticsTitle": "嚴格審核疑難排解摘要", + "diagnosticsTitle": "嚴格 Review 排障摘要", "diagnosticsErrorType": "錯誤類型", "diagnosticsDescription": "錯誤描述", "diagnosticsSuggestedActions": "建議操作", @@ -1031,9 +884,9 @@ }, "resultRecovery": { "title": "嚴格審核需要補全結果", - "missingSubmitCodeReview": "審核已結束,但 BitFun 沒有收到可確認的結構化結果。繼續會保留已完成的審核工作,並補交缺失的審核報告。", - "invalidSubmitCodeReview": "審核回傳了 BitFun 無法讀取的結果。繼續會保留已完成的審核工作,並修正報告。", - "wrongReviewMode": "審核回傳了標準審核結果。繼續會保留已完成的審核工作,並提交嚴格審核報告。" + "missingSubmitCodeReview": "審核已結束,但 BitFun 沒有收到可確認的結構化結果。繼續會保留已完成的審核工作,並要求 agent 補交缺失的審核報告。", + "invalidSubmitCodeReview": "審核回傳了 BitFun 無法讀取的結果。繼續會保留已完成的審核工作,並要求 agent 修復報告。", + "wrongReviewMode": "審核回傳了標準審核結果。繼續會保留已完成的審核工作,並要求 agent 提交嚴格審核報告。" }, "capacityQueue": { "title": "審核正在等待容量", @@ -1075,7 +928,7 @@ "cancelQueued": "取消等待中的審核", "skipOptionalQueued": "保留核心檢查", "openReviewSettings": "開啟審核設定", - "controlFailed": "目前無法控制這個審核項目的容量。可停止本次審核,或等待容量狀態更新。", + "controlFailed": "目前無法控制這個 Review 項的容量。可使用停止來中斷 Review,或等待容量狀態刷新。", "controlFailedWithReason": "容量控制失敗:{{reason}}。請重試,或在審核卡住時使用停止來中斷。", "controlPartiallyFailedWithReason": "容量控制已部分套用;{{total}} 個審核項中有 {{failed}} 個失敗:{{reason}}。請等待容量狀態刷新後重試,或在卡住時使用停止。" }, @@ -1115,37 +968,39 @@ "deepReviewConsent": { "windowTitle": "審核方案", "eyebrow": "審核方案", - "title": "開始本次審核?", - "body": "BitFun 會先直接審核本次變更,只在證據顯示有必要時增加補充檢查。繼續前可確認目標範圍、預計耗時與唯讀邊界。", + "title": "增加更廣的審核覆蓋?", + "body": "BitFun 為本次變更選擇了額外的獨立檢查。繼續前可確認目標範圍、計畫檢查、耗時傾向和唯讀邊界。", "readonlyLabel": "唯讀", - "readonly": "審核不會修改檔案。", + "readonly": "審核者不會修改檔案。", "sessionConcurrencyTitle": "目前會話較忙", "sessionConcurrencyBody": "目標會話已有 {{count}} 個審核工作在執行,繼續後將共享目前可用的審核容量。", - "costLabel": "審核深度", - "cost": "本次審核會更深入調查目標,只在確有幫助時增加補充檢查。", + "costLabel": "覆蓋", + "cost": "計畫的審查代理執行;此處不估算底層模型請求或 Token。", "timeLabel": "耗時", "time": "背景執行,可能需要更久。", "cancel": "取消", "confirm": "開始審核", + "initialCalls": "獨立檢查:計畫 {{planned}} 次審查代理執行", + "parallelCalls": "無需再次確認時,最多可能執行 {{count}} 次審查代理。", "runStrategy": "運行策略:{{strategy}}", "strategyLabels": { - "quick": "快速", + "quick": "聚焦", "normal": "標準", "deep": "全面" }, "strategySummaries": { - "quick": "快速審核會檢查目前目標最相關的風險。", - "normal": "標準審核會更深入檢查目前目標,只在確有幫助時增加補充檢查。", - "deep": "全面審核會檢查所有適用的高風險領域,只在證據需要時增加獨立驗證。" + "quick": "聚焦審核會檢查目前目標最相關的風險。", + "normal": "標準審核會增加獨立覆蓋,同時保持成本合理。", + "deep": "全面審核會為高風險變更增加適用範圍內最廣的覆蓋。" }, "summaryTitle": "審核方案", "targetFiles": "{{count}} 個檔案", "targetFiles_one": "{{count}} 個檔案", "targetFiles_other": "{{count}} 個檔案", - "skippedReviewers": "{{count}} 項可選審核工作未執行", - "skippedReviewers_one": "{{count}} 項可選審核工作未執行", - "skippedReviewers_other": "{{count}} 項可選審核工作未執行", - "skippedGroupTitle": "未執行的審核工作", + "skippedReviewers": "{{count}} 個可選檢查未納入", + "skippedReviewers_one": "{{count}} 個可選檢查未納入", + "skippedReviewers_other": "{{count}} 個可選檢查未納入", + "skippedGroupTitle": "已選擇 Review 範圍", "targetSource": { "manualPrompt": "給定內容", "workspaceDiff": "工作區改動", @@ -1153,9 +1008,9 @@ "selectedContext": "已選上下文", "reviewTarget": "審核目標" }, - "skippedSummary": "{{count}} 項可選審核工作未執行;本次結果僅反映已完成的審核範圍。", - "skippedSummary_one": "{{count}} 項可選審核工作未執行;本次結果僅反映已完成的審核範圍。", - "skippedSummary_other": "{{count}} 項可選審核工作未執行;本次結果僅反映已完成的審核範圍。" + "skippedSummary": "BitFun 已為此目標選擇最相關的檢查。{{count}} 個可選檢查因適用性、設定或預算未納入本次執行。", + "skippedSummary_one": "BitFun 已為此目標選擇最相關的檢查。{{count}} 個可選檢查因適用性、設定或預算未納入本次執行。", + "skippedSummary_other": "BitFun 已為此目標選擇最相關的檢查。{{count}} 個可選檢查因適用性、設定或預算未納入本次執行。" }, "flowChatHeader": { "turnList": "輪次列表", @@ -1308,17 +1163,6 @@ "exporting": "正在導出...", "exportToImage": "導出為圖片" }, - "turnFailure": { - "showDetails": "顯示技術詳情", - "hideDetails": "隱藏技術詳情", - "provider": "服務商", - "errorCode": "錯誤碼", - "httpStatus": "HTTP 狀態", - "requestId": "請求 ID", - "providerError": "服務商錯誤", - "copy": "複製", - "copied": "已複製" - }, "modelRound": { "copyDialog": "複製對話", "copiedDialog": "已複製!", @@ -1331,6 +1175,7 @@ "feedbackDevVersion": "開發版本暫不提供反饋功能", "userLabel": "👤 用戶:", "toolCallLabel": "🔧 工具調用: {{name}}", + "loadingMoreHistory": "正在載入更多歷史...", "roundHistoryShow": "顯示 {{count}} 次歷史重試輪次", "roundHistoryHide": "隱藏歷史重試輪次", "roundRetryLabel": "重試輪次 {{index}}", @@ -1338,26 +1183,6 @@ "retryHistoryHide": "隱藏重試歷史", "attemptLabel": "第 {{index}} 次嘗試", "attemptSuperseded": "已被重試接替", - "attemptDiagnostics": { - "show": "顯示本次重試診斷詳情", - "hide": "隱藏本次重試診斷詳情", - "copy": "複製", - "copied": "已複製", - "providerError": "服務商錯誤", - "toolArguments": "工具參數:{{name}}", - "rawArguments": "原始參數", - "validationError": "解析或驗證錯誤", - "unknownTool": "未知工具", - "categories": { - "transientRequestError": "請求在服務商回應前失敗", - "interruptedToolArguments": "工具參數輸出被中斷", - "partialStreamError": "串流輸出以可復原錯誤結束", - "invalidToolArguments": "服務商回傳了不合法的工具參數", - "noEffectiveOutput": "服務商未回傳可用輸出", - "transientStreamError": "串流輸出在產生可用內容前失敗", - "unknown": "重試診斷:{{category}}" - } - }, "meta": { "label": "回應中繼資料", "completed": "完成", @@ -1507,7 +1332,6 @@ "deployingShort": "…", "untitled": "(未命名)", "deployFailed": "無法部署 BitFun Page 版本", - "openFailed": "無法為該頁面建立安全存取連結。", "labelSlug": "slug", "labelVersion": "version", "labelPath": "url_path", @@ -1518,7 +1342,6 @@ "publishingShort": "…", "untitled": "(未命名)", "publishFailed": "無法發佈 BitFun Page", - "openFailed": "無法為該頁面建立安全存取連結。", "labelSlug": "slug", "labelVersion": "version", "labelPath": "url_path", @@ -1605,11 +1428,6 @@ "loadingPlan": "正在載入計劃...", "generating": "生成中...", "clickToOpenPlan": "點擊開啟計劃檔案", - "saveToProject": "儲存到專案", - "savingToProject": "正在儲存計劃到專案...", - "saveToProjectUnavailable": "需要工作區才能儲存計劃", - "saveToProjectSuccess": "計劃已儲存至 .bitfun/plans", - "saveToProjectFailed": "儲存計劃到專案失敗", "revealPlanInExplorer": "在檔案管理器中定位計劃檔案", "revealPlanUnavailable": "僅本機工作區支援在檔案管理器中定位計劃檔案", "remainingTodos": "剩餘 {{count}} 個待辦", @@ -1684,10 +1502,6 @@ "openSettings": "打開系統設定", "openSettingsFailed": "無法打開系統設定" }, - "viewImage": { - "viewing": "正在查看圖像...", - "viewedImages": "已查看 {{count}} 張圖像" - }, "webFetch": { "parsingUrl": "解析連結中...", "readLabel": "讀取網頁:", @@ -1758,13 +1572,8 @@ "collapseDetails": "收起詳情", "expandDetails": "展開詳情", "openInPanel": "在面板中開啟詳情", - "reviewCoverageLabel": "補充檢查", - "reviewCoverageDescription": "正在補充審核證據", - "reviewFocusedDescription": "正在核查一個具體問題", - "reviewCheckUnavailable": "這項補充檢查未能完成,主要審核仍可繼續。", - "reviewPartialTimeout": "已逾時,但傳回了部分詳情", - "reviewTimedOut": "已逾時", - "reviewStopped": "已停止" + "reviewCoverageLabel": "Review 檢查", + "reviewCoverageDescription": "正在檢查 Review 覆蓋" }, "taskDetailPanel": { "untitled": "未命名任務", @@ -1790,9 +1599,7 @@ "loadingMore": "正在載入更多輸出...", "loading": "正在載入任務詳情...", "stopReviewWork": "停止此檢查", - "stoppingReviewWork": "正在停止此檢查...", - "stopReviewWorkFailed": "無法確認這項檢查是否已停止,它可能仍在執行。", - "stopReviewWorkHint": "僅取消這個檢查項目,整體審核仍可繼續並產生摘要。" + "stopReviewWorkHint": "僅取消這個檢查項,整體 Review 仍可繼續並產生摘要。" }, "timeout": { "disableTooltip": "關閉超時限制", @@ -2052,7 +1859,7 @@ "runManifest": { "recommendedStrategy": "推薦策略", "riskRecommendationTitle": "風險推薦", - "reviewDepth": "審核範圍", + "reviewDepth": "Review 範圍", "reviewDepthLabels": { "high_risk_only": "聚焦", "risk_expanded": "擴展", @@ -2060,12 +1867,12 @@ }, "target": "目標", "budget": "預算", - "estimatedCalls": "預計審核工作項", - "activeGroupTitle": "已完成的審核工作", - "skippedGroupTitle": "未執行的審核工作", - "reducedCoverageSummary": "{{count}} 項可選審核工作未執行;本次結果僅反映已完成的審核範圍。", - "reducedCoverageSummary_one": "{{count}} 項可選審核工作未執行;本次結果僅反映已完成的審核範圍。", - "reducedCoverageSummary_other": "{{count}} 項可選審核工作未執行;本次結果僅反映已完成的審核範圍。" + "estimatedCalls": "預計審核檢查", + "activeGroupTitle": "已覆蓋", + "skippedGroupTitle": "已選擇 Review 範圍", + "reducedCoverageSummary": "BitFun 已為此目標選擇最相關的檢查。{{count}} 個可選檢查因適用性、設定或預算未納入本次執行。", + "reducedCoverageSummary_one": "BitFun 已為此目標選擇最相關的檢查。{{count}} 個可選檢查因適用性、設定或預算未納入本次執行。", + "reducedCoverageSummary_other": "BitFun 已為此目標選擇最相關的檢查。{{count}} 個可選檢查因適用性、設定或預算未納入本次執行。" }, "sectionItemCount": "{{count}} 項", "remediationPlan": "修復計劃", @@ -2073,7 +1880,7 @@ "title": "審核狀態", "context_pressure": { "label": "上下文壓力上升", - "detail": "大型或受限目標已規劃 {{count}} 項審核工作。" + "detail": "大型或受限目標預計需要 {{count}} 項審核檢查。" }, "compression_preserved": { "label": "壓縮已保留關鍵事實", @@ -2096,8 +1903,8 @@ "detail": "{{count}} 個審核結果是部分結果,可信度有限。" }, "reduced_scope": { - "label": "審核範圍受限", - "detail": "本次審核只涵蓋有限範圍。" + "label": "聚焦 Review 範圍", + "detail": "本次 Review 使用了聚焦範圍設定。" }, "target_evidence_limited": { "label": "目標證據受限", @@ -2108,12 +1915,12 @@ "detail": "{{count}} 條重試指引用於補足部分審核覆蓋。" }, "skipped_reviewers": { - "label": "審核範圍已匹配", - "detail": "未執行的可選審核工作:{{count}} 項(適用性、設定或預算限制)。" + "label": "Review 範圍已匹配", + "detail": "{{count}} 個可選檢查因適用性、設定或預算未納入本次執行。" }, "token_budget_limited": { "label": "Token 預算限制了審核覆蓋", - "detail": "因 Token 預算未執行的可選審核工作:{{count}} 項。" + "detail": "Token 預算模式將 {{count}} 個可選檢查保留在本次範圍之外。" }, "user_decision": { "label": "需要使用者決策", @@ -2147,7 +1954,6 @@ "security": "安全覆蓋", "architecture": "架構覆蓋", "frontend": "前端覆蓋", - "focusedCheck": "補充檢查", "qualityGate": "品質檢查" }, "report": { @@ -2290,8 +2096,6 @@ "autoModelDesc": "自動路由最優模型", "primaryModel": "Primary 模型", "fastModel": "Fast 模型", - "fastMode": "Fast 模式", - "fastModeDescription": "1.5 倍速度,消耗更多額度", "modelNotConfigured": "未設定", "contextUsage": { "agentPrompt": "上次請求輸入上下文: {{usage}}", @@ -2299,6 +2103,21 @@ "toolNote": "工具輸出在後續請求中可能會被摘要或截斷。" } }, + "modelBrainstorm": { + "menuTitle": "頭腦風暴模型", + "disabledTooltip": "多模型頭腦風暴", + "enabledTooltip": "使用 {{count}} 個模型頭腦風暴", + "notEnoughModels": "至少需要設定兩個已啟用的聊天模型", + "needMoreModels": "頭腦風暴模式至少選擇 {{count}} 個模型", + "launchFailed": "頭腦風暴啟動失敗", + "groupEyebrow": "多模型頭腦風暴", + "progress": "{{completed}}/{{total}} 已完成", + "readonlyToolMetric": "{{count}} 個唯讀工具", + "thinkingMetric": "{{value}} 推理字元", + "noReadonlyTools": "未使用唯讀工具", + "selectCandidate": "沿此模型繼續", + "selectedCandidate": "已選擇" + }, "reasoningEffort": { "title": "推理深度", "tooltip": "調整模型推理深度(僅 Responses API)", @@ -2453,10 +2272,5 @@ }, "actions": { "send": "傳送" - }, - "miniAppComposer": { - "placeholder": "把需求傳給 {{app}}…", - "send": "傳送", - "hint": "訊息將由 {{app}} 處理" } } From 15fc1eadf63ebc364a65f1071b326fda6dd5d537 Mon Sep 17 00:00:00 2001 From: weixin_44319083 Date: Sat, 25 Jul 2026 14:11:03 +0800 Subject: [PATCH 2/4] fix: stabilize model brainstorm follow-up context --- .../src/flow_chat/components/ChatInput.tsx | 73 ++++++- .../components/ModelBrainstormControl.scss | 15 +- .../components/ModelBrainstormControl.tsx | 1 + .../components/RichTextInput.test.tsx | 96 ++++----- .../flow_chat/components/RichTextInput.tsx | 166 ++------------- .../modern/ModelBrainstormGroup.tsx | 54 ++++- .../services/ModelBrainstormService.ts | 96 +++++++-- .../store/modelBrainstormStore.test.ts | 128 ++++++++++++ .../flow_chat/store/modelBrainstormStore.ts | 189 +++++++++++++++++- src/web-ui/src/locales/en-US/flow-chat.json | 3 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 3 +- src/web-ui/src/locales/zh-TW/flow-chat.json | 3 +- 12 files changed, 572 insertions(+), 255 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index bb57499767..afe9bee1ce 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -667,6 +667,29 @@ export const ChatInput: React.FC = ({ const [isModelSwitching, setIsModelSwitching] = useState(false); const [brainstormEnabled, setBrainstormEnabled] = useState(false); const [brainstormModelIds, setBrainstormModelIds] = useState([]); + const brainstormSubmitStateRef = useRef<{ + enabled: boolean; + modelIds: string[]; + canUse: boolean; + }>({ + enabled: false, + modelIds: [], + canUse: false, + }); + 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 isAssistantWorkspace = useMemo( () => resolveSessionAssistantWorkspace({ currentWorkspace: workspace, @@ -917,11 +940,19 @@ export const ChatInput: React.FC = ({ const targetWorkspacePath = (workspacePath || effectiveTargetSession?.workspacePath || '').trim(); const canUseModelBrainstorm = !isAcpInputSession && !isBtwSession && !isSubagentInputTarget; + useEffect(() => { + brainstormSubmitStateRef.current = { + enabled: brainstormEnabled, + modelIds: brainstormModelIds, + canUse: canUseModelBrainstorm, + }; + }, [brainstormEnabled, brainstormModelIds, canUseModelBrainstorm]); + useEffect(() => { if (!canUseModelBrainstorm && brainstormEnabled) { - setBrainstormEnabled(false); + updateBrainstormEnabled(false); } - }, [brainstormEnabled, canUseModelBrainstorm]); + }, [brainstormEnabled, canUseModelBrainstorm, updateBrainstormEnabled]); useEffect(() => { if (!isSubagentInputTarget) { @@ -2717,12 +2748,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 () => { if (!derivedState) return; const { sendButtonMode } = derivedState; - const draftTrimmed = 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. @@ -2833,8 +2876,10 @@ export const ChatInput: React.FC = ({ return; } - if (brainstormEnabled && canUseModelBrainstorm) { - if (brainstormModelIds.length < MODEL_BRAINSTORM_MIN_CANDIDATES) { + 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 }) ); @@ -2851,6 +2896,11 @@ export const ChatInput: React.FC = ({ setQueuedInput(null); try { + log.debug('Launching model brainstorm from chat input', { + sourceSessionId: effectiveTargetSessionId || undefined, + modelCount: modelIdsForBrainstorm.length, + modelIds: modelIdsForBrainstorm, + }); await launchModelBrainstorm({ message, displayMessage: originalMessage, @@ -2858,7 +2908,7 @@ export const ChatInput: React.FC = ({ sourceSessionId: effectiveTargetSessionId || undefined, workspaceConfig: flowChatSessionConfigForCurrentWorkspace(workspace), agentType: effectiveSendAgentType, - modelIds: brainstormModelIds, + modelIds: modelIdsForBrainstorm, }); clearContexts(); dispatchInput({ type: 'CLEAR_VALUE' }); @@ -2913,7 +2963,7 @@ export const ChatInput: React.FC = ({ brainstormEnabled, brainstormModelIds, canUseModelBrainstorm, - inputState.value, + getCurrentDraftValue, derivedState, handleCancelCurrentTask, transition, @@ -3614,6 +3664,7 @@ export const ChatInput: React.FC = ({ if (!derivedState) return ; const { sendButtonMode, hasQueuedInput } = derivedState; + const hasSendableDraft = Boolean(getCurrentDraftValue().trim()); if (sendButtonMode === 'cancel') { return ( @@ -3661,7 +3712,7 @@ export const ChatInput: React.FC = ({ = ({ = ({ enabled={brainstormEnabled} selectedModelIds={brainstormModelIds} disabled={!!derivedState?.isProcessing || isModelSwitching} - onEnabledChange={setBrainstormEnabled} - onSelectedModelIdsChange={setBrainstormModelIds} + onEnabledChange={updateBrainstormEnabled} + onSelectedModelIdsChange={updateBrainstormModelIds} /> )} = ({ }} > + {t('modelBrainstorm.triggerLabel')} {enabled && ( {selectedCount} )} 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 6016a80acf..aa81b6fbbd 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 1e539ed61c..6e871fdb0b 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(
+
+ + {t('modelBrainstorm.contextModeLabel')} + +
+ {(['independent', 'shared'] as const).map(mode => ( + + ))} +
+
{models.map(model => { const selected = selectedAvailableModelIds.includes(model.id); diff --git a/src/web-ui/src/flow_chat/components/modern/ModelBrainstormGroup.tsx b/src/web-ui/src/flow_chat/components/modern/ModelBrainstormGroup.tsx index 529eefea91..1ccca77100 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelBrainstormGroup.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelBrainstormGroup.tsx @@ -180,7 +180,9 @@ export const ModelBrainstormGroup: React.FC = ({ batc const projections = batch.candidates.map(candidate => projectCandidate(batch, candidate)); const completedCount = projections.filter(projection => projection.status === 'completed').length; - const selectedCandidateId = batch.selectedCandidateId; + const selectedCandidateIds = + batch.selectedCandidateIds ?? + (batch.selectedCandidateId ? [batch.selectedCandidateId] : []); const statusLabels: Record = { pending: t('shared:statuses.loading'), starting: t('shared:statuses.loading'), @@ -197,18 +199,12 @@ export const ModelBrainstormGroup: React.FC = ({ batc } const answer = extractAssistantAnswer(projection.turn); - useModelBrainstormStore.getState().selectCandidate(batch.id, projection.candidate.id); - if (answer) { - useModelBrainstormStore.getState().setPublicContextForSession(batch.sourceSessionId, { - sessionId: batch.sourceSessionId, - batchId: batch.id, - candidateId: projection.candidate.id, - modelId: projection.candidate.modelId, - modelLabel: projection.candidate.modelLabel, - answer, - createdAt: Date.now(), - }); + if (!answer) { + return; } + useModelBrainstormStore + .getState() + .toggleCandidatePublicSelection(batch.id, projection.candidate.id, answer); }; return ( @@ -232,7 +228,7 @@ export const ModelBrainstormGroup: React.FC = ({ batc
{projections.map(projection => { - const selected = selectedCandidateId === projection.candidate.id; + const selected = selectedCandidateIds.includes(projection.candidate.id); const statusLabel = statusLabels[projection.status]; const readonlyTooltip = projection.readonlyToolNames.length > 0 ? projection.readonlyToolNames.join(', ') diff --git a/src/web-ui/src/flow_chat/services/ModelBrainstormService.ts b/src/web-ui/src/flow_chat/services/ModelBrainstormService.ts index fa6ea52143..ceea695ea7 100644 --- a/src/web-ui/src/flow_chat/services/ModelBrainstormService.ts +++ b/src/web-ui/src/flow_chat/services/ModelBrainstormService.ts @@ -1,14 +1,21 @@ import { configManager } from '@/infrastructure/config/services/ConfigManager'; import { getProviderDisplayName } from '@/infrastructure/config/services/modelConfigs'; import type { AIModelConfig } from '@/infrastructure/config/types'; -import { createLogger } from '@/shared/utils/logger'; +import { + areSensitiveDiagnosticsEnabled, + createLogger, + logger, + LogLevel, +} from '@/shared/utils/logger'; import type { ContextItem, ImageContext } from '@/shared/types/context'; -import type { SessionConfig } from '../types/flow-chat'; +import type { DialogTurn, FlowTextItem, SessionConfig } from '../types/flow-chat'; import { FlowChatManager } from './FlowChatManager'; import { useModelBrainstormStore, + type ModelBrainstormBatch, type ModelBrainstormCandidate, - type ModelBrainstormPublicContext, + type ModelBrainstormContextMode, + type ModelBrainstormPublicSelection, } from '../store/modelBrainstormStore'; import { buildImagePayload } from '../utils/imagePayload'; import { buildPromptMessage, stripInlineImageTags } from '../utils/messagePrompt'; @@ -17,6 +24,7 @@ const log = createLogger('ModelBrainstormService'); export const MODEL_BRAINSTORM_MAX_CANDIDATES = 4; export const MODEL_BRAINSTORM_MIN_CANDIDATES = 2; +const LEDGER_LOG_PREVIEW_MAX_CHARS = 360; interface BrainstormModelInfo { id: string; @@ -32,6 +40,7 @@ export interface LaunchModelBrainstormRequest { workspaceConfig: SessionConfig; agentType: string; modelIds: string[]; + contextMode: ModelBrainstormContextMode; } export interface LaunchModelBrainstormResult { @@ -91,31 +100,262 @@ function buildCandidateSessionTitle(model: BrainstormModelInfo): string { return `Brainstorm · ${model.label}`; } -function buildPromptWithPublicContext( +function normalizeAnswerKey(answer: string): string { + return answer.replace(/\s+/g, ' ').trim().toLowerCase(); +} + +function shouldLogLedgerDiagnostics(): boolean { + return logger.getLevel() <= LogLevel.DEBUG; +} + +function previewForLog(value: string | undefined, maxChars = LEDGER_LOG_PREVIEW_MAX_CHARS): string | undefined { + if (!value || !areSensitiveDiagnosticsEnabled()) { + return undefined; + } + + const normalized = value.replace(/\s+/g, ' ').trim(); + if (normalized.length <= maxChars) { + return normalized; + } + + return `${normalized.slice(0, maxChars)}...`; +} + +function extractAssistantAnswer(turn: DialogTurn | undefined): string { + if (!turn) { + return ''; + } + + const roundsFromLatestFirst = [...turn.modelRounds].reverse(); + for (const round of roundsFromLatestFirst) { + const text = round.items + .filter((item): item is FlowTextItem => item.type === 'text') + .filter(item => !item.runtimeStatus) + .map(item => item.content.trim()) + .filter(Boolean) + .join('\n\n') + .trim(); + + if (text) { + return text; + } + } + + return ''; +} + +function getCandidateTurn( + batch: ModelBrainstormBatch, + candidate: ModelBrainstormCandidate, + manager: FlowChatManager, +): DialogTurn | undefined { + const session = candidate.sessionId + ? manager.getFlowChatState().sessions.get(candidate.sessionId) + : undefined; + return session?.dialogTurns.find(dialogTurn => + dialogTurn.userMessage?.metadata?.brainstormBatchId === batch.id && + dialogTurn.userMessage?.metadata?.brainstormCandidateId === candidate.id + ) ?? session?.dialogTurns[session.dialogTurns.length - 1]; +} + +function hydrateCompletedCandidateAnswers( + sourceSessionId: string, + manager: FlowChatManager, +): ModelBrainstormBatch[] { + const store = useModelBrainstormStore.getState(); + const batches = store.getBatchesForSession(sourceSessionId); + + for (const batch of batches) { + for (const candidate of batch.candidates) { + if (candidate.answer?.trim()) { + continue; + } + + const turn = getCandidateTurn(batch, candidate, manager); + if (turn?.status !== 'completed') { + continue; + } + + const answer = extractAssistantAnswer(turn); + if (answer) { + store.setCandidateAnswer(batch.id, candidate.id, answer, turn.endTime); + } + } + } + + return useModelBrainstormStore.getState().getBatchesForSession(sourceSessionId); +} + +function appendPublicSelections( + lines: string[], + publicSelections: ModelBrainstormPublicSelection[], + seenAnswerKeys: Set, +): void { + const uniqueSelections = publicSelections.filter(selection => { + const key = normalizeAnswerKey(selection.answer); + if (!key || seenAnswerKeys.has(key)) { + return false; + } + seenAnswerKeys.add(key); + return true; + }); + + if (uniqueSelections.length === 0) { + return; + } + + lines.push('Public selections from this round:'); + uniqueSelections.forEach(selection => { + lines.push(`${selection.modelLabel}:`); + lines.push(selection.answer.trim()); + lines.push(''); + }); +} + +function buildLedgerPrompt( promptMessage: string, - publicContext: ModelBrainstormPublicContext | undefined, + targetModel: BrainstormModelInfo, + contextMode: ModelBrainstormContextMode, + previousBatches: ModelBrainstormBatch[], ): string { - const publicAnswer = publicContext?.answer.trim(); - if (!publicContext || !publicAnswer) { + if (previousBatches.length === 0) { return promptMessage; } - return [ - 'In the previous brainstorm round, the user selected the final answer below.', - 'This selected answer is now public context for the current brainstorm round. Every candidate model must incorporate it into the current context.', - 'If the current question refers to "this", "it", "the answer above", or similar wording, treat that as referring to this selected public answer. Do not claim you cannot see the previous selection.', - 'You may still reason independently and point out concerns, but first acknowledge that this selected public answer is the user-chosen conclusion from the previous round.', - '', - `Selected model: ${publicContext.modelLabel}`, - '', - 'Selected public answer:', - publicAnswer, + const lines = [ + 'You are participating in a multi-model brainstorm.', + contextMode === 'independent' + ? 'Context mode: independent. Use your own previous answers plus the user-selected public answers.' + : 'Context mode: shared. Use only user questions and user-selected public answers; ignore private answers that were not selected.', + 'The ledger below is the authoritative context for this brainstorm. Public selections are user-approved shared context.', + 'Do not claim you cannot see selected public answers that appear in the ledger.', '', + ]; + + previousBatches.forEach((batch, index) => { + const seenAnswerKeys = new Set(); + const publicSelections = batch.publicSelections ?? []; + lines.push(`Round ${index + 1} user question:`); + lines.push(batch.question || batch.displayQuestion); + lines.push(''); + + if (contextMode === 'independent') { + const ownCandidate = batch.candidates.find(candidate => + candidate.modelId === targetModel.id && candidate.answer?.trim() + ); + if (ownCandidate?.answer) { + const wasPublic = publicSelections.some(selection => selection.candidateId === ownCandidate.id); + lines.push(`${ownCandidate.modelLabel} previous answer${wasPublic ? ' (selected public answer)' : ''}:`); + lines.push(ownCandidate.answer.trim()); + lines.push(''); + seenAnswerKeys.add(normalizeAnswerKey(ownCandidate.answer)); + } + } + + appendPublicSelections(lines, publicSelections, seenAnswerKeys); + }); + + return [ + ...lines, 'Current user question:', promptMessage, ].join('\n'); } +function buildLedgerLogSummary( + targetModel: BrainstormModelInfo, + contextMode: ModelBrainstormContextMode, + previousBatches: ModelBrainstormBatch[], +) { + return previousBatches.map((batch, index) => { + const publicSelections = batch.publicSelections ?? []; + const seenAnswerKeys = new Set(); + const ownCandidate = contextMode === 'independent' + ? batch.candidates.find(candidate => + candidate.modelId === targetModel.id && candidate.answer?.trim() + ) + : undefined; + const ownAnswer = ownCandidate?.answer?.trim(); + const includedOwnAnswer = ownCandidate && ownAnswer + ? { + candidateId: ownCandidate.id, + modelId: ownCandidate.modelId, + modelLabel: ownCandidate.modelLabel, + answerChars: ownAnswer.length, + answerPreview: previewForLog(ownAnswer), + alsoPublic: publicSelections.some(selection => selection.candidateId === ownCandidate.id), + } + : undefined; + + if (ownAnswer) { + seenAnswerKeys.add(normalizeAnswerKey(ownAnswer)); + } + + const includedPublicSelections: Array> = []; + const skippedDuplicatePublicSelections: Array> = []; + for (const selection of publicSelections) { + const answer = selection.answer.trim(); + const key = normalizeAnswerKey(answer); + const summary = { + candidateId: selection.candidateId, + modelId: selection.modelId, + modelLabel: selection.modelLabel, + answerChars: answer.length, + answerPreview: previewForLog(answer), + }; + + if (!key || seenAnswerKeys.has(key)) { + skippedDuplicatePublicSelections.push(summary); + continue; + } + + seenAnswerKeys.add(key); + includedPublicSelections.push(summary); + } + + const privateAnswersNotIncluded = batch.candidates + .filter(candidate => { + const hasAnswer = Boolean(candidate.answer?.trim()); + if (!hasAnswer) { + return false; + } + if (includedOwnAnswer?.candidateId === candidate.id) { + return false; + } + return !publicSelections.some(selection => selection.candidateId === candidate.id); + }) + .map(candidate => ({ + candidateId: candidate.id, + modelId: candidate.modelId, + modelLabel: candidate.modelLabel, + answerChars: candidate.answer?.trim().length ?? 0, + })); + + const question = batch.question || batch.displayQuestion; + return { + round: index + 1, + batchId: batch.id, + sourceSessionId: batch.sourceSessionId, + questionChars: question.length, + questionPreview: previewForLog(question), + includedOwnAnswer, + includedPublicSelections, + skippedDuplicatePublicSelections, + privateAnswersNotIncluded, + }; + }); +} + +function buildDisplayPromptWithLedgerNote(promptMessage: string, contextMode: ModelBrainstormContextMode): string { + return [ + contextMode === 'independent' + ? 'Use the independent brainstorm ledger supplied above the current question.' + : 'Use the shared brainstorm ledger supplied above the current question.', + '', + promptMessage, + ].join('\n'); +} + async function ensureSourceSession( manager: FlowChatManager, sourceSessionId: string | undefined, @@ -137,6 +377,7 @@ export async function launchModelBrainstorm( const displayMessage = request.displayMessage?.trim() || request.message.trim(); const basePromptMessage = buildPromptMessage(stripInlineImageTags(request.message), request.contexts); const selectedModels = await resolveSelectedModels(request.modelIds); + const contextMode = request.contextMode; if (selectedModels.length < MODEL_BRAINSTORM_MIN_CANDIDATES) { throw new Error('Select at least two enabled chat models for brainstorm mode.'); @@ -151,11 +392,7 @@ export async function launchModelBrainstorm( request.workspaceConfig, request.agentType, ); - const pendingPublicContext = useModelBrainstormStore.getState().getPublicContextForSession(sourceSessionId); - const promptMessage = buildPromptWithPublicContext(basePromptMessage, pendingPublicContext); - const reusableCandidateSessions = useModelBrainstormStore - .getState() - .getReusableCandidateSessionsForSession(sourceSessionId); + const previousBatches = hydrateCompletedCandidateAnswers(sourceSessionId, manager); const batchId = buildBatchId(); const candidates: ModelBrainstormCandidate[] = selectedModels.map((model, index) => ({ id: buildCandidateId(batchId, model.id, index), @@ -167,10 +404,14 @@ export async function launchModelBrainstorm( useModelBrainstormStore.getState().createBatch({ id: batchId, + roomId: sourceSessionId, sourceSessionId, - question: promptMessage, + contextMode, + question: basePromptMessage, displayQuestion: displayMessage, createdAt: Date.now(), + selectedCandidateIds: [], + publicSelections: [], candidates, }); @@ -181,37 +422,55 @@ export async function launchModelBrainstorm( try { updateCandidate({ status: 'starting' }); - const reusableSession = reusableCandidateSessions[candidate.modelId]; - let sessionId = reusableSession?.sessionId; - if (!sessionId) { - sessionId = await manager.createChatSession( - { - ...request.workspaceConfig, - modelName: candidate.modelId, - }, - request.agentType, - { - activate: false, - title: buildCandidateSessionTitle({ + const promptMessage = buildLedgerPrompt( + basePromptMessage, + { + id: candidate.modelId, + label: candidate.modelLabel, + providerName: candidate.providerName || '', + }, + contextMode, + previousBatches, + ); + if (shouldLogLedgerDiagnostics()) { + log.debug('Prepared model brainstorm candidate context', { + batchId, + sourceSessionId, + candidateId: candidate.id, + modelId: candidate.modelId, + modelLabel: candidate.modelLabel, + contextMode, + previousRoundCount: previousBatches.length, + promptChars: promptMessage.length, + promptPreview: previewForLog(promptMessage, 800), + ledger: buildLedgerLogSummary( + { id: candidate.modelId, label: candidate.modelLabel, providerName: candidate.providerName || '', - }), - }, - ); + }, + contextMode, + previousBatches, + ), + }); } + const sessionId = await manager.createChatSession( + { + ...request.workspaceConfig, + modelName: candidate.modelId, + }, + request.agentType, + { + activate: false, + title: buildCandidateSessionTitle({ + id: candidate.modelId, + label: candidate.modelLabel, + providerName: candidate.providerName || '', + }), + }, + ); updateCandidate({ sessionId, status: 'running' }); - if (reusableSession) { - log.debug('Reusing brainstorm candidate session history', { - batchId, - candidateId: candidate.id, - modelId: candidate.modelId, - sessionId, - previousBatchId: reusableSession.batchId, - previousCandidateId: reusableSession.candidateId, - }); - } await manager.sendMessage( promptMessage, sessionId, @@ -225,10 +484,8 @@ export async function launchModelBrainstorm( brainstormBatchId: batchId, brainstormCandidateId: candidate.id, brainstormModelId: candidate.modelId, - brainstormPublicContextBatchId: pendingPublicContext?.batchId, - brainstormPublicContextCandidateId: pendingPublicContext?.candidateId, - brainstormReusedSessionBatchId: reusableSession?.batchId, - brainstormReusedSessionCandidateId: reusableSession?.candidateId, + brainstormContextMode: contextMode, + brainstormLedgerDisplayPrompt: buildDisplayPromptWithLedgerNote(basePromptMessage, contextMode), }, }, ); @@ -254,16 +511,6 @@ export async function launchModelBrainstorm( if (launchedCount === 0) { throw new Error('Failed to launch any brainstorm candidates.'); } - if (pendingPublicContext) { - useModelBrainstormStore.getState().consumePublicContextForSession(sourceSessionId); - log.debug('Consumed selected brainstorm answer as public context', { - sourceSessionId, - batchId, - publicContextBatchId: pendingPublicContext.batchId, - publicContextCandidateId: pendingPublicContext.candidateId, - modelId: pendingPublicContext.modelId, - }); - } return { batchId, diff --git a/src/web-ui/src/flow_chat/store/modelBrainstormStore.test.ts b/src/web-ui/src/flow_chat/store/modelBrainstormStore.test.ts index 2b952c818a..6362dce71c 100644 --- a/src/web-ui/src/flow_chat/store/modelBrainstormStore.test.ts +++ b/src/web-ui/src/flow_chat/store/modelBrainstormStore.test.ts @@ -5,6 +5,7 @@ function makeBatch(): ModelBrainstormBatch { return { id: 'batch-1', sourceSessionId: 'source-1', + contextMode: 'independent', question: 'Question with context', displayQuestion: 'Question', createdAt: 100, @@ -50,9 +51,11 @@ describe('modelBrainstormStore', () => { useModelBrainstormStore.getState().createBatch(makeBatch()); useModelBrainstormStore.getState().selectCandidate('batch-1', 'missing'); expect(useModelBrainstormStore.getState().batches['batch-1'].selectedCandidateId).toBeUndefined(); + expect(useModelBrainstormStore.getState().batches['batch-1'].selectedCandidateIds).toEqual([]); useModelBrainstormStore.getState().selectCandidate('batch-1', 'candidate-b'); expect(useModelBrainstormStore.getState().batches['batch-1'].selectedCandidateId).toBe('candidate-b'); + expect(useModelBrainstormStore.getState().batches['batch-1'].selectedCandidateIds).toEqual(['candidate-b']); }); it('removes batches for a source session', () => { @@ -70,55 +73,44 @@ describe('modelBrainstormStore', () => { expect(useModelBrainstormStore.getState().order).toEqual(['batch-2']); }); - it('stores selected candidate output as one-time public context', () => { - useModelBrainstormStore.getState().setPublicContextForSession('session-a', { - sessionId: 'session-a', - batchId: 'batch-1', - candidateId: 'candidate-a', - modelId: 'model-a', - modelLabel: 'Model A', - answer: 'Selected answer', - createdAt: 200, - }); - - expect(useModelBrainstormStore.getState().publicContexts['session-a']?.answer).toBe('Selected answer'); - - const context = useModelBrainstormStore.getState().consumePublicContextForSession('session-a'); - expect(context?.answer).toBe('Selected answer'); - expect(useModelBrainstormStore.getState().publicContexts['session-a']).toBeUndefined(); - expect(useModelBrainstormStore.getState().consumePublicContextForSession('session-a')).toBeUndefined(); + it('stores completed candidate answers on the candidate ledger', () => { + useModelBrainstormStore.getState().createBatch(makeBatch()); + useModelBrainstormStore.getState().setCandidateAnswer( + 'batch-1', + 'candidate-a', + ' Candidate answer ', + 200, + ); + + const candidate = useModelBrainstormStore.getState().batches['batch-1'].candidates[0]; + expect(candidate.answer).toBe('Candidate answer'); + expect(candidate.completedAt).toBe(200); }); - it('finds and consumes public context across the brainstorm lineage', () => { - useModelBrainstormStore.getState().createBatch({ - ...makeBatch(), - sourceSessionId: 'source-root', - candidates: [ - { - id: 'candidate-a', - modelId: 'model-a', - modelLabel: 'Model A', - sessionId: 'session-a', - status: 'running', - }, - ], - }); - useModelBrainstormStore.getState().setPublicContextForSession('session-a', { - sessionId: 'session-a', - batchId: 'batch-1', - candidateId: 'candidate-a', - modelId: 'model-a', - modelLabel: 'Model A', - answer: 'Selected answer from candidate session', - createdAt: 300, - }); + it('toggles multiple public selections and keeps selected answers on the ledger', () => { + useModelBrainstormStore.getState().createBatch(makeBatch()); + useModelBrainstormStore + .getState() + .toggleCandidatePublicSelection('batch-1', 'candidate-a', 'Answer A'); + useModelBrainstormStore + .getState() + .toggleCandidatePublicSelection('batch-1', 'candidate-b', 'Answer B'); + + let batch = useModelBrainstormStore.getState().batches['batch-1']; + expect(batch.selectedCandidateId).toBe('candidate-a'); + expect(batch.selectedCandidateIds).toEqual(['candidate-a', 'candidate-b']); + expect(batch.publicSelections?.map(selection => selection.answer)).toEqual(['Answer A', 'Answer B']); + expect(batch.candidates[0].answer).toBe('Answer A'); + expect(batch.candidates[1].answer).toBe('Answer B'); - expect(useModelBrainstormStore.getState().getPublicContextForSession('source-root')?.answer) - .toBe('Selected answer from candidate session'); + useModelBrainstormStore + .getState() + .toggleCandidatePublicSelection('batch-1', 'candidate-a', 'Answer A'); - const context = useModelBrainstormStore.getState().consumePublicContextForSession('source-root'); - expect(context?.candidateId).toBe('candidate-a'); - expect(useModelBrainstormStore.getState().publicContexts['session-a']).toBeUndefined(); + batch = useModelBrainstormStore.getState().batches['batch-1']; + expect(batch.selectedCandidateId).toBe('candidate-b'); + expect(batch.selectedCandidateIds).toEqual(['candidate-b']); + expect(batch.publicSelections?.map(selection => selection.candidateId)).toEqual(['candidate-b']); }); it('finds the latest reusable candidate session in the same brainstorm lineage', () => { diff --git a/src/web-ui/src/flow_chat/store/modelBrainstormStore.ts b/src/web-ui/src/flow_chat/store/modelBrainstormStore.ts index 8cbf2e9549..5c450451e8 100644 --- a/src/web-ui/src/flow_chat/store/modelBrainstormStore.ts +++ b/src/web-ui/src/flow_chat/store/modelBrainstormStore.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { useShallow } from 'zustand/react/shallow'; export type ModelBrainstormCandidateStatus = 'pending' | 'starting' | 'running' | 'failed'; +export type ModelBrainstormContextMode = 'independent' | 'shared'; export interface ModelBrainstormCandidate { id: string; @@ -11,28 +12,33 @@ export interface ModelBrainstormCandidate { sessionId?: string; status: ModelBrainstormCandidateStatus; error?: string; + answer?: string; + completedAt?: number; +} + +export interface ModelBrainstormPublicSelection { + id: string; + candidateId: string; + modelId: string; + modelLabel: string; + answer: string; + createdAt: number; } export interface ModelBrainstormBatch { id: string; + roomId?: string; sourceSessionId: string; + contextMode?: ModelBrainstormContextMode; question: string; displayQuestion: string; createdAt: number; selectedCandidateId?: string; + selectedCandidateIds?: string[]; + publicSelections?: ModelBrainstormPublicSelection[]; candidates: ModelBrainstormCandidate[]; } -export interface ModelBrainstormPublicContext { - sessionId: string; - batchId: string; - candidateId: string; - modelId: string; - modelLabel: string; - answer: string; - createdAt: number; -} - export interface ModelBrainstormReusableCandidateSession { sessionId: string; batchId: string; @@ -45,20 +51,26 @@ export interface ModelBrainstormReusableCandidateSession { interface ModelBrainstormState { batches: Record; order: string[]; - publicContexts: Record; createBatch: (batch: ModelBrainstormBatch) => void; updateCandidate: ( batchId: string, candidateId: string, updates: Partial, ) => void; + setCandidateAnswer: ( + batchId: string, + candidateId: string, + answer: string, + completedAt?: number, + ) => void; selectCandidate: (batchId: string, candidateId: string) => void; - setPublicContextForSession: ( - sessionId: string, - context: ModelBrainstormPublicContext, + toggleCandidatePublicSelection: ( + batchId: string, + candidateId: string, + answer: string, + selected?: boolean, ) => void; - getPublicContextForSession: (sessionId: string) => ModelBrainstormPublicContext | undefined; - consumePublicContextForSession: (sessionId: string) => ModelBrainstormPublicContext | undefined; + getBatchesForSession: (sessionId: string) => ModelBrainstormBatch[]; getReusableCandidateSessionsForSession: ( sessionId: string, ) => Record; @@ -66,6 +78,21 @@ interface ModelBrainstormState { reset: () => void; } +function normalizeBatch(batch: ModelBrainstormBatch): ModelBrainstormBatch { + const selectedCandidateIds = + batch.selectedCandidateIds ?? + (batch.selectedCandidateId ? [batch.selectedCandidateId] : []); + + return { + ...batch, + roomId: batch.roomId || batch.sourceSessionId, + contextMode: batch.contextMode || 'independent', + selectedCandidateIds, + selectedCandidateId: selectedCandidateIds[0], + publicSelections: batch.publicSelections ?? [], + }; +} + function getBatchSessionIds(batch: ModelBrainstormBatch): string[] { return [ batch.sourceSessionId, @@ -102,44 +129,43 @@ function getConnectedSessionIds( return connectedSessionIds; } -function findPublicContextEntry( - state: Pick, +function getRelatedBatches( + batches: Record, + order: string[], sessionId: string, -): [string, ModelBrainstormPublicContext] | undefined { - const directContext = state.publicContexts[sessionId]; - if (directContext) { - return [sessionId, directContext]; - } - - const connectedSessionIds = getConnectedSessionIds(state.batches, sessionId); - let latestEntry: [string, ModelBrainstormPublicContext] | undefined; - for (const [publicContextSessionId, publicContext] of Object.entries(state.publicContexts)) { - if (!connectedSessionIds.has(publicContextSessionId)) { - continue; - } - - if (!latestEntry || publicContext.createdAt > latestEntry[1].createdAt) { - latestEntry = [publicContextSessionId, publicContext]; - } +): ModelBrainstormBatch[] { + const seedSessionId = sessionId.trim(); + if (!seedSessionId) { + return []; } - return latestEntry; + const connectedSessionIds = getConnectedSessionIds(batches, seedSessionId); + return order + .map(batchId => batches[batchId]) + .filter((batch): batch is ModelBrainstormBatch => { + if (!batch) { + return false; + } + return getBatchSessionIds(batch).some(batchSessionId => connectedSessionIds.has(batchSessionId)); + }); } export const useModelBrainstormStore = create((set, get) => ({ batches: {}, order: [], - publicContexts: {}, - - createBatch: (batch) => set((state) => ({ - batches: { - ...state.batches, - [batch.id]: batch, - }, - order: state.order.includes(batch.id) - ? state.order - : [...state.order, batch.id], - })), + + createBatch: (batch) => set((state) => { + const nextBatch = normalizeBatch(batch); + return { + batches: { + ...state.batches, + [nextBatch.id]: nextBatch, + }, + order: state.order.includes(nextBatch.id) + ? state.order + : [...state.order, nextBatch.id], + }; + }), updateCandidate: (batchId, candidateId, updates) => set((state) => { const batch = state.batches[batchId]; @@ -174,9 +200,30 @@ export const useModelBrainstormStore = create((set, get) = }; }), - selectCandidate: (batchId, candidateId) => set((state) => { + setCandidateAnswer: (batchId, candidateId, answer, completedAt) => set((state) => { const batch = state.batches[batchId]; - if (!batch || !batch.candidates.some(candidate => candidate.id === candidateId)) { + const trimmedAnswer = answer.trim(); + if (!batch || !trimmedAnswer) { + return state; + } + + let changed = false; + const candidates = batch.candidates.map(candidate => { + if (candidate.id !== candidateId) { + return candidate; + } + if (candidate.answer === trimmedAnswer && candidate.completedAt === completedAt) { + return candidate; + } + changed = true; + return { + ...candidate, + answer: trimmedAnswer, + completedAt: completedAt ?? candidate.completedAt ?? Date.now(), + }; + }); + + if (!changed) { return state; } @@ -185,80 +232,93 @@ export const useModelBrainstormStore = create((set, get) = ...state.batches, [batchId]: { ...batch, - selectedCandidateId: candidateId, + candidates, }, }, }; }), - setPublicContextForSession: (sessionId, context) => set((state) => ({ - publicContexts: { - ...state.publicContexts, - [sessionId]: context, - }, - })), - - getPublicContextForSession: (sessionId) => { - const seedSessionId = sessionId.trim(); - if (!seedSessionId) { - return undefined; + selectCandidate: (batchId, candidateId) => set((state) => { + const batch = state.batches[batchId]; + if (!batch || !batch.candidates.some(candidate => candidate.id === candidateId)) { + return state; } - return findPublicContextEntry(get(), seedSessionId)?.[1]; - }, - - consumePublicContextForSession: (sessionId) => { - const seedSessionId = sessionId.trim(); - if (!seedSessionId) { - return undefined; - } + return { + batches: { + ...state.batches, + [batchId]: { + ...batch, + selectedCandidateId: candidateId, + selectedCandidateIds: [candidateId], + }, + }, + }; + }), - const entry = findPublicContextEntry(get(), seedSessionId); - if (!entry) { - return undefined; + toggleCandidatePublicSelection: (batchId, candidateId, answer, selected) => set((state) => { + const batch = state.batches[batchId]; + const candidate = batch?.candidates.find(item => item.id === candidateId); + const trimmedAnswer = answer.trim(); + if (!batch || !candidate || !trimmedAnswer) { + return state; } - const [publicContextSessionId, context] = entry; - set((state) => { - if (!state.publicContexts[publicContextSessionId]) { - return state; - } - - const nextPublicContexts = { ...state.publicContexts }; - delete nextPublicContexts[publicContextSessionId]; + const currentSelectedIds = batch.selectedCandidateIds ?? (batch.selectedCandidateId ? [batch.selectedCandidateId] : []); + const isSelected = currentSelectedIds.includes(candidateId); + const shouldSelect = selected ?? !isSelected; + const nextSelectedIds = shouldSelect + ? [...currentSelectedIds.filter(id => id !== candidateId), candidateId] + : currentSelectedIds.filter(id => id !== candidateId); + const currentSelections = batch.publicSelections ?? []; + const nextPublicSelections = shouldSelect + ? [ + ...currentSelections.filter(selection => selection.candidateId !== candidateId), + { + id: `${batchId}:${candidateId}`, + candidateId, + modelId: candidate.modelId, + modelLabel: candidate.modelLabel, + answer: trimmedAnswer, + createdAt: Date.now(), + }, + ] + : currentSelections.filter(selection => selection.candidateId !== candidateId); + const candidates = batch.candidates.map(item => item.id === candidateId + ? { + ...item, + answer: trimmedAnswer, + completedAt: item.completedAt ?? Date.now(), + } + : item); - return { - publicContexts: nextPublicContexts, - }; - }); + return { + batches: { + ...state.batches, + [batchId]: { + ...batch, + selectedCandidateId: nextSelectedIds[0], + selectedCandidateIds: nextSelectedIds, + publicSelections: nextPublicSelections, + candidates, + }, + }, + }; + }), - return context; + getBatchesForSession: (sessionId) => { + const state = get(); + return getRelatedBatches(state.batches, state.order, sessionId); }, getReusableCandidateSessionsForSession: (sessionId) => { - const seedSessionId = sessionId.trim(); - if (!seedSessionId) { - return {}; - } - const state = get(); - const connectedSessionIds = getConnectedSessionIds(state.batches, seedSessionId); - + const relatedBatches = getRelatedBatches(state.batches, state.order, sessionId); const reusableSessions: Record = {}; - for (const batchId of state.order) { - const batch = state.batches[batchId]; - if (!batch) { - continue; - } - - const batchSessionIds = getBatchSessionIds(batch); - const isRelatedBatch = batchSessionIds.some(batchSessionId => connectedSessionIds.has(batchSessionId)); - if (!isRelatedBatch) { - continue; - } + for (const batch of relatedBatches) { for (const candidate of batch.candidates) { - if (!candidate.sessionId || !connectedSessionIds.has(candidate.sessionId)) { + if (!candidate.sessionId) { continue; } @@ -278,13 +338,6 @@ export const useModelBrainstormStore = create((set, get) = removeBatchesForSession: (sessionId) => set((state) => { const nextBatches = { ...state.batches }; - const nextPublicContexts = { ...state.publicContexts }; - let removedPublicContext = false; - if (nextPublicContexts[sessionId]) { - delete nextPublicContexts[sessionId]; - removedPublicContext = true; - } - const nextOrder = state.order.filter(batchId => { const batch = state.batches[batchId]; if (batch?.sourceSessionId !== sessionId) { @@ -294,18 +347,17 @@ export const useModelBrainstormStore = create((set, get) = return false; }); - if (nextOrder.length === state.order.length && !removedPublicContext) { + if (nextOrder.length === state.order.length) { return state; } return { batches: nextBatches, order: nextOrder, - publicContexts: nextPublicContexts, }; }), - reset: () => set({ batches: {}, order: [], publicContexts: {} }), + reset: () => set({ batches: {}, order: [] }), })); export function useModelBrainstormBatchesForSession(sessionId: string | undefined | null): ModelBrainstormBatch[] { @@ -314,8 +366,7 @@ export function useModelBrainstormBatchesForSession(sessionId: string | undefine return []; } - return state.order - .map(batchId => state.batches[batchId]) - .filter((batch): batch is ModelBrainstormBatch => batch?.sourceSessionId === sessionId); + return getRelatedBatches(state.batches, state.order, sessionId) + .filter(batch => batch.sourceSessionId === sessionId); })); } diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index c25db73687..87f06ac688 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -2117,7 +2117,12 @@ "thinkingMetric": "{{value}} thinking chars", "noReadonlyTools": "No read-only tools used", "selectCandidate": "Select this answer", - "selectedCandidate": "Selected" + "selectedCandidate": "Selected", + "contextModeLabel": "Context", + "contextModes": { + "independent": "Independent", + "shared": "Shared" + } }, "reasoningEffort": { "title": "Reasoning Effort", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 17d0792864..dddb5e0d29 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -2117,7 +2117,12 @@ "thinkingMetric": "{{value}} 推理字符", "noReadonlyTools": "未使用只读工具", "selectCandidate": "选择此答案", - "selectedCandidate": "已选择" + "selectedCandidate": "已选择", + "contextModeLabel": "上下文", + "contextModes": { + "independent": "独立", + "shared": "共享" + } }, "reasoningEffort": { "title": "推理深度", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 0975bff71a..606f4d686c 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -2117,7 +2117,12 @@ "thinkingMetric": "{{value}} 推理字元", "noReadonlyTools": "未使用唯讀工具", "selectCandidate": "選擇此答案", - "selectedCandidate": "已選擇" + "selectedCandidate": "已選擇", + "contextModeLabel": "上下文", + "contextModes": { + "independent": "獨立", + "shared": "共享" + } }, "reasoningEffort": { "title": "推理深度", From 9a3a1120927bcac52ed2277e6e5078c29f2cb8b3 Mon Sep 17 00:00:00 2001 From: weixin_44319083 Date: Mon, 27 Jul 2026 09:17:25 +0800 Subject: [PATCH 4/4] fix: reserve footer space for brainstorm-only view --- ...rnFlowChatContainer.history-state.test.tsx | 114 +++++++++--------- .../modern/ModernFlowChatContainer.scss | 11 ++ .../modern/ModernFlowChatContainer.tsx | 16 ++- 3 files changed, 85 insertions(+), 56 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx index 1493370dcf..8305981dc4 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createRoot, type Root } from 'react-dom/client'; import { ModernFlowChatContainer } from './ModernFlowChatContainer'; import type { Session } from '../../types/flow-chat'; +import { computeFlowChatInputStackFooterPx } from '../../utils/flowChatScrollLayout'; import { flowChatStore } from '../../store/FlowChatStore'; import { clearHistorySessionOpenTransition, @@ -19,13 +20,18 @@ const stateMocks = vi.hoisted(() => ({ virtualItems: [] as unknown[], visibleTurnInfo: null as unknown, })); +const modelBrainstormStateMock = vi.hoisted(() => ({ + batches: [] as Array>, +})); +const chatInputStateMock = vi.hoisted(() => ({ + isActive: false, + inputHeight: 0, +})); const switchChatSessionMock = vi.hoisted(() => vi.fn()); const virtualListMock = vi.hoisted(() => ({ scrollToTurn: vi.fn(), scrollToIndex: vi.fn(), - scrollToSearchMatch: vi.fn(), - clearSearchMatch: vi.fn(), scrollToPhysicalBottomAndClearPin: vi.fn(), scrollToTurnEndAndClearPin: vi.fn(() => true), scrollToLatestEndPosition: vi.fn(), @@ -62,9 +68,6 @@ const virtualListPropsMock = vi.hoisted(() => ({ })); const agentApiMock = vi.hoisted(() => ({ listBackgroundCommandActivities: vi.fn(() => Promise.resolve({ activities: [] })), - onPermissionRequestEvent: vi.fn(() => vi.fn()), - subscribePermissionRequests: vi.fn(() => Promise.resolve()), - listPendingPermissionRequests: vi.fn(() => Promise.resolve([])), })); vi.mock('react-i18next', () => ({ @@ -128,6 +131,14 @@ vi.mock('../../store/modernFlowChatStore', () => ({ useVisibleTurnInfo: () => stateMocks.visibleTurnInfo, })); +vi.mock('../../store/modelBrainstormStore', () => ({ + useModelBrainstormBatchesForSession: () => modelBrainstormStateMock.batches, +})); + +vi.mock('../../store/chatInputStateStore', () => ({ + useChatInputState: (selector: (state: typeof chatInputStateMock) => unknown) => selector(chatInputStateMock), +})); + vi.mock('./VirtualMessageList', () => ({ VirtualMessageList: React.forwardRef((props: Record, ref) => { virtualListPropsMock.latest = props; @@ -160,6 +171,14 @@ vi.mock('../WelcomePanel', () => ({ WelcomePanel: () =>
Welcome panel
, })); +vi.mock('./ModelBrainstormGroup', () => ({ + ModelBrainstormGroup: ({ batch }: { batch: { id: string } }) => ( +
+ Brainstorm group +
+ ), +})); + vi.mock('./useExploreGroupState', () => ({ useExploreGroupState: () => ({ exploreGroupStates: {}, @@ -271,8 +290,6 @@ describe('ModernFlowChatContainer historical empty state', () => { switchChatSessionMock.mockReset(); virtualListMock.scrollToTurn.mockReset(); virtualListMock.scrollToIndex.mockReset(); - virtualListMock.scrollToSearchMatch.mockReset(); - virtualListMock.clearSearchMatch.mockReset(); virtualListMock.scrollToPhysicalBottomAndClearPin.mockReset(); virtualListMock.scrollToTurnEndAndClearPin.mockReset(); virtualListMock.scrollToTurnEndAndClearPin.mockReturnValue(true); @@ -304,6 +321,9 @@ describe('ModernFlowChatContainer historical empty state', () => { searchStateMock.clearSearch.mockReset(); headerPropsMock.latest = null; virtualListPropsMock.latest = null; + modelBrainstormStateMock.batches = []; + chatInputStateMock.isActive = false; + chatInputStateMock.inputHeight = 0; clearHistorySessionOpenTransition(); }); @@ -331,6 +351,38 @@ describe('ModernFlowChatContainer historical empty state', () => { expect(container.querySelector('[data-testid="welcome-panel"]')).toBeNull(); }); + it('reserves brainstorm-only footer space using the floating input height', () => { + chatInputStateMock.isActive = true; + chatInputStateMock.inputHeight = 168; + modelBrainstormStateMock.batches = [{ + id: 'brainstorm-batch-1', + sourceSessionId: 'session-1', + }]; + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + dialogTurns: [], + } as Partial); + + act(() => { + root.render(); + }); + + expect(container.querySelector('[data-testid="virtual-list"]')).toBeNull(); + expect(container.querySelector('[data-testid="welcome-panel"]')).toBeNull(); + expect(container.querySelector('[data-testid="model-brainstorm-group"]')).not.toBeNull(); + + const footer = container.querySelector('.modern-flowchat-container__brainstorm-only .message-list-footer') as HTMLDivElement | null; + const expectedFooterHeight = computeFlowChatInputStackFooterPx( + chatInputStateMock.inputHeight, + chatInputStateMock.isActive, + ); + + expect(footer).not.toBeNull(); + expect(footer?.style.height).toBe(`${expectedFooterHeight}px`); + expect(footer?.style.minHeight).toBe(`${expectedFooterHeight}px`); + }); + it('keeps the loading shell while historical sessions are hydrating', () => { stateMocks.activeSession = createSession({ historyState: 'hydrating' } as Partial); @@ -837,54 +889,6 @@ describe('ModernFlowChatContainer historical empty state', () => { pendingSpy.mockRestore(); }); - it('repositions an unchanged virtual match when the search query changes', async () => { - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - dialogTurns: [createTurn('turn-1', 'Searchable prompt')], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Searchable prompt' } }, - ]; - searchStateMock.searchQuery = 'search'; - searchStateMock.matches = [{ - virtualItemIndex: 0, - turnId: 'turn-1', - type: 'user-message', - occurrenceIndex: 0, - }]; - searchStateMock.currentMatchIndex = 0; - searchStateMock.currentMatchVirtualIndex = 0; - - await act(async () => { - root.render(); - }); - flushAnimationFrame(); - - expect(virtualListMock.scrollToSearchMatch).toHaveBeenLastCalledWith({ - virtualItemIndex: 0, - query: 'search', - flowItemId: undefined, - occurrenceIndex: 0, - expandableIds: undefined, - }); - - searchStateMock.searchQuery = 'searchable'; - await act(async () => { - root.render(); - }); - flushAnimationFrame(); - - expect(virtualListMock.scrollToSearchMatch).toHaveBeenCalledTimes(2); - expect(virtualListMock.scrollToSearchMatch).toHaveBeenLastCalledWith({ - virtualItemIndex: 0, - query: 'searchable', - flowItemId: undefined, - occurrenceIndex: 0, - expandableIds: undefined, - }); - }); - it('keeps the new-session welcome for genuinely new empty sessions', () => { stateMocks.activeSession = createSession({ isHistorical: false, diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss index 02ed49a419..d6f5be294c 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss @@ -42,6 +42,17 @@ width: 100%; height: 100%; overflow-y: auto; + + .message-list-header { + height: 57px; + min-height: 57px; + flex-shrink: 0; + } + + .message-list-footer { + min-height: 72px; + overflow-anchor: none; + } } &__brainstorm-groups { diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index 387cfdb7cd..883d6b897c 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -32,6 +32,7 @@ import { useFlowChatToolActions } from './useFlowChatToolActions'; import { useFlowChatSearch } from './useFlowChatSearch'; import { useVirtualItems, useActiveSession, useVisibleTurnInfo, type VisibleTurnInfo } from '../../store/modernFlowChatStore'; import { useModelBrainstormBatchesForSession } from '../../store/modelBrainstormStore'; +import { useChatInputState } from '../../store/chatInputStateStore'; import type { FlowChatConfig, DialogTurn } from '../../types/flow-chat'; import { useBackgroundCommandActivityStore, @@ -77,6 +78,7 @@ import { import { type BackgroundSubagentActivityItem, } from '../../utils/backgroundSubagentActivity'; +import { computeFlowChatInputStackFooterPx } from '../../utils/flowChatScrollLayout'; import './ModernFlowChatContainer.scss'; const log = createLogger('ModernFlowChatContainer'); @@ -294,6 +296,12 @@ export const ModernFlowChatContainer: React.FC = (
) : null ), [modelBrainstormBatches]); + const isInputActive = useChatInputState(state => state.isActive); + const inputHeight = useChatInputState(state => state.inputHeight); + const brainstormOnlyFooterHeightPx = useMemo( + () => computeFlowChatInputStackFooterPx(inputHeight, isInputActive), + [inputHeight, isInputActive], + ); const { handleFileViewRequest } = useFlowChatFileActions({ workspacePath, @@ -1534,7 +1542,13 @@ export const ModernFlowChatContainer: React.FC = (
{modelBrainstormFooter} -
+
) : (