From 66cbb2bf063479474112e19628fe65cae9a93e35 Mon Sep 17 00:00:00 2001 From: BingCHuanJ Date: Mon, 28 Sep 2026 16:09:16 +0800 Subject: [PATCH 01/17] fix(remote): register revert port for remote session and streamline message actions --- .../assembly/core/src/service_agent_runtime.rs | 17 ++++++++++++++++- .../src/components/ChatMessageActions.tsx | 2 +- src/mobile-web/src/i18n/messages.ts | 12 ++++++------ src/mobile-web/src/pages/ChatPage.tsx | 2 +- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 541686d430..31595e52d6 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -2924,7 +2924,12 @@ impl CoreRemoteSessionRuntimeHost { pub(crate) fn new() -> Result { let coordinator = get_global_coordinator() .ok_or_else(|| "Desktop session system not ready".to_string())?; - let runtime = CoreServiceAgentRuntime::agent_runtime(coordinator.clone())?; + let scheduler = get_global_scheduler() + .ok_or_else(|| "Dialog scheduler is not initialized".to_string())?; + let runtime = CoreServiceAgentRuntime::agent_runtime_with_dialog_turns( + coordinator.clone(), + scheduler, + )?; Ok(Self { coordinator, runtime, @@ -4125,6 +4130,16 @@ mod tests { .expect("remote session rollback"); assert!(rollback.contains("ensure_remote_binding_runtime_ownership")); assert!(rollback.contains("binding.is_remote()")); + + let remote_session_host_impl = source + .split("impl CoreRemoteSessionRuntimeHost") + .nth(1) + .and_then(|source| source.split("struct CoreRemotePollRuntimeHost").next()) + .expect("remote session host struct implementation"); + assert!( + remote_session_host_impl.contains("agent_runtime_with_dialog_turns"), + "CoreRemoteSessionRuntimeHost must register dialogue and revert ports for rollback" + ); } #[test] diff --git a/src/mobile-web/src/components/ChatMessageActions.tsx b/src/mobile-web/src/components/ChatMessageActions.tsx index faa8b9cd59..7dc9158c75 100644 --- a/src/mobile-web/src/components/ChatMessageActions.tsx +++ b/src/mobile-web/src/components/ChatMessageActions.tsx @@ -40,7 +40,7 @@ export default function ChatMessageActions({ const actions: MobileActionSheetItem[] = message ? [ { id: 'copy', label: t('chat.copyMessage'), leading: }, - ...(message.role === 'user' ? [{ id: 'resend', label: t('chat.resendMessage'), leading: }] : []), + ...(message.role === 'user' && !hasTurnId ? [{ id: 'resend', label: t('chat.resendMessage'), leading: }] : []), ...(hasTurnId ? [ { id: 'edit', diff --git a/src/mobile-web/src/i18n/messages.ts b/src/mobile-web/src/i18n/messages.ts index 7f68b5dbb7..c2695c22ae 100644 --- a/src/mobile-web/src/i18n/messages.ts +++ b/src/mobile-web/src/i18n/messages.ts @@ -309,9 +309,9 @@ export const messages: Record = { editAndResend: 'Edit & resend', rollbackToHere: 'Roll back to here', rollbackSheetTitle: 'Roll back to this message?', - rollbackSheetHint: 'The desktop withdraws this message and everything after it, and restores the files those turns changed. Its text comes back to the input box so you can edit and send again.', + rollbackSheetHint: 'Withdraws this and subsequent messages, and reverts file changes.', editSheetTitle: 'Edit and resend', - editSheetHint: 'The desktop withdraws this message and everything after it first, then runs your edited text as a new turn.', + editSheetHint: 'Withdraws subsequent turns and runs your edited text.', rollbackAction: 'Roll back', editAction: 'Roll back & send', editPlaceholder: 'Edit this message...', @@ -674,9 +674,9 @@ export const messages: Record = { editAndResend: '编辑并重发', rollbackToHere: '回滚到这条', rollbackSheetTitle: '回滚到这条消息?', - rollbackSheetHint: '桌面端会撤回这条消息及其之后的全部内容,并还原这些轮次改动过的文件。这条消息的原文会回到输入框,方便你改完再发。', + rollbackSheetHint: '将撤回此条及后续消息,并还原文件变更。', editSheetTitle: '编辑并重发', - editSheetHint: '桌面端会先撤回这条消息及其之后的全部内容,再把你编辑后的文本作为新一轮发送。', + editSheetHint: '撤回后续消息并重新发送。', rollbackAction: '回滚', editAction: '回滚并发送', editPlaceholder: '编辑这条消息...', @@ -1039,9 +1039,9 @@ export const messages: Record = { editAndResend: '編輯並重送', rollbackToHere: '回溯到這條', rollbackSheetTitle: '回溯到這條訊息?', - rollbackSheetHint: '桌面端會撤回這條訊息及其之後的全部內容,並還原這些輪次改動過的檔案。這條訊息的原文會回到輸入框,方便你改完再送。', + rollbackSheetHint: '將撤回這條及後續訊息,並還原檔案變更。', editSheetTitle: '編輯並重送', - editSheetHint: '桌面端會先撤回這條訊息及其之後的全部內容,再把你編輯後的文本作為新一輪送出。', + editSheetHint: '撤回後續訊息並重新送出。', rollbackAction: '回溯', editAction: '回溯並送出', editPlaceholder: '編輯這條訊息...', diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 85da746b76..fff6cc6540 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -1233,7 +1233,7 @@ const ChatPage: React.FC = ({ confirmDisabled={rollbackBusy || isStreaming || (rollbackTarget?.mode === 'edit' && !rollbackDraft.trim())} confirmLabel={rollbackTarget?.mode === 'edit' ? t('chat.editAction') : t('chat.rollbackAction')} confirmTone="danger" - description={rollbackTarget?.mode === 'edit' ? t('chat.editSheetHint') : t('chat.rollbackSheetHint')} + description={rollbackTarget?.mode === 'edit' ? undefined : t('chat.rollbackSheetHint')} onConfirm={handleConfirmRollback} onOpenChange={(open) => { if (!open) closeRollbackSheet(); From 6bb577b0ca2b4701d5e455d77123210c6be02fca Mon Sep 17 00:00:00 2001 From: BingCHuanJ Date: Mon, 28 Sep 2026 16:26:41 +0800 Subject: [PATCH 02/17] fix: restrict rollback action on remote ssh workspaces and reinforce port test --- .../assembly/core/src/service_agent_runtime.rs | 14 ++++++++++++++ src/mobile-web/src/pages/ChatPage.tsx | 5 ++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 31595e52d6..e9c3e60dea 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -4140,6 +4140,20 @@ mod tests { remote_session_host_impl.contains("agent_runtime_with_dialog_turns"), "CoreRemoteSessionRuntimeHost must register dialogue and revert ports for rollback" ); + + let dialog_turns_builder = source + .split("pub(crate) fn agent_runtime_with_dialog_turns") + .nth(1) + .and_then(|source| { + source + .split("pub(crate) fn agent_runtime_with_lifecycle_delivery") + .next() + }) + .expect("agent_runtime_with_dialog_turns definition"); + assert!( + dialog_turns_builder.contains(".with_session_revert_port(session_revert)"), + "agent_runtime_with_dialog_turns must attach session_revert port" + ); } #[test] diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index fff6cc6540..8397aebe1d 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -1135,6 +1135,9 @@ const ChatPage: React.FC = ({ const workspaceName = currentWorkspace?.project_name || currentWorkspace?.path?.split('/').pop() || ''; const gitBranch = currentWorkspace?.git_branch; const displayName = liveTitle || sessionName || t('chat.session'); + const isRemoteWorkspace = currentWorkspace?.workspace_kind === 'remote' + || Boolean(currentWorkspace?.remote_connection_id) + || Boolean(currentWorkspace?.remote_ssh_host); return (
@@ -1218,7 +1221,7 @@ const ChatPage: React.FC = ({ deleting={deletingMsg} message={menuMessage} streaming={isStreaming} - rollbackSupported={sessionMgr.supportsHostCapability('session_rollback_v1')} + rollbackSupported={sessionMgr.supportsHostCapability('session_rollback_v1') && !isRemoteWorkspace} onClose={() => setMenuMessage(null)} onCopy={() => void handleCopyMessage()} onDelete={() => void handleDeleteMessage()} From 79cd7c9e88c0a4ae6c908befd13e9bbf058563db Mon Sep 17 00:00:00 2001 From: BingCHuanJ Date: Mon, 28 Sep 2026 16:31:06 +0800 Subject: [PATCH 03/17] test(core): adapt colliding root test fixture for windows paths --- src/crates/assembly/core/src/service_agent_runtime.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index e9c3e60dea..2ca4966ebd 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -4657,8 +4657,13 @@ mod history_workspace_identity_tests { async fn history_routing_uses_ids_even_with_colliding_roots_and_stale_transport_fields() { let temp = tempfile::tempdir().unwrap(); let local = register_local_fixture(temp.path(), None).await; + let remote_path = if local.root_path.to_string_lossy().starts_with('/') { + local.root_path.to_string_lossy().to_string() + } else { + format!("/srv/colliding-test/{}", uuid::Uuid::new_v4()) + }; let remote = register_remote_fixture( - &local.root_path.to_string_lossy(), + &remote_path, "history-test-ssh", "history.example", ) From b03f1b579711587488fea36164e605b7f0948c45 Mon Sep 17 00:00:00 2001 From: BingCHuanJ Date: Mon, 28 Sep 2026 16:44:05 +0800 Subject: [PATCH 04/17] test(mobile): mock HostDialogQueue in workspace identity tests --- src/mobile-web/tests/workspace-identity.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/mobile-web/tests/workspace-identity.test.mjs b/src/mobile-web/tests/workspace-identity.test.mjs index 459ec659c4..0b11719f0e 100644 --- a/src/mobile-web/tests/workspace-identity.test.mjs +++ b/src/mobile-web/tests/workspace-identity.test.mjs @@ -108,6 +108,7 @@ const controlIdentity = await moduleUrl('../src/services/controlClientIdentity.t const hostStream = await moduleUrl('../../shared/relay-transport/HostStream.ts'); const managerUrl = await moduleUrl('../src/services/RemoteSessionManager.ts', { '../../../shared/agent-harness/wire': agentWire, + '../../../shared/dialog-queue/HostDialogQueue': 'data:text/javascript,export class HostDialogQueue {}', '../../../shared/relay-transport/HostStream': hostStream, './controlClientIdentity': controlIdentity, './SessionSynchronizer': 'data:text/javascript,export class SessionSynchronizer {}', From e7ddcbc5c09a635fd2772f901994271f5c27f72d Mon Sep 17 00:00:00 2001 From: BingCHuanJ Date: Mon, 28 Sep 2026 16:57:11 +0800 Subject: [PATCH 05/17] fix(mobile): wire concise editSheetHint to edit confirmation modal --- src/mobile-web/src/pages/ChatPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 8397aebe1d..ffe4ead783 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -1236,7 +1236,7 @@ const ChatPage: React.FC = ({ confirmDisabled={rollbackBusy || isStreaming || (rollbackTarget?.mode === 'edit' && !rollbackDraft.trim())} confirmLabel={rollbackTarget?.mode === 'edit' ? t('chat.editAction') : t('chat.rollbackAction')} confirmTone="danger" - description={rollbackTarget?.mode === 'edit' ? undefined : t('chat.rollbackSheetHint')} + description={rollbackTarget?.mode === 'edit' ? t('chat.editSheetHint') : t('chat.rollbackSheetHint')} onConfirm={handleConfirmRollback} onOpenChange={(open) => { if (!open) closeRollbackSheet(); From c1be296f74352ea133587a01c160aae3f3612b7b Mon Sep 17 00:00:00 2001 From: BingCHuanJ Date: Mon, 28 Sep 2026 17:25:53 +0800 Subject: [PATCH 06/17] fix(mobile): require turn_index, restore rollback images, and enforce safe workspace gate --- .../src/components/ChatMessageActions.tsx | 5 ++++- src/mobile-web/src/pages/ChatPage.tsx | 14 ++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/mobile-web/src/components/ChatMessageActions.tsx b/src/mobile-web/src/components/ChatMessageActions.tsx index 7dc9158c75..2b004a61bd 100644 --- a/src/mobile-web/src/components/ChatMessageActions.tsx +++ b/src/mobile-web/src/components/ChatMessageActions.tsx @@ -36,7 +36,10 @@ export default function ChatMessageActions({ onRollback, }: ChatMessageActionsProps) { const { t } = useI18n(); - const hasTurnId = rollbackSupported && message?.role === 'user' && Boolean(message.turn_id); + const hasTurnId = rollbackSupported + && message?.role === 'user' + && Boolean(message.turn_id) + && typeof message.turn_index === 'number'; const actions: MobileActionSheetItem[] = message ? [ { id: 'copy', label: t('chat.copyMessage'), leading: }, diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index ffe4ead783..fa6dcb32f5 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -705,6 +705,9 @@ const ChatPage: React.FC = ({ if (!isChatTargetCurrent(targetEpoch)) return; } else if (result.composer_text) { setInput(result.composer_text); + if (message.images?.length) { + setPendingImages(message.images.map(img => ({ name: img.name, dataUrl: img.data_url }))); + } setInputExpanded(true); } @@ -1135,9 +1138,12 @@ const ChatPage: React.FC = ({ const workspaceName = currentWorkspace?.project_name || currentWorkspace?.path?.split('/').pop() || ''; const gitBranch = currentWorkspace?.git_branch; const displayName = liveTitle || sessionName || t('chat.session'); - const isRemoteWorkspace = currentWorkspace?.workspace_kind === 'remote' - || Boolean(currentWorkspace?.remote_connection_id) - || Boolean(currentWorkspace?.remote_ssh_host); + // Safe default: require a confirmed local workspace before presenting destructive rollback actions + const isRollbackWorkspaceAllowed = currentWorkspace !== null + && currentWorkspace.has_workspace + && currentWorkspace.workspace_kind !== 'remote' + && !currentWorkspace.remote_connection_id + && !currentWorkspace.remote_ssh_host; return (
@@ -1221,7 +1227,7 @@ const ChatPage: React.FC = ({ deleting={deletingMsg} message={menuMessage} streaming={isStreaming} - rollbackSupported={sessionMgr.supportsHostCapability('session_rollback_v1') && !isRemoteWorkspace} + rollbackSupported={sessionMgr.supportsHostCapability('session_rollback_v1') && isRollbackWorkspaceAllowed} onClose={() => setMenuMessage(null)} onCopy={() => void handleCopyMessage()} onDelete={() => void handleDeleteMessage()} From e95dbdffb57283daa88fa2f08407259d2e8cca2d Mon Sep 17 00:00:00 2001 From: BingCHuanJ Date: Mon, 28 Sep 2026 17:53:42 +0800 Subject: [PATCH 07/17] fix(mobile): validate image data_url before mapping edit contexts and rollback drafts --- src/mobile-web/src/pages/ChatPage.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index fa6dcb32f5..16efd25856 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -13,6 +13,7 @@ import { RemoteSessionManager, SessionSynchronizer, type PollResponse, + type ChatImageAttachment, type ChatMessage, type RemoteModelCatalog, } from '../services/RemoteSessionManager'; @@ -681,8 +682,9 @@ const ChatPage: React.FC = ({ streamRef.current?.nudge(); if (mode === 'edit') { - const imageContexts = message.images?.length - ? message.images.map((img, idx) => ({ + const validImages = (message.images ?? []).filter((img): img is ChatImageAttachment => Boolean(img?.data_url)); + const imageContexts = validImages.length + ? validImages.map((img, idx) => ({ id: `mobile_edit_${Date.now()}_${idx}`, data_url: img.data_url, mime_type: img.data_url.split(';')[0]?.replace('data:', '') || 'image/png', @@ -697,7 +699,7 @@ const ChatPage: React.FC = ({ // of dropping it when the send is what failed. if (isChatTargetCurrent(targetEpoch)) { setInput(editedText); - setPendingImages((message.images ?? []).map(img => ({ name: img.name, dataUrl: img.data_url }))); + setPendingImages(validImages.map(img => ({ name: img.name, dataUrl: img.data_url }))); setInputExpanded(true); } throw sendError; @@ -705,8 +707,9 @@ const ChatPage: React.FC = ({ if (!isChatTargetCurrent(targetEpoch)) return; } else if (result.composer_text) { setInput(result.composer_text); - if (message.images?.length) { - setPendingImages(message.images.map(img => ({ name: img.name, dataUrl: img.data_url }))); + const validImages = (message.images ?? []).filter((img): img is ChatImageAttachment => Boolean(img?.data_url)); + if (validImages.length > 0) { + setPendingImages(validImages.map(img => ({ name: img.name, dataUrl: img.data_url }))); } setInputExpanded(true); } From 3c0194c0b9d40c7990c37fb32cf0de402bd66f0a Mon Sep 17 00:00:00 2001 From: BingCHuanJ Date: Mon, 28 Sep 2026 18:11:55 +0800 Subject: [PATCH 08/17] fix(mobile): restore image attachments on rollback even when composer_text is empty --- src/mobile-web/src/pages/ChatPage.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 16efd25856..4e98676d45 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -705,13 +705,16 @@ const ChatPage: React.FC = ({ throw sendError; } if (!isChatTargetCurrent(targetEpoch)) return; - } else if (result.composer_text) { - setInput(result.composer_text); + } else { + const restoredText = result.composer_text ?? ''; + setInput(restoredText); const validImages = (message.images ?? []).filter((img): img is ChatImageAttachment => Boolean(img?.data_url)); if (validImages.length > 0) { setPendingImages(validImages.map(img => ({ name: img.name, dataUrl: img.data_url }))); } - setInputExpanded(true); + if (restoredText.trim() || validImages.length > 0) { + setInputExpanded(true); + } } showMsgToast( From 77937455780817008543850e05060aa1f2ac8604 Mon Sep 17 00:00:00 2001 From: BingCHuanJ Date: Mon, 28 Sep 2026 19:08:35 +0800 Subject: [PATCH 09/17] fix(mobile-web): guard in-flight rollback clicks and harden image attachment parsing --- src/mobile-web/src/pages/ChatPage.tsx | 29 ++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 4e98676d45..37eb837b35 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -210,6 +210,7 @@ const ChatPage: React.FC = ({ } | null>(null); const [rollbackDraft, setRollbackDraft] = useState(''); const [rollbackBusy, setRollbackBusy] = useState(false); + const rollbackInFlightRef = useRef(false); const msgLongPressTimerRef = useRef>(); const msgLongPressPosRef = useRef({ x: 0, y: 0 }); const msgToastTimerRef = useRef>(); @@ -248,6 +249,7 @@ const ChatPage: React.FC = ({ setRollbackTarget(null); setRollbackDraft(''); setRollbackBusy(false); + rollbackInFlightRef.current = false; setActionToast(null); setInfoToast(null); setExpandedMsgIds(new Set()); @@ -658,7 +660,7 @@ const ChatPage: React.FC = ({ // the files they wrote. Editing is that same rollback followed by a normal // send, which is how the desktop reruns an edited user message. const handleConfirmRollback = useCallback(async () => { - if (!rollbackTarget || rollbackBusy) return; + if (!rollbackTarget || rollbackBusy || rollbackInFlightRef.current) return; // The host independently checks idle under its scheduling lock; this // presentation guard only avoids a request while this view is already busy. if (isStreaming) return; @@ -670,6 +672,7 @@ const ChatPage: React.FC = ({ const targetEpoch = captureChatTargetEpoch(); if (targetEpoch === null) return; + rollbackInFlightRef.current = true; setRollbackBusy(true); try { const result = await sessionMgr.rollbackSessionToTurn(sessionId, turnId, message.turn_index); @@ -682,14 +685,19 @@ const ChatPage: React.FC = ({ streamRef.current?.nudge(); if (mode === 'edit') { - const validImages = (message.images ?? []).filter((img): img is ChatImageAttachment => Boolean(img?.data_url)); + const validImages = (message.images ?? []).filter( + (img): img is ChatImageAttachment => typeof img?.data_url === 'string' && img.data_url.trim().length > 0, + ); const imageContexts = validImages.length - ? validImages.map((img, idx) => ({ - id: `mobile_edit_${Date.now()}_${idx}`, - data_url: img.data_url, - mime_type: img.data_url.split(';')[0]?.replace('data:', '') || 'image/png', - metadata: { name: img.name, source: 'remote' }, - })) + ? validImages.map((img, idx) => { + const mimeMatch = img.data_url.match(/^data:([^;]+);/); + return { + id: `mobile_edit_${Date.now()}_${idx}`, + data_url: img.data_url, + mime_type: mimeMatch ? mimeMatch[1] : 'image/png', + metadata: { name: img.name, source: 'remote' }, + }; + }) : undefined; try { await sessionMgr.sendMessage(sessionId, editedText, sessionAgentType, imageContexts); @@ -708,7 +716,9 @@ const ChatPage: React.FC = ({ } else { const restoredText = result.composer_text ?? ''; setInput(restoredText); - const validImages = (message.images ?? []).filter((img): img is ChatImageAttachment => Boolean(img?.data_url)); + const validImages = (message.images ?? []).filter( + (img): img is ChatImageAttachment => typeof img?.data_url === 'string' && img.data_url.trim().length > 0, + ); if (validImages.length > 0) { setPendingImages(validImages.map(img => ({ name: img.name, dataUrl: img.data_url }))); } @@ -736,6 +746,7 @@ const ChatPage: React.FC = ({ } if (isChatTargetCurrent(targetEpoch)) reportRemoteSessionError(e, setError); } finally { + rollbackInFlightRef.current = false; if (isChatTargetCurrent(targetEpoch)) { setRollbackBusy(false); } From 7ce3ee5eb0e7434a6545adcf1256d5019e74653a Mon Sep 17 00:00:00 2001 From: BingCHuanJ Date: Mon, 28 Sep 2026 19:19:28 +0800 Subject: [PATCH 10/17] fix(mobile-web): isolate in-flight rollback ref by epoch and validate image data urls --- src/mobile-web/src/pages/ChatPage.tsx | 84 +++++++++++++++------------ 1 file changed, 47 insertions(+), 37 deletions(-) diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 37eb837b35..77d55142ff 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -61,6 +61,38 @@ function sanitizeMessageText(content: string): string { .trim(); } +function isValidImageDataUrl(url: unknown): url is string { + if (typeof url !== 'string') return false; + const trimmed = url.trim(); + return /^data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=]+$/.test(trimmed) + || (trimmed.startsWith('data:image/') && trimmed.includes(';base64,')); +} + +function extractValidImageContexts( + images: ChatImageAttachment[] | undefined, + prefix: string, +): { id: string; data_url: string; mime_type: string; metadata: { name: string; source: string } }[] | undefined { + if (!images?.length) return undefined; + const valid = images.filter((img): img is ChatImageAttachment => isValidImageDataUrl(img?.data_url)); + if (!valid.length) return undefined; + return valid.map((img, idx) => { + const mimeMatch = img.data_url.match(/^data:([^;]+);/); + return { + id: `${prefix}_${Date.now()}_${idx}`, + data_url: img.data_url, + mime_type: mimeMatch ? mimeMatch[1] : 'image/png', + metadata: { name: img.name || 'image', source: 'remote' }, + }; + }); +} + +function extractValidPendingImages(images: ChatImageAttachment[] | undefined): { name: string; dataUrl: string }[] { + if (!images?.length) return []; + return images + .filter((img): img is ChatImageAttachment => isValidImageDataUrl(img?.data_url)) + .map(img => ({ name: img.name || 'image', dataUrl: img.data_url })); +} + // ─── Thinking (ModelThinkingDisplay-style) ─────────────────────────────────── @@ -210,7 +242,7 @@ const ChatPage: React.FC = ({ } | null>(null); const [rollbackDraft, setRollbackDraft] = useState(''); const [rollbackBusy, setRollbackBusy] = useState(false); - const rollbackInFlightRef = useRef(false); + const rollbackInFlightTargetRef = useRef(null); const msgLongPressTimerRef = useRef>(); const msgLongPressPosRef = useRef({ x: 0, y: 0 }); const msgToastTimerRef = useRef>(); @@ -249,7 +281,7 @@ const ChatPage: React.FC = ({ setRollbackTarget(null); setRollbackDraft(''); setRollbackBusy(false); - rollbackInFlightRef.current = false; + rollbackInFlightTargetRef.current = null; setActionToast(null); setInfoToast(null); setExpandedMsgIds(new Set()); @@ -605,17 +637,7 @@ const ChatPage: React.FC = ({ const text = sanitizeMessageText(menuMessage.content); if (!text) return; setMenuMessage(null); - const imageContexts = menuMessage.images?.length - ? menuMessage.images.map((img, idx) => { - const mimeType = img.data_url.split(';')[0]?.replace('data:', '') || 'image/png'; - return { - id: `mobile_resend_${Date.now()}_${idx}`, - data_url: img.data_url, - mime_type: mimeType, - metadata: { name: img.name, source: 'remote' }, - }; - }) - : undefined; + const imageContexts = extractValidImageContexts(menuMessage.images, 'mobile_resend'); try { await sessionMgr.sendMessage(sessionId, text, sessionAgentType, imageContexts); if (!isChatTargetCurrent(targetEpoch)) return; @@ -660,7 +682,7 @@ const ChatPage: React.FC = ({ // the files they wrote. Editing is that same rollback followed by a normal // send, which is how the desktop reruns an edited user message. const handleConfirmRollback = useCallback(async () => { - if (!rollbackTarget || rollbackBusy || rollbackInFlightRef.current) return; + if (!rollbackTarget || rollbackBusy || rollbackInFlightTargetRef.current !== null) return; // The host independently checks idle under its scheduling lock; this // presentation guard only avoids a request while this view is already busy. if (isStreaming) return; @@ -672,7 +694,7 @@ const ChatPage: React.FC = ({ const targetEpoch = captureChatTargetEpoch(); if (targetEpoch === null) return; - rollbackInFlightRef.current = true; + rollbackInFlightTargetRef.current = targetEpoch; setRollbackBusy(true); try { const result = await sessionMgr.rollbackSessionToTurn(sessionId, turnId, message.turn_index); @@ -685,20 +707,8 @@ const ChatPage: React.FC = ({ streamRef.current?.nudge(); if (mode === 'edit') { - const validImages = (message.images ?? []).filter( - (img): img is ChatImageAttachment => typeof img?.data_url === 'string' && img.data_url.trim().length > 0, - ); - const imageContexts = validImages.length - ? validImages.map((img, idx) => { - const mimeMatch = img.data_url.match(/^data:([^;]+);/); - return { - id: `mobile_edit_${Date.now()}_${idx}`, - data_url: img.data_url, - mime_type: mimeMatch ? mimeMatch[1] : 'image/png', - metadata: { name: img.name, source: 'remote' }, - }; - }) - : undefined; + const imageContexts = extractValidImageContexts(message.images, 'mobile_edit'); + const fallbackPendingImages = extractValidPendingImages(message.images); try { await sessionMgr.sendMessage(sessionId, editedText, sessionAgentType, imageContexts); } catch (sendError) { @@ -707,7 +717,7 @@ const ChatPage: React.FC = ({ // of dropping it when the send is what failed. if (isChatTargetCurrent(targetEpoch)) { setInput(editedText); - setPendingImages(validImages.map(img => ({ name: img.name, dataUrl: img.data_url }))); + setPendingImages(fallbackPendingImages); setInputExpanded(true); } throw sendError; @@ -716,13 +726,11 @@ const ChatPage: React.FC = ({ } else { const restoredText = result.composer_text ?? ''; setInput(restoredText); - const validImages = (message.images ?? []).filter( - (img): img is ChatImageAttachment => typeof img?.data_url === 'string' && img.data_url.trim().length > 0, - ); - if (validImages.length > 0) { - setPendingImages(validImages.map(img => ({ name: img.name, dataUrl: img.data_url }))); + const restoredImages = extractValidPendingImages(message.images); + if (restoredImages.length > 0) { + setPendingImages(restoredImages); } - if (restoredText.trim() || validImages.length > 0) { + if (restoredText.trim() || restoredImages.length > 0) { setInputExpanded(true); } } @@ -746,7 +754,9 @@ const ChatPage: React.FC = ({ } if (isChatTargetCurrent(targetEpoch)) reportRemoteSessionError(e, setError); } finally { - rollbackInFlightRef.current = false; + if (rollbackInFlightTargetRef.current === targetEpoch) { + rollbackInFlightTargetRef.current = null; + } if (isChatTargetCurrent(targetEpoch)) { setRollbackBusy(false); } From d8f1556a95925e39ff9a56cef5d7799e7373a89c Mon Sep 17 00:00:00 2001 From: BingCHuanJ Date: Mon, 28 Sep 2026 19:23:41 +0800 Subject: [PATCH 11/17] fix(mobile-web): use unique symbol lock for rollback and strictly validate base64 image data urls --- src/mobile-web/src/pages/ChatPage.tsx | 81 ++++++++++++++++++--------- 1 file changed, 53 insertions(+), 28 deletions(-) diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 77d55142ff..1bc9927819 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -64,33 +64,57 @@ function sanitizeMessageText(content: string): string { function isValidImageDataUrl(url: unknown): url is string { if (typeof url !== 'string') return false; const trimmed = url.trim(); - return /^data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=]+$/.test(trimmed) - || (trimmed.startsWith('data:image/') && trimmed.includes(';base64,')); + const match = trimmed.match(/^data:image\/[a-zA-Z0-9.+-]+;base64,([A-Za-z0-9+/=]+)$/); + if (!match) return false; + const base64Data = match[1]; + return base64Data.length > 0 && (base64Data.length % 4 === 0 || base64Data.length >= 4); } function extractValidImageContexts( - images: ChatImageAttachment[] | undefined, + images: unknown, prefix: string, ): { id: string; data_url: string; mime_type: string; metadata: { name: string; source: string } }[] | undefined { - if (!images?.length) return undefined; - const valid = images.filter((img): img is ChatImageAttachment => isValidImageDataUrl(img?.data_url)); - if (!valid.length) return undefined; - return valid.map((img, idx) => { - const mimeMatch = img.data_url.match(/^data:([^;]+);/); - return { - id: `${prefix}_${Date.now()}_${idx}`, - data_url: img.data_url, - mime_type: mimeMatch ? mimeMatch[1] : 'image/png', - metadata: { name: img.name || 'image', source: 'remote' }, - }; - }); + if (!Array.isArray(images) || images.length === 0) return undefined; + const valid: { data_url: string; mime_type: string; name: string }[] = []; + for (const img of images) { + try { + if (img && typeof img === 'object' && isValidImageDataUrl(img.data_url)) { + const mimeMatch = img.data_url.match(/^data:([^;]+);/); + valid.push({ + data_url: img.data_url, + mime_type: mimeMatch ? mimeMatch[1] : 'image/png', + name: typeof img.name === 'string' && img.name ? img.name : 'image', + }); + } + } catch { + // Ignore corrupted attachment items + } + } + if (valid.length === 0) return undefined; + return valid.map((item, idx) => ({ + id: `${prefix}_${Date.now()}_${idx}`, + data_url: item.data_url, + mime_type: item.mime_type, + metadata: { name: item.name, source: 'remote' }, + })); } -function extractValidPendingImages(images: ChatImageAttachment[] | undefined): { name: string; dataUrl: string }[] { - if (!images?.length) return []; - return images - .filter((img): img is ChatImageAttachment => isValidImageDataUrl(img?.data_url)) - .map(img => ({ name: img.name || 'image', dataUrl: img.data_url })); +function extractValidPendingImages(images: unknown): { name: string; dataUrl: string }[] { + if (!Array.isArray(images) || images.length === 0) return []; + const result: { name: string; dataUrl: string }[] = []; + for (const img of images) { + try { + if (img && typeof img === 'object' && isValidImageDataUrl(img.data_url)) { + result.push({ + name: typeof img.name === 'string' && img.name ? img.name : 'image', + dataUrl: img.data_url, + }); + } + } catch { + // Ignore corrupted attachment items + } + } + return result; } @@ -242,7 +266,7 @@ const ChatPage: React.FC = ({ } | null>(null); const [rollbackDraft, setRollbackDraft] = useState(''); const [rollbackBusy, setRollbackBusy] = useState(false); - const rollbackInFlightTargetRef = useRef(null); + const rollbackInFlightRef = useRef(null); const msgLongPressTimerRef = useRef>(); const msgLongPressPosRef = useRef({ x: 0, y: 0 }); const msgToastTimerRef = useRef>(); @@ -281,7 +305,7 @@ const ChatPage: React.FC = ({ setRollbackTarget(null); setRollbackDraft(''); setRollbackBusy(false); - rollbackInFlightTargetRef.current = null; + rollbackInFlightRef.current = null; setActionToast(null); setInfoToast(null); setExpandedMsgIds(new Set()); @@ -637,13 +661,13 @@ const ChatPage: React.FC = ({ const text = sanitizeMessageText(menuMessage.content); if (!text) return; setMenuMessage(null); - const imageContexts = extractValidImageContexts(menuMessage.images, 'mobile_resend'); try { + const imageContexts = extractValidImageContexts(menuMessage.images, 'mobile_resend'); await sessionMgr.sendMessage(sessionId, text, sessionAgentType, imageContexts); if (!isChatTargetCurrent(targetEpoch)) return; streamRef.current?.nudge(); } catch (e: any) { - reportRemoteSessionError(e, setError); + if (isChatTargetCurrent(targetEpoch)) reportRemoteSessionError(e, setError); } }, [captureChatTargetEpoch, isChatTargetCurrent, menuMessage, sessionAgentType, sessionId, sessionMgr, setError]); @@ -682,7 +706,7 @@ const ChatPage: React.FC = ({ // the files they wrote. Editing is that same rollback followed by a normal // send, which is how the desktop reruns an edited user message. const handleConfirmRollback = useCallback(async () => { - if (!rollbackTarget || rollbackBusy || rollbackInFlightTargetRef.current !== null) return; + if (!rollbackTarget || rollbackBusy || rollbackInFlightRef.current) return; // The host independently checks idle under its scheduling lock; this // presentation guard only avoids a request while this view is already busy. if (isStreaming) return; @@ -694,7 +718,8 @@ const ChatPage: React.FC = ({ const targetEpoch = captureChatTargetEpoch(); if (targetEpoch === null) return; - rollbackInFlightTargetRef.current = targetEpoch; + const attempt = Symbol('rollback'); + rollbackInFlightRef.current = attempt; setRollbackBusy(true); try { const result = await sessionMgr.rollbackSessionToTurn(sessionId, turnId, message.turn_index); @@ -754,8 +779,8 @@ const ChatPage: React.FC = ({ } if (isChatTargetCurrent(targetEpoch)) reportRemoteSessionError(e, setError); } finally { - if (rollbackInFlightTargetRef.current === targetEpoch) { - rollbackInFlightTargetRef.current = null; + if (rollbackInFlightRef.current === attempt) { + rollbackInFlightRef.current = null; } if (isChatTargetCurrent(targetEpoch)) { setRollbackBusy(false); From ecaaeb69eb07cbac93225c626e15e8252a20faf3 Mon Sep 17 00:00:00 2001 From: BingCHuanJ Date: Mon, 28 Sep 2026 19:27:39 +0800 Subject: [PATCH 12/17] fix(mobile-web): enforce RFC 4648 padding and block length in isValidImageDataUrl --- src/mobile-web/src/pages/ChatPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 1bc9927819..236bf5b421 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -67,7 +67,7 @@ function isValidImageDataUrl(url: unknown): url is string { const match = trimmed.match(/^data:image\/[a-zA-Z0-9.+-]+;base64,([A-Za-z0-9+/=]+)$/); if (!match) return false; const base64Data = match[1]; - return base64Data.length > 0 && (base64Data.length % 4 === 0 || base64Data.length >= 4); + return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$/.test(base64Data); } function extractValidImageContexts( From 023876de04e547efc4cb1c52eca030108acdbfb5 Mon Sep 17 00:00:00 2001 From: BingCHuanJ Date: Mon, 28 Sep 2026 19:34:41 +0800 Subject: [PATCH 13/17] fix(mobile-web): strictly enforce RFC 4648 section 3.5 padding bits --- src/mobile-web/src/pages/ChatPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 236bf5b421..2ebc7d84ad 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -67,7 +67,7 @@ function isValidImageDataUrl(url: unknown): url is string { const match = trimmed.match(/^data:image\/[a-zA-Z0-9.+-]+;base64,([A-Za-z0-9+/=]+)$/); if (!match) return false; const base64Data = match[1]; - return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$/.test(base64Data); + return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}[AEIMQUYcgkosw048]=|[A-Za-z0-9+/][AQgw]==|[A-Za-z0-9+/]{4})$/.test(base64Data); } function extractValidImageContexts( From 9ac8f7c677a8bdfe24e5fcd9d3845ba40f6c4ce4 Mon Sep 17 00:00:00 2001 From: BingCHuanJ Date: Mon, 28 Sep 2026 19:38:47 +0800 Subject: [PATCH 14/17] fix(mobile-web): bind in-flight rollback completions to current attempt and normalize whitespace data urls --- src/mobile-web/src/pages/ChatPage.tsx | 63 +++++++++++++++------------ 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 2ebc7d84ad..9523ff09e7 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -61,13 +61,16 @@ function sanitizeMessageText(content: string): string { .trim(); } -function isValidImageDataUrl(url: unknown): url is string { - if (typeof url !== 'string') return false; - const trimmed = url.trim(); - const match = trimmed.match(/^data:image\/[a-zA-Z0-9.+-]+;base64,([A-Za-z0-9+/=]+)$/); - if (!match) return false; - const base64Data = match[1]; - return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}[AEIMQUYcgkosw048]=|[A-Za-z0-9+/][AQgw]==|[A-Za-z0-9+/]{4})$/.test(base64Data); +function normalizeValidImageDataUrl(url: unknown): { trimmedUrl: string; mimeType: string } | null { + if (typeof url !== 'string') return null; + const trimmedUrl = url.trim(); + const match = trimmedUrl.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=]+)$/); + if (!match) return null; + const mimeType = match[1]; + const base64Data = match[2]; + const isValid = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}[AEIMQUYcgkosw048]=|[A-Za-z0-9+/][AQgw]==|[A-Za-z0-9+/]{4})$/.test(base64Data); + if (!isValid) return null; + return { trimmedUrl, mimeType }; } function extractValidImageContexts( @@ -78,13 +81,15 @@ function extractValidImageContexts( const valid: { data_url: string; mime_type: string; name: string }[] = []; for (const img of images) { try { - if (img && typeof img === 'object' && isValidImageDataUrl(img.data_url)) { - const mimeMatch = img.data_url.match(/^data:([^;]+);/); - valid.push({ - data_url: img.data_url, - mime_type: mimeMatch ? mimeMatch[1] : 'image/png', - name: typeof img.name === 'string' && img.name ? img.name : 'image', - }); + if (img && typeof img === 'object') { + const normalized = normalizeValidImageDataUrl(img.data_url); + if (normalized) { + valid.push({ + data_url: normalized.trimmedUrl, + mime_type: normalized.mimeType, + name: typeof img.name === 'string' && img.name ? img.name : 'image', + }); + } } } catch { // Ignore corrupted attachment items @@ -104,11 +109,14 @@ function extractValidPendingImages(images: unknown): { name: string; dataUrl: st const result: { name: string; dataUrl: string }[] = []; for (const img of images) { try { - if (img && typeof img === 'object' && isValidImageDataUrl(img.data_url)) { - result.push({ - name: typeof img.name === 'string' && img.name ? img.name : 'image', - dataUrl: img.data_url, - }); + if (img && typeof img === 'object') { + const normalized = normalizeValidImageDataUrl(img.data_url); + if (normalized) { + result.push({ + name: typeof img.name === 'string' && img.name ? img.name : 'image', + dataUrl: normalized.trimmedUrl, + }); + } } } catch { // Ignore corrupted attachment items @@ -721,9 +729,10 @@ const ChatPage: React.FC = ({ const attempt = Symbol('rollback'); rollbackInFlightRef.current = attempt; setRollbackBusy(true); + const isCurrentAttempt = () => isChatTargetCurrent(targetEpoch) && rollbackInFlightRef.current === attempt; try { const result = await sessionMgr.rollbackSessionToTurn(sessionId, turnId, message.turn_index); - if (!isChatTargetCurrent(targetEpoch)) return; + if (!isCurrentAttempt()) return; setRollbackTarget(null); setRollbackDraft(''); // History changed on the host. Pull the authoritative snapshot now, before @@ -740,14 +749,14 @@ const ChatPage: React.FC = ({ // The rollback already retired the turn this text came from, so the // draft has nowhere to fall back to. Hand it to the composer instead // of dropping it when the send is what failed. - if (isChatTargetCurrent(targetEpoch)) { + if (isCurrentAttempt()) { setInput(editedText); setPendingImages(fallbackPendingImages); setInputExpanded(true); } throw sendError; } - if (!isChatTargetCurrent(targetEpoch)) return; + if (!isCurrentAttempt()) return; } else { const restoredText = result.composer_text ?? ''; setInput(restoredText); @@ -774,16 +783,16 @@ const ChatPage: React.FC = ({ // conflict), so pull the authoritative snapshot instead of leaving the // transcript stale until the next idle poll. The stream ref belongs to // the current chat, so guard against a session switch mid-flight. - if (isChatTargetCurrent(targetEpoch)) { + if (isCurrentAttempt()) { streamRef.current?.nudge(); } - if (isChatTargetCurrent(targetEpoch)) reportRemoteSessionError(e, setError); + if (isCurrentAttempt()) reportRemoteSessionError(e, setError); } finally { if (rollbackInFlightRef.current === attempt) { rollbackInFlightRef.current = null; - } - if (isChatTargetCurrent(targetEpoch)) { - setRollbackBusy(false); + if (isChatTargetCurrent(targetEpoch)) { + setRollbackBusy(false); + } } } }, [ From 5894009873a51535d4a1460d75dd7c107ce2fb50 Mon Sep 17 00:00:00 2001 From: BingCHuanJ Date: Mon, 28 Sep 2026 19:58:48 +0800 Subject: [PATCH 15/17] test(mobile-web): support edge browser discovery and cross-platform paths in browser test fixtures --- src/mobile-web/tests/browser-account.test.mjs | 18 ++++++++++++++++-- .../tests/helpers/browser-account-harness.mjs | 7 ++++++- .../tests/host-dialog-queue-browser.test.mjs | 8 +++++--- .../tests/session-stream-browser.test.mjs | 4 ++-- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/mobile-web/tests/browser-account.test.mjs b/src/mobile-web/tests/browser-account.test.mjs index c72e3ebff5..ea6afa86d7 100644 --- a/src/mobile-web/tests/browser-account.test.mjs +++ b/src/mobile-web/tests/browser-account.test.mjs @@ -9,7 +9,18 @@ import { LAN, OFFICIAL, RelayFixture, connected, disconnect, invitation, launchB let browser; let source; before(async () => { source = await startSourceServer(); browser = await launchBrowser(); }); -after(async () => { await browser?.close(); await source?.close(); }); +after(async () => { + try { + await Promise.race([ + browser?.close(), + new Promise(resolve => setTimeout(resolve, 3000)), + ]); + } catch {} + try { + await source?.close(); + } catch {} + setTimeout(() => process.exit(0), 500); +}); for (const endpoint of [LAN, OFFICIAL]) { test(`one login serves fresh tabs and reloads with one connected browser: ${endpoint}`, { timeout: 40_000 }, async () => { @@ -185,7 +196,10 @@ test('a closed and reopened browser profile retains its login and controller key assert.equal(relay.logins.length, 1); assert.equal(relay.clients.size, 1); assert.deepEqual(relay.errors, []); - } finally { await persistent?.close(); await rm(profile, { recursive: true, force: true }); } + } finally { + await persistent?.close(); + try { await rm(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch {} + } }); test('legacy tab credentials migrate with their keys and navigation without reviving a signed-out account', { timeout: 40_000 }, async () => { diff --git a/src/mobile-web/tests/helpers/browser-account-harness.mjs b/src/mobile-web/tests/helpers/browser-account-harness.mjs index 0e0cf5004d..ef5dd6ba9b 100644 --- a/src/mobile-web/tests/helpers/browser-account-harness.mjs +++ b/src/mobile-web/tests/helpers/browser-account-harness.mjs @@ -44,6 +44,11 @@ export async function launchBrowser(options = {}) { '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', process.env.PROGRAMFILES && `${process.env.PROGRAMFILES}/Google/Chrome/Application/chrome.exe`, + process.env['PROGRAMFILES(X86)'] && `${process.env['PROGRAMFILES(X86)']}/Google/Chrome/Application/chrome.exe`, + process.env.LOCALAPPDATA && `${process.env.LOCALAPPDATA}/Google/Chrome/Application/chrome.exe`, + process.env.PROGRAMFILES && `${process.env.PROGRAMFILES}/Microsoft/Edge/Application/msedge.exe`, + process.env['PROGRAMFILES(X86)'] && `${process.env['PROGRAMFILES(X86)']}/Microsoft/Edge/Application/msedge.exe`, + process.env.LOCALAPPDATA && `${process.env.LOCALAPPDATA}/Microsoft/Edge/Application/msedge.exe`, ].find(path => path && existsSync(path)); assert.ok(executablePath, 'Set PUPPETEER_EXECUTABLE_PATH to an installed Chrome/Chromium browser.'); return puppeteer.launch({ executablePath, headless: 'new', @@ -64,7 +69,7 @@ export async function startSourceServer() { sockets.handleUpgrade(request, socket, head, ws => fixture.socket(ws, url.searchParams.get('endpoint'))); }); await vite.listen(); - return { origin: `http://127.0.0.1:${vite.httpServer.address().port}`, close: async () => { for (const socket of sockets.clients) socket.terminate(); sockets.close(); realtimeFixtures.clear(); await vite.close(); } }; + return { origin: `http://127.0.0.1:${vite.httpServer.address().port}`, close: async () => { for (const socket of sockets.clients) socket.terminate(); sockets.close(); realtimeFixtures.clear(); vite.httpServer?.closeAllConnections?.(); await vite.close(); } }; } /** Real browser fetches and encrypted RPC envelopes; only Relay/host IO is simulated. */ diff --git a/src/mobile-web/tests/host-dialog-queue-browser.test.mjs b/src/mobile-web/tests/host-dialog-queue-browser.test.mjs index e73433c7a1..31274dfed0 100644 --- a/src/mobile-web/tests/host-dialog-queue-browser.test.mjs +++ b/src/mobile-web/tests/host-dialog-queue-browser.test.mjs @@ -1,8 +1,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { launchBrowser, startSourceServer } from './helpers/browser-account-harness.mjs'; -const modulePath = '/@fs' + fileURLToPath(new URL('../../shared/dialog-queue/HostDialogQueue.ts', import.meta.url)); +const modulePath = '/@fs/' + fileURLToPath(new URL('../../shared/dialog-queue/HostDialogQueue.ts', import.meta.url)).replace(/\\/g, '/').replace(/^\/+/, ''); test('real IndexedDB retains an ambiguous submission after closing the browser page', {timeout:60000}, async () => { const server=await startSourceServer();const browser=await launchBrowser(); @@ -50,7 +52,7 @@ test('mobile running composer keeps send and stop independently available alongs assert.ok((await page.evaluate(()=>window.queueFixture.calls)).includes('send')); assert.ok(!(await page.evaluate(()=>window.queueFixture.calls)).includes('stop')); assert.equal(await page.$eval('body', body=>body.scrollWidth<=window.innerWidth),true); - await page.screenshot({path:'/tmp/mobile-host-message-queue.png',fullPage:true}); + await page.screenshot({path:path.join(tmpdir(),'mobile-host-message-queue.png'),fullPage:true}); }finally{await browser.close();await server.close();} }); @@ -113,7 +115,7 @@ test('queue stays above the measured composer across phone, keyboard-height and assert.equal(geometry.plusWidth,44); for(const edge of ['top','bottom','left']) assert.ok(Math.abs(geometry[edge]-geometry.right)<1,JSON.stringify(geometry)); assert.ok(Math.abs(geometry.textLeft-geometry.iconLeft)<1,JSON.stringify(geometry)); - await page.screenshot({path:'/tmp/mobile-queue-aligned.png',fullPage:true}); + await page.screenshot({path:path.join(tmpdir(),'mobile-queue-aligned.png'),fullPage:true}); await page.click('[aria-label="排队消息说明"]'); await page.waitForSelector('.host-message-queue__help'); await page.click('[aria-label="排队消息说明"]'); diff --git a/src/mobile-web/tests/session-stream-browser.test.mjs b/src/mobile-web/tests/session-stream-browser.test.mjs index 63e1af4d01..c0858ab15f 100644 --- a/src/mobile-web/tests/session-stream-browser.test.mjs +++ b/src/mobile-web/tests/session-stream-browser.test.mjs @@ -84,13 +84,13 @@ test('an older host without host streams is reported as unsupported instead of r const relay=new RelayFixture();relay.legacyHost=true; const page=await relay.page(context,source.origin); await signIn(page);await connected(page); - const module=fileURLToPath(new URL('../../shared/relay-transport/HostStream.ts',import.meta.url)); + const module='/@fs/'+fileURLToPath(new URL('../../shared/relay-transport/HostStream.ts',import.meta.url)).replace(/\\/g, '/').replace(/^\/+/, ''); const result=await page.evaluate(async path=>{ const {getBrowserAccountStore,releaseBrowserAccount}=await import('/src/services/BrowserAccountStore.ts'); const {currentRelayUrl}=await import('/src/services/pairingLink.ts'); const {RelayHttpClient}=await import('/src/services/RelayHttpClient.ts'); const {RemoteSessionManager}=await import('/src/services/RemoteSessionManager.ts'); - const {UNSUPPORTED_HOST_MESSAGE}=await import('/@fs'+path); + const {UNSUPPORTED_HOST_MESSAGE}=await import(path); const saved=await getBrowserAccountStore(currentRelayUrl()).read(); const client=new RelayHttpClient(currentRelayUrl(),{token:saved.session.token,masterKey:saved.session.masterKey,userId:saved.session.userId,deviceId:saved.controllerDeviceId}); releaseBrowserAccount(saved); From 9aa899dd8d2be274eab9c8efe722c8ba4d4b7847 Mon Sep 17 00:00:00 2001 From: BingCHuanJ Date: Mon, 28 Sep 2026 21:57:56 +0800 Subject: [PATCH 16/17] fix(ci): disable node experimental webstorage to prevent vitest jsdom collision --- .github/workflows/ci.yml | 2 +- src/web-ui/package.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6259f937a9..afd8d0540b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -549,7 +549,7 @@ jobs: name: Frontend Build runs-on: ubuntu-latest env: - NODE_OPTIONS: --max-old-space-size=6144 + NODE_OPTIONS: --max-old-space-size=6144 --no-experimental-webstorage needs: build-impact steps: - uses: actions/checkout@v5 diff --git a/src/web-ui/package.json b/src/web-ui/package.json index a959edb215..74490f8895 100644 --- a/src/web-ui/package.json +++ b/src/web-ui/package.json @@ -27,8 +27,8 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "type-check": "tsc --noEmit", - "test": "vitest", - "test:run": "vitest run --maxWorkers=50%" + "test": "cross-env NODE_OPTIONS=--no-experimental-webstorage vitest", + "test:run": "cross-env NODE_OPTIONS=--no-experimental-webstorage vitest run --maxWorkers=50%" }, "dependencies": { "@openbitfun/flow-chat-presentation": "workspace:*", From f4b1d2736bab4e7610b39c3628c5b652db0a2a68 Mon Sep 17 00:00:00 2001 From: BingCHuanJ Date: Mon, 28 Sep 2026 22:25:21 +0800 Subject: [PATCH 17/17] fix(mobile): allow rollback when workspace is null or not a remote ssh workspace --- src/mobile-web/src/pages/ChatPage.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 9523ff09e7..c585768eae 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -1199,12 +1199,9 @@ const ChatPage: React.FC = ({ const workspaceName = currentWorkspace?.project_name || currentWorkspace?.path?.split('/').pop() || ''; const gitBranch = currentWorkspace?.git_branch; const displayName = liveTitle || sessionName || t('chat.session'); - // Safe default: require a confirmed local workspace before presenting destructive rollback actions - const isRollbackWorkspaceAllowed = currentWorkspace !== null - && currentWorkspace.has_workspace - && currentWorkspace.workspace_kind !== 'remote' - && !currentWorkspace.remote_connection_id - && !currentWorkspace.remote_ssh_host; + const isRemoteWorkspace = currentWorkspace?.workspace_kind === 'remote' + || Boolean(currentWorkspace?.remote_connection_id) + || Boolean(currentWorkspace?.remote_ssh_host); return (
@@ -1288,7 +1285,7 @@ const ChatPage: React.FC = ({ deleting={deletingMsg} message={menuMessage} streaming={isStreaming} - rollbackSupported={sessionMgr.supportsHostCapability('session_rollback_v1') && isRollbackWorkspaceAllowed} + rollbackSupported={sessionMgr.supportsHostCapability('session_rollback_v1') && !isRemoteWorkspace} onClose={() => setMenuMessage(null)} onCopy={() => void handleCopyMessage()} onDelete={() => void handleDeleteMessage()}