diff --git a/client/src/chat/ChatMessage.jsx b/client/src/chat/ChatMessage.jsx
index 36d8c3e..6a33c95 100644
--- a/client/src/chat/ChatMessage.jsx
+++ b/client/src/chat/ChatMessage.jsx
@@ -7,7 +7,15 @@ import { MessageContent, splitMessageImages } from './MarkdownContent.jsx';
import { PlanMessage } from './PlanMessage.jsx';
import { UserImageStrip } from './ImagePreview.jsx';
-export function ChatMessage({ message, now, onPreviewImage, onDeleteMessage, onImplementPlan, onAdjustPlan }) {
+export function ChatMessage({
+ message,
+ now,
+ latestActivity = false,
+ onPreviewImage,
+ onDeleteMessage,
+ onImplementPlan,
+ onAdjustPlan
+}) {
const [copied, setCopied] = useState(false);
const copiedTimerRef = useRef(null);
@@ -18,7 +26,7 @@ export function ChatMessage({ message, now, onPreviewImage, onDeleteMessage, onI
}, []);
if (message.role === 'activity') {
- return
;
+ return
;
}
if (message.role === 'plan' || message.role === 'plan_request') {
return (
diff --git a/client/src/chat/ChatPane.jsx b/client/src/chat/ChatPane.jsx
index 4d8ca02..93be3e9 100644
--- a/client/src/chat/ChatPane.jsx
+++ b/client/src/chat/ChatPane.jsx
@@ -120,11 +120,12 @@ export function ChatPane({ messages, selectedSession, loading = false, loadError
return (
- {messages.map((message) => (
+ {messages.map((message, index) => (
item?.role !== 'activity')}
onPreviewImage={onPreviewImage}
onDeleteMessage={onDeleteMessage}
onImplementPlan={onImplementPlan}
diff --git a/client/src/chat/activity-card-state.js b/client/src/chat/activity-card-state.js
index 7cd8a25..840cf2c 100644
--- a/client/src/chat/activity-card-state.js
+++ b/client/src/chat/activity-card-state.js
@@ -1,3 +1,3 @@
-export function activityCardShouldOpen({ running, hasProcess }) {
- return Boolean(running && hasProcess);
+export function activityCardShouldOpen({ running, hasProcess, latestActivity = false }) {
+ return Boolean(hasProcess && (running || latestActivity));
}
diff --git a/client/src/panels/BackgroundHandoffCard.jsx b/client/src/panels/BackgroundHandoffCard.jsx
new file mode 100644
index 0000000..7f64b0b
--- /dev/null
+++ b/client/src/panels/BackgroundHandoffCard.jsx
@@ -0,0 +1,22 @@
+import { SendHorizontal, X } from 'lucide-react';
+
+export function BackgroundHandoffCard({ handoff, onSync, onDismiss }) {
+ if (!handoff) {
+ return null;
+ }
+ return (
+
+
+ 后台结果可同步
+ 桌面端已恢复,可把手机后台执行摘要发回当前线程。
+
+
+
+
+ );
+}
diff --git a/client/src/panels/index.js b/client/src/panels/index.js
index ceac733..0aa0c5a 100644
--- a/client/src/panels/index.js
+++ b/client/src/panels/index.js
@@ -1,4 +1,5 @@
export { ConnectionRecoveryCard } from './ConnectionRecoveryCard.jsx';
+export { BackgroundHandoffCard } from './BackgroundHandoffCard.jsx';
export { DocsPanel, FeishuLogoIcon } from './DocsPanel.jsx';
export { Drawer } from './Drawer.jsx';
export { GitPanel } from './GitPanel.jsx';
diff --git a/client/src/session-live-refresh.js b/client/src/session-live-refresh.js
index a14f153..336ac3d 100644
--- a/client/src/session-live-refresh.js
+++ b/client/src/session-live-refresh.js
@@ -65,13 +65,51 @@ function completeLocalActivityMessage(message, loaded = []) {
};
}
-function activityInsertIndex(loaded, activity) {
+function sameMessageIdentity(left, right) {
+ const leftId = String(left?.id || '');
+ const rightId = String(right?.id || '');
+ if (leftId && rightId && leftId === rightId) {
+ return true;
+ }
+ if (left?.role === 'user' && right?.role === 'user') {
+ return sameUserMessageContent(left.content, right.content);
+ }
+ return false;
+}
+
+function activityAnchorMessage(current, activity) {
+ const activityId = String(activity?.id || '');
+ const index = current.findIndex((message) => String(message?.id || '') === activityId);
+ if (index < 0) {
+ return null;
+ }
+ for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
+ const message = current[cursor];
+ if (message?.role === 'user') {
+ return message;
+ }
+ }
+ return null;
+}
+
+function activityInsertIndex(loaded, activity, current = []) {
const keys = new Set(messageRunKeys(activity));
const index = loaded.findIndex((message) => message?.role === 'assistant' && messageMatchesRunKeys(message, keys));
- return index >= 0 ? index : loaded.length;
+ if (index >= 0) {
+ return index;
+ }
+ const anchor = activityAnchorMessage(current, activity);
+ if (anchor) {
+ const anchorIndex = loaded.findIndex((message) => sameMessageIdentity(anchor, message));
+ if (anchorIndex >= 0) {
+ return anchorIndex + 1;
+ }
+ }
+ const userIndex = loaded.findLastIndex((message) => message?.role === 'user' && messageMatchesRunKeys(message, keys));
+ return userIndex >= 0 ? userIndex + 1 : loaded.length;
}
-function preserveLocalActivityMessages(current = [], loaded = []) {
+function preserveLocalActivityMessages(current = [], loaded = [], { preserveActivityState = false } = {}) {
const loadedIds = new Set(loaded.map((message) => String(message?.id || '')).filter(Boolean));
const preserved = current
.filter((message) => message?.role === 'activity' && !loadedIds.has(String(message?.id || '')))
@@ -89,7 +127,7 @@ function preserveLocalActivityMessages(current = [], loaded = []) {
return loaded.some((item) => messageMatchesRunKeys(item, keys)) || ['running', 'queued'].includes(String(message?.status || ''));
})
.map((message) =>
- isTransientActivityMessage(message)
+ isTransientActivityMessage(message) || preserveActivityState
? message
: completeLocalActivityMessage(message, loaded)
);
@@ -99,8 +137,8 @@ function preserveLocalActivityMessages(current = [], loaded = []) {
}
const result = [...loaded];
- for (const activity of preserved.sort((a, b) => activityInsertIndex(result, a) - activityInsertIndex(result, b))) {
- result.splice(activityInsertIndex(result, activity), 0, activity);
+ for (const activity of preserved.sort((a, b) => activityInsertIndex(result, a, current) - activityInsertIndex(result, b, current))) {
+ result.splice(activityInsertIndex(result, activity, current), 0, activity);
}
return result;
}
@@ -226,7 +264,7 @@ export function shouldPollSelectedSessionMessages({
return desktopBridgeUsesExternalThreadRefresh(desktopBridge) && Boolean(hasExternalThreadRefresh);
}
-export function mergeLiveSelectedThreadMessages(current = [], loaded = []) {
+export function mergeLiveSelectedThreadMessages(current = [], loaded = [], options = {}) {
if (!Array.isArray(loaded)) {
return Array.isArray(current) ? current : [];
}
@@ -242,7 +280,7 @@ export function mergeLiveSelectedThreadMessages(current = [], loaded = []) {
);
if (!hasUncaughtLocalUser) {
- return preserveLocalActivityMessages(current, loaded);
+ return preserveLocalActivityMessages(current, loaded, options);
}
const loadedIds = new Set(loaded.map((message) => String(message?.id || '')).filter(Boolean));
@@ -259,7 +297,7 @@ export function mergeLiveSelectedThreadMessages(current = [], loaded = []) {
return true;
});
- return preserveLocalActivityMessages(current, [...loaded, ...pending]).sort(
+ return preserveLocalActivityMessages(current, [...loaded, ...pending], options).sort(
(a, b) => new Date(a?.timestamp || 0).getTime() - new Date(b?.timestamp || 0).getTime()
);
}
diff --git a/client/src/session-live-refresh.test.mjs b/client/src/session-live-refresh.test.mjs
index 15675ef..ca8b9e7 100644
--- a/client/src/session-live-refresh.test.mjs
+++ b/client/src/session-live-refresh.test.mjs
@@ -187,6 +187,61 @@ test('mergeLiveSelectedThreadMessages keeps local activity when desktop messages
assert.equal(merged[1].activities[0].status, 'completed');
});
+test('mergeLiveSelectedThreadMessages can preserve running activity state during lightweight polls', () => {
+ const current = [
+ { id: 'local-user', role: 'user', content: '状态显示自测', sessionId: 'thread-1', turnId: 'turn-1', timestamp: '2026-05-07T06:01:00.000Z' },
+ {
+ id: 'status-turn-1',
+ role: 'activity',
+ status: 'running',
+ sessionId: 'thread-1',
+ turnId: 'turn-1',
+ content: '正在处理',
+ timestamp: '2026-05-07T06:01:01.000Z',
+ activities: [
+ { id: 'thinking', kind: 'reasoning', label: '正在思考', status: 'running' },
+ { id: 'cmd', kind: 'command_execution', label: '运行命令', status: 'completed', command: 'date' }
+ ]
+ }
+ ];
+ const loaded = [
+ { id: 'desktop-user', role: 'user', content: '状态显示自测', sessionId: 'thread-1', turnId: 'turn-1', timestamp: '2026-05-07T06:01:00.000Z' },
+ { id: 'desktop-assistant', role: 'assistant', content: '已经有部分输出', sessionId: 'thread-1', turnId: 'turn-1', timestamp: '2026-05-07T06:01:08.000Z' }
+ ];
+
+ const merged = mergeLiveSelectedThreadMessages(current, loaded, { preserveActivityState: true });
+
+ assert.deepEqual(merged.map((message) => message.id), ['desktop-user', 'status-turn-1', 'desktop-assistant']);
+ assert.equal(merged[1].status, 'running');
+ assert.equal(merged[1].activities[0].status, 'running');
+});
+
+test('mergeLiveSelectedThreadMessages keeps preserved activity beside its user during lightweight polls', () => {
+ const current = [
+ { id: 'desktop-user-1', role: 'user', content: '先处理这个项目', sessionId: 'thread-1', turnId: 'turn-1', timestamp: '2026-05-07T06:01:00.000Z' },
+ {
+ id: 'activity-turn-1',
+ role: 'activity',
+ status: 'running',
+ sessionId: 'thread-1',
+ turnId: 'turn-1',
+ content: '正在处理',
+ timestamp: '2026-05-07T06:01:00.000Z',
+ activities: [{ id: 'cmd', kind: 'command_execution', label: '运行命令', status: 'running' }]
+ },
+ { id: 'desktop-user-2', role: 'user', content: '再补一个要求', sessionId: 'thread-1', turnId: 'turn-2', timestamp: '2026-05-07T06:01:02.000Z' }
+ ];
+ const lightweightLoaded = [
+ { id: 'desktop-user-1', role: 'user', content: '先处理这个项目', sessionId: 'thread-1', turnId: 'turn-1', timestamp: '2026-05-07T06:01:00.000Z' },
+ { id: 'desktop-user-2', role: 'user', content: '再补一个要求', sessionId: 'thread-1', turnId: 'turn-2', timestamp: '2026-05-07T06:01:02.000Z' }
+ ];
+
+ const merged = mergeLiveSelectedThreadMessages(current, lightweightLoaded, { preserveActivityState: true });
+
+ assert.deepEqual(merged.map((message) => message.id), ['desktop-user-1', 'activity-turn-1', 'desktop-user-2']);
+ assert.equal(merged[1].status, 'running');
+});
+
test('desktopRunningActivityPayload exposes a selected desktop running activity for sidebar runtime', () => {
assert.deepEqual(
desktopRunningActivityPayload([
diff --git a/client/src/styles/chat.css b/client/src/styles/chat.css
index 6587e4a..9e47b5b 100644
--- a/client/src/styles/chat.css
+++ b/client/src/styles/chat.css
@@ -1563,6 +1563,32 @@
background: #fff;
}
+.file-preview-media-shell {
+ min-height: 100%;
+ padding: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #f6f6f6;
+}
+
+.file-preview-media-shell.is-audio {
+ align-items: flex-start;
+ padding-top: 28px;
+}
+
+.file-preview-media {
+ display: block;
+ max-width: 100%;
+ max-height: calc(100dvh - 136px);
+ object-fit: contain;
+ background: #000;
+}
+
+.file-preview-audio {
+ width: min(620px, 100%);
+}
+
.file-preview-notice {
position: absolute;
left: 50%;
@@ -1785,6 +1811,10 @@
color: #fff;
}
+[data-theme="dark"] .file-preview-media-shell {
+ background: #000;
+}
+
[data-theme="dark"] .pdf-preview {
background: #000;
}
diff --git a/client/src/styles/panels-chat.css b/client/src/styles/panels-chat.css
index 3f72849..49aa4a2 100644
--- a/client/src/styles/panels-chat.css
+++ b/client/src/styles/panels-chat.css
@@ -78,6 +78,78 @@
margin: 0 auto;
}
+.background-handoff-card {
+ position: fixed;
+ left: 14px;
+ right: 14px;
+ bottom: calc(var(--composer-height) + 14px);
+ z-index: 35;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto auto;
+ align-items: center;
+ gap: 10px;
+ max-width: 820px;
+ margin: 0 auto;
+ padding: 10px 10px 10px 12px;
+ border: 1px solid rgba(37, 99, 235, 0.18);
+ border-radius: 8px;
+ color: var(--text);
+ background: rgba(255, 255, 255, 0.94);
+ box-shadow: var(--soft-shadow);
+ backdrop-filter: blur(14px) saturate(1.08);
+}
+
+.background-handoff-card div {
+ display: grid;
+ gap: 2px;
+ min-width: 0;
+}
+
+.background-handoff-card strong,
+.background-handoff-card span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.background-handoff-card strong {
+ font-size: 13px;
+ font-weight: 650;
+}
+
+.background-handoff-card span {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.background-handoff-card button {
+ border: 0;
+ border-radius: 8px;
+}
+
+.background-handoff-primary {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ min-height: 34px;
+ padding: 0 10px;
+ color: #fff;
+ background: var(--accent);
+}
+
+.background-handoff-primary span {
+ color: inherit;
+}
+
+.background-handoff-close {
+ display: grid;
+ place-items: center;
+ width: 34px;
+ height: 34px;
+ color: var(--muted);
+ background: rgba(17, 24, 39, 0.06);
+}
+
.empty-chat {
display: flex;
flex-direction: column;
diff --git a/client/src/turn-submission-utils.test.mjs b/client/src/turn-submission-utils.test.mjs
index 3d06799..706dc13 100644
--- a/client/src/turn-submission-utils.test.mjs
+++ b/client/src/turn-submission-utils.test.mjs
@@ -10,6 +10,7 @@ import {
restoredComposerText,
sessionForTurnSelection,
selectedSkillsForPaths,
+ shouldShowOptimisticSubmission,
shouldPollTurnEndpointAfterSend,
turnMatchesSelection,
userMessageMetadataForSendMode
@@ -45,6 +46,14 @@ test('sessionForTurnSelection prefers the synchronous selection ref', () => {
assert.equal(sessionForTurnSelection(staleSession, { current: null }), staleSession);
});
+test('sessionForTurnSelection ignores sessions from another selected project', () => {
+ const project = { id: 'project-2' };
+ const staleSession = { id: 'thread-1', projectId: 'project-1' };
+ const staleRef = { current: { id: 'thread-2', projectId: 'project-1' } };
+
+ assert.equal(sessionForTurnSelection(staleSession, staleRef, project), null);
+});
+
test('projectForTurnSelection prefers the synchronous project ref', () => {
const staleProject = { id: 'project-before-render' };
const currentProject = { id: 'project-current' };
@@ -87,6 +96,12 @@ test('userMessageMetadataForSendMode marks steer messages as guided followups',
});
});
+test('queued submissions do not render optimistic chat messages', () => {
+ assert.equal(shouldShowOptimisticSubmission('start'), true);
+ assert.equal(shouldShowOptimisticSubmission('steer'), true);
+ assert.equal(shouldShowOptimisticSubmission('queue'), false);
+});
+
test('implementationPromptForPlan builds the desktop-compatible followup prompt', () => {
assert.equal(
implementationPromptForPlan(' 1. 定位同步链路\n2. 补测试 '),
@@ -142,6 +157,14 @@ test('completeLocalAbortMessages finishes the optimistic running activity', () =
});
test('external thread handoff uses thread refresh instead of client turn polling', () => {
+ assert.equal(
+ shouldPollTurnEndpointAfterSend({ queued: true }),
+ false
+ );
+ assert.equal(
+ shouldPollTurnEndpointAfterSend({ delivery: 'queued' }),
+ false
+ );
assert.equal(
shouldPollTurnEndpointAfterSend({ desktopBridge: { mode: 'desktop-ipc' } }),
false
diff --git a/server/chat-delivery.js b/server/chat-delivery.js
index 2b78183..6ade3ce 100644
--- a/server/chat-delivery.js
+++ b/server/chat-delivery.js
@@ -1,5 +1,8 @@
import { buildCodexTurnInput } from './codex-native-images.js';
+const DESKTOP_FOLLOWER_PREFLIGHT_TIMEOUT_MS = 2_000;
+const DESKTOP_FOLLOWER_TURN_TIMEOUT_MS = 12_000;
+
export async function assertDesktopBridgeAvailable(getDesktopBridgeStatus) {
const bridge = getDesktopBridgeStatus ? await getDesktopBridgeStatus({ force: true }) : null;
if (bridge && !bridge.connected) {
@@ -11,10 +14,15 @@ export async function assertDesktopBridgeAvailable(getDesktopBridgeStatus) {
return bridge;
}
-function desktopIpcUnavailableError(message = '桌面端 Codex 已连接,但当前线程没有可接管的桌面窗口。') {
+function desktopIpcUnavailableError(
+ message = '桌面端 Codex 已连接,但当前线程没有可接管的桌面窗口。',
+ { fallbackSafe = false, reason = '' } = {}
+) {
const error = new Error(message);
error.statusCode = 409;
error.code = 'CODEXMOBILE_DESKTOP_THREAD_OWNER_UNAVAILABLE';
+ error.fallbackSafe = Boolean(fallbackSafe);
+ error.reason = reason || '';
return error;
}
@@ -32,14 +40,37 @@ function isDesktopFollowerPreflightTimeout(error) {
return /thread-follower-set-(?:model-and-reasoning|collaboration-mode)\b/.test(String(error.message || ''));
}
+function isDesktopNullSettingsError(error) {
+ return /Cannot read properties of null \(reading 'settings'\)/.test(String(error?.message || ''));
+}
+
function isDesktopThreadOwnerUnavailable(error) {
return (
error?.message === 'no-client-found' ||
error?.statusCode === 409 ||
- isDesktopFollowerPreflightTimeout(error)
+ isDesktopFollowerPreflightTimeout(error) ||
+ isDesktopNullSettingsError(error)
+ );
+}
+
+function shouldRetryDesktopFollowerOwner(error) {
+ return (
+ isDesktopThreadOwnerUnavailable(error) &&
+ !isDesktopFollowerPreflightTimeout(error) &&
+ !isDesktopNullSettingsError(error)
);
}
+function desktopFollowerUnavailableMetadata(error) {
+ if (isDesktopNullSettingsError(error)) {
+ return { fallbackSafe: true, reason: 'null-settings' };
+ }
+ if (isDesktopFollowerPreflightTimeout(error)) {
+ return { fallbackSafe: true, reason: 'preflight-timeout' };
+ }
+ return { fallbackSafe: false, reason: '' };
+}
+
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -89,12 +120,24 @@ function userMessageMetadataForSendMode(sendMode = 'start') {
async function syncDesktopFollowerCollaborationMode({
selectedSessionId,
collaborationMode,
- setDesktopFollowerCollaborationMode
+ setDesktopFollowerCollaborationMode,
+ timeoutMs = DESKTOP_FOLLOWER_PREFLIGHT_TIMEOUT_MS
}) {
if (!setDesktopFollowerCollaborationMode) {
return;
}
- await setDesktopFollowerCollaborationMode(selectedSessionId, collaborationMode || null);
+ try {
+ await setDesktopFollowerCollaborationMode(selectedSessionId, collaborationMode || null, { timeoutMs });
+ } catch (error) {
+ const clearingMode = collaborationMode == null;
+ const unsupportedClear =
+ clearingMode &&
+ isDesktopNullSettingsError(error);
+ if (!unsupportedClear) {
+ throw error;
+ }
+ console.warn('[desktop-ipc] Desktop does not support null collaboration mode clear; continuing turn start.');
+ }
}
export async function sendViaDesktopIpc({
@@ -146,14 +189,21 @@ export async function sendViaDesktopIpc({
model: model || null,
effort: reasoningEffort || null,
serviceTier: serviceTier || null,
- collaborationMode: collaborationMode || null,
attachments: []
};
+ if (collaborationMode) {
+ baseTurnStartParams.collaborationMode = collaborationMode;
+ }
async function attemptDesktopFollowerTurn() {
if (sendMode === 'steer') {
if (setDesktopFollowerModelAndReasoning) {
- await setDesktopFollowerModelAndReasoning(selectedSessionId, model || null, reasoningEffort || null);
+ await setDesktopFollowerModelAndReasoning(
+ selectedSessionId,
+ model || null,
+ reasoningEffort || null,
+ { timeoutMs: DESKTOP_FOLLOWER_PREFLIGHT_TIMEOUT_MS }
+ );
}
await syncDesktopFollowerCollaborationMode({
selectedSessionId,
@@ -168,24 +218,33 @@ export async function sendViaDesktopIpc({
cwd: lastSession?.cwd || project.path || null,
context: {
workspaceRoots: project.path ? [project.path] : [],
- collaborationMode: collaborationMode || null
+ ...(collaborationMode ? { collaborationMode } : {})
},
responsesapiClientMetadata: null
}
- });
+ }, { timeoutMs: DESKTOP_FOLLOWER_TURN_TIMEOUT_MS });
} else {
if (sendMode === 'interrupt') {
- await interruptDesktopFollowerTurn(selectedSessionId);
+ await interruptDesktopFollowerTurn(selectedSessionId, { timeoutMs: DESKTOP_FOLLOWER_PREFLIGHT_TIMEOUT_MS });
}
if (setDesktopFollowerModelAndReasoning) {
- await setDesktopFollowerModelAndReasoning(selectedSessionId, model || null, reasoningEffort || null);
+ await setDesktopFollowerModelAndReasoning(
+ selectedSessionId,
+ model || null,
+ reasoningEffort || null,
+ { timeoutMs: DESKTOP_FOLLOWER_PREFLIGHT_TIMEOUT_MS }
+ );
}
await syncDesktopFollowerCollaborationMode({
selectedSessionId,
collaborationMode,
setDesktopFollowerCollaborationMode
});
- result = await startDesktopFollowerTurn(selectedSessionId, baseTurnStartParams);
+ result = await startDesktopFollowerTurn(
+ selectedSessionId,
+ baseTurnStartParams,
+ { timeoutMs: DESKTOP_FOLLOWER_TURN_TIMEOUT_MS }
+ );
}
return result;
}
@@ -198,7 +257,7 @@ export async function sendViaDesktopIpc({
result = await attemptDesktopFollowerTurn();
break;
} catch (error) {
- if (!isDesktopThreadOwnerUnavailable(error) || attempt >= ownerRetryDelays.length) {
+ if (!shouldRetryDesktopFollowerOwner(error) || attempt >= ownerRetryDelays.length) {
throw error;
}
const delay = ownerRetryDelays[attempt] || 0;
@@ -209,7 +268,10 @@ export async function sendViaDesktopIpc({
}
} catch (error) {
if (isDesktopThreadOwnerUnavailable(error)) {
- throw desktopIpcUnavailableError(error?.message || undefined);
+ throw desktopIpcUnavailableError(
+ error?.message || undefined,
+ desktopFollowerUnavailableMetadata(error)
+ );
}
throw error;
}
diff --git a/server/chat-service.js b/server/chat-service.js
index 3bc9e47..9c2eb0f 100644
--- a/server/chat-service.js
+++ b/server/chat-service.js
@@ -20,6 +20,48 @@ import { createDesktopTurnMonitor } from './desktop-turn-monitor.js';
export { normalizeSelectedSkills } from './chat-request-prep.js';
+const BACKGROUND_FALLBACK_SESSION_SOURCES = new Set([
+ 'codexmobile',
+ 'headless-local',
+ 'mobile'
+]);
+
+function sessionCanUseBackgroundFallback(session) {
+ const source = String(session?.source || '').trim().toLowerCase();
+ return Boolean(session?.mobileOnly || BACKGROUND_FALLBACK_SESSION_SOURCES.has(source));
+}
+
+function canUseBackgroundFallbackAfterDesktopIpcFailure({
+ bridge,
+ selectedSessionId,
+ selectedSessionResolvedFromBackgroundAlias,
+ session,
+ error
+}) {
+ if (!desktopIpcCanUseBackgroundFallback(bridge)) {
+ return false;
+ }
+ if (error?.fallbackSafe) {
+ return true;
+ }
+ if (!selectedSessionId) {
+ return true;
+ }
+ if (selectedSessionResolvedFromBackgroundAlias) {
+ return true;
+ }
+ return sessionCanUseBackgroundFallback(session);
+}
+
+function desktopThreadOwnerUnavailableForExistingThread(error) {
+ const message = '桌面端当前无法接管这个线程。为避免手机和桌面内容不同步,未自动转后台执行。请在桌面端重新打开该线程或重启桌面端 Codex 后再发送。';
+ const wrapped = new Error(error?.message ? `${message} 原始错误:${error.message}` : message);
+ wrapped.statusCode = error?.statusCode || 409;
+ wrapped.code = error?.code || 'CODEXMOBILE_DESKTOP_THREAD_OWNER_UNAVAILABLE';
+ wrapped.cause = error;
+ return wrapped;
+}
+
export function createChatService({
imagePromptState,
defaultReasoningEffort = 'xhigh',
@@ -229,6 +271,14 @@ export function createChatService({
visibleMessage,
codexMessage
} = prepared;
+ if (prepared.session?.projectId && prepared.session.projectId !== project.id) {
+ console.warn(
+ `[chat] rejected session/project mismatch: project=${project.id} session=${prepared.session.id} sessionProject=${prepared.session.projectId}`
+ );
+ const error = new Error('Session does not belong to project');
+ error.statusCode = 409;
+ throw error;
+ }
let selectedSessionId = prepared.selectedSessionId;
let conversationSessionId = prepared.conversationSessionId;
let bridge = await assertDesktopBridgeAvailable(getDesktopBridgeStatus);
@@ -328,13 +378,24 @@ export function createChatService({
} catch (error) {
const canFallBackToBackground =
error?.code === 'CODEXMOBILE_DESKTOP_THREAD_OWNER_UNAVAILABLE' &&
- desktopIpcCanUseBackgroundFallback(bridge);
+ canUseBackgroundFallbackAfterDesktopIpcFailure({
+ bridge,
+ selectedSessionId,
+ selectedSessionResolvedFromBackgroundAlias,
+ session: prepared.session,
+ error
+ });
if (!canFallBackToBackground) {
+ if (error?.code === 'CODEXMOBILE_DESKTOP_THREAD_OWNER_UNAVAILABLE') {
+ throw desktopThreadOwnerUnavailableForExistingThread(error);
+ }
throw error;
}
bridge = backgroundFallbackBridge(
bridge,
- selectedSessionResolvedFromBackgroundAlias
+ error?.fallbackSafe
+ ? '桌面端 IPC 状态异常,已自动转后台 Codex 继续执行。'
+ : selectedSessionResolvedFromBackgroundAlias
? undefined
: '桌面端当前没有接管这个线程,已改用后台 Codex 继续执行。'
);
diff --git a/server/chat-service.test.mjs b/server/chat-service.test.mjs
index 5c93770..a534521 100644
--- a/server/chat-service.test.mjs
+++ b/server/chat-service.test.mjs
@@ -84,6 +84,22 @@ test('sendChat rejects when strict desktop bridge is unavailable', async () => {
assert.equal(broadcasts.length, 0);
});
+test('sendChat rejects sessions that belong to another project', async () => {
+ const { service } = makeChatService({
+ getProject: (projectId) => ({ id: projectId, name: 'Other Project', path: '/tmp/other', projectless: false }),
+ getSession: () => ({ id: 'thread-1', projectId: 'project-1' })
+ });
+
+ await assert.rejects(
+ () => service.sendChat({
+ projectId: 'project-2',
+ sessionId: 'thread-1',
+ message: 'should not cross projects'
+ }),
+ /Session does not belong to project/
+ );
+});
+
test('abortChat records and broadcasts an aborted turn even after the backend run is gone', async () => {
let abortedIdentifier = null;
const { service, broadcasts } = makeChatService({
@@ -449,10 +465,77 @@ test('sendChat clears desktop collaboration mode for normal follow-up turns', as
conversationId: 'thread-1',
collaborationMode: null
});
- assert.equal(started.params.collaborationMode, null);
+ assert.equal(Object.hasOwn(started.params, 'collaborationMode'), false);
+});
+
+test('sendChat continues normal desktop turn when clearing collaboration mode is unsupported', async () => {
+ let started = null;
+ let collaborationUpdate = 'not-called';
+ const { service } = makeChatService({
+ getDesktopBridgeStatus: async () => ({
+ strict: true,
+ connected: true,
+ mode: 'desktop-ipc',
+ reason: null,
+ capabilities: { sendToOpenDesktopThread: true, createThread: false }
+ }),
+ setDesktopFollowerCollaborationMode: async (conversationId, collaborationMode) => {
+ collaborationUpdate = { conversationId, collaborationMode };
+ throw new TypeError("Cannot read properties of null (reading 'settings')");
+ },
+ startDesktopFollowerTurn: async (conversationId, params) => {
+ started = { conversationId, params };
+ return { result: { turn: { id: 'desktop-normal-turn-1' } } };
+ }
+ });
+
+ const result = await service.sendChat({
+ projectId: 'project-1',
+ sessionId: 'thread-1',
+ message: '继续执行'
+ });
+
+ assert.equal(result.delivery, 'started');
+ assert.deepEqual(collaborationUpdate, {
+ conversationId: 'thread-1',
+ collaborationMode: null
+ });
+ assert.equal(started.conversationId, 'thread-1');
+ assert.equal(Object.hasOwn(started.params, 'collaborationMode'), false);
+});
+
+test('sendChat does not pass null collaboration mode into desktop start params', async () => {
+ let started = null;
+ const { service } = makeChatService({
+ getDesktopBridgeStatus: async () => ({
+ strict: true,
+ connected: true,
+ mode: 'desktop-ipc',
+ reason: null,
+ capabilities: { sendToOpenDesktopThread: true, createThread: false }
+ }),
+ setDesktopFollowerCollaborationMode: async () => ({ ok: true }),
+ startDesktopFollowerTurn: async (conversationId, params) => {
+ started = { conversationId, params };
+ if (Object.hasOwn(params, 'collaborationMode') && params.collaborationMode == null) {
+ throw new TypeError("Cannot read properties of null (reading 'settings')");
+ }
+ return { result: { turn: { id: 'desktop-normal-turn-1' } } };
+ }
+ });
+
+ const result = await service.sendChat({
+ projectId: 'project-1',
+ sessionId: 'thread-1',
+ message: '普通消息'
+ });
+
+ assert.equal(result.delivery, 'started');
+ assert.equal(started.conversationId, 'thread-1');
+ assert.equal(Object.hasOwn(started.params, 'collaborationMode'), false);
});
-test('sendChat falls back to headless local when an existing desktop-ipc thread has no owner', async () => {
+test('sendChat rejects existing desktop-ipc threads when the desktop owner is unavailable', async () => {
let runPayload = null;
const { service, broadcasts } = makeChatService({
getDesktopBridgeStatus: async () => ({
@@ -479,25 +562,77 @@ test('sendChat falls back to headless local when an existing desktop-ipc thread
}
});
+ await assert.rejects(
+ () => service.sendChat({
+ projectId: 'project-1',
+ sessionId: 'thread-1',
+ clientTurnId: 'client-turn',
+ message: '桌面窗口不在时继续执行'
+ }),
+ (error) => {
+ assert.equal(error.statusCode, 409);
+ assert.equal(error.code, 'CODEXMOBILE_DESKTOP_THREAD_OWNER_UNAVAILABLE');
+ assert.match(error.message, /未自动转后台执行|没有可接管的桌面窗口/);
+ return true;
+ }
+ );
+ assert.equal(runPayload, null);
+ assert.equal(broadcasts.filter((payload) => payload.type === 'user-message').length, 0);
+});
+
+test('sendChat falls back to background when desktop settings sync times out before start', async () => {
+ let runPayload = null;
+ let startCalled = false;
+ const { service, broadcasts } = makeChatService({
+ getDesktopBridgeStatus: async () => ({
+ strict: true,
+ connected: true,
+ mode: 'desktop-ipc',
+ reason: null,
+ capabilities: {
+ sendToOpenDesktopThread: true,
+ createThread: false,
+ backgroundCodex: true
+ }
+ }),
+ setDesktopFollowerModelAndReasoning: async () => {
+ const error = new Error('桌面端 Codex IPC 请求超时: thread-follower-set-model-and-reasoning');
+ error.code = 'CODEXMOBILE_DESKTOP_IPC_TIMEOUT';
+ throw error;
+ },
+ startDesktopFollowerTurn: async () => {
+ startCalled = true;
+ return { result: { turn: { id: 'desktop-turn-should-not-start' } } };
+ },
+ runCodexTurn: async (payload, emit) => {
+ runPayload = payload;
+ emit({ type: 'chat-complete', sessionId: payload.sessionId, turnId: payload.turnId });
+ return payload.sessionId;
+ }
+ });
+
const result = await service.sendChat({
projectId: 'project-1',
sessionId: 'thread-1',
clientTurnId: 'client-turn',
- message: '桌面窗口不在时继续执行'
+ message: '确认执行这个计划',
+ model: 'gpt-5.5',
+ reasoningEffort: 'medium'
});
assert.equal(result.accepted, true);
- assert.equal(result.delivery, 'started');
assert.equal(result.desktopBridge.mode, 'headless-local');
+ assert.match(result.desktopBridge.reason, /IPC 状态异常/);
+ assert.equal(startCalled, false);
assert.equal(runPayload.sessionId, 'thread-1');
- assert.match(runPayload.message, /桌面窗口不在时继续执行/);
assert.equal(broadcasts.filter((payload) => payload.type === 'user-message').length, 1);
});
-test('sendChat falls back to headless local when settings sync times out before start', async () => {
+test('sendChat does not retry desktop settings sync timeouts before falling back', async () => {
+ let modelAttempts = 0;
let runPayload = null;
- let startCalled = false;
const { service } = makeChatService({
+ desktopOwnerRetryDelays: [0, 0, 0],
getDesktopBridgeStatus: async () => ({
strict: true,
connected: true,
@@ -509,14 +644,99 @@ test('sendChat falls back to headless local when settings sync times out before
backgroundCodex: true
}
}),
- setDesktopFollowerModelAndReasoning: async () => {
+ setDesktopFollowerModelAndReasoning: async (_conversationId, _model, _reasoningEffort, options = {}) => {
+ modelAttempts += 1;
+ assert.equal(options.timeoutMs, 2000);
const error = new Error('桌面端 Codex IPC 请求超时: thread-follower-set-model-and-reasoning');
error.code = 'CODEXMOBILE_DESKTOP_IPC_TIMEOUT';
throw error;
},
+ runCodexTurn: async (payload, emit) => {
+ runPayload = payload;
+ emit({ type: 'chat-complete', sessionId: payload.sessionId, turnId: payload.turnId });
+ return payload.sessionId;
+ }
+ });
+
+ const result = await service.sendChat({
+ projectId: 'project-1',
+ sessionId: 'thread-1',
+ clientTurnId: 'client-turn',
+ message: '桌面 IPC 卡住时不要堆住发送'
+ });
+
+ assert.equal(result.accepted, true);
+ assert.equal(result.desktopBridge.mode, 'headless-local');
+ assert.equal(modelAttempts, 1);
+ assert.equal(runPayload.sessionId, 'thread-1');
+});
+
+test('sendChat falls back to background when desktop returns null settings errors', async () => {
+ let modelAttempts = 0;
+ let runPayload = null;
+ const { service } = makeChatService({
+ desktopOwnerRetryDelays: [0, 0, 0],
+ getDesktopBridgeStatus: async () => ({
+ strict: true,
+ connected: true,
+ mode: 'desktop-ipc',
+ reason: null,
+ capabilities: {
+ sendToOpenDesktopThread: true,
+ createThread: false,
+ backgroundCodex: true
+ }
+ }),
+ setDesktopFollowerModelAndReasoning: async () => {
+ modelAttempts += 1;
+ throw new TypeError("Cannot read properties of null (reading 'settings')");
+ },
+ runCodexTurn: async (payload, emit) => {
+ runPayload = payload;
+ emit({ type: 'chat-complete', sessionId: payload.sessionId, turnId: payload.turnId });
+ return payload.sessionId;
+ }
+ });
+
+ const result = await service.sendChat({
+ projectId: 'project-1',
+ sessionId: 'thread-1',
+ clientTurnId: 'client-turn',
+ message: '桌面 settings 为空时转后台'
+ });
+
+ assert.equal(result.accepted, true);
+ assert.equal(result.desktopBridge.mode, 'headless-local');
+ assert.match(result.desktopBridge.reason, /IPC 状态异常/);
+ assert.equal(modelAttempts, 1);
+ assert.equal(runPayload.sessionId, 'thread-1');
+});
+
+test('sendChat can fall back to headless local for mobile-only sessions when the desktop owner is unavailable', async () => {
+ let runPayload = null;
+ const { service, broadcasts } = makeChatService({
+ getSession: () => ({
+ id: 'thread-1',
+ projectId: 'project-1',
+ mobileOnly: true,
+ source: 'codexmobile'
+ }),
+ getDesktopBridgeStatus: async () => ({
+ strict: true,
+ connected: true,
+ mode: 'desktop-ipc',
+ reason: null,
+ capabilities: {
+ sendToOpenDesktopThread: true,
+ createThread: false,
+ backgroundCodex: true
+ }
+ }),
startDesktopFollowerTurn: async () => {
- startCalled = true;
- return { result: { turn: { id: 'desktop-turn-should-not-start' } } };
+ const error = new Error('桌面端 Codex 已连接,但当前线程没有可接管的桌面窗口。');
+ error.statusCode = 409;
+ error.code = 'CODEXMOBILE_DESKTOP_THREAD_OWNER_UNAVAILABLE';
+ throw error;
},
runCodexTurn: async (payload, emit) => {
runPayload = payload;
@@ -529,17 +749,15 @@ test('sendChat falls back to headless local when settings sync times out before
projectId: 'project-1',
sessionId: 'thread-1',
clientTurnId: 'client-turn',
- message: '确认执行这个计划',
- model: 'gpt-5.5',
- reasoningEffort: 'medium'
+ message: '移动端后台线程继续执行'
});
assert.equal(result.accepted, true);
+ assert.equal(result.delivery, 'started');
assert.equal(result.desktopBridge.mode, 'headless-local');
- assert.equal(startCalled, false);
assert.equal(runPayload.sessionId, 'thread-1');
- assert.equal(runPayload.model, 'gpt-5.5');
- assert.equal(runPayload.reasoningEffort, 'medium');
+ assert.match(runPayload.message, /移动端后台线程继续执行/);
+ assert.equal(broadcasts.filter((payload) => payload.type === 'user-message').length, 1);
});
test('sendChat waits for a desktop-ipc owner before falling back to headless local', async () => {
diff --git a/server/codex-data-desktop-activity.test.mjs b/server/codex-data-desktop-activity.test.mjs
index 5106482..38f92de 100644
--- a/server/codex-data-desktop-activity.test.mjs
+++ b/server/codex-data-desktop-activity.test.mjs
@@ -162,6 +162,50 @@ test('raw desktop activities are inserted next to their matching steered user se
assert.equal(messages[4].activities[0].command, 'rg steer client/src');
});
+test('desktop activity containers keep a stable conversation timestamp', () => {
+ const messages = [
+ {
+ id: 'user-1',
+ role: 'user',
+ content: '先处理这个项目',
+ turnId: 'turn-1',
+ timestamp: '2026-02-02T00:00:00.000Z'
+ },
+ {
+ id: 'user-2',
+ role: 'user',
+ content: '再补一个要求',
+ turnId: 'turn-2',
+ timestamp: '2026-02-02T00:00:02.000Z'
+ }
+ ];
+
+ upsertDesktopActivity(messages, 'turn-1', {
+ id: 'turn-1-raw-command-0',
+ kind: 'command_execution',
+ label: '本地任务已处理',
+ command: 'npm run build',
+ status: 'completed',
+ timestamp: '2026-02-02T00:00:03.000Z'
+ });
+
+ const activity = messages.find((message) => message.role === 'activity');
+ assert.equal(activity.timestamp, '2026-02-02T00:00:00.000Z');
+ assert.deepEqual(messages.map((message) => message.id), ['user-1', 'activity-turn-1', 'user-2']);
+
+ upsertDesktopActivity(messages, 'turn-1', {
+ id: 'turn-1-raw-command-1',
+ kind: 'command_execution',
+ label: '本地任务已处理',
+ command: 'node --test',
+ status: 'completed',
+ timestamp: '2026-02-02T00:00:05.000Z'
+ });
+
+ assert.equal(activity.timestamp, '2026-02-02T00:00:00.000Z');
+ assert.equal(activity.completedAt, '2026-02-02T00:00:05.000Z');
+});
+
test('completed raw desktop activities create terminal activity containers', () => {
const messages = [
{
diff --git a/server/codex-runner-status.test.mjs b/server/codex-runner-status.test.mjs
index 6de8a50..515102e 100644
--- a/server/codex-runner-status.test.mjs
+++ b/server/codex-runner-status.test.mjs
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
-import { shouldCompleteTurnFromAppServerItem, statusLabel } from './codex-runner.js';
+import { buildTurnStartParams, shouldCompleteTurnFromAppServerItem, statusLabel } from './codex-runner.js';
test('statusLabel uses mobile-friendly command labels', () => {
assert.equal(statusLabel('command_execution', 'running'), '正在处理本地任务');
@@ -43,3 +43,41 @@ test('completed final assistant item can finish a headless turn without turn com
false
);
});
+
+test('buildTurnStartParams omits null collaboration mode', () => {
+ const params = buildTurnStartParams({
+ threadId: 'thread-1',
+ input: [{ type: 'text', text: 'hello' }],
+ cwd: '/tmp/project',
+ approvalPolicy: 'never',
+ sandboxPolicy: { type: 'dangerFullAccess' },
+ model: 'gpt-5.5',
+ effort: 'medium',
+ collaborationMode: null
+ });
+
+ assert.equal(Object.hasOwn(params, 'collaborationMode'), false);
+});
+
+test('buildTurnStartParams preserves plan collaboration mode', () => {
+ const collaborationMode = {
+ mode: 'plan',
+ settings: {
+ model: 'gpt-5.5',
+ reasoning_effort: 'medium',
+ developer_instructions: null
+ }
+ };
+ const params = buildTurnStartParams({
+ threadId: 'thread-1',
+ input: [{ type: 'text', text: 'plan' }],
+ cwd: '/tmp/project',
+ approvalPolicy: 'never',
+ sandboxPolicy: { type: 'dangerFullAccess' },
+ model: 'gpt-5.5',
+ effort: 'medium',
+ collaborationMode
+ });
+
+ assert.deepEqual(params.collaborationMode, collaborationMode);
+});
diff --git a/server/codex-runner.js b/server/codex-runner.js
index 0d673b5..4cd4b10 100644
--- a/server/codex-runner.js
+++ b/server/codex-runner.js
@@ -482,6 +482,31 @@ export function shouldCompleteTurnFromAppServerItem(method, item, content = '')
return Boolean(String(content || item?.text || '').trim());
}
+export function buildTurnStartParams({
+ threadId,
+ input,
+ cwd,
+ approvalPolicy,
+ sandboxPolicy,
+ model = null,
+ effort = null,
+ collaborationMode = null
+}) {
+ const params = {
+ threadId,
+ input,
+ cwd,
+ approvalPolicy,
+ sandboxPolicy,
+ model: model || null,
+ effort: effort || null
+ };
+ if (collaborationMode) {
+ params.collaborationMode = collaborationMode;
+ }
+ return params;
+}
+
function normalizeAppItem(item, state = {}) {
if (!item || typeof item !== 'object') {
return item;
@@ -939,7 +964,7 @@ export async function runCodexTurn({ sessionId, draftSessionId, projectPath, mes
});
emitStatus(emit, { sessionId: currentSessionId, turnId, kind: 'reasoning', status: 'running', label: '正在思考' });
- const turnStartParams = {
+ const turnStartParams = buildTurnStartParams({
threadId: currentSessionId,
input: buildCodexTurnInput({
message,
@@ -950,10 +975,10 @@ export async function runCodexTurn({ sessionId, draftSessionId, projectPath, mes
cwd: workingDirectory,
approvalPolicy,
sandboxPolicy: sandboxPolicyFromMode(sandboxMode, { networkAccess: larkCliContext.enabled }),
- model: model || null,
- effort: modelReasoningEffort || null,
- collaborationMode: collaborationMode || null
- };
+ model,
+ effort: modelReasoningEffort,
+ collaborationMode
+ });
if (normalizedServiceTier) {
turnStartParams.serviceTier = normalizedServiceTier;
}
diff --git a/server/desktop-thread-projector.js b/server/desktop-thread-projector.js
index 94d1358..0d5c7dd 100644
--- a/server/desktop-thread-projector.js
+++ b/server/desktop-thread-projector.js
@@ -242,6 +242,14 @@ function findDesktopActivityInsertIndex(messages, turnId, segmentIndex) {
return lastTurnIndex >= 0 ? lastTurnIndex + 1 : messages.length;
}
+function desktopActivityAnchorTimestamp(messages, insertIndex, turnId, activity) {
+ const previous = messages[insertIndex - 1];
+ if (previous?.turnId === turnId && previous.timestamp) {
+ return previous.timestamp;
+ }
+ return activity.startedAt || activity.timestamp || new Date().toISOString();
+}
+
export function upsertDesktopActivity(messages, turnId, activity, segmentIndex = 0) {
if (!activity) {
return;
@@ -269,10 +277,13 @@ export function upsertDesktopActivity(messages, turnId, activity, segmentIndex =
} else {
existing.activities = [...current, activity];
}
- existing.timestamp = activity.timestamp || existing.timestamp;
+ existing.timestamp = existing.timestamp || activity.startedAt || activity.timestamp || new Date().toISOString();
+ existing.startedAt = existing.startedAt || activity.startedAt || activity.timestamp || null;
applyDesktopActivityContainerStatus(existing);
return;
}
+ const insertIndex = findDesktopActivityInsertIndex(messages, turnId, segmentIndex);
+ const timestamp = desktopActivityAnchorTimestamp(messages, insertIndex, turnId, activity);
const nextMessage = {
id,
role: 'activity',
@@ -282,12 +293,12 @@ export function upsertDesktopActivity(messages, turnId, activity, segmentIndex =
label: '正在处理',
kind: 'desktop',
status: 'running',
- timestamp: activity.timestamp || new Date().toISOString(),
- startedAt: activity.startedAt || activity.timestamp || null,
+ timestamp,
+ startedAt: activity.startedAt || timestamp || activity.timestamp || null,
activities: [activity]
};
applyDesktopActivityContainerStatus(nextMessage);
- messages.splice(findDesktopActivityInsertIndex(messages, turnId, segmentIndex), 0, nextMessage);
+ messages.splice(insertIndex, 0, nextMessage);
}
function normalizedActivityStatus(value) {
diff --git a/server/index.js b/server/index.js
index 6f6e656..ec20aa5 100644
--- a/server/index.js
+++ b/server/index.js
@@ -80,6 +80,7 @@ const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
const MAX_VOICE_BYTES = 10 * 1024 * 1024;
const DEFAULT_REASONING_EFFORT = 'xhigh';
const SYNC_RESPONSE_TIMEOUT_MS = Math.max(1000, Number(process.env.CODEXMOBILE_SYNC_RESPONSE_TIMEOUT_MS) || 12_000);
+const SYNC_CACHE_TTL_MS = Math.max(0, Number(process.env.CODEXMOBILE_SYNC_CACHE_TTL_MS) || 45_000);
let syncRefreshPromise = null;
const sockets = new Set();
@@ -232,7 +233,19 @@ function startSyncRefresh() {
return syncRefreshPromise;
}
-async function refreshCodexCacheForSyncResponse() {
+function cachedSyncSnapshotIsFresh(snapshot = getCacheSnapshot()) {
+ if (!SYNC_CACHE_TTL_MS || !snapshot.syncedAt) {
+ return false;
+ }
+ const syncedAt = Date.parse(snapshot.syncedAt);
+ return Number.isFinite(syncedAt) && Date.now() - syncedAt < SYNC_CACHE_TTL_MS;
+}
+
+async function refreshCodexCacheForSyncResponse({ force = false } = {}) {
+ const currentSnapshot = getCacheSnapshot();
+ if (!force && cachedSyncSnapshotIsFresh(currentSnapshot)) {
+ return { timedOut: false, snapshot: currentSnapshot, fromCache: true };
+ }
const refresh = startSyncRefresh();
const timeout = new Promise((resolve) => {
const timer = setTimeout(() => {
@@ -328,12 +341,13 @@ async function handleApi(req, res, url) {
}
if (method === 'POST' && pathname === '/api/sync') {
- const result = await refreshCodexCacheForSyncResponse();
+ const force = url.searchParams.get('force') === '1';
+ const result = await refreshCodexCacheForSyncResponse({ force });
const { snapshot, timedOut } = result;
- if (!timedOut) {
+ if (!timedOut && !result.fromCache) {
broadcast({ type: 'sync-complete', syncedAt: snapshot.syncedAt, projects: snapshot.projects });
}
- sendJson(res, 200, { success: !timedOut && !result.error, pending: timedOut, error: result.error?.message || null, ...snapshot });
+ sendJson(res, 200, { success: !timedOut && !result.error, pending: timedOut, cached: Boolean(result.fromCache), error: result.error?.message || null, ...snapshot });
return;
}
diff --git a/server/session-message-reader.js b/server/session-message-reader.js
index 3f237f4..fec8377 100644
--- a/server/session-message-reader.js
+++ b/server/session-message-reader.js
@@ -1,5 +1,6 @@
import fs from 'node:fs/promises';
import fsSync from 'node:fs';
+import path from 'node:path';
import readline from 'node:readline';
import { readDesktopThread as defaultReadDesktopThread } from './codex-app-server.js';
import {
@@ -456,6 +457,83 @@ function sortMessagesByConversationOrder(messages) {
.map((item) => item.message);
}
+function normalizeVisibleText(value) {
+ return String(value || '').replace(/\s+/g, ' ').trim();
+}
+
+function latestAgentActivityMessage(activityMessage) {
+ const activities = Array.isArray(activityMessage?.activities) ? activityMessage.activities : [];
+ return activities
+ .filter((activity) => activity?.kind === 'agent_message' && normalizeVisibleText(activity.label || activity.content || activity.detail))
+ .sort((left, right) => messageTimestampValue(left) - messageTimestampValue(right))
+ .at(-1) || null;
+}
+
+function addVisibleAgentActivitySummaries(messages) {
+ const result = [...messages];
+ const assistantByTurn = new Map();
+ for (const message of result) {
+ if (message?.role !== 'assistant' || !message.turnId) {
+ continue;
+ }
+ const existing = assistantByTurn.get(message.turnId) || [];
+ existing.push(normalizeVisibleText(message.content));
+ assistantByTurn.set(message.turnId, existing);
+ }
+
+ for (const message of messages) {
+ if (message?.role !== 'activity' || !message.turnId || message.status === 'running') {
+ continue;
+ }
+ const latest = latestAgentActivityMessage(message);
+ if (!latest) {
+ continue;
+ }
+ const content = normalizeVisibleText(latest.label || latest.content || latest.detail);
+ if (!content) {
+ continue;
+ }
+ const existingAssistantTexts = assistantByTurn.get(message.turnId) || [];
+ if (existingAssistantTexts.length > 0) {
+ continue;
+ }
+ const id = `${latest.id || message.id}-visible`;
+ if (result.some((item) => item.id === id)) {
+ continue;
+ }
+ result.push({
+ id,
+ role: 'assistant',
+ content,
+ kind: 'activity_summary',
+ timestamp: latest.timestamp || message.completedAt || message.timestamp || new Date().toISOString(),
+ turnId: message.turnId,
+ sessionId: message.sessionId
+ });
+ assistantByTurn.set(message.turnId, [...existingAssistantTexts, content]);
+ }
+ return result;
+}
+
+function cacheKeyForMessages(sessionId, includeActivity) {
+ return `${sessionId}:${includeActivity ? 'activity' : 'plain'}`;
+}
+
+async function rolloutFileSignature(filePath) {
+ if (!filePath) {
+ return '';
+ }
+ try {
+ const stats = await fs.stat(filePath);
+ if (!stats.isFile()) {
+ return '';
+ }
+ return `${path.resolve(filePath)}:${stats.size}:${Math.round(stats.mtimeMs)}`;
+ } catch {
+ return '';
+ }
+}
+
export function isoFromEpochSeconds(value) {
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) {
@@ -478,6 +556,8 @@ export function createSessionMessageReader({
resolveSessionThread = async () => null,
getConfigContext = () => ({})
} = {}) {
+ const messageCache = new Map();
+
async function readThread(sessionId) {
try {
const response = await readDesktopThread(sessionId, { includeTurns: true });
@@ -514,6 +594,20 @@ export function createSessionMessageReader({
{ limit = 120, offset = null, latest = true, includeActivity = false } = {}
) {
const deletedIds = await readDeletedMessageIds(sessionId);
+ const session = await resolveSessionThread(sessionId).catch(() => null);
+ const sessionFilePath = session?.filePath || session?.path || '';
+ const sessionSignature = await rolloutFileSignature(sessionFilePath);
+ const cacheKey = cacheKeyForMessages(sessionId, includeActivity);
+ const cached = sessionSignature ? messageCache.get(cacheKey) : null;
+ if (cached?.signature === sessionSignature) {
+ return {
+ ...paginateMessages(filterDeletedMessages(cached.messages, deletedIds), { limit, offset, latest }),
+ context: publicContextState(cached.contextState, getConfigContext() || {}),
+ cached: true,
+ revision: cached.signature
+ };
+ }
+
const thread = await readThread(sessionId);
const messages = Array.isArray(thread.messages)
@@ -530,13 +624,26 @@ export function createSessionMessageReader({
upsertDesktopActivity(messages, item.turnId, item.activity, item.segmentIndex);
}
sortDesktopActivitySteps(messages);
+ messages.splice(0, messages.length, ...addVisibleAgentActivitySummaries(messages));
}
const orderedMessages = sortMessagesByConversationOrder(messages);
const contextState = await readRolloutContextStateImpl(thread.path, sessionId);
+ const threadSignature = await rolloutFileSignature(thread.path);
+ if (threadSignature) {
+ messageCache.set(cacheKey, {
+ signature: threadSignature,
+ messages: orderedMessages,
+ contextState
+ });
+ } else {
+ messageCache.delete(cacheKey);
+ }
return {
...paginateMessages(filterDeletedMessages(orderedMessages, deletedIds), { limit, offset, latest }),
- context: publicContextState(contextState, getConfigContext() || {})
+ context: publicContextState(contextState, getConfigContext() || {}),
+ cached: false,
+ revision: threadSignature || sessionSignature || ''
};
}
diff --git a/server/session-message-reader.test.mjs b/server/session-message-reader.test.mjs
index f970f01..7c8222d 100644
--- a/server/session-message-reader.test.mjs
+++ b/server/session-message-reader.test.mjs
@@ -61,6 +61,7 @@ test('session message reader filters hidden messages, paginates, and exposes con
assert.equal(result.context.contextWindow, 100000);
assert.equal(result.context.autoCompact.detected, true);
assert.equal(result.context.autoCompact.reason, '上下文用量回落');
+ assert.match(result.revision, /rollout\.jsonl:\d+:\d+/);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
@@ -207,6 +208,75 @@ test('session message reader merges raw and collaboration activities only when r
]);
});
+test('session message reader reuses projected messages while the rollout file is unchanged', async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmobile-message-reader-cache-'));
+ try {
+ const rolloutPath = path.join(dir, 'rollout.jsonl');
+ await fs.writeFile(rolloutPath, [
+ JSON.stringify({
+ timestamp: '2026-05-08T01:00:00.000Z',
+ type: 'turn_context',
+ payload: { turn_id: 'turn-1' }
+ })
+ ].join('\n'));
+
+ let desktopReads = 0;
+ let rawReads = 0;
+ let contextReads = 0;
+ let deletedReads = 0;
+ const reader = createSessionMessageReader({
+ readDeletedMessageIds: async () => {
+ deletedReads += 1;
+ return deletedReads === 1 ? new Set(['message-2']) : new Set();
+ },
+ resolveSessionThread: async (sessionId) => ({
+ id: sessionId,
+ filePath: rolloutPath
+ }),
+ readDesktopThread: async () => {
+ desktopReads += 1;
+ return {
+ thread: {
+ id: 'session-1',
+ path: rolloutPath,
+ turns: [{ id: 'turn-1' }]
+ }
+ };
+ },
+ messagesFromDesktopThread: () => [
+ { id: 'message-1', role: 'user', content: 'first', timestamp: '2026-05-08T01:00:00.000Z' },
+ { id: 'message-2', role: 'assistant', content: 'second', timestamp: '2026-05-08T01:01:00.000Z' }
+ ],
+ readRawSessionActivities: async () => {
+ rawReads += 1;
+ return [];
+ },
+ readDesktopCollabActivities: async () => [],
+ readRolloutContextState: async () => {
+ contextReads += 1;
+ return { sessionId: 'session-1' };
+ }
+ });
+
+ const first = await reader.readSessionMessages('session-1', { includeActivity: true, limit: 1, latest: true });
+ const second = await reader.readSessionMessages('session-1', { includeActivity: true, limit: 2, latest: true });
+
+ assert.deepEqual(first.messages.map((message) => message.id), ['message-1']);
+ assert.deepEqual(second.messages.map((message) => message.id), ['message-1', 'message-2']);
+ assert.equal(desktopReads, 1);
+ assert.equal(rawReads, 1);
+ assert.equal(contextReads, 1);
+ assert.equal(second.revision, first.revision);
+
+ await fs.appendFile(rolloutPath, '\n');
+ const changed = await reader.readSessionMessages('session-1', { includeActivity: true });
+ assert.equal(desktopReads, 2);
+ assert.notEqual(changed.revision, first.revision);
+ } finally {
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+});
+
test('session message reader preserves raw activity segment indices', async () => {
const upserts = [];
const reader = createSessionMessageReader({
@@ -309,6 +379,109 @@ test('session message reader keeps raw activity beside its steered message after
);
});
+test('session message reader keeps earlier turn activity before later user messages', async () => {
+ const reader = createSessionMessageReader({
+ readDeletedMessageIds: async () => new Set(),
+ readDesktopThread: async () => ({
+ thread: {
+ id: 'session-1',
+ path: '/tmp/rollout.jsonl',
+ turns: [{ id: 'turn-1' }, { id: 'turn-2' }]
+ }
+ }),
+ messagesFromDesktopThread: () => [
+ {
+ id: 'user-1',
+ role: 'user',
+ content: '先处理这个项目',
+ turnId: 'turn-1',
+ timestamp: '2026-02-02T00:00:00.000Z'
+ },
+ {
+ id: 'user-2',
+ role: 'user',
+ content: '再补一个要求',
+ turnId: 'turn-2',
+ timestamp: '2026-02-02T00:00:02.000Z'
+ }
+ ],
+ readRawSessionActivities: async () => [
+ {
+ turnId: 'turn-1',
+ segmentIndex: 0,
+ activity: {
+ id: 'raw-1',
+ kind: 'command_execution',
+ label: '本地任务已处理',
+ command: 'npm run build',
+ timestamp: '2026-02-02T00:00:03.000Z'
+ }
+ }
+ ],
+ readDesktopCollabActivities: async () => [],
+ readRolloutContextState: async () => ({ sessionId: 'session-1' })
+ });
+
+ const result = await reader.readSessionMessages('session-1', { includeActivity: true });
+
+ assert.deepEqual(result.messages.map((message) => message.id), ['user-1', 'activity-turn-1', 'user-2']);
+});
+
+test('session message reader surfaces latest agent activity when a turn has no final answer', async () => {
+ const reader = createSessionMessageReader({
+ readDeletedMessageIds: async () => new Set(),
+ readDesktopThread: async () => ({
+ thread: {
+ id: 'session-1',
+ path: '/tmp/rollout.jsonl',
+ turns: [{ id: 'turn-1' }, { id: 'turn-2' }]
+ }
+ }),
+ messagesFromDesktopThread: () => [
+ {
+ id: 'user-1',
+ role: 'user',
+ content: '继续吧',
+ turnId: 'turn-1',
+ timestamp: '2026-02-02T00:00:00.000Z'
+ },
+ {
+ id: 'assistant-2',
+ role: 'assistant',
+ content: '后面的旧 final',
+ turnId: 'turn-2',
+ timestamp: '2026-02-02T00:00:05.000Z'
+ }
+ ],
+ readRawSessionActivities: async () => [
+ {
+ turnId: 'turn-1',
+ segmentIndex: 0,
+ activity: {
+ id: 'agent-1',
+ kind: 'agent_message',
+ status: 'completed',
+ label: '我已经继续处理到最新一步',
+ timestamp: '2026-02-02T00:00:10.000Z'
+ }
+ }
+ ],
+ readDesktopCollabActivities: async () => [],
+ readRolloutContextState: async () => ({ sessionId: 'session-1' })
+ });
+
+ const result = await reader.readSessionMessages('session-1', { includeActivity: true });
+
+ assert.deepEqual(result.messages.map((message) => message.id), [
+ 'user-1',
+ 'activity-turn-1',
+ 'assistant-2',
+ 'agent-1-visible'
+ ]);
+ assert.equal(result.messages.at(-1).role, 'assistant');
+ assert.equal(result.messages.at(-1).content, '我已经继续处理到最新一步');
+});
+
test('session message reader falls back to rollout jsonl when desktop thread is not loaded', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmobile-message-reader-rollout-'));
try {
diff --git a/server/static-service.js b/server/static-service.js
index 676332f..60d2134 100644
--- a/server/static-service.js
+++ b/server/static-service.js
@@ -1,3 +1,4 @@
+import fsSync from 'node:fs';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
@@ -20,6 +21,17 @@ export const DEFAULT_MIME_TYPES = new Map([
['.gif', 'image/gif'],
['.ico', 'image/x-icon'],
['.pdf', 'application/pdf'],
+ ['.mp4', 'video/mp4'],
+ ['.m4v', 'video/mp4'],
+ ['.mov', 'video/quicktime'],
+ ['.webm', 'video/webm'],
+ ['.ogv', 'video/ogg'],
+ ['.mp3', 'audio/mpeg'],
+ ['.m4a', 'audio/mp4'],
+ ['.aac', 'audio/aac'],
+ ['.wav', 'audio/wav'],
+ ['.ogg', 'audio/ogg'],
+ ['.flac', 'audio/flac'],
['.md', 'text/markdown; charset=utf-8'],
['.markdown', 'text/markdown; charset=utf-8'],
['.txt', 'text/plain; charset=utf-8'],
@@ -142,6 +154,74 @@ function backupFileName(filePath) {
return `${now}-${baseName}`;
}
+function parseRangeHeader(value, size) {
+ const match = String(value || '').match(/^bytes=(\d*)-(\d*)$/);
+ if (!match || !Number.isFinite(size) || size <= 0) {
+ return null;
+ }
+ let start;
+ let end;
+ if (match[1] === '' && match[2] === '') {
+ return null;
+ }
+ if (match[1] === '') {
+ const suffixLength = Number(match[2]);
+ if (!Number.isFinite(suffixLength) || suffixLength <= 0) {
+ return null;
+ }
+ start = Math.max(0, size - suffixLength);
+ end = size - 1;
+ } else {
+ start = Number(match[1]);
+ end = match[2] === '' ? size - 1 : Number(match[2]);
+ }
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || start >= size) {
+ return null;
+ }
+ return { start, end: Math.min(end, size - 1) };
+}
+
+function sendFileStream(req, res, filePath, stat, headers) {
+ const range = parseRangeHeader(req.headers?.range, stat.size);
+ const baseHeaders = {
+ ...headers,
+ 'accept-ranges': 'bytes'
+ };
+ const streamToResponse = (options = {}) => new Promise((resolve, reject) => {
+ const stream = fsSync.createReadStream(filePath, options);
+ let settled = false;
+ const settle = (callback, value) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ callback(value);
+ };
+ stream.on('data', (chunk) => {
+ res.write(chunk);
+ });
+ stream.once('error', (error) => settle(reject, error));
+ stream.once('end', () => {
+ res.end();
+ settle(resolve);
+ });
+ });
+ if (range) {
+ const contentLength = range.end - range.start + 1;
+ res.writeHead(206, {
+ ...baseHeaders,
+ 'content-range': `bytes ${range.start}-${range.end}/${stat.size}`,
+ 'content-length': contentLength
+ });
+ return streamToResponse({ start: range.start, end: range.end });
+ }
+ res.writeHead(200, {
+ ...baseHeaders,
+ 'content-length': stat.size
+ });
+ return streamToResponse();
+}
+
export async function sendLocalImage(req, res, url, {
mimeTypes = DEFAULT_MIME_TYPES
} = {}) {
@@ -168,12 +248,11 @@ export async function sendLocalImage(req, res, url, {
if (!stat.isFile()) {
continue;
}
- const content = await fs.readFile(filePath);
- sendStaticContent(req, res, 200, content, {
+ await sendFileStream(req, res, filePath, stat, {
'content-type': contentType,
'cache-control': 'private, max-age=3600',
'x-content-type-options': 'nosniff'
- }, ext);
+ });
return;
} catch {
// Try the decoded candidate before reporting a miss.
@@ -190,8 +269,7 @@ export async function sendLocalFile(req, res, url, {
const { filePath, stat } = await resolveExistingLocalFile(url);
const ext = path.extname(filePath).toLowerCase();
const contentType = mimeTypes.get(ext) || 'application/octet-stream';
- const content = await fs.readFile(filePath);
- sendStaticContent(req, res, 200, content, {
+ await sendFileStream(req, res, filePath, stat, {
'content-type': contentType,
'cache-control': 'private, max-age=60',
'content-disposition': inlineContentDisposition(filePath),
@@ -199,7 +277,7 @@ export async function sendLocalFile(req, res, url, {
'x-local-file-size': String(stat.size),
'x-local-file-editable': EDITABLE_TEXT_EXTENSIONS.has(ext) ? '1' : '0',
'x-content-type-options': 'nosniff'
- }, ext);
+ });
} catch (error) {
console.warn(`[local-file] read failed path=${error.requestedPath || ''} checked=${(error.checkedPaths || []).join(' | ')} errors=${JSON.stringify(error.details || [])}`);
sendJson(res, error.statusCode || 500, {
diff --git a/server/static-service.test.mjs b/server/static-service.test.mjs
index 2ce2f4e..161b638 100644
--- a/server/static-service.test.mjs
+++ b/server/static-service.test.mjs
@@ -18,8 +18,20 @@ function res() {
this.statusCode = statusCode;
this.headers = headers;
},
- end(body = '') {
- this.body = Buffer.isBuffer(body) ? body : Buffer.from(String(body));
+ end(body) {
+ if (body !== undefined) {
+ this.body = Buffer.isBuffer(body) ? body : Buffer.from(String(body));
+ }
+ },
+ write(chunk) {
+ const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
+ this.body = Buffer.concat([this.body, data]);
+ },
+ once(event, callback) {
+ if (event === 'finish') {
+ callback();
+ }
+ return this;
}
};
}
@@ -37,6 +49,7 @@ async function withTempService(fn) {
await fs.writeFile(path.join(generatedRoot, 'image.png'), Buffer.from([137, 80, 78, 71]));
await fs.writeFile(path.join(root, 'report.md'), '# Report');
await fs.writeFile(path.join(root, 'brief.pdf'), Buffer.from('%PDF-1.7'));
+ await fs.writeFile(path.join(root, 'clip.mp4'), Buffer.from('0123456789'));
await fs.writeFile(path.join(root, '甘肃临夏萌宠乐园丨政府汇报项目前置简介.md'), '# 中文文件名');
await fs.writeFile(path.join(root, 'secret.txt'), 'secret');
await fs.writeFile(certPath, 'cert');
@@ -113,6 +126,24 @@ test('sendLocalFile serves pdf files with pdf content type', async () => {
});
});
+test('sendLocalFile serves byte ranges for video preview', async () => {
+ await withTempService(async (service, root) => {
+ const filePath = path.join(root, 'clip.mp4');
+ const response = res();
+ await service.sendLocalFile(
+ req({ range: 'bytes=2-5' }),
+ response,
+ new URL(`http://local/api/local-file?path=${encodeURIComponent(filePath)}`)
+ );
+
+ assert.equal(response.statusCode, 206);
+ assert.equal(response.headers['content-type'], 'video/mp4');
+ assert.equal(response.headers['accept-ranges'], 'bytes');
+ assert.equal(response.headers['content-range'], 'bytes 2-5/10');
+ assert.equal(response.body.toString('utf8'), '2345');
+ });
+});
+
test('sendLocalFile tolerates Codex style line suffixes on file links', async () => {
await withTempService(async (service, root) => {
const filePath = `${path.join(root, 'report.md')}:12`;