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
51 changes: 50 additions & 1 deletion src/apps/desktop/src/api/agentic_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2365,10 +2365,28 @@ async fn ensure_session_loaded_for_selector_update(
include_internal,
)
.await
.map_err(|error| format!("Failed to restore session before selector update: {error}"))?;
.map_err(selector_update_restore_error)?;
Ok(())
}

/// Keeps a Session restore failure readable without burying its stable code.
///
/// Callers recognize `session_in_use` and `outcome_unknown` by the message
/// prefix, so wrapping those two in prose would make a recognizable state look
/// like a generic failure. Every other reason keeps the selector-update context.
fn selector_update_restore_error(error: DesktopSessionApplicationError) -> String {
let carries_stable_code = matches!(
error,
DesktopSessionApplicationError::SessionInUse(_)
| DesktopSessionApplicationError::OutcomeUnknown(_)
);
let message = error.to_string();
if carries_stable_code {
return message;
}
format!("Failed to restore session before selector update: {message}")
}

#[tauri::command]
pub async fn reload_session_context(
runtime: State<'_, DesktopRuntimeContext>,
Expand Down Expand Up @@ -4952,6 +4970,37 @@ mod tests {
SetSubagentTimeoutActionDTO::Disable
));
}

#[test]
fn selector_update_restore_error_keeps_the_stable_session_in_use_code() {
let message = selector_update_restore_error(DesktopSessionApplicationError::SessionInUse(
"Session is already open for writing: session-1".to_string(),
));
assert_eq!(
message,
"session_in_use: Session is already open for writing: session-1"
);
}

#[test]
fn selector_update_restore_error_keeps_the_stable_outcome_unknown_code() {
let message = selector_update_restore_error(
DesktopSessionApplicationError::OutcomeUnknown("commit may have landed".to_string()),
);
assert_eq!(message, "outcome_unknown: commit may have landed");
}

#[test]
fn selector_update_restore_error_keeps_the_selector_update_context_for_other_reasons() {
let message = selector_update_restore_error(DesktopSessionApplicationError::Validation(
"workspace_id is required when the session is not loaded".to_string(),
));
assert_eq!(
message,
"Failed to restore session before selector update: \
workspace_id is required when the session is not loaded"
);
}
}

