Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
66cbb2b
fix(remote): register revert port for remote session and streamline m…
BingCHuanJ Sep 28, 2026
6bb577b
fix: restrict rollback action on remote ssh workspaces and reinforce …
BingCHuanJ Sep 28, 2026
79cd7c9
test(core): adapt colliding root test fixture for windows paths
BingCHuanJ Sep 28, 2026
b03f1b5
test(mobile): mock HostDialogQueue in workspace identity tests
BingCHuanJ Sep 28, 2026
e7ddcbc
fix(mobile): wire concise editSheetHint to edit confirmation modal
BingCHuanJ Sep 28, 2026
c1be296
fix(mobile): require turn_index, restore rollback images, and enforce…
BingCHuanJ Sep 28, 2026
e95dbdf
fix(mobile): validate image data_url before mapping edit contexts and…
BingCHuanJ Sep 28, 2026
3c0194c
fix(mobile): restore image attachments on rollback even when composer…
BingCHuanJ Sep 28, 2026
7793745
fix(mobile-web): guard in-flight rollback clicks and harden image att…
BingCHuanJ Sep 28, 2026
7ce3ee5
fix(mobile-web): isolate in-flight rollback ref by epoch and validate…
BingCHuanJ Sep 28, 2026
d8f1556
fix(mobile-web): use unique symbol lock for rollback and strictly val…
BingCHuanJ Sep 28, 2026
ecaaeb6
fix(mobile-web): enforce RFC 4648 padding and block length in isValid…
BingCHuanJ Sep 28, 2026
023876d
fix(mobile-web): strictly enforce RFC 4648 section 3.5 padding bits
BingCHuanJ Sep 28, 2026
9ac8f7c
fix(mobile-web): bind in-flight rollback completions to current attem…
BingCHuanJ Sep 28, 2026
5894009
test(mobile-web): support edge browser discovery and cross-platform p…
BingCHuanJ Sep 28, 2026
9aa899d
fix(ci): disable node experimental webstorage to prevent vitest jsdom…
BingCHuanJ Sep 28, 2026
f4b1d27
fix(mobile): allow rollback when workspace is null or not a remote ss…
BingCHuanJ Sep 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 36 additions & 2 deletions src/crates/assembly/core/src/service_agent_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2924,7 +2924,12 @@ impl CoreRemoteSessionRuntimeHost {
pub(crate) fn new() -> Result<Self, String> {
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,
Expand Down Expand Up @@ -4125,6 +4130,30 @@ 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"
);

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]
Expand Down Expand Up @@ -4628,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",
)
Expand Down
7 changes: 5 additions & 2 deletions src/mobile-web/src/components/ChatMessageActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,14 @@ 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: <CopyIcon /> },
...(message.role === 'user' ? [{ id: 'resend', label: t('chat.resendMessage'), leading: <ResendIcon /> }] : []),
...(message.role === 'user' && !hasTurnId ? [{ id: 'resend', label: t('chat.resendMessage'), leading: <ResendIcon /> }] : []),
...(hasTurnId ? [
{
id: 'edit',
Expand Down
12 changes: 6 additions & 6 deletions src/mobile-web/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,9 @@ export const messages: Record<MobileLanguage, MessageTree> = {
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...',
Expand Down Expand Up @@ -674,9 +674,9 @@ export const messages: Record<MobileLanguage, MessageTree> = {
editAndResend: '编辑并重发',
rollbackToHere: '回滚到这条',
rollbackSheetTitle: '回滚到这条消息?',
rollbackSheetHint: '桌面端会撤回这条消息及其之后的全部内容,并还原这些轮次改动过的文件。这条消息的原文会回到输入框,方便你改完再发。',
rollbackSheetHint: '将撤回此条及后续消息,并还原文件变更。',
editSheetTitle: '编辑并重发',
editSheetHint: '桌面端会先撤回这条消息及其之后的全部内容,再把你编辑后的文本作为新一轮发送。',
editSheetHint: '撤回后续消息并重新发送。',
rollbackAction: '回滚',
editAction: '回滚并发送',
editPlaceholder: '编辑这条消息...',
Expand Down Expand Up @@ -1039,9 +1039,9 @@ export const messages: Record<MobileLanguage, MessageTree> = {
editAndResend: '編輯並重送',
rollbackToHere: '回溯到這條',
rollbackSheetTitle: '回溯到這條訊息?',
rollbackSheetHint: '桌面端會撤回這條訊息及其之後的全部內容,並還原這些輪次改動過的檔案。這條訊息的原文會回到輸入框,方便你改完再送。',
rollbackSheetHint: '將撤回這條及後續訊息,並還原檔案變更。',
editSheetTitle: '編輯並重送',
editSheetHint: '桌面端會先撤回這條訊息及其之後的全部內容,再把你編輯後的文本作為新一輪送出。',
editSheetHint: '撤回後續訊息並重新送出。',
rollbackAction: '回溯',
editAction: '回溯並送出',
editPlaceholder: '編輯這條訊息...',
Expand Down
133 changes: 100 additions & 33 deletions src/mobile-web/src/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
RemoteSessionManager,
SessionSynchronizer,
type PollResponse,
type ChatImageAttachment,
type ChatMessage,
type RemoteModelCatalog,
} from '../services/RemoteSessionManager';
Expand Down Expand Up @@ -60,6 +61,70 @@ 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) ───────────────────────────────────

Expand Down Expand Up @@ -209,6 +274,7 @@ const ChatPage: React.FC<ChatPageProps> = ({
} | null>(null);
const [rollbackDraft, setRollbackDraft] = useState('');
const [rollbackBusy, setRollbackBusy] = useState(false);
const rollbackInFlightRef = useRef<symbol | null>(null);
const msgLongPressTimerRef = useRef<ReturnType<typeof setTimeout>>();
const msgLongPressPosRef = useRef({ x: 0, y: 0 });
const msgToastTimerRef = useRef<ReturnType<typeof setTimeout>>();
Expand Down Expand Up @@ -247,6 +313,7 @@ const ChatPage: React.FC<ChatPageProps> = ({
setRollbackTarget(null);
setRollbackDraft('');
setRollbackBusy(false);
rollbackInFlightRef.current = null;
setActionToast(null);
setInfoToast(null);
setExpandedMsgIds(new Set());
Expand Down Expand Up @@ -602,23 +669,13 @@ const ChatPage: React.FC<ChatPageProps> = ({
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;
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]);

Expand Down Expand Up @@ -657,7 +714,7 @@ const ChatPage: React.FC<ChatPageProps> = ({
// 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;
Expand All @@ -669,10 +726,13 @@ const ChatPage: React.FC<ChatPageProps> = ({
const targetEpoch = captureChatTargetEpoch();
if (targetEpoch === null) return;

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
Expand All @@ -681,31 +741,32 @@ const ChatPage: React.FC<ChatPageProps> = ({
streamRef.current?.nudge();

if (mode === 'edit') {
const imageContexts = message.images?.length
? message.images.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' },
}))
: undefined;
const imageContexts = extractValidImageContexts(message.images, 'mobile_edit');
const fallbackPendingImages = extractValidPendingImages(message.images);
try {
await sessionMgr.sendMessage(sessionId, editedText, sessionAgentType, imageContexts);
} catch (sendError) {
// 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((message.images ?? []).map(img => ({ name: img.name, dataUrl: img.data_url })));
setPendingImages(fallbackPendingImages);
setInputExpanded(true);
}
throw sendError;
}
if (!isChatTargetCurrent(targetEpoch)) return;
} else if (result.composer_text) {
setInput(result.composer_text);
setInputExpanded(true);
if (!isCurrentAttempt()) return;
} else {
const restoredText = result.composer_text ?? '';
setInput(restoredText);
const restoredImages = extractValidPendingImages(message.images);
if (restoredImages.length > 0) {
setPendingImages(restoredImages);
}
if (restoredText.trim() || restoredImages.length > 0) {
setInputExpanded(true);
}
}

showMsgToast(
Expand All @@ -722,13 +783,16 @@ const ChatPage: React.FC<ChatPageProps> = ({
// 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 (isChatTargetCurrent(targetEpoch)) {
setRollbackBusy(false);
if (rollbackInFlightRef.current === attempt) {
rollbackInFlightRef.current = null;
if (isChatTargetCurrent(targetEpoch)) {
setRollbackBusy(false);
}
}
}
}, [
Expand Down Expand Up @@ -1135,6 +1199,9 @@ const ChatPage: React.FC<ChatPageProps> = ({
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 (
<div className={`chat-page${wideLayout ? ' chat-page--wide' : ''}`} style={{ '--chat-composer-height': `${composerHeight}px` } as React.CSSProperties}>
Expand Down Expand Up @@ -1218,7 +1285,7 @@ const ChatPage: React.FC<ChatPageProps> = ({
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()}
Expand Down
18 changes: 16 additions & 2 deletions src/mobile-web/tests/browser-account.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading
Loading