From 66cbb2bf063479474112e19628fe65cae9a93e35 Mon Sep 17 00:00:00 2001 From: BingCHuanJ Date: Mon, 28 Sep 2026 16:09:16 +0800 Subject: [PATCH 01/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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()} From 33064cc50c41047ebd3bde2736d4a4072b5be89f Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 19 Oct 2026 09:49:54 +0800 Subject: [PATCH 18/20] fix(mobile-web): isolate image attachment validation and drop superseded host fix Move remote image data-URL validation and transcript projection into a pure service module and cover it with canonical base64 and rollback-draft tests. Drop the CI NODE_OPTIONS entry: the web-ui test script sets the flag it needs, so unrelated frontend steps keep their memory settings. Main already registers the scheduler revert port on the remote session host, so this PR now carries only the mobile controller behavior. --- .github/workflows/ci.yml | 2 +- src/mobile-web/src/pages/ChatPage.tsx | 75 ++-------------- .../src/services/chatImageAttachments.ts | 90 +++++++++++++++++++ .../tests/helpers/browser-account-harness.mjs | 2 +- .../tests/image-compressor.test.mjs | 84 +++++++++++++++++ 5 files changed, 182 insertions(+), 71 deletions(-) create mode 100644 src/mobile-web/src/services/chatImageAttachments.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 492b595317..98ba8ba715 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -569,7 +569,7 @@ jobs: name: Frontend Build runs-on: ubuntu-latest env: - NODE_OPTIONS: --max-old-space-size=6144 --no-experimental-webstorage + NODE_OPTIONS: --max-old-space-size=6144 needs: build-impact steps: - uses: actions/checkout@v5 diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index fe1d225fc3..4162948919 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -13,10 +13,14 @@ import { RemoteSessionManager, SessionSynchronizer, type PollResponse, - type ChatImageAttachment, type ChatMessage, type RemoteModelCatalog, } from '../services/RemoteSessionManager'; +import { + extractValidImageContexts, + extractValidPendingImages, + type PendingChatImage, +} from '../services/chatImageAttachments'; import { useMobileStore } from '../services/store'; import { createRemoteCacheScope, remoteCache } from '../services/RemoteCache'; import ChatHeader from '../components/ChatHeader'; @@ -61,75 +65,8 @@ function sanitizeMessageText(content: string): string { .trim(); } -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( - images: unknown, - prefix: string, -): { id: string; data_url: string; mime_type: string; metadata: { name: string; source: string } }[] | undefined { - 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') { - 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 - } - } - 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: 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') { - 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 - } - } - return result; -} - - // ─── Thinking (ModelThinkingDisplay-style) ─────────────────────────────────── - - // ─── ChatPage ─────────────────────────────────────────────────────────────── const ChatPage: React.FC = ({ @@ -164,7 +101,7 @@ const ChatPage: React.FC = ({ const [modelCatalog, setModelCatalog] = useState(null); const [selectedModelId, setSelectedModelId] = useState('auto'); const [modelUpdating, setModelUpdating] = useState(false); - const [pendingImages, setPendingImages] = useState<{ name: string; dataUrl: string }[]>([]); + const [pendingImages, setPendingImages] = useState([]); const [imageAnalyzing, setImageAnalyzing] = useState(false); const [isSending, setIsSending] = useState(false); const sendInFlightRef = useRef(null); diff --git a/src/mobile-web/src/services/chatImageAttachments.ts b/src/mobile-web/src/services/chatImageAttachments.ts new file mode 100644 index 0000000000..2b2562f42e --- /dev/null +++ b/src/mobile-web/src/services/chatImageAttachments.ts @@ -0,0 +1,90 @@ +/** Model-ready image attachment projected from a remote transcript message. */ +export interface RemoteImageContext { + id: string; + data_url: string; + mime_type: string; + metadata: { name: string; source: string }; +} + +/** Attachment restored into the mobile composer. */ +export interface PendingChatImage { + name: string; + dataUrl: string; +} + +/** + * Accept only canonical base64 image data URLs. The host may replay transcript + * data that was edited or truncated, so padding bits and quantum length are + * checked as well as the alphabet (RFC 4648 section 3.5) before the payload is + * forwarded to the model or decoded by the browser. + */ +export 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 }; +} + +/** Project valid transcript images into `send_message` image contexts. */ +export function extractValidImageContexts( + images: unknown, + prefix: string, +): RemoteImageContext[] | undefined { + 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') { + 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. + } + } + 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' }, + })); +} + +/** Project valid transcript images into composer attachments. */ +export function extractValidPendingImages(images: unknown): PendingChatImage[] { + if (!Array.isArray(images) || images.length === 0) return []; + const result: PendingChatImage[] = []; + for (const img of images) { + try { + 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. + } + } + return result; +} diff --git a/src/mobile-web/tests/helpers/browser-account-harness.mjs b/src/mobile-web/tests/helpers/browser-account-harness.mjs index ef5dd6ba9b..b81b9bc9f3 100644 --- a/src/mobile-web/tests/helpers/browser-account-harness.mjs +++ b/src/mobile-web/tests/helpers/browser-account-harness.mjs @@ -50,7 +50,7 @@ export async function launchBrowser(options = {}) { 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.'); + assert.ok(executablePath, 'Set PUPPETEER_EXECUTABLE_PATH to an installed Chrome, Chromium, or Edge browser.'); return puppeteer.launch({ executablePath, headless: 'new', // Fixture pages use remote-looking origins while their simulated Relay is loopback. args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-background-timer-throttling', diff --git a/src/mobile-web/tests/image-compressor.test.mjs b/src/mobile-web/tests/image-compressor.test.mjs index 4826a12349..0d8f941d07 100644 --- a/src/mobile-web/tests/image-compressor.test.mjs +++ b/src/mobile-web/tests/image-compressor.test.mjs @@ -56,3 +56,87 @@ test('unreadable photos fail before upload instead of using raw bytes as a fallb browserImages({ invalid: true }); await assert.rejects(compressImageDataUrl('data:image/heic;base64,YQ==', 'camera.heic')); }); + +const attachmentsSource = await readFile( + new URL('../src/services/chatImageAttachments.ts', import.meta.url), + 'utf8', +); +const attachmentsCode = ts.transpileModule(attachmentsSource, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const { + normalizeValidImageDataUrl, + extractValidImageContexts, + extractValidPendingImages, +} = await import(`data:text/javascript;base64,${Buffer.from(attachmentsCode).toString('base64')}`); + +test('image data URLs must use canonical RFC 4648 base64', () => { + assert.deepEqual(normalizeValidImageDataUrl('data:image/png;base64,YQ=='), { + trimmedUrl: 'data:image/png;base64,YQ==', + mimeType: 'image/png', + }); + assert.deepEqual(normalizeValidImageDataUrl(' data:image/jpeg;base64,YWI= '), { + trimmedUrl: 'data:image/jpeg;base64,YWI=', + mimeType: 'image/jpeg', + }); + assert.equal(normalizeValidImageDataUrl('data:image/webp;base64,YWJj')?.mimeType, 'image/webp'); + assert.equal(normalizeValidImageDataUrl('data:image/svg+xml;base64,PHN2Zy8+')?.mimeType, 'image/svg+xml'); + + for (const invalid of [ + undefined, + null, + 42, + '', + 'data:image/png;base64,', + 'data:image/png;base64,YQ', + 'data:image/png;base64,YR==', // non-zero padding bits + 'data:image/png;base64,YQ=', + 'data:image/png;base64,Y Q==', + 'data:text/plain;base64,YQ==', + 'data:image/png,YQ==', + 'data:image/png;base64,YQ--', + ]) { + assert.equal(normalizeValidImageDataUrl(invalid), null, String(invalid)); + } +}); + +test('transcript images project into model contexts without corrupted entries', () => { + const images = [ + { name: 'screen.png', data_url: 'data:image/png;base64,YQ==' }, + { name: '', data_url: ' data:image/jpeg;base64,YWI= ' }, + { name: 'broken.png', data_url: 'data:image/png;base64,YR==' }, + null, + 'not-an-object', + { name: 'missing.png' }, + ]; + const contexts = extractValidImageContexts(images, 'mobile_edit'); + assert.deepEqual(contexts?.map(({ id, ...rest }) => rest), [ + { + data_url: 'data:image/png;base64,YQ==', + mime_type: 'image/png', + metadata: { name: 'screen.png', source: 'remote' }, + }, + { + data_url: 'data:image/jpeg;base64,YWI=', + mime_type: 'image/jpeg', + metadata: { name: 'image', source: 'remote' }, + }, + ]); + assert.match(contexts[0].id, /^mobile_edit_\d+_0$/); + assert.match(contexts[1].id, /^mobile_edit_\d+_1$/); + assert.equal(extractValidImageContexts([], 'mobile_edit'), undefined); + assert.equal(extractValidImageContexts([{ data_url: 'data:image/png;base64,YR==' }], 'x'), undefined); +}); + +test('rollback drafts restore only valid images', () => { + assert.deepEqual( + extractValidPendingImages([ + { name: 'screen.png', data_url: 'data:image/png;base64,YQ==' }, + { name: 'broken.png', data_url: 'data:image/png;base64,YR==' }, + null, + ]), + [{ name: 'screen.png', dataUrl: 'data:image/png;base64,YQ==' }], + ); + assert.deepEqual(extractValidPendingImages(undefined), []); + assert.deepEqual(extractValidPendingImages('not-an-array'), []); +}); From 456b8e14055836ce9b7388fb57ec49a2fdfecaa4 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 19 Oct 2026 09:51:03 +0800 Subject: [PATCH 19/20] test(mobile-web): keep runner exit status during account-test cleanup --- src/mobile-web/tests/browser-account.test.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/mobile-web/tests/browser-account.test.mjs b/src/mobile-web/tests/browser-account.test.mjs index ea6afa86d7..e600a1ac96 100644 --- a/src/mobile-web/tests/browser-account.test.mjs +++ b/src/mobile-web/tests/browser-account.test.mjs @@ -10,6 +10,9 @@ let browser; let source; before(async () => { source = await startSourceServer(); browser = await launchBrowser(); }); after(async () => { + // Bound cleanup so a stuck browser or dev server cannot hang the runner, + // but never force the process exit: the test runner still owns the exit + // code and a forced zero would mask failed assertions. try { await Promise.race([ browser?.close(), @@ -17,9 +20,11 @@ after(async () => { ]); } catch {} try { - await source?.close(); + await Promise.race([ + source?.close(), + new Promise(resolve => setTimeout(resolve, 3000)), + ]); } catch {} - setTimeout(() => process.exit(0), 500); }); for (const endpoint of [LAN, OFFICIAL]) { From acbac2811d03ff723d01df2b06698c1da62838a2 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 19 Oct 2026 10:05:50 +0800 Subject: [PATCH 20/20] fix(mobile-web): restore streamed user images and harden rollback Project userMessage.metadata.images from streamed turn records so the transcript, plain rollback, and edit-resend see the same attachments the host published. Guard rollback against an in-flight send and only replace the composer when the rollback restored text or images, clearing stale attachments instead of leaving them on a restored prompt. Run web-ui Vitest with --no-experimental-webstorage appended to NODE_OPTIONS so the CI heap setting survives, and let the account browser test force-exit after completion without masking failing assertions. --- src/mobile-web/package.json | 2 +- src/mobile-web/src/pages/ChatPage.tsx | 15 +++++---- .../src/services/SessionRecordPresentation.ts | 24 +++++++++++++- src/mobile-web/tests/host-stream.test.mjs | 32 +++++++++++++++++++ src/web-ui/package.json | 4 +-- src/web-ui/scripts/run-vitest.mjs | 32 +++++++++++++++++++ 6 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 src/web-ui/scripts/run-vitest.mjs diff --git a/src/mobile-web/package.json b/src/mobile-web/package.json index 38d1075671..e3378e8d75 100644 --- a/src/mobile-web/package.json +++ b/src/mobile-web/package.json @@ -12,7 +12,7 @@ "test:ui-components": "node --test tests/mobile-ui-components.test.mjs", "test:account-login": "node --test tests/account-login.test.mjs", "test:device-compatibility": "node --test tests/device-compatibility.test.mjs", - "test:account-browser": "node --test tests/browser-account.test.mjs", + "test:account-browser": "node --test --test-force-exit tests/browser-account.test.mjs", "test:images": "node --test tests/image-compressor.test.mjs", "test:workspace-identity": "node --test tests/workspace-identity.test.mjs tests/workspace-routing.test.mjs", "test:remote-cache-browser": "node --test tests/remote-cache-browser.test.mjs", diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 4162948919..08cf2dc2d0 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -655,7 +655,7 @@ const ChatPage: React.FC = ({ 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; + if (isStreaming || isSending || sendInFlightRef.current) return; const { message, mode } = rollbackTarget; const turnId = message.turn_id; if (!turnId) return; @@ -697,12 +697,14 @@ const ChatPage: React.FC = ({ if (!isCurrentAttempt()) return; } else { const restoredText = result.composer_text ?? ''; - setInput(restoredText); const restoredImages = extractValidPendingImages(message.images); - if (restoredImages.length > 0) { - setPendingImages(restoredImages); - } + // Replace the composer only when the rollback actually restored + // something. Replacing it with an empty draft would silently discard + // text the user started after opening the sheet, while keeping the old + // attachment set would attach unrelated images to the restored prompt. if (restoredText.trim() || restoredImages.length > 0) { + setInput(restoredText); + setPendingImages(restoredImages); setInputExpanded(true); } } @@ -736,6 +738,7 @@ const ChatPage: React.FC = ({ }, [ captureChatTargetEpoch, isChatTargetCurrent, + isSending, isStreaming, rollbackBusy, rollbackDraft, @@ -1236,7 +1239,7 @@ const ChatPage: React.FC = ({ setMenuMessage(null)} onCopy={() => void handleCopyMessage()} diff --git a/src/mobile-web/src/services/SessionRecordPresentation.ts b/src/mobile-web/src/services/SessionRecordPresentation.ts index be88b2ec94..d6b7699616 100644 --- a/src/mobile-web/src/services/SessionRecordPresentation.ts +++ b/src/mobile-web/src/services/SessionRecordPresentation.ts @@ -9,6 +9,28 @@ export interface MobileStoredTurn { } interface RichText { id: string; content: string; orderIndex?: number; timestamp?: number; status?: string; subagentSessionId?: string } interface RichTool { id: string; toolName: string; orderIndex?: number; status?: string; startTime?: number; durationMs?: number; subagentSessionId?: string; toolCall: {id:string;input:unknown}; toolResult?:{result:unknown;success:boolean;error?:string} } + +/** + * Streamed turn records carry user attachment pixels in + * `userMessage.metadata.images`; the timeline and rollback/edit drafts read + * them from `ChatMessage.images`. Keep entries whose pixels arrived so a + * rollback never re-sends a blank frame. + */ +function presentUserMessageImages(metadata: unknown): ChatMessage['images'] { + if (!metadata || typeof metadata !== 'object') return undefined; + const images = (metadata as { images?: unknown }).images; + if (!Array.isArray(images)) return undefined; + const presented = images.flatMap((image) => { + if (!image || typeof image !== 'object') return []; + const record = image as { name?: unknown; data_url?: unknown }; + const dataUrl = typeof record.data_url === 'string' ? record.data_url.trim() : ''; + if (!dataUrl) return []; + const name = typeof record.name === 'string' && record.name.trim() ? record.name : 'image'; + return [{ name, data_url: dataUrl }]; + }); + return presented.length > 0 ? presented : undefined; +} + export function presentSessionTurn(turn: MobileStoredTurn): ChatMessage[] { const items: ChatMessageItem[] = [...turn.modelRounds].sort((a,b)=>a.roundIndex-b.roundIndex).flatMap(round => { const ordered = [ @@ -25,7 +47,7 @@ export function presentSessionTurn(turn: MobileStoredTurn): ChatMessage[] { }); }); return [ - {id:turn.userMessage.id,turn_id:turn.turnId,turn_index:turn.turnIndex,role:'user',content:turn.userMessage.content,timestamp:String(turn.userMessage.timestamp),metadata:turn.userMessage.metadata}, + {id:turn.userMessage.id,turn_id:turn.turnId,turn_index:turn.turnIndex,role:'user',content:turn.userMessage.content,timestamp:String(turn.userMessage.timestamp),metadata:turn.userMessage.metadata,images:presentUserMessageImages(turn.userMessage.metadata)}, {id:`${turn.turnId}_assistant`,turn_id:turn.turnId,role:'assistant',content:items.filter(i=>i.type==='text').map(i=>i.content??'').join(''),thinking:items.filter(i=>i.type==='thinking').map(i=>i.content??'').join(''),items,tools:items.flatMap(i=>i.tool?[i.tool]:[]),status:turn.status==='inprogress'?'streaming':turn.status,error:turn.error,timestamp:String(turn.timestamp)}, ]; } diff --git a/src/mobile-web/tests/host-stream.test.mjs b/src/mobile-web/tests/host-stream.test.mjs index 4d4cc239c4..c7917842b7 100644 --- a/src/mobile-web/tests/host-stream.test.mjs +++ b/src/mobile-web/tests/host-stream.test.mjs @@ -273,3 +273,35 @@ test('formal steering turns render once with stable message ownership after hist assert.equal(live[3].content,'New answer'); assert.deepEqual(render([...records].reverse().concat(records)),live,'newest-first reconnect and duplicate replay retain the same transcript'); }); + + +test('streamed user turns project attachment pixels for transcript and draft restore', async () => { + async function load(path) { + const source = await readFile(new URL(path, import.meta.url), 'utf8'); + const compiled = ts.transpileModule(source, {compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText; + return import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`); + } + const {presentSessionTurn} = await load('../src/services/SessionRecordPresentation.ts'); + const metadata = {images: [ + {id: 'img-1', name: 'screen.png', mime_type: 'image/png', data_url: ' data:image/png;base64,YQ== '}, + {id: 'img-2', name: 'path-only.png', mime_type: 'image/png'}, + null, + {data_url: 'data:image/jpeg;base64,YWI='}, + ]}; + const [user] = presentSessionTurn({ + turnId: 'turn-a', turnIndex: 3, status: 'completed', timestamp: 1, + userMessage: {id: 'user-a', content: '', timestamp: 1, metadata}, + modelRounds: [], + }); + assert.deepEqual(user.images, [ + {name: 'screen.png', data_url: 'data:image/png;base64,YQ=='}, + {name: 'image', data_url: 'data:image/jpeg;base64,YWI='}, + ], 'only attachments whose pixels arrived reach the composer'); + assert.equal(user.metadata, metadata, 'protocol metadata stays intact for other projections'); + const [withoutImages] = presentSessionTurn({ + turnId: 'turn-b', turnIndex: 4, status: 'completed', timestamp: 2, + userMessage: {id: 'user-b', content: 'hello', timestamp: 2}, + modelRounds: [], + }); + assert.equal(withoutImages.images, undefined); +}); diff --git a/src/web-ui/package.json b/src/web-ui/package.json index 642c757ac5..b2b49da9d3 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": "cross-env NODE_OPTIONS=--no-experimental-webstorage vitest", - "test:run": "cross-env NODE_OPTIONS=--no-experimental-webstorage vitest run --maxWorkers=50%" + "test": "node scripts/run-vitest.mjs", + "test:run": "node scripts/run-vitest.mjs run --maxWorkers=50%" }, "dependencies": { "@openbitfun/flow-chat-presentation": "workspace:*", diff --git a/src/web-ui/scripts/run-vitest.mjs b/src/web-ui/scripts/run-vitest.mjs new file mode 100644 index 0000000000..654b7429e5 --- /dev/null +++ b/src/web-ui/scripts/run-vitest.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +// Node 25+ exposes its own webstorage globals, which shadow jsdom's +// localStorage/sessionStorage in Vitest environments. Append the disable flag +// instead of replacing NODE_OPTIONS so the caller's heap or tracing flags stay +// intact. +const WEBSTORAGE_FLAG = '--no-experimental-webstorage'; +const existing = (process.env.NODE_OPTIONS ?? '').trim(); +if (!existing.split(/\s+/).includes(WEBSTORAGE_FLAG)) { + process.env.NODE_OPTIONS = [existing, WEBSTORAGE_FLAG].filter(Boolean).join(' '); +} + +const require = createRequire(import.meta.url); +const vitestPackagePath = require.resolve('vitest/package.json'); +const vitestBin = path.join( + path.dirname(vitestPackagePath), + require('vitest/package.json').bin.vitest, +); +const child = spawn(process.execPath, [vitestBin, ...process.argv.slice(2)], { + env: process.env, + stdio: 'inherit', +}); +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); +});