From 980a7e407e0156c1b44e4a616c7ebbed43f2ef6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E7=8B=BC=E7=81=B0=E7=81=B0?= Date: Thu, 14 May 2026 15:30:16 +0800 Subject: [PATCH 1/9] Improve mobile sync reliability --- client/src/activity-card-state.test.mjs | 5 + client/src/app-state.test.mjs | 16 ++ client/src/app/App.jsx | 56 ++++- client/src/app/AppShell.jsx | 4 +- client/src/app/FilePreviewApp.jsx | 44 +++- client/src/app/PdfPreview.jsx | 9 +- client/src/app/background-handoff.js | 106 +++++++++ client/src/app/message-cache.js | 113 +++++++++ client/src/app/message-cache.test.mjs | 37 +++ client/src/app/session-utils.js | 6 + client/src/app/turn-submission-utils.js | 8 +- client/src/app/useAppBootstrap.js | 18 +- client/src/app/useAppWebSocket.js | 3 + client/src/app/useConnectionActions.js | 2 +- client/src/app/useSessionActions.js | 26 +- client/src/app/useSessionLivePolling.js | 25 +- client/src/app/useTurnSubmission.js | 26 +- client/src/background-handoff.test.mjs | 60 +++++ client/src/chat/ActivityMessage.jsx | 10 +- client/src/chat/ChatMessage.jsx | 12 +- client/src/chat/ChatPane.jsx | 3 +- client/src/chat/activity-card-state.js | 4 +- client/src/panels/BackgroundHandoffCard.jsx | 22 ++ client/src/panels/index.js | 1 + client/src/session-live-refresh.js | 10 +- client/src/session-live-refresh.test.mjs | 29 +++ client/src/styles/chat.css | 30 +++ client/src/styles/panels-chat.css | 72 ++++++ client/src/turn-submission-utils.test.mjs | 8 + server/chat-delivery.js | 88 ++++++- server/chat-service.js | 65 ++++- server/chat-service.test.mjs | 250 ++++++++++++++++++-- server/codex-runner-status.test.mjs | 40 +++- server/codex-runner.js | 35 ++- server/index.js | 22 +- server/session-message-reader.js | 50 +++- server/session-message-reader.test.mjs | 70 ++++++ server/static-service.js | 90 ++++++- server/static-service.test.mjs | 35 ++- 39 files changed, 1424 insertions(+), 86 deletions(-) create mode 100644 client/src/app/background-handoff.js create mode 100644 client/src/app/message-cache.js create mode 100644 client/src/app/message-cache.test.mjs create mode 100644 client/src/background-handoff.test.mjs create mode 100644 client/src/panels/BackgroundHandoffCard.jsx diff --git a/client/src/activity-card-state.test.mjs b/client/src/activity-card-state.test.mjs index 1c39072..6cbe920 100644 --- a/client/src/activity-card-state.test.mjs +++ b/client/src/activity-card-state.test.mjs @@ -7,3 +7,8 @@ test('activity card opens only while a visible process is running', () => { assert.equal(activityCardShouldOpen({ running: false, hasProcess: true }), false); assert.equal(activityCardShouldOpen({ running: true, hasProcess: false }), false); }); + +test('activity card opens the latest processed activity by default', () => { + assert.equal(activityCardShouldOpen({ running: false, hasProcess: true, latestActivity: true }), true); + assert.equal(activityCardShouldOpen({ running: false, hasProcess: false, latestActivity: true }), false); +}); diff --git a/client/src/app-state.test.mjs b/client/src/app-state.test.mjs index 8627f67..7e1a9d2 100644 --- a/client/src/app-state.test.mjs +++ b/client/src/app-state.test.mjs @@ -12,6 +12,8 @@ import { resolveNewConversationProject, runningByIdWithSelectedActivity, selectedSessionIsRunning, + sessionMessagesApiPath, + sessionLivePollMessageOptions, sessionRunBadgeState, shouldClearRuntimeWhenNoActiveRuns, shouldDropRunningActivityMissingFromActiveRuns, @@ -410,3 +412,17 @@ test('localFilePreviewPath routes local files through the mobile preview page', '/preview/file?path=%2FUsers%2Fdemo%2Freport.md&token=secret+token' ); }); + +test('sessionMessagesApiPath can skip expensive activity projection for initial loads', () => { + assert.equal( + sessionMessagesApiPath('thread-1', { activity: false }), + '/api/sessions/thread-1/messages?limit=120' + ); +}); + +test('sessionLivePollMessageOptions keeps frequent running polls lightweight', () => { + assert.deepEqual(sessionLivePollMessageOptions(0), { activity: true }); + assert.deepEqual(sessionLivePollMessageOptions(1), { activity: false }); + assert.deepEqual(sessionLivePollMessageOptions(5), { activity: false }); + assert.deepEqual(sessionLivePollMessageOptions(6), { activity: true }); +}); diff --git a/client/src/app/App.jsx b/client/src/app/App.jsx index 4879dd7..f4ffca9 100644 --- a/client/src/app/App.jsx +++ b/client/src/app/App.jsx @@ -22,6 +22,10 @@ import { useViewportSizing } from './useViewportSizing.js'; import { applyPwaTheme } from './pwa-theme.js'; import { nextSyncedComposerSettings } from './model-sync.js'; import { rememberSelectedSession } from './selection-persistence.js'; +import { + buildBackgroundHandoffMessage, + visibleBackgroundHandoff +} from './background-handoff.js'; import { buildComposerRunStatus, emptyContextStatus, @@ -102,6 +106,7 @@ export default function App() { const [threadRuntimeById, setThreadRuntimeById] = useState({}); const [syncing, setSyncing] = useState(false); const [connectionState, setConnectionState] = useState(() => (getToken() ? 'connecting' : 'disconnected')); + const [backgroundHandoffs, setBackgroundHandoffs] = useState([]); const wsRef = useRef(null); const selectedProjectRef = useRef(null); const selectedSessionRef = useRef(null); @@ -415,6 +420,7 @@ export default function App() { setSessionsByProject, setMessages, setContextStatus, + setBackgroundHandoffs, applyAutoSessionTitle, notifyFromPayload, loadQueueDrafts, @@ -451,6 +457,7 @@ export default function App() { }); const { + submitCodexMessage, handleSubmit, handleImplementPlan, handleAdjustPlan, @@ -489,9 +496,46 @@ export default function App() { markSessionCompleteNotice, markTurnCompleted, scheduleTurnRefresh, - loadQueueDrafts + loadQueueDrafts, + setBackgroundHandoffs }); + async function handleSyncBackgroundHandoff(handoff) { + const message = buildBackgroundHandoffMessage(handoff); + try { + await submitCodexMessage({ + message, + clearComposer: false, + restoreTextOnError: false, + sendMode: 'start', + visibleMessageOverride: message, + codexMessageOverride: message + }); + const syncedAt = new Date().toISOString(); + setBackgroundHandoffs((current) => + current.map((item) => (item.id === handoff.id ? { ...item, syncedAt } : item)) + ); + showToast({ + level: 'success', + title: '已同步到桌面', + body: '后台执行摘要已发送到当前线程。' + }); + } catch (error) { + showToast({ + level: 'error', + title: '同步失败', + body: error.message || '请稍后重试。' + }); + } + } + + function handleDismissBackgroundHandoff(handoff) { + const syncedAt = new Date().toISOString(); + setBackgroundHandoffs((current) => + current.map((item) => (item.id === handoff.id ? { ...item, syncedAt } : item)) + ); + } + async function handleGitAction(action) { if (!selectedProject || selectedRunning) { return; @@ -531,6 +575,11 @@ export default function App() { syncing }); const topBarRuntime = selectedRuntime || (selectedRunning ? { status: 'running' } : null); + const visibleHandoff = visibleBackgroundHandoff(backgroundHandoffs, { + selectedProject, + selectedSession, + desktopBridge: status.desktopBridge + }); if (!authenticated) { return ; @@ -581,6 +630,11 @@ export default function App() { onPair: handleResetPairing, onStatus: handleShowConnectionStatus }, + backgroundHandoffProps: { + handoff: visibleHandoff, + onSync: handleSyncBackgroundHandoff, + onDismiss: handleDismissBackgroundHandoff + }, toastStackProps: { toasts, onDismiss: dismissToast diff --git a/client/src/app/AppShell.jsx b/client/src/app/AppShell.jsx index 7d235c8..ac7475c 100644 --- a/client/src/app/AppShell.jsx +++ b/client/src/app/AppShell.jsx @@ -1,7 +1,7 @@ import { Composer } from '../composer/Composer.jsx'; import { ChatPane } from '../chat/ChatPane.jsx'; import { ImagePreviewModal } from '../chat/ImagePreview.jsx'; -import { ConnectionRecoveryCard, DocsPanel, Drawer, GitPanel, ToastStack, TopBar } from '../panels/index.js'; +import { BackgroundHandoffCard, ConnectionRecoveryCard, DocsPanel, Drawer, GitPanel, ToastStack, TopBar } from '../panels/index.js'; export function AppShell({ shellClass, panelProps, drawerProps, chatProps, composerProps }) { const { @@ -9,6 +9,7 @@ export function AppShell({ shellClass, panelProps, drawerProps, chatProps, compo docsPanelProps, gitPanelProps, recoveryCardProps, + backgroundHandoffProps, toastStackProps, imagePreviewProps } = panelProps; @@ -20,6 +21,7 @@ export function AppShell({ shellClass, panelProps, drawerProps, chatProps, compo + diff --git a/client/src/app/FilePreviewApp.jsx b/client/src/app/FilePreviewApp.jsx index e9e9cbe..1d954ad 100644 --- a/client/src/app/FilePreviewApp.jsx +++ b/client/src/app/FilePreviewApp.jsx @@ -16,6 +16,15 @@ function fileNameFromPath(value) { function previewKind(pathValue, contentType) { const lowerType = String(contentType || '').toLowerCase(); const lowerPath = String(pathValue || '').toLowerCase(); + if (lowerType.startsWith('image/') || /\.(?:png|jpe?g|webp|gif|svg|ico)(?:$|[:?#])/i.test(lowerPath)) { + return 'image'; + } + if (lowerType.startsWith('video/') || /\.(?:mp4|m4v|mov|webm|ogv)(?:$|[:?#])/i.test(lowerPath)) { + return 'video'; + } + if (lowerType.startsWith('audio/') || /\.(?:mp3|m4a|aac|wav|ogg|flac)(?:$|[:?#])/i.test(lowerPath)) { + return 'audio'; + } if (lowerType.includes('pdf') || /\.pdf(?:$|[:?#])/i.test(lowerPath)) { return 'pdf'; } @@ -31,6 +40,10 @@ function previewKind(pathValue, contentType) { return 'download'; } +function isNativeMediaKind(kind) { + return kind === 'image' || kind === 'video' || kind === 'audio'; +} + function stripFrontmatter(value) { const text = String(value || ''); if (!text.startsWith('---')) { @@ -107,6 +120,20 @@ export default function FilePreviewApp() { } setState({ loading: true, error: '', text: '', objectUrl: '', pdfData: null, contentType: '', mtimeMs: 0, editable: false }); try { + const pathKind = previewKind(filePath, ''); + if (isNativeMediaKind(pathKind) || pathKind === 'pdf') { + setState({ + loading: false, + error: '', + text: '', + objectUrl: '', + pdfData: null, + contentType: pathKind === 'pdf' ? 'application/pdf' : '', + mtimeMs: 0, + editable: false + }); + return; + } const response = await fetch(localFileApiPath(filePath, urlToken || getToken()), { headers: getToken() ? { authorization: `Bearer ${getToken()}` } : {} }); @@ -380,9 +407,24 @@ export default function FilePreviewApp() { spellCheck={false} /> ) : null} - {!state.loading && !state.error && kind === 'pdf' && state.pdfData ? ( + {!state.loading && !state.error && kind === 'pdf' ? ( ) : null} + {!state.loading && !state.error && kind === 'image' ? ( +
+ {title} setState((current) => ({ ...current, error: '图片加载失败' }))} /> +
+ ) : null} + {!state.loading && !state.error && kind === 'video' ? ( +
+
+ ) : null} + {!state.loading && !state.error && kind === 'audio' ? ( +
+
+ ) : null} {!state.loading && !state.error && kind === 'download' && state.objectUrl ? ( diff --git a/client/src/app/PdfPreview.jsx b/client/src/app/PdfPreview.jsx index a1938b9..c73c8a4 100644 --- a/client/src/app/PdfPreview.jsx +++ b/client/src/app/PdfPreview.jsx @@ -20,7 +20,12 @@ export function PdfPreview({ data, fileUrl = '' }) { const [canFitPage, setCanFitPage] = useState(false); const [error, setError] = useState(''); - const source = useMemo(() => (data ? new Uint8Array(data.slice(0)) : null), [data]); + const source = useMemo(() => { + if (data) { + return { data: new Uint8Array(data.slice(0)) }; + } + return fileUrl ? { url: fileUrl } : null; + }, [data, fileUrl]); useEffect(() => { let cancelled = false; @@ -31,7 +36,7 @@ export function PdfPreview({ data, fileUrl = '' }) { if (!source) { return undefined; } - const loadingTask = pdfjs.getDocument({ data: source }); + const loadingTask = pdfjs.getDocument(source); loadingTask.promise .then((pdf) => { if (!cancelled) { diff --git a/client/src/app/background-handoff.js b/client/src/app/background-handoff.js new file mode 100644 index 0000000..b0bfe7d --- /dev/null +++ b/client/src/app/background-handoff.js @@ -0,0 +1,106 @@ +export function shouldCreateBackgroundHandoff(result = {}) { + return Boolean( + result?.desktopBridge?.mode === 'headless-local' && + /IPC 状态异常|settings|timeout|超时/i.test(String(result.desktopBridge.reason || '')) + ); +} + +export function createBackgroundHandoff({ + projectId = '', + sessionId = '', + previousSessionId = '', + turnId = '', + userMessage = '', + reason = '', + createdAt = new Date().toISOString() +} = {}) { + const targetSessionId = String(previousSessionId || sessionId || '').trim(); + const id = [targetSessionId, turnId, createdAt].filter(Boolean).join(':'); + if (!targetSessionId || !turnId) { + return null; + } + return { + id, + projectId, + sessionId: targetSessionId, + backgroundSessionId: sessionId || targetSessionId, + turnId, + userMessage: String(userMessage || '').trim(), + reason: reason || '桌面端 IPC 状态异常,手机端已自动转后台执行。', + status: 'running', + createdAt, + completedAt: '', + syncedAt: '' + }; +} + +export function updateBackgroundHandoffOnPayload(current = [], payload = {}) { + if (!Array.isArray(current) || !current.length || payload?.source !== 'headless-local') { + return current; + } + const turnId = String(payload.turnId || '').trim(); + const sessionId = String(payload.sessionId || payload.previousSessionId || '').trim(); + if (!turnId && !sessionId) { + return current; + } + let changed = false; + const next = current.map((item) => { + const matchesTurn = turnId && item.turnId === turnId; + const matchesSession = sessionId && (item.sessionId === sessionId || item.backgroundSessionId === sessionId); + if (!matchesTurn && !matchesSession) { + return item; + } + if (payload.type === 'chat-complete') { + changed = true; + return { + ...item, + status: 'completed', + completedAt: payload.completedAt || payload.timestamp || new Date().toISOString() + }; + } + if (payload.type === 'chat-error') { + changed = true; + return { + ...item, + status: 'failed', + completedAt: payload.completedAt || payload.timestamp || new Date().toISOString() + }; + } + return item; + }); + return changed ? next : current; +} + +export function visibleBackgroundHandoff(current = [], { + selectedProject = null, + selectedSession = null, + desktopBridge = null +} = {}) { + if (desktopBridge?.mode !== 'desktop-ipc' || desktopBridge?.connected !== true) { + return null; + } + const projectId = selectedProject?.id || selectedSession?.projectId || ''; + const sessionId = selectedSession?.id || ''; + return (Array.isArray(current) ? current : []).find((item) => ( + item && + item.status === 'completed' && + !item.syncedAt && + (!projectId || item.projectId === projectId) && + (!sessionId || item.sessionId === sessionId) + )) || null; +} + +export function buildBackgroundHandoffMessage(handoff = {}) { + const userMessage = String(handoff.userMessage || '').trim(); + const reason = String(handoff.reason || '').trim(); + const completedAt = String(handoff.completedAt || '').trim(); + return [ + '刚才手机端因桌面 IPC 异常转后台执行了一轮。以下是交接摘要:', + '', + `原因:${reason || '桌面端未能接管该线程。'}`, + completedAt ? `完成时间:${completedAt}` : '', + userMessage ? `用户问题:${userMessage}` : '', + '', + '请基于当前仓库状态和最近文件改动继续;不要假设桌面线程里已经包含手机后台那轮上下文。' + ].filter((line) => line !== '').join('\n'); +} diff --git a/client/src/app/message-cache.js b/client/src/app/message-cache.js new file mode 100644 index 0000000..d3e1085 --- /dev/null +++ b/client/src/app/message-cache.js @@ -0,0 +1,113 @@ +const DB_NAME = 'codexmobile-message-cache'; +const DB_VERSION = 1; +const STORE_NAME = 'sessionMessages'; + +let dbPromise = null; + +export function sessionMessageCacheKey(sessionId) { + return String(sessionId || ''); +} + +export function createSessionMessageCacheRecord(sessionId, payload) { + const key = sessionMessageCacheKey(sessionId); + const revision = typeof payload?.revision === 'string' ? payload.revision : ''; + if (!key || !revision || !Array.isArray(payload?.messages)) { + return null; + } + return { + key, + revision, + messages: payload.messages, + context: payload.context || null, + savedAt: Date.now() + }; +} + +export function normalizeSessionMessageCacheRecord(sessionId, record) { + const key = sessionMessageCacheKey(sessionId); + if (!record || record.key !== key || !record.revision || !Array.isArray(record.messages)) { + return null; + } + return { + key, + revision: record.revision, + messages: record.messages, + context: record.context || null, + savedAt: Number(record.savedAt) || 0 + }; +} + +function openDb(indexedDBImpl = globalThis.indexedDB) { + if (!indexedDBImpl) { + return Promise.resolve(null); + } + if (dbPromise) { + return dbPromise; + } + dbPromise = new Promise((resolve) => { + const request = indexedDBImpl.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: 'key' }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => resolve(null); + request.onblocked = () => resolve(null); + }); + return dbPromise; +} + +function requestToPromise(request) { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error('IndexedDB request failed')); + }); +} + +export async function readCachedSessionMessages(sessionId) { + try { + const db = await openDb(); + if (!db) { + return null; + } + const tx = db.transaction(STORE_NAME, 'readonly'); + const record = await requestToPromise(tx.objectStore(STORE_NAME).get(sessionMessageCacheKey(sessionId))); + return normalizeSessionMessageCacheRecord(sessionId, record); + } catch { + return null; + } +} + +export async function writeCachedSessionMessages(sessionId, payload) { + const record = createSessionMessageCacheRecord(sessionId, payload); + if (!record) { + return false; + } + try { + const db = await openDb(); + if (!db) { + return false; + } + const tx = db.transaction(STORE_NAME, 'readwrite'); + await requestToPromise(tx.objectStore(STORE_NAME).put(record)); + return true; + } catch { + return false; + } +} + +export async function deleteCachedSessionMessages(sessionId) { + try { + const db = await openDb(); + if (!db) { + return false; + } + const tx = db.transaction(STORE_NAME, 'readwrite'); + await requestToPromise(tx.objectStore(STORE_NAME).delete(sessionMessageCacheKey(sessionId))); + return true; + } catch { + return false; + } +} diff --git a/client/src/app/message-cache.test.mjs b/client/src/app/message-cache.test.mjs new file mode 100644 index 0000000..848be05 --- /dev/null +++ b/client/src/app/message-cache.test.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createSessionMessageCacheRecord, + normalizeSessionMessageCacheRecord, + sessionMessageCacheKey +} from './message-cache.js'; + +test('session message cache records only cache versioned pure message payloads', () => { + const record = createSessionMessageCacheRecord('session-1', { + revision: 'rollout.jsonl:12:1778202000000', + messages: [{ id: 'm1', role: 'user', content: 'hello' }], + context: { inputTokens: 12 } + }); + + assert.equal(record.key, 'session-1'); + assert.equal(record.revision, 'rollout.jsonl:12:1778202000000'); + assert.deepEqual(record.messages.map((message) => message.id), ['m1']); + assert.equal(record.context.inputTokens, 12); + assert.equal(typeof record.savedAt, 'number'); +}); + +test('session message cache rejects unversioned or mismatched records', () => { + assert.equal(createSessionMessageCacheRecord('session-1', { messages: [] }), null); + assert.equal(createSessionMessageCacheRecord('', { revision: 'r1', messages: [] }), null); + assert.equal(createSessionMessageCacheRecord('session-1', { revision: 'r1', messages: null }), null); + + assert.equal(normalizeSessionMessageCacheRecord('session-1', { + key: 'session-2', + revision: 'r1', + messages: [] + }), null); +}); + +test('session message cache keys are stable and session-scoped', () => { + assert.equal(sessionMessageCacheKey('session/1'), 'session/1'); +}); diff --git a/client/src/app/session-utils.js b/client/src/app/session-utils.js index bc1a63f..8c5bf33 100644 --- a/client/src/app/session-utils.js +++ b/client/src/app/session-utils.js @@ -309,6 +309,12 @@ export function sessionMessagesApiPath(sessionId, { limit = 120, activity = true return `/api/sessions/${encodeURIComponent(sessionId)}/messages?${params.toString()}`; } +export function sessionLivePollMessageOptions(pollCount, { fullEvery = 6 } = {}) { + const count = Math.max(0, Number(pollCount) || 0); + const interval = Math.max(1, Number(fullEvery) || 1); + return { activity: count % interval === 0 }; +} + export function titleFromFirstMessage(message) { return provisionalSessionTitle(message); } diff --git a/client/src/app/turn-submission-utils.js b/client/src/app/turn-submission-utils.js index 03f2f07..9042a3e 100644 --- a/client/src/app/turn-submission-utils.js +++ b/client/src/app/turn-submission-utils.js @@ -24,8 +24,12 @@ export function turnMatchesSelection(currentSession, { turnId, optimisticSession ); } -export function sessionForTurnSelection(selectedSession, selectedSessionRef) { - return selectedSessionRef?.current || selectedSession || null; +export function sessionForTurnSelection(selectedSession, selectedSessionRef, selectedProject = null) { + const session = selectedSessionRef?.current || selectedSession || null; + if (!session || !selectedProject?.id || !session.projectId) { + return session; + } + return session.projectId === selectedProject.id ? session : null; } export function projectForTurnSelection(selectedProject, selectedProjectRef, selectedSession = null, selectedSessionRef = null, projects = []) { diff --git a/client/src/app/useAppBootstrap.js b/client/src/app/useAppBootstrap.js index 3c838e3..631bb43 100644 --- a/client/src/app/useAppBootstrap.js +++ b/client/src/app/useAppBootstrap.js @@ -6,6 +6,7 @@ import { sessionMessagesApiPath } from './session-utils.js'; import { normalizeContextStatus } from './context-status.js'; +import { readCachedSessionMessages, writeCachedSessionMessages } from './message-cache.js'; import { preferredProjectFromStoredSelection, readStoredSelection, @@ -88,10 +89,25 @@ export function useAppBootstrap({ } setSelectedSession((current) => (current?.id === next.id ? { ...current, ...next } : next)); setContextStatus(normalizeContextStatus(next.context || defaultStatus.context, defaultStatus.context)); - const messageData = await apiFetch(sessionMessagesApiPath(next.id)); + const plainMessagesPromise = apiFetch(sessionMessagesApiPath(next.id, { activity: false })); + const cachedMessageData = await readCachedSessionMessages(next.id); + if (cachedMessageData && selectedSessionRef.current?.id === next.id) { + setMessages(cachedMessageData.messages || []); + setContextStatus(normalizeContextStatus(cachedMessageData.context || next.context || defaultStatus.context, defaultStatus.context)); + } + const messageData = await plainMessagesPromise; if (selectedSessionRef.current?.id === next.id) { setMessages(messageData.messages || []); setContextStatus(normalizeContextStatus(messageData.context || next.context || defaultStatus.context, defaultStatus.context)); + writeCachedSessionMessages(next.id, messageData); + apiFetch(sessionMessagesApiPath(next.id)) + .then((fullMessageData) => { + if (selectedSessionRef.current?.id === next.id) { + setMessages(fullMessageData.messages || []); + setContextStatus(normalizeContextStatus(fullMessageData.context || next.context || defaultStatus.context, defaultStatus.context)); + } + }) + .catch(() => null); } return; } diff --git a/client/src/app/useAppWebSocket.js b/client/src/app/useAppWebSocket.js index a7c603a..ce67b22 100644 --- a/client/src/app/useAppWebSocket.js +++ b/client/src/app/useAppWebSocket.js @@ -9,6 +9,7 @@ import { } from '../chat/activity-model.js'; import { sameUserMessageContent } from '../chat/message-identity.js'; import { mergeContextStatus, normalizeContextStatus } from './context-status.js'; +import { updateBackgroundHandoffOnPayload } from './background-handoff.js'; const EXTERNAL_THREAD_SOURCES = new Set(['desktop-ipc', 'desktop-thread', 'headless-local']); @@ -81,6 +82,7 @@ export function useAppWebSocket({ setSessionsByProject, setMessages, setContextStatus, + setBackgroundHandoffs, applyAutoSessionTitle, notifyFromPayload, loadQueueDrafts, @@ -314,6 +316,7 @@ export function useAppWebSocket({ return; } if (payload.type === 'chat-complete' || payload.type === 'chat-error' || payload.type === 'chat-aborted') { + setBackgroundHandoffs?.((current) => updateBackgroundHandoffOnPayload(current, payload)); notifyFromPayload(payload); loadQueueDrafts(selectedSessionRef.current).catch(() => null); if (payload.type === 'chat-complete') { diff --git a/client/src/app/useConnectionActions.js b/client/src/app/useConnectionActions.js index 7a9f65a..d25e901 100644 --- a/client/src/app/useConnectionActions.js +++ b/client/src/app/useConnectionActions.js @@ -15,7 +15,7 @@ export function useConnectionActions({ async function handleSync() { setSyncing(true); try { - await apiFetch('/api/sync', { method: 'POST' }); + await apiFetch('/api/sync?force=1', { method: 'POST' }); await loadStatus(); await loadProjects({ preserveSelection: true }); showToast({ level: 'success', title: '同步完成', body: '线程和状态已经刷新。' }); diff --git a/client/src/app/useSessionActions.js b/client/src/app/useSessionActions.js index e675b7d..2720c1e 100644 --- a/client/src/app/useSessionActions.js +++ b/client/src/app/useSessionActions.js @@ -2,6 +2,11 @@ import { apiFetch } from '../api.js'; import { desktopBridgeCanCreateThread } from '../send-state.js'; import { sessionTitleFromConversation } from '../../../shared/session-title.js'; import { normalizeContextStatus } from './context-status.js'; +import { + deleteCachedSessionMessages, + readCachedSessionMessages, + writeCachedSessionMessages +} from './message-cache.js'; import { autoTitlePatch, createDraftSession, @@ -52,8 +57,10 @@ export function useSessionActions({ setExpandedProjectIds((current) => ({ ...current, [project.id]: true })); const projectChanged = selectedProject?.id !== project.id; + selectedProjectRef.current = project; setSelectedProject(project); if (projectChanged) { + selectedSessionRef.current = null; setSelectedSession(null); setMessages([]); setSessionLoadingId(null); @@ -83,12 +90,27 @@ export function useSessionActions({ setContextStatus(normalizeContextStatus(session?.context || defaultStatus.context, defaultStatus.context)); setDrawerOpen(false); try { - const data = await apiFetch(sessionMessagesApiPath(session.id)); + const plainMessagesPromise = apiFetch(sessionMessagesApiPath(session.id, { activity: false })); + const cachedData = await readCachedSessionMessages(session.id); + if (cachedData && selectedSessionRef.current?.id === requestedSessionId) { + setMessages(cachedData.messages || []); + setContextStatus(normalizeContextStatus(cachedData.context || session.context || defaultStatus.context, defaultStatus.context)); + } + const data = await plainMessagesPromise; if (selectedSessionRef.current?.id !== requestedSessionId) { return; } setMessages(data.messages || []); setContextStatus(normalizeContextStatus(data.context || session.context || defaultStatus.context, defaultStatus.context)); + writeCachedSessionMessages(session.id, data); + apiFetch(sessionMessagesApiPath(session.id)) + .then((fullData) => { + if (selectedSessionRef.current?.id === requestedSessionId) { + setMessages(fullData.messages || []); + setContextStatus(normalizeContextStatus(fullData.context || session.context || defaultStatus.context, defaultStatus.context)); + } + }) + .catch(() => null); } catch (error) { if (selectedSessionRef.current?.id === requestedSessionId) { setSessionLoadError(error.message || '加载失败'); @@ -238,6 +260,7 @@ export function useSessionActions({ await apiFetch(`/api/projects/${encodeURIComponent(project.id)}/sessions/${encodeURIComponent(session.id)}`, { method: 'DELETE' }); + deleteCachedSessionMessages(session.id); removeLocalSession(); await refreshProjectSessions(project); } catch (error) { @@ -273,6 +296,7 @@ export function useSessionActions({ `/api/sessions/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`, { method: 'DELETE' } ); + deleteCachedSessionMessages(sessionId); } catch (error) { setMessages((current) => { if (current.some((item) => String(item.id) === messageId)) { diff --git a/client/src/app/useSessionLivePolling.js b/client/src/app/useSessionLivePolling.js index 26802ff..56d61fb 100644 --- a/client/src/app/useSessionLivePolling.js +++ b/client/src/app/useSessionLivePolling.js @@ -14,6 +14,7 @@ import { isDraftSession, isLiveThreadRuntime, selectedRunKeys, + sessionLivePollMessageOptions, sessionMessagesApiPath } from './session-utils.js'; @@ -42,6 +43,7 @@ export function useSessionLivePolling({ const sessionId = selectedSession.id; let stopped = false; + let pollCount = 0; async function pollSelectedSession() { if (stopped || sessionLivePollRef.current) { return; @@ -66,22 +68,25 @@ export function useSessionLivePolling({ return; } sessionLivePollRef.current = true; + const pollOptions = sessionLivePollMessageOptions(pollCount++); try { - const data = await apiFetch(sessionMessagesApiPath(sessionId)); + const data = await apiFetch(sessionMessagesApiPath(sessionId, pollOptions)); if (!stopped && selectedSessionRef.current?.id === sessionId && Array.isArray(data.messages)) { - syncDesktopActivityRuntimeFromMessages({ - messages: data.messages, - sessionId, - selectedRunRuntime, - markRun, - clearRun, - markSessionCompleteNotice - }); + if (pollOptions.activity) { + syncDesktopActivityRuntimeFromMessages({ + messages: data.messages, + sessionId, + selectedRunRuntime, + markRun, + clearRun, + markSessionCompleteNotice + }); + } setContextStatus((current) => mergeContextStatus(current, data.context || defaultStatus.context, defaultStatus.context)); setMessages((current) => messageStreamSignature(current) === messageStreamSignature(data.messages) ? current - : mergeLiveSelectedThreadMessages(current, data.messages) + : mergeLiveSelectedThreadMessages(current, data.messages, { preserveActivityState: !pollOptions.activity }) ); } } catch { diff --git a/client/src/app/useTurnSubmission.js b/client/src/app/useTurnSubmission.js index 3e0c544..ca13fe0 100644 --- a/client/src/app/useTurnSubmission.js +++ b/client/src/app/useTurnSubmission.js @@ -16,6 +16,10 @@ import { sessionMessagesApiPath, titleFromFirstMessage } from './session-utils.js'; +import { + createBackgroundHandoff, + shouldCreateBackgroundHandoff +} from './background-handoff.js'; import { displayMessageForTurn, completeLocalAbortMessages, @@ -66,7 +70,8 @@ export function useTurnSubmission({ markSessionCompleteNotice, markTurnCompleted, scheduleTurnRefresh, - loadQueueDrafts + loadQueueDrafts, + setBackgroundHandoffs = () => null }) { function applyTurnSession(turn, optimisticSessionId, projectId, previousSessionId) { const realSessionId = realSessionIdFromTurn(turn); @@ -255,7 +260,7 @@ export function useTurnSubmission({ throw new Error(project ? 'message or attachments are required' : '请先选择项目'); } - let sessionForTurn = sessionForTurnSelection(selectedSession, selectedSessionRef); + let sessionForTurn = sessionForTurnSelection(selectedSession, selectedSessionRef, project); if (!sessionForTurn) { sessionForTurn = createDraftSession(project); selectedSessionRef.current = sessionForTurn; @@ -358,6 +363,23 @@ export function useTurnSubmission({ steerable: resultBridgeMode === 'desktop-ipc' ? false : undefined }); } + if (shouldCreateBackgroundHandoff(result)) { + const handoff = createBackgroundHandoff({ + projectId: project.id, + sessionId: resultSessionId, + previousSessionId: draftSessionId || outgoingSessionId, + turnId: resultTurnId, + userMessage: displayMessage, + reason: result.desktopBridge?.reason || '', + createdAt: submittedAt + }); + if (handoff) { + setBackgroundHandoffs((current) => [ + handoff, + ...(Array.isArray(current) ? current.filter((item) => item.id !== handoff.id) : []) + ].slice(0, 8)); + } + } if (shouldPollTurnEndpointAfterSend(result)) { pollTurnUntilComplete({ turnId: resultTurnId, diff --git a/client/src/background-handoff.test.mjs b/client/src/background-handoff.test.mjs new file mode 100644 index 0000000..18606ea --- /dev/null +++ b/client/src/background-handoff.test.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildBackgroundHandoffMessage, + createBackgroundHandoff, + shouldCreateBackgroundHandoff, + updateBackgroundHandoffOnPayload, + visibleBackgroundHandoff +} from './app/background-handoff.js'; + +test('background handoff is created only for IPC fallback runs', () => { + assert.equal(shouldCreateBackgroundHandoff({ + desktopBridge: { mode: 'headless-local', reason: '桌面端 IPC 状态异常,已自动转后台 Codex 继续执行。' } + }), true); + assert.equal(shouldCreateBackgroundHandoff({ + desktopBridge: { mode: 'headless-local', reason: '桌面端不可用' } + }), false); +}); + +test('background handoff tracks completion and visibility for restored desktop IPC', () => { + const handoff = createBackgroundHandoff({ + projectId: 'project-1', + sessionId: 'thread-1', + turnId: 'turn-1', + userMessage: '继续处理' + }); + assert.equal(handoff.sessionId, 'thread-1'); + assert.equal(handoff.status, 'running'); + + const completed = updateBackgroundHandoffOnPayload([handoff], { + type: 'chat-complete', + source: 'headless-local', + sessionId: 'thread-1', + turnId: 'turn-1', + completedAt: '2026-05-14T07:00:00.000Z' + }); + assert.equal(completed[0].status, 'completed'); + + assert.equal(visibleBackgroundHandoff(completed, { + selectedProject: { id: 'project-1' }, + selectedSession: { id: 'thread-1' }, + desktopBridge: { connected: true, mode: 'desktop-ipc' } + })?.id, handoff.id); + assert.equal(visibleBackgroundHandoff([{ ...completed[0], backgroundSessionId: 'background-thread-1' }], { + selectedProject: { id: 'project-1' }, + selectedSession: { id: 'background-thread-1' }, + desktopBridge: { connected: true, mode: 'desktop-ipc' } + }), null); +}); + +test('background handoff message reminds desktop to inspect current files', () => { + const text = buildBackgroundHandoffMessage({ + reason: '桌面端 IPC 状态异常', + userMessage: '修复同步', + completedAt: '2026-05-14T07:00:00.000Z' + }); + assert.match(text, /手机端因桌面 IPC 异常/); + assert.match(text, /用户问题:修复同步/); + assert.match(text, /当前仓库状态/); +}); diff --git a/client/src/chat/ActivityMessage.jsx b/client/src/chat/ActivityMessage.jsx index b1ca8de..028de6a 100644 --- a/client/src/chat/ActivityMessage.jsx +++ b/client/src/chat/ActivityMessage.jsx @@ -14,7 +14,7 @@ function hasPendingPlanImplementation(activities = []) { ); } -export function ActivityMessage({ message, now = Date.now(), onImplementPlan }) { +export function ActivityMessage({ message, now = Date.now(), latestActivity = false, onImplementPlan }) { if (!shouldRenderActivityMessageInChat(message)) { return null; } @@ -25,15 +25,17 @@ export function ActivityMessage({ message, now = Date.now(), onImplementPlan }) const visibleSteps = activities.filter((activity) => isVisibleActivityStep(activity, message.status)); const { timeRange, timeline, fileSummary } = projectActivityView(visibleSteps, { running }); const hasProcess = timeline.length > 0 || Boolean(fileSummary); - const [open, setOpen] = useState(() => pendingPlanImplementation || activityCardShouldOpen({ running, hasProcess })); + const [open, setOpen] = useState(() => + pendingPlanImplementation || activityCardShouldOpen({ running, hasProcess, latestActivity }) + ); const startedAt = message.startedAt || timeRange.startedAt || message.timestamp; const endedAt = running ? now : message.completedAt || timeRange.endedAt || message.timestamp || now; const duration = !running ? formatDurationMs(message.durationMs) || formatDuration(startedAt, endedAt) : formatDuration(startedAt, endedAt); const headline = failed ? '处理失败' : pendingPlanImplementation ? '等待确认' : running ? '处理中' : '已处理'; useEffect(() => { - setOpen(pendingPlanImplementation || activityCardShouldOpen({ running, hasProcess })); - }, [message.id, running, hasProcess, pendingPlanImplementation]); + setOpen(pendingPlanImplementation || activityCardShouldOpen({ running, hasProcess, latestActivity })); + }, [message.id, running, hasProcess, latestActivity, pendingPlanImplementation]); return (
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..2569dd3 100644 --- a/client/src/session-live-refresh.js +++ b/client/src/session-live-refresh.js @@ -71,7 +71,7 @@ function activityInsertIndex(loaded, activity) { return index >= 0 ? index : 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 +89,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) ); @@ -226,7 +226,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 +242,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 +259,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..36716a2 100644 --- a/client/src/session-live-refresh.test.mjs +++ b/client/src/session-live-refresh.test.mjs @@ -187,6 +187,35 @@ 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('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..17817fe 100644 --- a/client/src/turn-submission-utils.test.mjs +++ b/client/src/turn-submission-utils.test.mjs @@ -45,6 +45,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' }; 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-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/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..9c31375 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,25 @@ function sortMessagesByConversationOrder(messages) { .map((item) => item.message); } +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 +498,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 +536,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) @@ -534,9 +570,21 @@ export function createSessionMessageReader({ 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..f6144a4 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({ 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`; From 5d56126740e1605fc41a0e32806f248ebe5c36de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E7=8B=BC=E7=81=B0=E7=81=B0?= Date: Thu, 14 May 2026 17:20:45 +0800 Subject: [PATCH 2/9] Stabilize desktop activity ordering --- server/codex-data-desktop-activity.test.mjs | 44 +++++++++++++++++++ server/desktop-thread-projector.js | 19 ++++++-- server/session-message-reader.test.mjs | 48 +++++++++++++++++++++ 3 files changed, 107 insertions(+), 4 deletions(-) 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/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/session-message-reader.test.mjs b/server/session-message-reader.test.mjs index f6144a4..4c5c1a4 100644 --- a/server/session-message-reader.test.mjs +++ b/server/session-message-reader.test.mjs @@ -379,6 +379,54 @@ 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 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 { From 7589bf1ff57a44f46f13d18d9e0161b96b4d203b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E7=8B=BC=E7=81=B0=E7=81=B0?= Date: Thu, 14 May 2026 17:24:03 +0800 Subject: [PATCH 3/9] Keep preserved activity anchored during lightweight polls --- client/src/session-live-refresh.js | 46 +++++++++++++++++++++--- client/src/session-live-refresh.test.mjs | 26 ++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/client/src/session-live-refresh.js b/client/src/session-live-refresh.js index 2569dd3..336ac3d 100644 --- a/client/src/session-live-refresh.js +++ b/client/src/session-live-refresh.js @@ -65,10 +65,48 @@ 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 = [], { preserveActivityState = false } = {}) { @@ -99,8 +137,8 @@ function preserveLocalActivityMessages(current = [], loaded = [], { preserveActi } 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; } diff --git a/client/src/session-live-refresh.test.mjs b/client/src/session-live-refresh.test.mjs index 36716a2..ca8b9e7 100644 --- a/client/src/session-live-refresh.test.mjs +++ b/client/src/session-live-refresh.test.mjs @@ -216,6 +216,32 @@ test('mergeLiveSelectedThreadMessages can preserve running activity state during 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([ From c65b962edf9d3e3324510a968f4df3422bce4c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E7=8B=BC=E7=81=B0=E7=81=B0?= Date: Thu, 14 May 2026 17:44:05 +0800 Subject: [PATCH 4/9] Keep queued drafts out of chat stream --- client/src/app-websocket-events.test.mjs | 22 +++++++- client/src/app/turn-submission-utils.js | 11 ++++ client/src/app/useAppWebSocket.js | 12 ++++- client/src/app/useTurnSubmission.js | 65 +++++++++++++---------- client/src/turn-submission-utils.test.mjs | 15 ++++++ 5 files changed, 94 insertions(+), 31 deletions(-) diff --git a/client/src/app-websocket-events.test.mjs b/client/src/app-websocket-events.test.mjs index 42ccd03..451f3d4 100644 --- a/client/src/app-websocket-events.test.mjs +++ b/client/src/app-websocket-events.test.mjs @@ -6,7 +6,8 @@ import { shouldRefreshCurrentSessionAfterReconnect, shouldRenderActivityMessageForPayload, shouldRenderAssistantMessageForPayload, - shouldRenderStatusMessageForPayload + shouldRenderStatusMessageForPayload, + shouldTrackStatusRuntime } from './app/useAppWebSocket.js'; test('desktop IPC status updates drive runtime without rendering local activity cards', () => { @@ -125,6 +126,25 @@ test('headless fallback activity and assistant updates are read from the thread ); }); +test('queued turn updates refresh the queue without rendering chat activity', () => { + const payload = { + type: 'status-update', + kind: 'turn', + status: 'queued' + }; + + assert.equal(shouldRenderStatusMessageForPayload(payload), false); + assert.equal(shouldTrackStatusRuntime(payload), false); + assert.equal( + shouldTrackStatusRuntime({ + type: 'status-update', + kind: 'reasoning', + status: 'queued' + }), + true + ); +}); + test('websocket reconnect refresh skips drafts and restores real selected sessions', () => { assert.equal(shouldRefreshCurrentSessionAfterReconnect({ id: 'thread-1' }), true); assert.equal(shouldRefreshCurrentSessionAfterReconnect({ id: 'draft-project-1' }), false); diff --git a/client/src/app/turn-submission-utils.js b/client/src/app/turn-submission-utils.js index 9042a3e..79da2ac 100644 --- a/client/src/app/turn-submission-utils.js +++ b/client/src/app/turn-submission-utils.js @@ -69,6 +69,14 @@ export function userMessageMetadataForSendMode(sendMode = 'start') { : {}; } +export function shouldShowOptimisticSubmission(sendMode = 'start') { + return String(sendMode || 'start') !== 'queue'; +} + +export function isQueuedSendResult(result = {}) { + return Boolean(result?.queued || result?.delivery === 'queued'); +} + export const IMPLEMENT_PLAN_PROMPT_PREFIX = 'PLEASE IMPLEMENT THIS PLAN:'; export function implementationPromptForPlan(planContent) { @@ -115,6 +123,9 @@ export function restoredComposerText(current, nextText) { } export function shouldPollTurnEndpointAfterSend(result = {}) { + if (isQueuedSendResult(result)) { + return false; + } return !['desktop-ipc', 'headless-local'].includes(result?.desktopBridge?.mode); } diff --git a/client/src/app/useAppWebSocket.js b/client/src/app/useAppWebSocket.js index ce67b22..e05ae90 100644 --- a/client/src/app/useAppWebSocket.js +++ b/client/src/app/useAppWebSocket.js @@ -22,12 +22,22 @@ export function isDesktopThreadStatusPayload(payload = {}) { } export function shouldRenderStatusMessageForPayload(payload = {}) { + if (payload?.kind === 'turn' && payload?.status === 'queued') { + return false; + } if (isExternalThreadPayload(payload)) { return false; } return true; } +export function shouldTrackStatusRuntime(payload = {}) { + if (payload?.status === 'running') { + return true; + } + return payload?.status === 'queued' && payload?.kind !== 'turn'; +} + export function shouldRenderActivityMessageForPayload(payload = {}) { return !isExternalThreadPayload(payload); } @@ -263,7 +273,7 @@ export function useAppWebSocket({ return; } if (payload.type === 'status-update') { - if (payload.status === 'running' || payload.status === 'queued') { + if (shouldTrackStatusRuntime(payload)) { markRun(payload); } notifyFromPayload(payload); diff --git a/client/src/app/useTurnSubmission.js b/client/src/app/useTurnSubmission.js index ca13fe0..3cd8402 100644 --- a/client/src/app/useTurnSubmission.js +++ b/client/src/app/useTurnSubmission.js @@ -31,6 +31,7 @@ import { restoredComposerText, sessionForTurnSelection, selectedSkillsForPaths, + shouldShowOptimisticSubmission, shouldPollTurnEndpointAfterSend, turnMatchesSelection, userMessageMetadataForSendMode @@ -270,6 +271,7 @@ export function useTurnSubmission({ } const turnId = createClientTurnId(); + const showOptimisticSubmission = shouldShowOptimisticSubmission(sendMode); const draftSessionId = isDraftSession(sessionForTurn) ? sessionForTurn.id : null; const outgoingSessionId = draftSessionId ? null : sessionForTurn?.id || null; const optimisticSessionId = draftSessionId || outgoingSessionId || turnId; @@ -284,22 +286,24 @@ export function useTurnSubmission({ setFileMentions([]); } - markRun({ turnId, sessionId: optimisticSessionId, previousSessionId: draftSessionId || outgoingSessionId }); - const optimisticSessionPatch = { turnId, ...autoTitlePatch(initialTitle) }; - selectedSessionRef.current = { ...sessionForTurn, ...optimisticSessionPatch }; - setSelectedSession((current) => - current?.id === sessionForTurn?.id - ? { ...current, ...optimisticSessionPatch } - : current - ); - setSessionsByProject((current) => ({ - ...current, - [project.id]: (current[project.id] || []).map((item) => - item.id === sessionForTurn.id - ? { ...item, ...optimisticSessionPatch } - : item - ) - })); + if (showOptimisticSubmission) { + markRun({ turnId, sessionId: optimisticSessionId, previousSessionId: draftSessionId || outgoingSessionId }); + const optimisticSessionPatch = { turnId, ...autoTitlePatch(initialTitle) }; + selectedSessionRef.current = { ...sessionForTurn, ...optimisticSessionPatch }; + setSelectedSession((current) => + current?.id === sessionForTurn?.id + ? { ...current, ...optimisticSessionPatch } + : current + ); + setSessionsByProject((current) => ({ + ...current, + [project.id]: (current[project.id] || []).map((item) => + item.id === sessionForTurn.id + ? { ...item, ...optimisticSessionPatch } + : item + ) + })); + } const submittedAt = new Date().toISOString(); const localUserMessage = { id: `local-${Date.now()}`, @@ -310,18 +314,20 @@ export function useTurnSubmission({ sessionId: optimisticSessionId, turnId }; - setMessages((current) => { - const next = [...current, localUserMessage]; - if (!draftSessionId || outgoingSessionId) { - return next; - } - return upsertStatusMessage(next, localHandoffStatusPayload({ - sessionId: optimisticSessionId, - previousSessionId: draftSessionId, - turnId, - timestamp: submittedAt - })); - }); + if (showOptimisticSubmission) { + setMessages((current) => { + const next = [...current, localUserMessage]; + if (!draftSessionId || outgoingSessionId) { + return next; + } + return upsertStatusMessage(next, localHandoffStatusPayload({ + sessionId: optimisticSessionId, + previousSessionId: draftSessionId, + turnId, + timestamp: submittedAt + })); + }); + } try { const result = await apiFetch('/api/chat/send', { @@ -353,7 +359,8 @@ export function useTurnSubmission({ : resultBridgeMode === 'headless-local' ? 'headless-local' : null; - if (resultTurnId !== turnId || resultSessionId !== optimisticSessionId || resultRuntimeSource) { + const resultQueued = result.queued || result.delivery === 'queued'; + if (!resultQueued && (resultTurnId !== turnId || resultSessionId !== optimisticSessionId || resultRuntimeSource)) { markRun({ turnId: resultTurnId, sessionId: resultSessionId, diff --git a/client/src/turn-submission-utils.test.mjs b/client/src/turn-submission-utils.test.mjs index 17817fe..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 @@ -95,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. 补测试 '), @@ -150,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 From 19d85c0248bdae9dd4e4bb9f95ca7c0ae8c3a523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E7=8B=BC=E7=81=B0=E7=81=B0?= Date: Thu, 14 May 2026 18:13:29 +0800 Subject: [PATCH 5/9] Stabilize message refresh rendering --- client/src/app/useAppBootstrap.js | 19 ++++++++++++++++--- client/src/app/useSessionActions.js | 19 ++++++++++++++++--- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/client/src/app/useAppBootstrap.js b/client/src/app/useAppBootstrap.js index 631bb43..893b4c0 100644 --- a/client/src/app/useAppBootstrap.js +++ b/client/src/app/useAppBootstrap.js @@ -1,5 +1,7 @@ import { useCallback } from 'react'; import { apiFetch, clearToken } from '../api.js'; +import { messageStreamSignature } from '../chat/activity-model.js'; +import { mergeLiveSelectedThreadMessages } from '../session-live-refresh.js'; import { emptyContextStatus, isDraftSession, @@ -29,6 +31,17 @@ export function useAppBootstrap({ setSelectedProject, setExpandedProjectIds }) { + function mergeLoadedMessages(current, loaded, options = {}) { + const messages = Array.isArray(loaded) ? loaded : []; + if (!Array.isArray(current) || !current.length) { + return messages; + } + if (messageStreamSignature(current) === messageStreamSignature(messages)) { + return current; + } + return mergeLiveSelectedThreadMessages(current, messages, options); + } + const loadStatus = useCallback(async () => { const data = await apiFetch('/api/status'); setStatus(data); @@ -92,18 +105,18 @@ export function useAppBootstrap({ const plainMessagesPromise = apiFetch(sessionMessagesApiPath(next.id, { activity: false })); const cachedMessageData = await readCachedSessionMessages(next.id); if (cachedMessageData && selectedSessionRef.current?.id === next.id) { - setMessages(cachedMessageData.messages || []); + setMessages((current) => mergeLoadedMessages(current, cachedMessageData.messages || [], { preserveActivityState: true })); setContextStatus(normalizeContextStatus(cachedMessageData.context || next.context || defaultStatus.context, defaultStatus.context)); } const messageData = await plainMessagesPromise; if (selectedSessionRef.current?.id === next.id) { - setMessages(messageData.messages || []); + setMessages((current) => mergeLoadedMessages(current, messageData.messages || [], { preserveActivityState: true })); setContextStatus(normalizeContextStatus(messageData.context || next.context || defaultStatus.context, defaultStatus.context)); writeCachedSessionMessages(next.id, messageData); apiFetch(sessionMessagesApiPath(next.id)) .then((fullMessageData) => { if (selectedSessionRef.current?.id === next.id) { - setMessages(fullMessageData.messages || []); + setMessages((current) => mergeLoadedMessages(current, fullMessageData.messages || [])); setContextStatus(normalizeContextStatus(fullMessageData.context || next.context || defaultStatus.context, defaultStatus.context)); } }) diff --git a/client/src/app/useSessionActions.js b/client/src/app/useSessionActions.js index 2720c1e..bb5a8bb 100644 --- a/client/src/app/useSessionActions.js +++ b/client/src/app/useSessionActions.js @@ -1,4 +1,6 @@ import { apiFetch } from '../api.js'; +import { messageStreamSignature } from '../chat/activity-model.js'; +import { mergeLiveSelectedThreadMessages } from '../session-live-refresh.js'; import { desktopBridgeCanCreateThread } from '../send-state.js'; import { sessionTitleFromConversation } from '../../../shared/session-title.js'; import { normalizeContextStatus } from './context-status.js'; @@ -44,6 +46,17 @@ export function useSessionActions({ upsertSessionInProject, clearSessionCompleteNotice }) { + function mergeLoadedMessages(current, loaded, options = {}) { + const nextMessages = Array.isArray(loaded) ? loaded : []; + if (!Array.isArray(current) || !current.length) { + return nextMessages; + } + if (messageStreamSignature(current) === messageStreamSignature(nextMessages)) { + return current; + } + return mergeLiveSelectedThreadMessages(current, nextMessages, options); + } + async function handleToggleProject(project) { const isExpanded = Boolean(expandedProjectIds[project.id]); if (isExpanded) { @@ -93,20 +106,20 @@ export function useSessionActions({ const plainMessagesPromise = apiFetch(sessionMessagesApiPath(session.id, { activity: false })); const cachedData = await readCachedSessionMessages(session.id); if (cachedData && selectedSessionRef.current?.id === requestedSessionId) { - setMessages(cachedData.messages || []); + setMessages((current) => mergeLoadedMessages(current, cachedData.messages || [], { preserveActivityState: true })); setContextStatus(normalizeContextStatus(cachedData.context || session.context || defaultStatus.context, defaultStatus.context)); } const data = await plainMessagesPromise; if (selectedSessionRef.current?.id !== requestedSessionId) { return; } - setMessages(data.messages || []); + setMessages((current) => mergeLoadedMessages(current, data.messages || [], { preserveActivityState: true })); setContextStatus(normalizeContextStatus(data.context || session.context || defaultStatus.context, defaultStatus.context)); writeCachedSessionMessages(session.id, data); apiFetch(sessionMessagesApiPath(session.id)) .then((fullData) => { if (selectedSessionRef.current?.id === requestedSessionId) { - setMessages(fullData.messages || []); + setMessages((current) => mergeLoadedMessages(current, fullData.messages || [])); setContextStatus(normalizeContextStatus(fullData.context || session.context || defaultStatus.context, defaultStatus.context)); } }) From d31de808a8f50c257a16dc4f40ad6dd753d334fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E7=8B=BC=E7=81=B0=E7=81=B0?= Date: Thu, 14 May 2026 18:24:31 +0800 Subject: [PATCH 6/9] Surface latest desktop activity summaries --- server/session-message-reader.js | 59 ++++++++++++++++++++++++++ server/session-message-reader.test.mjs | 55 ++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/server/session-message-reader.js b/server/session-message-reader.js index 9c31375..fec8377 100644 --- a/server/session-message-reader.js +++ b/server/session-message-reader.js @@ -457,6 +457,64 @@ 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'}`; } @@ -566,6 +624,7 @@ export function createSessionMessageReader({ upsertDesktopActivity(messages, item.turnId, item.activity, item.segmentIndex); } sortDesktopActivitySteps(messages); + messages.splice(0, messages.length, ...addVisibleAgentActivitySummaries(messages)); } const orderedMessages = sortMessagesByConversationOrder(messages); diff --git a/server/session-message-reader.test.mjs b/server/session-message-reader.test.mjs index 4c5c1a4..7c8222d 100644 --- a/server/session-message-reader.test.mjs +++ b/server/session-message-reader.test.mjs @@ -427,6 +427,61 @@ test('session message reader keeps earlier turn activity before later user messa 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 { From d456d8be73c90e519a0f7132da765cdba0cea839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E7=8B=BC=E7=81=B0=E7=81=B0?= Date: Thu, 14 May 2026 18:29:37 +0800 Subject: [PATCH 7/9] Surface active desktop command status --- client/src/app-state.test.mjs | 25 +++++++++++++++++++++++++ client/src/app/session-utils.js | 13 +++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/client/src/app-state.test.mjs b/client/src/app-state.test.mjs index 7e1a9d2..890ff4e 100644 --- a/client/src/app-state.test.mjs +++ b/client/src/app-state.test.mjs @@ -4,6 +4,7 @@ import { appReducer, createInitialUiState } from './app/AppState.js'; import { applyPwaTheme } from './app/pwa-theme.js'; import { createDraftSession, + buildComposerRunStatus, externalThreadRuntimeById, localFileApiPath, localFilePreviewPath, @@ -203,6 +204,30 @@ test('selected desktop activity counts as running for composer controls', () => }), false); }); +test('composer run status surfaces the active desktop command', () => { + const status = buildComposerRunStatus([ + { + id: 'activity-running-command', + role: 'activity', + status: 'running', + label: '正在处理本地任务', + startedAt: '2026-05-14T06:00:00.000Z', + activities: [ + { + kind: 'command_execution', + status: 'running', + label: '正在处理本地任务', + command: 'for i in 1 2 3 4 5 6 7 8; do sleep 5; curl -s http://example.test/api/sim; done' + } + ] + } + ], true, new Date('2026-05-14T06:00:05.000Z').getTime()); + + assert.equal(status.running, true); + assert.match(status.label, /^正在运行 for i in 1 2 3 4 5 6 7 8;/); + assert.notEqual(status.label, '正在处理本地任务'); +}); + test('selected running activity marks the matching sidebar session as running', () => { const runningById = runningByIdWithSelectedActivity( {}, diff --git a/client/src/app/session-utils.js b/client/src/app/session-utils.js index 8c5bf33..82183ab 100644 --- a/client/src/app/session-utils.js +++ b/client/src/app/session-utils.js @@ -540,6 +540,15 @@ function isVisibleComposerActivityStep(step, messageStatus) { return true; } +function composerActivityCommandLabel(step) { + const command = String(step?.command || step?.detail || '').replace(/\s+/g, ' ').trim(); + if (command) { + return `正在运行 ${command}`; + } + const label = String(step?.label || '').trim(); + return label && !isGenericComposerActivityLabel(label) ? label : '运行命令'; +} + function describeComposerActivityStep(step) { const label = String(step?.label || '').trim(); const detail = String(step?.detail || step?.command || step?.toolName || '').trim(); @@ -548,7 +557,7 @@ function describeComposerActivityStep(step) { return { type: 'edit', label: compactComposerActivityText(label || '编辑文件') }; } if (step?.kind === 'command_execution' || /命令|shell|执行|npm|node|git|rg|sed|cat/.test(source)) { - return { type: 'command', label: compactComposerActivityText(label || '运行命令') }; + return { type: 'command', label: compactComposerActivityText(composerActivityCommandLabel(step), 96) }; } if (step?.kind === 'web_search' || /web_search|网页搜索|搜索网页/.test(source)) { return { type: 'web_search', label: compactComposerActivityText(label || '网页搜索') }; @@ -605,7 +614,7 @@ export function buildComposerRunStatus(messages, running, now = Date.now()) { } return { - label: compactComposerActivityText(label) || '正在处理', + label: compactComposerActivityText(label, 96) || '正在处理', duration, running: true }; From dc4911da04e4f90d02b877ebe4fb6ff12cc752f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E7=8B=BC=E7=81=B0=E7=81=B0?= Date: Thu, 14 May 2026 18:42:46 +0800 Subject: [PATCH 8/9] Cache full activity session messages --- client/src/app/message-cache.js | 43 ++++++++++++++++++------- client/src/app/message-cache.test.mjs | 24 ++++++++++++-- client/src/app/useAppBootstrap.js | 5 +-- client/src/app/useSessionActions.js | 5 +-- client/src/app/useSessionLivePolling.js | 2 ++ client/src/app/useTurnRuntime.js | 2 ++ client/src/app/useTurnSubmission.js | 2 ++ 7 files changed, 65 insertions(+), 18 deletions(-) diff --git a/client/src/app/message-cache.js b/client/src/app/message-cache.js index d3e1085..415ffd8 100644 --- a/client/src/app/message-cache.js +++ b/client/src/app/message-cache.js @@ -4,18 +4,28 @@ const STORE_NAME = 'sessionMessages'; let dbPromise = null; -export function sessionMessageCacheKey(sessionId) { +export function sessionMessageCacheKey(sessionId, { activity = true } = {}) { + const id = String(sessionId || ''); + if (!id) { + return ''; + } + return `${id}:${activity ? 'activity' : 'plain'}`; +} + +function legacySessionMessageCacheKey(sessionId) { return String(sessionId || ''); } -export function createSessionMessageCacheRecord(sessionId, payload) { - const key = sessionMessageCacheKey(sessionId); +export function createSessionMessageCacheRecord(sessionId, payload, options = {}) { + const activity = options.activity !== false; + const key = sessionMessageCacheKey(sessionId, { activity }); const revision = typeof payload?.revision === 'string' ? payload.revision : ''; if (!key || !revision || !Array.isArray(payload?.messages)) { return null; } return { key, + activity, revision, messages: payload.messages, context: payload.context || null, @@ -23,13 +33,16 @@ export function createSessionMessageCacheRecord(sessionId, payload) { }; } -export function normalizeSessionMessageCacheRecord(sessionId, record) { - const key = sessionMessageCacheKey(sessionId); - if (!record || record.key !== key || !record.revision || !Array.isArray(record.messages)) { +export function normalizeSessionMessageCacheRecord(sessionId, record, options = {}) { + const activity = options.activity !== false; + const key = sessionMessageCacheKey(sessionId, { activity }); + const allowLegacyPlain = activity === false && record?.key === legacySessionMessageCacheKey(sessionId); + if (!record || (record.key !== key && !allowLegacyPlain) || !record.revision || !Array.isArray(record.messages)) { return null; } return { - key, + key: record.key, + activity: record.activity ?? activity, revision: record.revision, messages: record.messages, context: record.context || null, @@ -66,22 +79,28 @@ function requestToPromise(request) { }); } -export async function readCachedSessionMessages(sessionId) { +export async function readCachedSessionMessages(sessionId, options = {}) { try { const db = await openDb(); if (!db) { return null; } const tx = db.transaction(STORE_NAME, 'readonly'); - const record = await requestToPromise(tx.objectStore(STORE_NAME).get(sessionMessageCacheKey(sessionId))); - return normalizeSessionMessageCacheRecord(sessionId, record); + const store = tx.objectStore(STORE_NAME); + const record = await requestToPromise(store.get(sessionMessageCacheKey(sessionId, options))); + const normalized = normalizeSessionMessageCacheRecord(sessionId, record, options); + if (normalized || options.activity !== false) { + return normalized; + } + const legacyRecord = await requestToPromise(store.get(legacySessionMessageCacheKey(sessionId))); + return normalizeSessionMessageCacheRecord(sessionId, legacyRecord, options); } catch { return null; } } -export async function writeCachedSessionMessages(sessionId, payload) { - const record = createSessionMessageCacheRecord(sessionId, payload); +export async function writeCachedSessionMessages(sessionId, payload, options = {}) { + const record = createSessionMessageCacheRecord(sessionId, payload, options); if (!record) { return false; } diff --git a/client/src/app/message-cache.test.mjs b/client/src/app/message-cache.test.mjs index 848be05..e4e2c4a 100644 --- a/client/src/app/message-cache.test.mjs +++ b/client/src/app/message-cache.test.mjs @@ -13,13 +13,32 @@ test('session message cache records only cache versioned pure message payloads', context: { inputTokens: 12 } }); - assert.equal(record.key, 'session-1'); + assert.equal(record.key, 'session-1:activity'); + assert.equal(record.activity, true); assert.equal(record.revision, 'rollout.jsonl:12:1778202000000'); assert.deepEqual(record.messages.map((message) => message.id), ['m1']); assert.equal(record.context.inputTokens, 12); assert.equal(typeof record.savedAt, 'number'); }); +test('session message cache keeps plain and activity payloads separate', () => { + const plain = createSessionMessageCacheRecord('session-1', { + revision: 'rollout.jsonl:12:1778202000000', + messages: [{ id: 'plain', role: 'assistant', content: 'final only' }] + }, { activity: false }); + const activity = createSessionMessageCacheRecord('session-1', { + revision: 'rollout.jsonl:12:1778202000000', + messages: [{ id: 'activity', role: 'activity', content: '过程已同步' }] + }, { activity: true }); + + assert.equal(plain.key, 'session-1:plain'); + assert.equal(plain.activity, false); + assert.equal(activity.key, 'session-1:activity'); + assert.equal(activity.activity, true); + assert.equal(normalizeSessionMessageCacheRecord('session-1', plain, { activity: true }), null); + assert.equal(normalizeSessionMessageCacheRecord('session-1', activity, { activity: false }), null); +}); + test('session message cache rejects unversioned or mismatched records', () => { assert.equal(createSessionMessageCacheRecord('session-1', { messages: [] }), null); assert.equal(createSessionMessageCacheRecord('', { revision: 'r1', messages: [] }), null); @@ -33,5 +52,6 @@ test('session message cache rejects unversioned or mismatched records', () => { }); test('session message cache keys are stable and session-scoped', () => { - assert.equal(sessionMessageCacheKey('session/1'), 'session/1'); + assert.equal(sessionMessageCacheKey('session/1'), 'session/1:activity'); + assert.equal(sessionMessageCacheKey('session/1', { activity: false }), 'session/1:plain'); }); diff --git a/client/src/app/useAppBootstrap.js b/client/src/app/useAppBootstrap.js index 893b4c0..edac266 100644 --- a/client/src/app/useAppBootstrap.js +++ b/client/src/app/useAppBootstrap.js @@ -103,7 +103,7 @@ export function useAppBootstrap({ setSelectedSession((current) => (current?.id === next.id ? { ...current, ...next } : next)); setContextStatus(normalizeContextStatus(next.context || defaultStatus.context, defaultStatus.context)); const plainMessagesPromise = apiFetch(sessionMessagesApiPath(next.id, { activity: false })); - const cachedMessageData = await readCachedSessionMessages(next.id); + const cachedMessageData = await readCachedSessionMessages(next.id, { activity: true }); if (cachedMessageData && selectedSessionRef.current?.id === next.id) { setMessages((current) => mergeLoadedMessages(current, cachedMessageData.messages || [], { preserveActivityState: true })); setContextStatus(normalizeContextStatus(cachedMessageData.context || next.context || defaultStatus.context, defaultStatus.context)); @@ -112,12 +112,13 @@ export function useAppBootstrap({ if (selectedSessionRef.current?.id === next.id) { setMessages((current) => mergeLoadedMessages(current, messageData.messages || [], { preserveActivityState: true })); setContextStatus(normalizeContextStatus(messageData.context || next.context || defaultStatus.context, defaultStatus.context)); - writeCachedSessionMessages(next.id, messageData); + writeCachedSessionMessages(next.id, messageData, { activity: false }); apiFetch(sessionMessagesApiPath(next.id)) .then((fullMessageData) => { if (selectedSessionRef.current?.id === next.id) { setMessages((current) => mergeLoadedMessages(current, fullMessageData.messages || [])); setContextStatus(normalizeContextStatus(fullMessageData.context || next.context || defaultStatus.context, defaultStatus.context)); + writeCachedSessionMessages(next.id, fullMessageData, { activity: true }); } }) .catch(() => null); diff --git a/client/src/app/useSessionActions.js b/client/src/app/useSessionActions.js index bb5a8bb..7d170aa 100644 --- a/client/src/app/useSessionActions.js +++ b/client/src/app/useSessionActions.js @@ -104,7 +104,7 @@ export function useSessionActions({ setDrawerOpen(false); try { const plainMessagesPromise = apiFetch(sessionMessagesApiPath(session.id, { activity: false })); - const cachedData = await readCachedSessionMessages(session.id); + const cachedData = await readCachedSessionMessages(session.id, { activity: true }); if (cachedData && selectedSessionRef.current?.id === requestedSessionId) { setMessages((current) => mergeLoadedMessages(current, cachedData.messages || [], { preserveActivityState: true })); setContextStatus(normalizeContextStatus(cachedData.context || session.context || defaultStatus.context, defaultStatus.context)); @@ -115,12 +115,13 @@ export function useSessionActions({ } setMessages((current) => mergeLoadedMessages(current, data.messages || [], { preserveActivityState: true })); setContextStatus(normalizeContextStatus(data.context || session.context || defaultStatus.context, defaultStatus.context)); - writeCachedSessionMessages(session.id, data); + writeCachedSessionMessages(session.id, data, { activity: false }); apiFetch(sessionMessagesApiPath(session.id)) .then((fullData) => { if (selectedSessionRef.current?.id === requestedSessionId) { setMessages((current) => mergeLoadedMessages(current, fullData.messages || [])); setContextStatus(normalizeContextStatus(fullData.context || session.context || defaultStatus.context, defaultStatus.context)); + writeCachedSessionMessages(session.id, fullData, { activity: true }); } }) .catch(() => null); diff --git a/client/src/app/useSessionLivePolling.js b/client/src/app/useSessionLivePolling.js index 56d61fb..1ab8e51 100644 --- a/client/src/app/useSessionLivePolling.js +++ b/client/src/app/useSessionLivePolling.js @@ -9,6 +9,7 @@ import { syncDesktopActivityRuntimeFromMessages } from '../session-live-refresh.js'; import { mergeContextStatus } from './context-status.js'; +import { writeCachedSessionMessages } from './message-cache.js'; import { hasRunningKey, isDraftSession, @@ -88,6 +89,7 @@ export function useSessionLivePolling({ ? current : mergeLiveSelectedThreadMessages(current, data.messages, { preserveActivityState: !pollOptions.activity }) ); + writeCachedSessionMessages(sessionId, data, { activity: Boolean(pollOptions.activity) }); } } catch { // Keep the currently rendered conversation if a transient poll fails. diff --git a/client/src/app/useTurnRuntime.js b/client/src/app/useTurnRuntime.js index 090f095..92eb000 100644 --- a/client/src/app/useTurnRuntime.js +++ b/client/src/app/useTurnRuntime.js @@ -7,6 +7,7 @@ import { upsertStatusMessage } from '../chat/activity-model.js'; import { mergeContextStatus } from './context-status.js'; +import { writeCachedSessionMessages } from './message-cache.js'; import { externalThreadRuntimeById, hasVisibleAssistantForTurn, @@ -276,6 +277,7 @@ export function useTurnRuntime({ if (data.messages?.length && hasVisibleAssistantForTurn(data.messages, payload)) { setContextStatus((current) => mergeContextStatus(current, data.context || defaultStatus.context, defaultStatus.context)); setMessages((current) => mergeLoadedMessagesPreservingActivity(current, data.messages, payload)); + writeCachedSessionMessages(payload.sessionId, data, { activity: true }); return true; } } catch { diff --git a/client/src/app/useTurnSubmission.js b/client/src/app/useTurnSubmission.js index 3cd8402..d104796 100644 --- a/client/src/app/useTurnSubmission.js +++ b/client/src/app/useTurnSubmission.js @@ -7,6 +7,7 @@ import { upsertStatusMessage } from '../chat/activity-model.js'; import { mergeContextStatus } from './context-status.js'; +import { writeCachedSessionMessages } from './message-cache.js'; import { autoTitlePatch, createClientTurnId, @@ -143,6 +144,7 @@ export function useTurnSubmission({ turnId }) ); + writeCachedSessionMessages(realSessionId, data, { activity: true }); return true; } return false; From c1cc17304e08b543f2164753a4f21a86b39244fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=81=E7=8B=BC=E7=81=B0=E7=81=B0?= Date: Thu, 14 May 2026 18:47:16 +0800 Subject: [PATCH 9/9] Invalidate stale mobile message cache --- client/src/app/message-cache.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/client/src/app/message-cache.js b/client/src/app/message-cache.js index 415ffd8..f026b85 100644 --- a/client/src/app/message-cache.js +++ b/client/src/app/message-cache.js @@ -1,4 +1,4 @@ -const DB_NAME = 'codexmobile-message-cache'; +const DB_NAME = 'codexmobile-message-cache-v2'; const DB_VERSION = 1; const STORE_NAME = 'sessionMessages'; @@ -124,7 +124,12 @@ export async function deleteCachedSessionMessages(sessionId) { return false; } const tx = db.transaction(STORE_NAME, 'readwrite'); - await requestToPromise(tx.objectStore(STORE_NAME).delete(sessionMessageCacheKey(sessionId))); + const store = tx.objectStore(STORE_NAME); + await Promise.all([ + requestToPromise(store.delete(sessionMessageCacheKey(sessionId, { activity: true }))), + requestToPromise(store.delete(sessionMessageCacheKey(sessionId, { activity: false }))), + requestToPromise(store.delete(legacySessionMessageCacheKey(sessionId))) + ]); return true; } catch { return false;