Skip to content

Commit 41beb1a

Browse files
authored
Merge pull request #3169 from nonoqing/fix/session-permission-mode-unread
fix(flowchat): report an unreadable session permission mode
2 parents 08da024 + 5cdb415 commit 41beb1a

7 files changed

Lines changed: 170 additions & 5 deletions

File tree

‎src/apps/desktop/src/api/agentic_api.rs‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2365,10 +2365,28 @@ async fn ensure_session_loaded_for_selector_update(
23652365
include_internal,
23662366
)
23672367
.await
2368-
.map_err(|error| format!("Failed to restore session before selector update: {error}"))?;
2368+
.map_err(selector_update_restore_error)?;
23692369
Ok(())
23702370
}
23712371

2372+
/// Keeps a Session restore failure readable without burying its stable code.
2373+
///
2374+
/// Callers recognize `session_in_use` and `outcome_unknown` by the message
2375+
/// prefix, so wrapping those two in prose would make a recognizable state look
2376+
/// like a generic failure. Every other reason keeps the selector-update context.
2377+
fn selector_update_restore_error(error: DesktopSessionApplicationError) -> String {
2378+
let carries_stable_code = matches!(
2379+
error,
2380+
DesktopSessionApplicationError::SessionInUse(_)
2381+
| DesktopSessionApplicationError::OutcomeUnknown(_)
2382+
);
2383+
let message = error.to_string();
2384+
if carries_stable_code {
2385+
return message;
2386+
}
2387+
format!("Failed to restore session before selector update: {message}")
2388+
}
2389+
23722390
#[tauri::command]
23732391
pub async fn reload_session_context(
23742392
runtime: State<'_, DesktopRuntimeContext>,
@@ -4952,6 +4970,37 @@ mod tests {
49524970
SetSubagentTimeoutActionDTO::Disable
49534971
));
49544972
}
4973+
4974+
#[test]
4975+
fn selector_update_restore_error_keeps_the_stable_session_in_use_code() {
4976+
let message = selector_update_restore_error(DesktopSessionApplicationError::SessionInUse(
4977+
"Session is already open for writing: session-1".to_string(),
4978+
));
4979+
assert_eq!(
4980+
message,
4981+
"session_in_use: Session is already open for writing: session-1"
4982+
);
4983+
}
4984+
4985+
#[test]
4986+
fn selector_update_restore_error_keeps_the_stable_outcome_unknown_code() {
4987+
let message = selector_update_restore_error(
4988+
DesktopSessionApplicationError::OutcomeUnknown("commit may have landed".to_string()),
4989+
);
4990+
assert_eq!(message, "outcome_unknown: commit may have landed");
4991+
}
4992+
4993+
#[test]
4994+
fn selector_update_restore_error_keeps_the_selector_update_context_for_other_reasons() {
4995+
let message = selector_update_restore_error(DesktopSessionApplicationError::Validation(
4996+
"workspace_id is required when the session is not loaded".to_string(),
4997+
));
4998+
assert_eq!(
4999+
message,
5000+
"Failed to restore session before selector update: \
5001+
workspace_id is required when the session is not loaded"
5002+
);
5003+
}
49555004
}
49565005

49575006
#[tauri::command]