#[tauri::command]
Expand Down
46 changes: 43 additions & 3 deletions src/web-ui/src/flow_chat/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ import {
} from '../utils/tokenUsageDisplay';
import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI';
import type { SessionPermissionMode } from '@/infrastructure/api/service-api/AgentAPI';
import { isSessionInUseError } from '@/infrastructure/api/errors/TauriCommandError';
import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFlag';
import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState';
import { isBtwSessionDraft } from '../utils/modelSelectionTarget';
Expand Down Expand Up @@ -577,6 +578,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({
// other open session.
const [sessionPermissionMode, setSessionPermissionMode] =
useState<SessionPermissionMode | null>(null);
// A failed read leaves `sessionPermissionMode` at null, which makes the control
// fall back to the user-level default. That fallback is safe, but it must not
// pass for the Session's own selection: this flag keeps the two apart.
const [sessionPermissionModeUnread, setSessionPermissionModeUnread] = useState(false);
// One-off state has two owners: the idle composer arms a future submission,
// while an executing turn keeps a mutable override until it ends.
const [armedTurnPermissionMode, setArmedTurnPermissionMode] =
Expand All @@ -589,6 +594,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({
sessionId: string | null;
activeTurnId: string | null;
}>({ sessionId: null, activeTurnId: null });
// Reports a fallback to the default once per Session: the read effect re-runs
// on every Session and turn change, so without this the same unresolved read
// would notify on each pass.
const permissionModeUnreadNotifiedRef = useRef<string | null>(null);
const { addMessage: addToHistory, getSessionHistory } = useInputHistoryStore();

const conversationScope = useConversationViewScope();
Expand Down Expand Up @@ -2426,6 +2435,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
if (sessionChanged) {
setArmedTurnPermissionMode(null);
setActiveTurnPermissionMode(null);
setSessionPermissionModeUnread(false);
} else if (activeTurnChanged) {
// A locally submitted one-off becomes the active turn's initial mode.
// Keep it armed until start_dialog_turn acknowledges so a failed send
Expand All @@ -2437,6 +2447,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({

if (!effectiveTargetSessionId || isAcpTargetSession) {
setSessionPermissionMode(null);
setSessionPermissionModeUnread(false);
setArmedTurnPermissionMode(null);
setActiveTurnPermissionMode(null);
return undefined;
Expand All @@ -2455,15 +2466,27 @@ export const ChatInput: React.FC<ChatInputProps> = ({
});
if (permissionModeRequestGenerationRef.current !== generation) return;
setSessionPermissionMode(response.mode ?? null);
setSessionPermissionModeUnread(false);
permissionModeUnreadNotifiedRef.current = null;
if (activePermissionTurnId && response.activeTurnId === activePermissionTurnId) {
setActiveTurnPermissionMode(response.turnMode ?? null);
}
} catch (error) {
log.warn('Failed to read session permission mode', error);
// Falling back to the global default is the safe read: it never shows a
// wider mode than the session actually runs with.
// wider mode than the session actually runs with. Report the fallback
// once per Session so it cannot pass for that Session's own selection.
if (permissionModeRequestGenerationRef.current === generation) {
setSessionPermissionMode(null);
setSessionPermissionModeUnread(true);
if (permissionModeUnreadNotifiedRef.current !== effectiveTargetSessionId) {
permissionModeUnreadNotifiedRef.current = effectiveTargetSessionId;
notificationService.error(t(
isSessionInUseError(error)
? 'chatInput.permissionMode.unreadSessionInUse'
: 'chatInput.permissionMode.unread',
));
}
}
}
})();
Expand All @@ -2477,6 +2500,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
effectiveTargetSession?.parentSessionId,
isBtwDraftTarget,
isAcpTargetSession,
t,
]);

const applySessionPermissionMode = useCallback(async (
Expand Down Expand Up @@ -2509,6 +2533,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({
&& effectiveTargetSessionIdRef.current === targetSessionId
) {
setSessionPermissionMode(response.mode ?? null);
setSessionPermissionModeUnread(false);
permissionModeUnreadNotifiedRef.current = null;
setActiveTurnPermissionMode(null);
}
} catch (error) {
Expand All @@ -2521,7 +2547,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({
if (activePermissionTurnIdRef.current === targetTurnId) {
setActiveTurnPermissionMode(previousActiveTurnMode);
}
notificationService.error(t('chatInput.permissionMode.changeFailed'));
notificationService.error(t(
isSessionInUseError(error)
? 'chatInput.permissionMode.changeFailedSessionInUse'
: 'chatInput.permissionMode.changeFailed',
));
}
} finally {
setPermissionModeSaving(false);
Expand Down Expand Up @@ -2625,6 +2655,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({
&& activePermissionTurnIdRef.current === targetTurnId
) {
setSessionPermissionMode(response.mode ?? null);
setSessionPermissionModeUnread(false);
permissionModeUnreadNotifiedRef.current = null;
setActiveTurnPermissionMode(response.turnMode ?? null);
}
} catch (error) {
Expand All @@ -2635,7 +2667,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({
&& activePermissionTurnIdRef.current === targetTurnId
) {
setActiveTurnPermissionMode(previousMode);
notificationService.error(t('chatInput.permissionMode.changeFailed'));
notificationService.error(t(
isSessionInUseError(error)
? 'chatInput.permissionMode.changeFailedSessionInUse'
: 'chatInput.permissionMode.changeFailed',
));
}
} finally {
setPermissionModeSaving(false);
Expand Down Expand Up @@ -6095,6 +6131,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({
saving: permissionModeSaving,
scopeLabel: t('chatInput.permissionMode.sessionScope'),
overridden: permissionModeOverridden,
// The trigger falls back to the user-level default when the read
// failed, so the menu must not mark that fallback as this
// Session's own selection.
unread: sessionPermissionModeUnread,
nextTurnMode: temporaryPermissionMode
? chatInputPermissionMode(temporaryPermissionMode)
: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,44 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => {
).toBeNull();
});

it('reports an unreadable session mode instead of passing the default off as its own', async () => {
await act(async () => {
root.render(
<ChatInputWorkspaceStrip workspaceId="workspace-1"
repositoryPath=""
workspaceLabel=""
permissionControl={{
// The read failed, so this is the user-level default, not a choice
// the Session made.
mode: 'ask',
overridden: false,
unread: true,
onChange: vi.fn(),
}}
/>
);
});

const trigger = container.querySelector<HTMLButtonElement>(
'[data-testid="chat-input-permission-trigger"]',
);
expect(trigger?.dataset.permissionUnread).toBe('true');
expect(trigger?.getAttribute('data-tooltip')).toBe('chatInput.permissionMode.unreadTooltip');

await act(async () => {
trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});

// No radio is marked, because the Session's own mode is unknown; the notice
// says why the list is bare.
expect(
document.querySelector('[data-testid="chat-input-permission-selected-ask"]'),
).toBeNull();
expect(
document.querySelector('[data-testid="chat-input-permission-unread-notice"]'),
).not.toBeNull();
});

it('shows ACP ownership without exposing native permission choices', async () => {
await act(async () => {
root.render(
Expand Down
25 changes: 24 additions & 1 deletion src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ export interface ChatInputWorkspaceStripProps {
* two sessions sitting on different modes is legible rather than confusing.
*/
overridden?: boolean;
/**
* The Session's own mode could not be read, so `mode` is the user-level
* default rather than this Session's selection. Reported instead of passed
* off as that selection.
*/
unread?: boolean;
/** Clears the session's own selection and follows the default again. */
onResetToDefault?: () => void | Promise<void>;
/** Opens the settings page that owns the default this row follows. */
Expand Down Expand Up @@ -423,6 +429,9 @@ export const ChatInputWorkspaceStrip: React.FC<ChatInputWorkspaceStripProps> = (
|| permissionControl?.saving
|| permissionMode === 'acp';
const permissionOverridden = !!permissionControl?.overridden && permissionMode !== 'acp';
// A Session whose own mode could not be read has no selection to mark; the
// default it displays is a fallback, not a choice it made.
const permissionUnread = !!permissionControl?.unread && permissionMode !== 'acp';
const permissionNextTurnMode = permissionMode === 'acp'
? null
: permissionControl?.nextTurnMode ?? null;
Expand All @@ -443,6 +452,8 @@ export const ChatInputWorkspaceStrip: React.FC<ChatInputWorkspaceStripProps> = (
)
: permissionOverridden
? t('chatInput.permissionMode.currentSessionOverride', { mode: permissionModeLabel })
: permissionUnread
? t('chatInput.permissionMode.unreadTooltip', { mode: permissionModeLabel })
: t('chatInput.permissionMode.current', { mode: permissionModeLabel });
const PermissionIcon = PERMISSION_MODE_ICONS[permissionDisplayMode];
const PermissionSessionIcon = PERMISSION_MODE_ICONS[permissionMode];
Expand Down Expand Up @@ -712,7 +723,7 @@ export const ChatInputWorkspaceStrip: React.FC<ChatInputWorkspaceStripProps> = (
const oneOff = selectionScope === 'turn';
const selected = oneOff
? permissionNextTurnMode === mode
: permissionMode === mode;
: permissionMode === mode && !permissionUnread;
const copy = permissionCopy[mode];
const OptionIcon = PERMISSION_MODE_ICONS[mode];
const accessibleLabel = oneOff
Expand Down Expand Up @@ -863,6 +874,7 @@ export const ChatInputWorkspaceStrip: React.FC<ChatInputWorkspaceStripProps> = (
data-testid="chat-input-permission-trigger"
data-permission-mode={permissionDisplayMode}
data-permission-overridden={permissionOverridden ? 'true' : undefined}
data-permission-unread={permissionUnread ? 'true' : undefined}
data-permission-next-turn={permissionNextTurnArmed ? 'true' : undefined}
data-permission-active-turn={permissionActiveTurn ? 'true' : undefined}
onClick={event => {
Expand Down Expand Up @@ -933,6 +945,17 @@ export const ChatInputWorkspaceStrip: React.FC<ChatInputWorkspaceStripProps> = (
data-openbitfun-component="chat-input-workspace-strip"
data-openbitfun-part="permissionOptions"
>
{/* With no readable Session mode there is no honest
checkmark to place, so say why the list is unmarked. */}
{permissionUnread ? (
<MenuItem
disabled
leading={<Icon name="info" size="sm" aria-hidden />}
data-testid="chat-input-permission-unread-notice"
>
{t('chatInput.permissionMode.unreadMenuNotice')}
</MenuItem>
) : null}
{permissionModes.map(mode => renderPermissionModeOption(mode, 'session'))}
</MenuSection>
{permissionControl.onChangeForNextTurn ? (
Expand Down
5 changes: 5 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 @@ -831,6 +831,7 @@
"globalScope": "Global",
"current": "Permissions: {{mode}}",
"changeFailed": "Failed to change the permission mode.",
"changeFailedSessionInUse": "This session is open for writing in another OpenBitFun instance. Close the other instance and try again.",
"ask": {
"label": "Ask",
"description": "External access, file changes, and command execution require confirmation."
Expand Down Expand Up @@ -859,6 +860,10 @@
"currentSessionOverride": "Permissions: {{mode}} (this session only)",
"resetToDefault": "Follow the default mode",
"noSession": "Open or start a session before changing its permission mode.",
"unread": "Could not read this session's permission mode. Showing the default mode instead.",
"unreadSessionInUse": "This session is open for writing in another OpenBitFun instance, so its permission mode cannot be read. The default mode is shown instead. Close the other instance and reopen this session.",
"unreadTooltip": "Permission mode could not be read; showing the default: {{mode}}",
"unreadMenuNotice": "This session's own mode could not be read, so no mode is marked.",
"turnScope": "Next message only",
"turnSettings": "Next message settings",
"followSessionMode": "Follow this session",
Expand Down
5 changes: 5 additions & 0 deletions src/web-ui/src/locales/zh-CN/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,7 @@
"globalScope": "全局",
"current": "权限:{{mode}}",
"changeFailed": "切换权限模式失败。",
"changeFailedSessionInUse": "此会话正在另一个 OpenBitFun 实例中被写入。请关闭其他实例后重试。",
"ask": {
"label": "需要确认",
"description": "外部访问,修改文件和执行命令需要确认。"
Expand Down Expand Up @@ -859,6 +860,10 @@
"currentSessionOverride": "权限:{{mode}}(仅当前会话)",
"resetToDefault": "跟随默认模式",
"noSession": "请先打开或新建会话,再修改其权限模式。",
"unread": "无法读取此会话的权限模式,当前显示的是默认模式。",
"unreadSessionInUse": "此会话正在另一个 OpenBitFun 实例中被写入,无法读取其权限模式,当前显示的是默认模式。请关闭其他实例后重新打开此会话。",
"unreadTooltip": "无法读取权限模式,当前显示默认模式:{{mode}}",
"unreadMenuNotice": "无法读取此会话自身的模式,因此未标记任何模式。",
"turnScope": "仅下一条消息",
"turnSettings": "下一条消息设置",
"followSessionMode": "跟随当前会话",
Expand Down
5 changes: 5 additions & 0 deletions src/web-ui/src/locales/zh-TW/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,7 @@
"globalScope": "全域",
"current": "權限:{{mode}}",
"changeFailed": "切換權限模式失敗。",
"changeFailedSessionInUse": "此工作階段正在另一個 OpenBitFun 實例中被寫入。請關閉其他實例後重試。",
"ask": {
"label": "需要確認",
"description": "外部存取、修改檔案和執行命令需要確認。"
Expand Down Expand Up @@ -859,6 +860,10 @@
"currentSessionOverride": "權限:{{mode}}(僅目前工作階段)",
"resetToDefault": "跟隨預設模式",
"noSession": "請先開啟或新建工作階段,再變更其權限模式。",
"unread": "無法讀取此工作階段的權限模式,目前顯示的是預設模式。",
"unreadSessionInUse": "此工作階段正在另一個 OpenBitFun 實例中被寫入,無法讀取其權限模式,目前顯示的是預設模式。請關閉其他實例後重新開啟此工作階段。",
"unreadTooltip": "無法讀取權限模式,目前顯示預設模式:{{mode}}",
"unreadMenuNotice": "無法讀取此工作階段本身的模式,因此未標記任何模式。",
"turnScope": "僅下一則訊息",
"turnSettings": "下一則訊息設定",
"followSessionMode": "跟隨目前工作階段",
Expand Down
Loading