diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index e274e3b736..becd1cd06e 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -233,12 +233,38 @@ pub struct ModelExchangeTracingConfig { } /// FlowChat UI preferences. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct AppFlowChatConfig { /// Optional user override for the default ChatInput mode id. #[serde(skip_serializing_if = "Option::is_none")] pub default_mode_id: Option, + /// Whether the chat input exposes the global permission-mode shortcut. + /// + /// The default is visible, but that value is omitted from persisted config + /// so existing config files remain unchanged until the user hides it. + #[serde( + default = "default_show_permission_mode_control", + skip_serializing_if = "is_permission_mode_control_visible" + )] + pub show_permission_mode_control: bool, +} + +fn default_show_permission_mode_control() -> bool { + true +} + +fn is_permission_mode_control_visible(value: &bool) -> bool { + *value +} + +impl Default for AppFlowChatConfig { + fn default() -> Self { + Self { + default_mode_id: None, + show_permission_mode_control: default_show_permission_mode_control(), + } + } } /// A user-defined quick action for the FlowChat post-coding actions menu. @@ -2395,6 +2421,40 @@ mod tests { ); } + #[test] + fn app_flow_chat_permission_mode_control_defaults_to_visible_without_persisting_default() { + let default_config: GlobalConfig = serde_json::from_value(serde_json::json!({ + "app": { + "flow_chat": {} + } + })) + .expect("flow chat config without visibility preference should deserialize"); + + assert!(default_config.app.flow_chat.show_permission_mode_control); + let default_serialized = + serde_json::to_value(&default_config).expect("config should serialize"); + assert!(default_serialized["app"]["flow_chat"] + .get("show_permission_mode_control") + .is_none()); + + let hidden_config: GlobalConfig = serde_json::from_value(serde_json::json!({ + "app": { + "flow_chat": { + "show_permission_mode_control": false + } + } + })) + .expect("flow chat config with hidden permission control should deserialize"); + + assert!(!hidden_config.app.flow_chat.show_permission_mode_control); + let hidden_serialized = + serde_json::to_value(&hidden_config).expect("config should serialize"); + assert_eq!( + hidden_serialized["app"]["flow_chat"]["show_permission_mode_control"], + false + ); + } + #[test] fn deserializes_compatibility_false_thinking_flag_into_default_reasoning_mode() { let config: AIModelConfig = serde_json::from_value(serde_json::json!({ diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index cbef43e31a..83d0d8c1db 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -394,6 +394,7 @@ export const ChatInput: React.FC = ({ 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); @@ -1687,6 +1688,35 @@ 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) => { @@ -1754,6 +1784,15 @@ export const ChatInput: React.FC = ({ } }, [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; @@ -5176,11 +5215,12 @@ export const ChatInput: React.FC = ({ repositoryPath={chatStripRepositoryPath} workspaceLabel={chatStripWorkspaceLabel} deferPassiveGitRefresh={deferChatStripPassiveGitRefresh} - permissionControl={{ + permissionControl={showPermissionModeControl ? { mode: permissionMode, saving: permissionModeSaving, onChange: isAcpTargetSession ? undefined : handlePermissionModeChange, - }} + onHide: isAcpTargetSession ? undefined : handleHidePermissionModeControl, + } : undefined} usageReport={ effectiveTargetSessionId && effectiveTargetSession ? { visible: true, onOpen: handleToolbarUsageReport } diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss index 59bf415981..ce3f00cf7f 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss @@ -209,6 +209,12 @@ gap: 2px; } + &__permission-menu-divider { + height: 1px; + margin: 6px 2px; + background: var(--border-subtle); + } + &__permission-option { display: grid; grid-template-columns: minmax(0, 1fr) 16px; @@ -268,6 +274,31 @@ white-space: normal; } + &__permission-visibility-action { + display: flex; + align-items: center; + gap: 7px; + width: 100%; + min-height: 32px; + padding: 6px 8px; + border: 0; + border-radius: $size-radius-sm; + background: transparent; + color: var(--color-text-secondary); + font: inherit; + font-size: var(--flowchat-font-size-xs); + letter-spacing: 0; + text-align: left; + cursor: pointer; + + &:hover, + &:focus-visible { + background: var(--element-bg-medium); + color: var(--color-text-primary); + outline: none; + } + } + &__goal-btn.icon-btn { width: 16px; height: 16px; diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx index b93c8117b8..66b7f8a01a 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx @@ -100,12 +100,13 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { it('keeps an ask-mode permission entry visible and switches from its menu', async () => { const onChange = vi.fn(); + const onHide = vi.fn(); await act(async () => { root.render( ); }); @@ -126,6 +127,17 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { }); expect(onChange).toHaveBeenCalledWith('auto'); expect(container.querySelector('[data-testid="chat-input-permission-menu"]')).toBeNull(); + + await act(async () => { + trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await act(async () => { + container + .querySelector('[data-testid="chat-input-permission-hide-control"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(onHide).toHaveBeenCalledOnce(); + expect(container.querySelector('[data-testid="chat-input-permission-menu"]')).toBeNull(); }); it('shows ACP ownership without exposing native permission choices', async () => { diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx index 3cc3763451..3dc371ee54 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Activity, Check, GitBranch, Shield, ShieldAlert, ShieldCheck } from 'lucide-react'; +import { Activity, Check, EyeOff, GitBranch, Shield, ShieldAlert, ShieldCheck } from 'lucide-react'; import { ThreadGoalStripButton } from './thread-goal/ThreadGoalStripButton'; import type { ThreadGoalSnapshot } from '../services/goalService'; import { Tooltip, IconButton } from '@/component-library'; @@ -32,6 +32,7 @@ export interface ChatInputWorkspaceStripProps { mode: ChatInputPermissionMode; saving?: boolean; onChange?: (mode: Exclude) => void | Promise; + onHide?: () => void | Promise; }; /** Keep the strip on cached Git state while historical content is still restoring. */ deferPassiveGitRefresh?: boolean; @@ -268,6 +269,25 @@ export const ChatInputWorkspaceStrip: React.FC = ( ); })} + {permissionControl.onHide ? ( + <> +
+ + + ) : null}
) : null} diff --git a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx index 48e07e73a7..32119a30bc 100644 --- a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx @@ -83,6 +83,7 @@ type ToolPermissionMode = 'ask' | 'auto' | 'full_access'; const DEFAULT_SUBAGENT_BATCH_EXECUTION_POLICY: SubagentBatchExecutionPolicy = 'force_parallel'; const DEFAULT_SUBAGENT_MAX_CONCURRENCY = 5; +const SHOW_PERMISSION_MODE_CONTROL_CONFIG_PATH = 'app.flow_chat.show_permission_mode_control'; function normalizeSubagentBatchExecutionPolicy(value: unknown): SubagentBatchExecutionPolicy { return value === 'force_parallel' || value === 'serial' || value === 'safe_only' @@ -127,6 +128,8 @@ const SessionSettingsPanels: React.FC = ({ variant } const [deferredToolLoadingConfigSaving, setDeferredToolLoadingConfigSaving] = useState(false); const [toolPermissionConfig, setToolPermissionConfig] = useState(DEFAULT_TOOL_PERMISSION_CONFIG); const [permissionConfigSaving, setPermissionConfigSaving] = useState(false); + const [showPermissionModeControl, setShowPermissionModeControl] = useState(true); + const [permissionModeControlVisibilitySaving, setPermissionModeControlVisibilitySaving] = useState(false); const [isGlobalPermissionRulesDialogOpen, setIsGlobalPermissionRulesDialogOpen] = useState(false); const [computerUseEnabled, setComputerUseEnabled] = useState(false); @@ -231,6 +234,7 @@ const SessionSettingsPanels: React.FC = ({ variant } computerUseCfg, browserControlPreferredBrowser, loadedToolPermissionConfig, + loadedPermissionModeControlVisibility, loadedCompanionPets, ] = await Promise.all([ aiExperienceConfigService.getSettingsAsync(), @@ -242,6 +246,7 @@ const SessionSettingsPanels: React.FC = ({ variant } configManager.getConfig('ai.computer_use_enabled'), configManager.getConfig('ai.browser_control_preferred_browser'), permissionConfigService.getConfig(), + configManager.getConfig(SHOW_PERMISSION_MODE_CONTROL_CONFIG_PATH), listAgentCompanionPets(), ]); @@ -256,6 +261,7 @@ const SessionSettingsPanels: React.FC = ({ variant } if (debugConfigData) setDebugConfig(debugConfigData); setPreferredBrowser(browserControlPreferredBrowser || DEFAULT_BROWSER_CONTROL_BROWSER); setToolPermissionConfig(normalizeToolPermissionConfig(loadedToolPermissionConfig)); + setShowPermissionModeControl(loadedPermissionModeControlVisibility !== false); refreshDesktopStatus(computerUseCfg); } catch (error) { @@ -332,6 +338,22 @@ const SessionSettingsPanels: React.FC = ({ variant } ); }; + const handlePermissionModeControlVisibilityChange = async (visible: boolean) => { + const previousVisibility = showPermissionModeControl; + setShowPermissionModeControl(visible); + setPermissionModeControlVisibilitySaving(true); + try { + await configManager.setConfig(SHOW_PERMISSION_MODE_CONTROL_CONFIG_PATH, visible); + notificationService.success(t('messages.saveSuccess'), { duration: 2000 }); + } catch (error) { + log.error('Failed to save permission mode control visibility', error); + setShowPermissionModeControl(previousVisibility); + notificationService.error(t('messages.saveFailed')); + } finally { + setPermissionModeControlVisibilitySaving(false); + } + }; + useEffect(() => { loadAllData(); }, [loadAllData]); @@ -1107,6 +1129,20 @@ const SessionSettingsPanels: React.FC = ({ variant } /> + +
+ void handlePermissionModeControlVisibilityChange(event.target.checked)} + size="small" + /> +
+