‎src/web-ui/src/flow_chat/components/ChatInput.tsx‎

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ import {
223223
} from '../utils/tokenUsageDisplay';
224224
import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI';
225225
import type { SessionPermissionMode } from '@/infrastructure/api/service-api/AgentAPI';
226+
import { isSessionInUseError } from '@/infrastructure/api/errors/TauriCommandError';
226227
import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFlag';
227228
import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState';
228229
import { isBtwSessionDraft } from '../utils/modelSelectionTarget';
@@ -577,6 +578,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({
577578
// other open session.
578579
const [sessionPermissionMode, setSessionPermissionMode] =
579580
useState<SessionPermissionMode | null>(null);
581+
// A failed read leaves `sessionPermissionMode` at null, which makes the control
582+
// fall back to the user-level default. That fallback is safe, but it must not
583+
// pass for the Session's own selection: this flag keeps the two apart.
584+
const [sessionPermissionModeUnread, setSessionPermissionModeUnread] = useState(false);
580585
// One-off state has two owners: the idle composer arms a future submission,
581586
// while an executing turn keeps a mutable override until it ends.
582587
const [armedTurnPermissionMode, setArmedTurnPermissionMode] =
@@ -589,6 +594,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({
589594
sessionId: string | null;
590595
activeTurnId: string | null;
591596
}>({ sessionId: null, activeTurnId: null });
597+
// Reports a fallback to the default once per Session: the read effect re-runs
598+
// on every Session and turn change, so without this the same unresolved read
599+
// would notify on each pass.
600+
const permissionModeUnreadNotifiedRef = useRef<string | null>(null);
592601
const { addMessage: addToHistory, getSessionHistory } = useInputHistoryStore();
593602

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

24382448
if (!effectiveTargetSessionId || isAcpTargetSession) {
24392449
setSessionPermissionMode(null);
2450+
setSessionPermissionModeUnread(false);
24402451
setArmedTurnPermissionMode(null);
24412452
setActiveTurnPermissionMode(null);
24422453
return undefined;
@@ -2455,15 +2466,27 @@ export const ChatInput: React.FC<ChatInputProps> = ({
24552466
});
24562467
if (permissionModeRequestGenerationRef.current !== generation) return;
24572468
setSessionPermissionMode(response.mode ?? null);
2469+
setSessionPermissionModeUnread(false);
2470+
permissionModeUnreadNotifiedRef.current = null;
24582471
if (activePermissionTurnId && response.activeTurnId === activePermissionTurnId) {
24592472
setActiveTurnPermissionMode(response.turnMode ?? null);
24602473
}
24612474
} catch (error) {
24622475
log.warn('Failed to read session permission mode', error);
24632476
// Falling back to the global default is the safe read: it never shows a
2464-
// wider mode than the session actually runs with.
2477+
// wider mode than the session actually runs with. Report the fallback
2478+
// once per Session so it cannot pass for that Session's own selection.
24652479
if (permissionModeRequestGenerationRef.current === generation) {
24662480
setSessionPermissionMode(null);
2481+
setSessionPermissionModeUnread(true);
2482+
if (permissionModeUnreadNotifiedRef.current !== effectiveTargetSessionId) {
2483+
permissionModeUnreadNotifiedRef.current = effectiveTargetSessionId;
2484+
notificationService.error(t(
2485+
isSessionInUseError(error)
2486+
? 'chatInput.permissionMode.unreadSessionInUse'
2487+
: 'chatInput.permissionMode.unread',
2488+
));
2489+
}
24672490
}
24682491
}
24692492
})();
@@ -2477,6 +2500,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
24772500
effectiveTargetSession?.parentSessionId,
24782501
isBtwDraftTarget,
24792502
isAcpTargetSession,
2503+
t,
24802504
]);
24812505

