From 5cdb415710b5743bf2ace6f9825fcb49821e3000 Mon Sep 17 00:00:00 2001 From: nonoqing Date: Mon, 21 Sep 2026 14:37:30 +0800 Subject: [PATCH] fix(flowchat): report an unreadable session permission mode A Session whose permission mode cannot be read falls back to the user-level default, and that fallback was displayed as if the Session had chosen it. Together with a generic switch failure it made a Session held open for writing by another OpenBitFun instance look like a lost setting. Mark the fallback as unread instead of passing it off as the Session's own selection: report it once per Session, name the cause when the host reports session_in_use, keep the permission menu from marking an unverified mode, and point the switch failure at the other instance. Stop wrapping session_in_use and outcome_unknown in the selector-update prose so their stable error codes reach the frontend, which recognizes those two by message prefix. Co-authored-by: bitfun-ai <318544290+bitfun-ai@users.noreply.github.com> --- src/apps/desktop/src/api/agentic_api.rs | 51 ++++++++++++++++++- .../src/flow_chat/components/ChatInput.tsx | 46 +++++++++++++++-- .../ChatInputWorkspaceStrip.test.tsx | 38 ++++++++++++++ .../components/ChatInputWorkspaceStrip.tsx | 25 ++++++++- src/web-ui/src/locales/en-US/flow-chat.json | 5 ++ src/web-ui/src/locales/zh-CN/flow-chat.json | 5 ++ src/web-ui/src/locales/zh-TW/flow-chat.json | 5 ++ 7 files changed, 170 insertions(+), 5 deletions(-) diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 105b5cb4f1..ef4c9258f7 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -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>, @@ -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] diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 6289832200..fd40f8d636 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -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'; @@ -577,6 +578,10 @@ export const ChatInput: React.FC = ({ // other open session. const [sessionPermissionMode, setSessionPermissionMode] = useState(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] = @@ -589,6 +594,10 @@ export const ChatInput: React.FC = ({ 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(null); const { addMessage: addToHistory, getSessionHistory } = useInputHistoryStore(); const conversationScope = useConversationViewScope(); @@ -2426,6 +2435,7 @@ export const ChatInput: React.FC = ({ 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 @@ -2437,6 +2447,7 @@ export const ChatInput: React.FC = ({ if (!effectiveTargetSessionId || isAcpTargetSession) { setSessionPermissionMode(null); + setSessionPermissionModeUnread(false); setArmedTurnPermissionMode(null); setActiveTurnPermissionMode(null); return undefined; @@ -2455,15 +2466,27 @@ export const ChatInput: React.FC = ({ }); 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', + )); + } } } })(); @@ -2477,6 +2500,7 @@ export const ChatInput: React.FC = ({ effectiveTargetSession?.parentSessionId, isBtwDraftTarget, isAcpTargetSession, + t, ]); const applySessionPermissionMode = useCallback(async ( @@ -2509,6 +2533,8 @@ export const ChatInput: React.FC = ({ && effectiveTargetSessionIdRef.current === targetSessionId ) { setSessionPermissionMode(response.mode ?? null); + setSessionPermissionModeUnread(false); + permissionModeUnreadNotifiedRef.current = null; setActiveTurnPermissionMode(null); } } catch (error) { @@ -2521,7 +2547,11 @@ export const ChatInput: React.FC = ({ 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); @@ -2625,6 +2655,8 @@ export const ChatInput: React.FC = ({ && activePermissionTurnIdRef.current === targetTurnId ) { setSessionPermissionMode(response.mode ?? null); + setSessionPermissionModeUnread(false); + permissionModeUnreadNotifiedRef.current = null; setActiveTurnPermissionMode(response.turnMode ?? null); } } catch (error) { @@ -2635,7 +2667,11 @@ export const ChatInput: React.FC = ({ && 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); @@ -6095,6 +6131,10 @@ export const ChatInput: React.FC = ({ 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, 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 30e61e1952..cddd829288 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx @@ -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( + + ); + }); + + const trigger = container.querySelector( + '[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( diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx index 21a5b0e0f7..350c3e66b5 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx @@ -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; /** Opens the settings page that owns the default this row follows. */ @@ -423,6 +429,9 @@ export const ChatInputWorkspaceStrip: React.FC = ( || 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; @@ -443,6 +452,8 @@ export const ChatInputWorkspaceStrip: React.FC = ( ) : 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]; @@ -712,7 +723,7 @@ export const ChatInputWorkspaceStrip: React.FC = ( 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 @@ -863,6 +874,7 @@ export const ChatInputWorkspaceStrip: React.FC = ( 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 => { @@ -933,6 +945,17 @@ export const ChatInputWorkspaceStrip: React.FC = ( 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 ? ( + } + data-testid="chat-input-permission-unread-notice" + > + {t('chatInput.permissionMode.unreadMenuNotice')} + + ) : null} {permissionModes.map(mode => renderPermissionModeOption(mode, 'session'))} {permissionControl.onChangeForNextTurn ? ( 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 99f83a969a..3e18179a94 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -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." @@ -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", 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 eb738033fc..7d6c260585 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -831,6 +831,7 @@ "globalScope": "全局", "current": "权限:{{mode}}", "changeFailed": "切换权限模式失败。", + "changeFailedSessionInUse": "此会话正在另一个 OpenBitFun 实例中被写入。请关闭其他实例后重试。", "ask": { "label": "需要确认", "description": "外部访问,修改文件和执行命令需要确认。" @@ -859,6 +860,10 @@ "currentSessionOverride": "权限:{{mode}}(仅当前会话)", "resetToDefault": "跟随默认模式", "noSession": "请先打开或新建会话,再修改其权限模式。", + "unread": "无法读取此会话的权限模式,当前显示的是默认模式。", + "unreadSessionInUse": "此会话正在另一个 OpenBitFun 实例中被写入,无法读取其权限模式,当前显示的是默认模式。请关闭其他实例后重新打开此会话。", + "unreadTooltip": "无法读取权限模式,当前显示默认模式:{{mode}}", + "unreadMenuNotice": "无法读取此会话自身的模式,因此未标记任何模式。", "turnScope": "仅下一条消息", "turnSettings": "下一条消息设置", "followSessionMode": "跟随当前会话", 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 b8b6ec2a4c..818b0b4146 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -831,6 +831,7 @@ "globalScope": "全域", "current": "權限:{{mode}}", "changeFailed": "切換權限模式失敗。", + "changeFailedSessionInUse": "此工作階段正在另一個 OpenBitFun 實例中被寫入。請關閉其他實例後重試。", "ask": { "label": "需要確認", "description": "外部存取、修改檔案和執行命令需要確認。" @@ -859,6 +860,10 @@ "currentSessionOverride": "權限:{{mode}}(僅目前工作階段)", "resetToDefault": "跟隨預設模式", "noSession": "請先開啟或新建工作階段,再變更其權限模式。", + "unread": "無法讀取此工作階段的權限模式,目前顯示的是預設模式。", + "unreadSessionInUse": "此工作階段正在另一個 OpenBitFun 實例中被寫入,無法讀取其權限模式,目前顯示的是預設模式。請關閉其他實例後重新開啟此工作階段。", + "unreadTooltip": "無法讀取權限模式,目前顯示預設模式:{{mode}}", + "unreadMenuNotice": "無法讀取此工作階段本身的模式,因此未標記任何模式。", "turnScope": "僅下一則訊息", "turnSettings": "下一則訊息設定", "followSessionMode": "跟隨目前工作階段",