diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 5926d958c..5fbc005db 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -65,7 +65,9 @@ import { DEFAULT_CHAT_INPUT_MODE_CONFIG_PATH, normalizeUserDefaultChatInputModeId, resolveAvailableChatInputMode, + resolveChatInputModePolicy, resolveSessionAssistantWorkspace, + resolveSwitchableChatInputModes, } from '../utils/chatInputMode'; import { useSceneStore } from '@/app/stores/sceneStore'; import type { SceneTabId } from '@/app/components/SceneBar/types'; @@ -657,16 +659,21 @@ export const ChatInput: React.FC = ({ const activeSessionMode = effectiveTargetSessionId ? acpTargetAgentType || flowChatState.sessions.get(effectiveTargetSessionId)?.mode : undefined; - const canSwitchModes = !isAssistantWorkspace && currentMode !== 'Cowork' && !isAcpTargetSession; + const chatInputModePolicy = useMemo( + () => resolveChatInputModePolicy({ + currentMode, + isAssistantWorkspace, + sessionMode: activeSessionMode, + isAcpTargetSession, + }), + [activeSessionMode, currentMode, isAcpTargetSession, isAssistantWorkspace], + ); + const canSwitchModes = chatInputModePolicy.canSwitchModes; - // Session-level mode policy: Cowork sessions are fixed; code sessions should not switch into Cowork. + // Session-level mode policy: fixed collaboration modes are not selectable boosts. const switchableModes = useMemo( - () => - modeState.available.filter(mode => - mode.id !== 'Cowork' && - (isAssistantWorkspace || mode.id !== 'Claw') - ), - [isAssistantWorkspace, modeState.available] + () => resolveSwitchableChatInputModes(modeState.available), + [modeState.available] ); // Stable refs for Shift+Tab mode cycling (avoids adding deps to handleKeyDown) diff --git a/src/web-ui/src/flow_chat/utils/chatInputMode.test.ts b/src/web-ui/src/flow_chat/utils/chatInputMode.test.ts index 5c50ad60b..6247757ba 100644 --- a/src/web-ui/src/flow_chat/utils/chatInputMode.test.ts +++ b/src/web-ui/src/flow_chat/utils/chatInputMode.test.ts @@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest'; import { normalizeUserDefaultChatInputModeId, resolveAvailableChatInputMode, + resolveChatInputModePolicy, resolveSessionAssistantWorkspace, + resolveSwitchableChatInputModes, resolveWorkspaceChatInputMode, } from './chatInputMode'; import { WorkspaceKind, type WorkspaceInfo, WorkspaceType } from '@/shared/types'; @@ -91,6 +93,117 @@ describe('resolveWorkspaceChatInputMode', () => { }) ).toBe('agentic'); }); + + it('keeps Claw sessions synchronized even before workspace state identifies the assistant workspace', () => { + expect( + resolveWorkspaceChatInputMode({ + currentMode: 'agentic', + isAssistantWorkspace: false, + sessionMode: 'Claw', + }) + ).toBe('Claw'); + }); +}); + +describe('resolveChatInputModePolicy', () => { + it('allows mode switching for normal code sessions', () => { + expect( + resolveChatInputModePolicy({ + currentMode: 'agentic', + isAssistantWorkspace: false, + sessionMode: 'agentic', + }), + ).toEqual({ + canSwitchModes: true, + fixedModeId: null, + fixedReason: null, + }); + }); + + it('fixes assistant workspaces to Claw', () => { + expect( + resolveChatInputModePolicy({ + currentMode: 'agentic', + isAssistantWorkspace: true, + sessionMode: 'agentic', + }), + ).toEqual({ + canSwitchModes: false, + fixedModeId: 'Claw', + fixedReason: 'assistant-workspace', + }); + }); + + it('fixes Claw sessions even when workspace resolution is temporarily stale', () => { + expect( + resolveChatInputModePolicy({ + currentMode: 'agentic', + isAssistantWorkspace: false, + sessionMode: 'claw', + }), + ).toEqual({ + canSwitchModes: false, + fixedModeId: 'Claw', + fixedReason: 'session-mode', + }); + }); + + it('fixes Cowork sessions from current or session mode', () => { + expect( + resolveChatInputModePolicy({ + currentMode: 'Cowork', + isAssistantWorkspace: false, + sessionMode: 'agentic', + }), + ).toMatchObject({ + canSwitchModes: false, + fixedModeId: 'Cowork', + fixedReason: 'current-mode', + }); + + expect( + resolveChatInputModePolicy({ + currentMode: 'agentic', + isAssistantWorkspace: false, + sessionMode: 'cowork', + }), + ).toMatchObject({ + canSwitchModes: false, + fixedModeId: 'Cowork', + fixedReason: 'session-mode', + }); + }); + + it('fixes ACP sessions without treating them as a product mode', () => { + expect( + resolveChatInputModePolicy({ + currentMode: 'agentic', + isAssistantWorkspace: false, + sessionMode: 'acp:example', + isAcpTargetSession: true, + }), + ).toEqual({ + canSwitchModes: false, + fixedModeId: null, + fixedReason: 'acp-session', + }); + }); +}); + +describe('resolveSwitchableChatInputModes', () => { + it('removes fixed collaboration modes from boost selection', () => { + expect( + resolveSwitchableChatInputModes([ + { id: 'agentic' }, + { id: 'Cowork' }, + { id: 'Claw' }, + { id: 'PlannerPlus' }, + ]), + ).toEqual([ + { id: 'agentic' }, + { id: 'PlannerPlus' }, + ]); + }); }); describe('resolveSessionAssistantWorkspace', () => { @@ -199,6 +312,17 @@ describe('resolveAvailableChatInputMode', () => { ).toBe('Claw'); }); + it('keeps Claw sessions pinned even before assistant workspace resolution catches up', () => { + expect( + resolveAvailableChatInputMode({ + currentMode: 'agentic', + isAssistantWorkspace: false, + sessionMode: 'Claw', + availableModeIds: ['agentic', 'Claw', 'PlannerPlus'], + }), + ).toBe('Claw'); + }); + it('falls back to the first available mode when agentic is unavailable', () => { expect( resolveAvailableChatInputMode({ diff --git a/src/web-ui/src/flow_chat/utils/chatInputMode.ts b/src/web-ui/src/flow_chat/utils/chatInputMode.ts index 9bb8d2ed6..e5cd476b0 100644 --- a/src/web-ui/src/flow_chat/utils/chatInputMode.ts +++ b/src/web-ui/src/flow_chat/utils/chatInputMode.ts @@ -2,11 +2,25 @@ import { WorkspaceKind, type WorkspaceInfo } from '@/shared/types'; export const DEFAULT_CHAT_INPUT_MODE_CONFIG_PATH = 'app.flow_chat.default_mode_id'; +const FIXED_CHAT_INPUT_MODE_IDS = new Set(['cowork', 'claw']); + type WorkspaceResolutionInfo = Pick< WorkspaceInfo, 'id' | 'rootPath' | 'workspaceKind' | 'connectionId' >; +export type ChatInputFixedModeReason = + | 'assistant-workspace' + | 'acp-session' + | 'current-mode' + | 'session-mode'; + +export interface ChatInputModePolicy { + canSwitchModes: boolean; + fixedModeId: string | null; + fixedReason: ChatInputFixedModeReason | null; +} + function normalizeOptionalString(value: string | null | undefined): string | null { if (typeof value !== 'string') { return null; @@ -139,6 +153,76 @@ export function resolveSessionAssistantWorkspace(params: { return params.currentWorkspace?.workspaceKind === WorkspaceKind.Assistant; } +function normalizeModeLookupId(value: string | null | undefined): string | null { + return normalizeOptionalString(value)?.toLowerCase() ?? null; +} + +function canonicalFixedModeId(value: string | null | undefined): string | null { + switch (normalizeModeLookupId(value)) { + case 'cowork': + return 'Cowork'; + case 'claw': + return 'Claw'; + default: + return null; + } +} + +export function resolveChatInputModePolicy(params: { + currentMode: string; + isAssistantWorkspace: boolean; + sessionMode?: string | null; + isAcpTargetSession?: boolean; +}): ChatInputModePolicy { + if (params.isAcpTargetSession) { + return { + canSwitchModes: false, + fixedModeId: null, + fixedReason: 'acp-session', + }; + } + + if (params.isAssistantWorkspace) { + return { + canSwitchModes: false, + fixedModeId: 'Claw', + fixedReason: 'assistant-workspace', + }; + } + + const fixedSessionModeId = canonicalFixedModeId(params.sessionMode); + if (fixedSessionModeId) { + return { + canSwitchModes: false, + fixedModeId: fixedSessionModeId, + fixedReason: 'session-mode', + }; + } + + const fixedCurrentModeId = canonicalFixedModeId(params.currentMode); + if (fixedCurrentModeId) { + return { + canSwitchModes: false, + fixedModeId: fixedCurrentModeId, + fixedReason: 'current-mode', + }; + } + + return { + canSwitchModes: true, + fixedModeId: null, + fixedReason: null, + }; +} + +export function resolveSwitchableChatInputModes( + availableModes: Iterable, +): TMode[] { + return Array.from(availableModes).filter( + mode => !FIXED_CHAT_INPUT_MODE_IDS.has(normalizeModeLookupId(mode.id) ?? ''), + ); +} + export function resolveWorkspaceChatInputMode(params: { currentMode: string; isAssistantWorkspace: boolean; @@ -151,7 +235,7 @@ export function resolveWorkspaceChatInputMode(params: { } if (normalizedSessionMode?.toLowerCase() === 'claw') { - return null; + return params.currentMode === 'Claw' ? null : 'Claw'; } if (normalizedSessionMode && normalizedSessionMode !== params.currentMode) {