24822506
const applySessionPermissionMode = useCallback(async (
@@ -2509,6 +2533,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({
25092533
&& effectiveTargetSessionIdRef.current === targetSessionId
25102534
) {
25112535
setSessionPermissionMode(response.mode ?? null);
2536+
setSessionPermissionModeUnread(false);
2537+
permissionModeUnreadNotifiedRef.current = null;
25122538
setActiveTurnPermissionMode(null);
25132539
}
25142540
} catch (error) {
@@ -2521,7 +2547,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({
25212547
if (activePermissionTurnIdRef.current === targetTurnId) {
25222548
setActiveTurnPermissionMode(previousActiveTurnMode);
25232549
}
2524-
notificationService.error(t('chatInput.permissionMode.changeFailed'));
2550+
notificationService.error(t(
2551+
isSessionInUseError(error)
2552+
? 'chatInput.permissionMode.changeFailedSessionInUse'
2553+
: 'chatInput.permissionMode.changeFailed',
2554+
));
25252555
}
25262556
} finally {
25272557
setPermissionModeSaving(false);
@@ -2625,6 +2655,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({
26252655
&& activePermissionTurnIdRef.current === targetTurnId
26262656
) {
26272657
setSessionPermissionMode(response.mode ?? null);
2658+
setSessionPermissionModeUnread(false);
2659+
permissionModeUnreadNotifiedRef.current = null;
26282660
setActiveTurnPermissionMode(response.turnMode ?? null);
26292661
}
26302662
} catch (error) {
@@ -2635,7 +2667,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({
26352667
&& activePermissionTurnIdRef.current === targetTurnId
26362668
) {
26372669
setActiveTurnPermissionMode(previousMode);
2638-
notificationService.error(t('chatInput.permissionMode.changeFailed'));
2670+
notificationService.error(t(
2671+
isSessionInUseError(error)
2672+
? 'chatInput.permissionMode.changeFailedSessionInUse'
2673+
: 'chatInput.permissionMode.changeFailed',
2674+
));
26392675
}
26402676
} finally {
26412677
setPermissionModeSaving(false);
@@ -6095,6 +6131,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({
60956131
saving: permissionModeSaving,
60966132
scopeLabel: t('chatInput.permissionMode.sessionScope'),
60976133
overridden: permissionModeOverridden,
6134+
// The trigger falls back to the user-level default when the read
6135+
// failed, so the menu must not mark that fallback as this
6136+
// Session's own selection.
6137+
unread: sessionPermissionModeUnread,
60986138
nextTurnMode: temporaryPermissionMode
60996139
? chatInputPermissionMode(temporaryPermissionMode)
61006140
: null,

‎src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -864,6 +864,44 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => {
864864
).toBeNull();
865865
});
866866

867+
it('reports an unreadable session mode instead of passing the default off as its own', async () => {
868+
await act(async () => {
869+
root.render(
870+
<ChatInputWorkspaceStrip workspaceId="workspace-1"
871+
repositoryPath=""
872+
workspaceLabel=""
873+
permissionControl={{
874+
// The read failed, so this is the user-level default, not a choice
875+
// the Session made.
876+
mode: 'ask',
877+
overridden: false,
878+
unread: true,
879+
onChange: vi.fn(),
880+
}}
881+
/>
882+
);
883+
});
884+
885+
const trigger = container.querySelector<HTMLButtonElement>(
886+
'[data-testid="chat-input-permission-trigger"]',
887+
);
888+
expect(trigger?.dataset.permissionUnread).toBe('true');
889+
expect(trigger?.getAttribute('data-tooltip')).toBe('chatInput.permissionMode.unreadTooltip');
890+
891+
await act(async () => {
892+
trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
893+
});
894+
895+
// No radio is marked, because the Session's own mode is unknown; the notice
896+
// says why the list is bare.
897+
expect(
898+
document.querySelector('[data-testid="chat-input-permission-selected-ask"]'),
899+
).toBeNull();
900+
expect(
901+
document.querySelector('[data-testid="chat-input-permission-unread-notice"]'),
902+
).not.toBeNull();
903+
});
904+
867905
it('shows ACP ownership without exposing native permission choices', async () => {
868906
await act(async () => {
869907
root.render(

‎src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ export interface ChatInputWorkspaceStripProps {
6565
* two sessions sitting on different modes is legible rather than confusing.
6666
*/
6767
overridden?: boolean;
68+
/**
69+
* The Session's own mode could not be read, so `mode` is the user-level
70+
* default rather than this Session's selection. Reported instead of passed
71+
* off as that selection.
72+
*/
73+
unread?: boolean;
6874
/** Clears the session's own selection and follows the default again. */
6975
onResetToDefault?: () => void | Promise<void>;
7076
/** Opens the settings page that owns the default this row follows. */
@@ -423,6 +429,9 @@ export const ChatInputWorkspaceStrip: React.FC<ChatInputWorkspaceStripProps> = (
423429
|| permissionControl?.saving
424430
|| permissionMode === 'acp';
425431
const permissionOverridden = !!permissionControl?.overridden && permissionMode !== 'acp';
432+
// A Session whose own mode could not be read has no selection to mark; the
433+
// default it displays is a fallback, not a choice it made.
434+
const permissionUnread = !!permissionControl?.unread && permissionMode !== 'acp';
426435
const permissionNextTurnMode = permissionMode === 'acp'
427436
? null
428437
: permissionControl?.nextTurnMode ?? null;
@@ -443,6 +452,8 @@ export const ChatInputWorkspaceStrip: React.FC<ChatInputWorkspaceStripProps> = (
443452
)
444453
: permissionOverridden
445454
? t('chatInput.permissionMode.currentSessionOverride', { mode: permissionModeLabel })
455+
: permissionUnread
456+
? t('chatInput.permissionMode.unreadTooltip', { mode: permissionModeLabel })
446457
: t('chatInput.permissionMode.current', { mode: permissionModeLabel });
447458
const PermissionIcon = PERMISSION_MODE_ICONS[permissionDisplayMode];
448459
const PermissionSessionIcon = PERMISSION_MODE_ICONS[permissionMode];
@@ -712,7 +723,7 @@ export const ChatInputWorkspaceStrip: React.FC<ChatInputWorkspaceStripProps> = (
712723
const oneOff = selectionScope === 'turn';
713724
const selected = oneOff
714725
? permissionNextTurnMode === mode
715-
: permissionMode === mode;
726+
: permissionMode === mode && !permissionUnread;
716727
const copy = permissionCopy[mode];
717728
const OptionIcon = PERMISSION_MODE_ICONS[mode];
718729
const accessibleLabel = oneOff
@@ -863,6 +874,7 @@ export const ChatInputWorkspaceStrip: React.FC<ChatInputWorkspaceStripProps> = (
863874
data-testid="chat-input-permission-trigger"
864875
data-permission-mode={permissionDisplayMode}
865876
data-permission-overridden={permissionOverridden ? 'true' : undefined}
877+
data-permission-unread={permissionUnread ? 'true' : undefined}
866878
data-permission-next-turn={permissionNextTurnArmed ? 'true' : undefined}
867879
data-permission-active-turn={permissionActiveTurn ? 'true' : undefined}
868880
onClick={event => {
@@ -933,6 +945,17 @@ export const ChatInputWorkspaceStrip: React.FC<ChatInputWorkspaceStripProps> = (
933945
data-openbitfun-component="chat-input-workspace-strip"
934946
data-openbitfun-part="permissionOptions"
935947
>
948+
{/* With no readable Session mode there is no honest
949+
checkmark to place, so say why the list is unmarked. */}
950+
{permissionUnread ? (
951+
<MenuItem
952+
disabled
953+
leading={<Icon name="info" size="sm" aria-hidden />}
954+
data-testid="chat-input-permission-unread-notice"
955+
>
956+
{t('chatInput.permissionMode.unreadMenuNotice')}
957+
</MenuItem>
958+
) : null}
936959
{permissionModes.map(mode => renderPermissionModeOption(mode, 'session'))}
937960
</MenuSection>
938961
{permissionControl.onChangeForNextTurn ? (

‎src/web-ui/src/locales/en-US/flow-chat.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -830,6 +830,7 @@
830830
"globalScope": "Global",
831831
"current": "Permissions: {{mode}}",
832832
"changeFailed": "Failed to change the permission mode.",
833+
"changeFailedSessionInUse": "This session is open for writing in another OpenBitFun instance. Close the other instance and try again.",
833834
"ask": {
834835
"label": "Ask",
835836
"description": "External access, file changes, and command execution require confirmation."
@@ -858,6 +859,10 @@
858859
"currentSessionOverride": "Permissions: {{mode}} (this session only)",
859860
"resetToDefault": "Follow the default mode",
860861
"noSession": "Open or start a session before changing its permission mode.",
862+
"unread": "Could not read this session's permission mode. Showing the default mode instead.",
863+
"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.",
864+
"unreadTooltip": "Permission mode could not be read; showing the default: {{mode}}",
865+
"unreadMenuNotice": "This session's own mode could not be read, so no mode is marked.",
861866
"turnScope": "Next message only",
862867
"turnSettings": "Next message settings",
863868
"followSessionMode": "Follow this session",

‎src/web-ui/src/locales/zh-CN/flow-chat.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -830,6 +830,7 @@
830830
"globalScope": "全局",
831831
"current": "权限:{{mode}}",
832832
"changeFailed": "切换权限模式失败。",
833+
"changeFailedSessionInUse": "此会话正在另一个 OpenBitFun 实例中被写入。请关闭其他实例后重试。",
833834
"ask": {
834835
"label": "需要确认",
835836
"description": "外部访问,修改文件和执行命令需要确认。"
@@ -858,6 +859,10 @@
858859
"currentSessionOverride": "权限:{{mode}}(仅当前会话)",
859860
"resetToDefault": "跟随默认模式",
860861
"noSession": "请先打开或新建会话,再修改其权限模式。",
862+
"unread": "无法读取此会话的权限模式,当前显示的是默认模式。",
863+
"unreadSessionInUse": "此会话正在另一个 OpenBitFun 实例中被写入,无法读取其权限模式,当前显示的是默认模式。请关闭其他实例后重新打开此会话。",
864+
"unreadTooltip": "无法读取权限模式,当前显示默认模式:{{mode}}",
865+
"unreadMenuNotice": "无法读取此会话自身的模式,因此未标记任何模式。",
861866
"turnScope": "仅下一条消息",
862867
"turnSettings": "下一条消息设置",
863868
"followSessionMode": "跟随当前会话",

‎src/web-ui/src/locales/zh-TW/flow-chat.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -830,6 +830,7 @@
830830
"globalScope": "全域",
831831
"current": "權限:{{mode}}",
832832
"changeFailed": "切換權限模式失敗。",
833+
"changeFailedSessionInUse": "此工作階段正在另一個 OpenBitFun 實例中被寫入。請關閉其他實例後重試。",
833834
"ask": {
834835
"label": "需要確認",
835836
"description": "外部存取、修改檔案和執行命令需要確認。"
@@ -858,6 +859,10 @@
858859
"currentSessionOverride": "權限:{{mode}}(僅目前工作階段)",
859860
"resetToDefault": "跟隨預設模式",
860861
"noSession": "請先開啟或新建工作階段,再變更其權限模式。",
862+
"unread": "無法讀取此工作階段的權限模式,目前顯示的是預設模式。",
863+
"unreadSessionInUse": "此工作階段正在另一個 OpenBitFun 實例中被寫入,無法讀取其權限模式,目前顯示的是預設模式。請關閉其他實例後重新開啟此工作階段。",
864+
"unreadTooltip": "無法讀取權限模式,目前顯示預設模式:{{mode}}",
865+
"unreadMenuNotice": "無法讀取此工作階段本身的模式,因此未標記任何模式。",
861866
"turnScope": "僅下一則訊息",
862867
"turnSettings": "下一則訊息設定",
863868
"followSessionMode": "跟隨目前工作階段",

0 commit comments

Comments
 (0)