Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion src/crates/assembly/core/src/service/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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.
Expand Down Expand Up @@ -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!({
Expand Down
44 changes: 42 additions & 2 deletions src/web-ui/src/flow_chat/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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);
Expand Down Expand Up @@ -1687,6 +1688,35 @@ export const ChatInput: React.FC<ChatInputProps> = ({
};
}, []);

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<boolean | undefined>(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) => {
Expand Down Expand Up @@ -1754,6 +1784,15 @@ export const ChatInput: React.FC<ChatInputProps> = ({
}
}, [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;
Expand Down Expand Up @@ -5176,11 +5215,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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 }
Expand Down
31 changes: 31 additions & 0 deletions src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ChatInputWorkspaceStrip
repositoryPath=""
workspaceLabel=""
permissionControl={{ mode: 'ask', onChange }}
permissionControl={{ mode: 'ask', onChange, onHide }}
/>
);
});
Expand All @@ -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<HTMLButtonElement>('[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 () => {
Expand Down
22 changes: 21 additions & 1 deletion src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -32,6 +32,7 @@ export interface ChatInputWorkspaceStripProps {
mode: ChatInputPermissionMode;
saving?: boolean;
onChange?: (mode: Exclude<ChatInputPermissionMode, 'acp'>) => void | Promise<void>;
onHide?: () => void | Promise<void>;
};
/** Keep the strip on cached Git state while historical content is still restoring. */
deferPassiveGitRefresh?: boolean;
Expand Down Expand Up @@ -268,6 +269,25 @@ export const ChatInputWorkspaceStrip: React.FC<ChatInputWorkspaceStripProps> = (
);
})}
</div>
{permissionControl.onHide ? (
<>
<div className="bitfun-chat-input-workspace-strip__permission-menu-divider" role="separator" />
<button
type="button"
role="menuitem"
className="bitfun-chat-input-workspace-strip__permission-visibility-action"
data-testid="chat-input-permission-hide-control"
onClick={event => {
event.stopPropagation();
setPermissionMenuOpen(false);
void permissionControl.onHide?.();
}}
>
<EyeOff size={14} strokeWidth={2} aria-hidden />
<span>{t('chatInput.permissionMode.hideControl')}</span>
</button>
</>
) : null}
</div>
) : null}
</div>
Expand Down
36 changes: 36 additions & 0 deletions src/web-ui/src/infrastructure/config/components/SessionConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -127,6 +128,8 @@ const SessionSettingsPanels: React.FC<SessionSettingsPanelsProps> = ({ variant }
const [deferredToolLoadingConfigSaving, setDeferredToolLoadingConfigSaving] = useState(false);
const [toolPermissionConfig, setToolPermissionConfig] = useState<ToolPermissionConfig>(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);
Expand Down Expand Up @@ -231,6 +234,7 @@ const SessionSettingsPanels: React.FC<SessionSettingsPanelsProps> = ({ variant }
computerUseCfg,
browserControlPreferredBrowser,
loadedToolPermissionConfig,
loadedPermissionModeControlVisibility,
loadedCompanionPets,
] = await Promise.all([
aiExperienceConfigService.getSettingsAsync(),
Expand All @@ -242,6 +246,7 @@ const SessionSettingsPanels: React.FC<SessionSettingsPanelsProps> = ({ variant }
configManager.getConfig<boolean>('ai.computer_use_enabled'),
configManager.getConfig<string>('ai.browser_control_preferred_browser'),
permissionConfigService.getConfig(),
configManager.getConfig<boolean | undefined>(SHOW_PERMISSION_MODE_CONTROL_CONFIG_PATH),
listAgentCompanionPets(),
]);

Expand All @@ -256,6 +261,7 @@ const SessionSettingsPanels: React.FC<SessionSettingsPanelsProps> = ({ variant }
if (debugConfigData) setDebugConfig(debugConfigData);
setPreferredBrowser(browserControlPreferredBrowser || DEFAULT_BROWSER_CONTROL_BROWSER);
setToolPermissionConfig(normalizeToolPermissionConfig(loadedToolPermissionConfig));
setShowPermissionModeControl(loadedPermissionModeControlVisibility !== false);

refreshDesktopStatus(computerUseCfg);
} catch (error) {
Expand Down Expand Up @@ -332,6 +338,22 @@ const SessionSettingsPanels: React.FC<SessionSettingsPanelsProps> = ({ 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]);
Expand Down Expand Up @@ -1107,6 +1129,20 @@ const SessionSettingsPanels: React.FC<SessionSettingsPanelsProps> = ({ variant }
/>
</div>
</ConfigPageRow>
<ConfigPageRow
label={t('permissionPolicy.showInChatInput')}
description={t('permissionPolicy.showInChatInputDescription')}
align="center"
>
<div className="bitfun-func-agent-config__row-control">
<Switch
checked={showPermissionModeControl}
disabled={permissionModeControlVisibilitySaving}
onChange={event => void handlePermissionModeControlVisibilityChange(event.target.checked)}
size="small"
/>
</div>
</ConfigPageRow>
<ConfigPageRow
label={t('permissionPolicy.globalRules')}
description={t('permissionPolicy.globalRulesDescription')}
Expand Down
2 changes: 2 additions & 0 deletions src/web-ui/src/infrastructure/config/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export interface AppLoggingConfig {

export interface AppFlowChatConfig {
default_mode_id?: string | null;
show_permission_mode_control?: boolean;
}

export interface SidebarConfig {
Expand Down Expand Up @@ -652,6 +653,7 @@ export type ConfigPath =
| 'app.telemetry'
| 'app.flow_chat'
| 'app.flow_chat.default_mode_id'
| 'app.flow_chat.show_permission_mode_control'
| 'app.sidebar'
| 'app.sidebar.width'
| 'app.sidebar.collapsed'
Expand Down
2 changes: 2 additions & 0 deletions src/web-ui/src/locales/en-US/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,8 @@
"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."
Expand Down
2 changes: 2 additions & 0 deletions src/web-ui/src/locales/en-US/settings/session-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@
"cancel": "Cancel",
"autoApprove": "Auto approve",
"autoApproveDescription": "Automatically approve requests that require confirmation.",
"showInChatInput": "Show permission mode selector",
"showInChatInputDescription": "Show the selector below the chat input. Hiding it does not change the current permission mode.",
"globalRules": "Global rules",
"globalRulesDescription": "Define user-level rules that apply after the selected mode and before project and Agent rules.",
"manageGlobalRules": "Manage rules",
Expand Down
Loading