diff --git a/apps/mobile/app/sessions/[sessionId].tsx b/apps/mobile/app/sessions/[sessionId].tsx index e2787165898..c164bb3e140 100644 --- a/apps/mobile/app/sessions/[sessionId].tsx +++ b/apps/mobile/app/sessions/[sessionId].tsx @@ -45,7 +45,7 @@ import { mobileDebugLog } from '@/debug/mobileDebugLog'; import { getMobileAuthOwner, isMobileAuthOwnerCurrent, subscribeMobileAuthOwner } from '@/auth/authOwnerGeneration'; import { mobileDurableOutbox, durableOutboxDisplayItem, getCurrentMobileOutboxRecords, reconcileMobileOutboxDrafts } from '@/session/mobileDurableOutbox'; import { retainOutboxFile, durableOutboxUploadUri, removeOutboxFiles, outboxAttachmentNeedsLocalBytes } from '@/session/durableOutboxFiles'; -import { isDurableOutboxSettled, isDurableOutboxUnsent, observeDurableOutboxSending, type DurableOutboxRecord } from '@/session/durableOutbox'; +import { isDurableOutboxSettled, observeDurableOutboxSending, type DurableOutboxRecord } from '@/session/durableOutbox'; import { isInFlightDeviceLinkError, isSharedTaskPeer, resolveRemoteText } from '@cindy/device-link'; import { takeRefinementContextTail, truncateRefinementReply } from '@cindy/voice-input-core'; import { @@ -446,7 +446,7 @@ import { reconcileOptimisticUserMessages, type OptimisticUserMessage, } from '@/session/optimisticUserMessages'; -import type { PendingSendBubbleActions } from '@/session/PendingSendBubble'; +import { usePendingOutboxActions, usePendingSendActions } from '@/session/usePendingSendActions'; import { acquireQueueEditLock, commitQueueEdit, @@ -672,7 +672,6 @@ import { sessionMetaWriteQueue, sessionPendingWrites, type RemoteSessionRunStatus, - useRemoteSessions, useRemoteSessionDetail, useSessionGoalStatus, useSessionInputProjection, @@ -1177,7 +1176,6 @@ function SessionScreenContent({ beforeSessionSwitchRef, newSessionActionRef }: S useEffect(() => { if (messageScreenFocusedRef.current && messageAppActiveRef.current) historyView.view.setActive(true); }, [connectionEpoch, messageReloadRevision, historyView.view]); - const sessions = useRemoteSessions(); const inheritedMessages = useSessionMessages(sessionId, deviceId); const rawMessages = useMemo(() => sideChatBoundary === undefined ? inheritedMessages : inheritedMessages.filter(message => Date.parse(message.createdAt) >= sideChatBoundary), [inheritedMessages, sideChatBoundary]); @@ -6069,55 +6067,10 @@ function SessionScreenContent({ beforeSessionSwitchRef, newSessionActionRef }: S return outboxConnectionBlockedNow() || isRemoteSessionMissing(row) || row?.cacheSeeded === true || getNewSessionCreationTask(sessionId) !== null; }; - const findDurableOutboxRecord = (clientId: string) => getCurrentMobileOutboxRecords().find((r) => - r.deviceId === deviceId && r.item.sessionId === sessionId && r.item.clientId === clientId); - const retryOutboxItem = (clientId: string) => { - const record = findDurableOutboxRecord(clientId); - if (!record || record.state !== 'failed') return; - if (record.creation && !record.prepared && !record.template) { - const owner = getMobileAuthOwner(); - void mobileDurableOutbox.update(record, { suspended: true }).then(() => { - if (isMobileAuthOwnerCurrent(owner) && outboxSessionAliveRef.current === sessionId) { - router.push({ pathname: '/sessions/new', params: { recoverySessionId: sessionId, deviceId, deviceName } }); - } - }).catch((err) => setError(formatRemoteError(err))); - return; - } - setQueueSelectedClientId(null); - void mobileDurableOutbox.update(record, { - state: record.prepared ? 'confirming' : 'queued', error: undefined, - }).catch((err) => setError(formatRemoteError(err))); - }; - const removeOutboxItem = (clientId: string) => { - const record = findDurableOutboxRecord(clientId); - if (!record) return; - // Until creation succeeds, this row also owns the recovery form for every later message. - if (record.creation && !record.prepared && !record.template) { - setError(t('session.outbox.creationRecoveryRequired')); - return; - } - // 删除发送失败的消息不可撤销:先确认(破坏性按钮),确认后才真正移除。 - const confirmRemove = () => { - setQueueSelectedClientId(null); - const owner = getMobileAuthOwner(); - const remove = async () => { - if (isDurableOutboxUnsent(record)) { - await mobileDurableOutbox.update(record, { state: 'host-owned', cleanupOutcome: 'cancelled', cancelRequested: true, error: undefined }); - } else await mobileDurableOutbox.update(record, { cancelRequested: true, state: 'confirming' }); - }; - void remove().catch((err) => { - if (isMobileAuthOwnerCurrent(owner) && outboxSessionAliveRef.current === sessionId) setError(formatRemoteError(err)); - }); - }; - Alert.alert( - t('session.outbox.deleteConfirmTitle'), - t('session.outbox.deleteConfirmBody'), - [ - { text: t('session.common.cancel'), style: 'cancel' }, - { text: t('session.common.delete'), style: 'destructive', onPress: confirmRemove }, - ], - ); - }; + const { findDurableOutboxRecord, retryOutboxItem, removeOutboxItem } = usePendingOutboxActions({ + deviceId, deviceName, sessionId, router, outboxSessionAliveRef, + setError, setQueueSelectedClientId, t, + }); const outboxDisplayItems = useMemo(() => outboxItems.map((item) => { const record = findDurableOutboxRecord(item.clientId); return { @@ -7744,11 +7697,20 @@ function SessionScreenContent({ beforeSessionSwitchRef, newSessionActionRef }: S }; }; + // Shared by queue receipts and file actions; declared before memoized consumers. + const [transientNotice, setTransientNotice] = useState(null); + const transientNoticeSeqRef = useRef(0); + const showTransientNotice = useCallback((message: string) => { + transientNoticeSeqRef.current += 1; + setTransientNotice({ id: transientNoticeSeqRef.current, message }); + }, []); + const dismissTransientNotice = useCallback(() => setTransientNotice(null), []); + const resumeQueue = () => { void runQueueAction(() => maker.input.resume(sessionId)); }; - const steerQueueItem = (item: QueuedRemoteMessage) => { + const steerQueueItem = useCallback((item: QueuedRemoteMessage) => { void runQueueAction(async () => { // Projections omit trusted bodies. Prefer fresh source data, while allowing the // target to restore its main-owned snapshot when the source is unavailable. @@ -7776,7 +7738,7 @@ function SessionScreenContent({ beforeSessionSwitchRef, newSessionActionRef }: S } return accepted; }); - }; + }, [deviceId, invoke, maker, runQueueAction, sessionId, showTransientNotice, t]); const moveQueueItem = useCallback((clientId: string, direction: 'up' | 'down') => { // 菜单打开期间队列可能变化:按最新位置重新判断方向和目标。 @@ -7809,7 +7771,7 @@ function SessionScreenContent({ beforeSessionSwitchRef, newSessionActionRef }: S }); }, [applyProjectionIfCurrent, maker, sessionId]); - const removeQueueItem = (clientId: string) => { + const removeQueueItem = useCallback((clientId: string) => { const before = remoteSessionStore.getInputProjection(sessionId); const index = before.pendingQueue.findIndex((item) => item.clientId === clientId); const removed = index >= 0 ? before.pendingQueue[index] : undefined; @@ -7843,7 +7805,7 @@ function SessionScreenContent({ beforeSessionSwitchRef, newSessionActionRef }: S }, action: () => maker.input.remove(sessionId, clientId), }); - }; + }, [maker, runOptimisticQueueAction, runQueueAction, sessionId]); /** * 回收某次排队编辑期间"新增"的附件(不在 stash、也不是该队列条目自身 files): @@ -7901,7 +7863,7 @@ function SessionScreenContent({ beforeSessionSwitchRef, newSessionActionRef }: S * 已在编辑另一条时切换目标:沿用最初的 stash(用户真正的草稿),旧条目解锁, * 且旧条目编辑期间新增的附件先回收再覆写托盘(否则成为 OSS 孤儿,review P2)。 */ - const beginQueueEdit = (item: QueuedRemoteMessage) => { + const beginQueueEdit = useCallback((item: QueuedRemoteMessage) => { if (queueAvailabilityReason || queueBusy) return; // 上一条的保存(update-content)在途时不允许进入/切换编辑:切换路径会立即解锁 // 旧条目并回收其编辑期附件,与在途 RPC 竞争——桌面端可能用旧内容抢先派发,或 @@ -7953,36 +7915,14 @@ function SessionScreenContent({ beforeSessionSwitchRef, newSessionActionRef }: S setQueueEditLock, ); composerInputRef.current?.applyDocumentAndSetSelectionToEnd(textState.document); - }; + }, [applyComposerDocument, discardQueueEditTransientAttachments, pendingUploads.length, + queueAvailabilityReason, queueBusy, setQueueEditLock, t]); - /** 放弃排队消息编辑:解锁 + 回收编辑期新增附件 + 恢复进入前的草稿与附件托盘。 */ - const pendingSendActions = useMemo( - () => ({ - busy: queueBusy, - selectedClientId: queueSelectedClientId, - onSelect: setQueueSelectedClientId, - onRemove: (clientId: string) => { - setQueueSelectedClientId(null); - removeQueueItem(clientId); - }, - onBeginEdit: (clientId: string) => { - const target = remoteSessionStore.getInputProjection(sessionId).pendingQueue - .find((entry) => entry.clientId === clientId); - if (target) beginQueueEdit(target); - }, - onSteer: (clientId: string) => { - const target = remoteSessionStore.getInputProjection(sessionId).pendingQueue - .find((entry) => entry.clientId === clientId); - if (!target) return; - setQueueSelectedClientId(null); - steerQueueItem(target); - }, - onMove: moveQueueItem, - onRetryOutbox: retryOutboxItem, - onRemoveOutbox: removeOutboxItem, - }), - [beginQueueEdit, moveQueueItem, queueBusy, queueSelectedClientId, removeQueueItem, removeOutboxItem, retryOutboxItem, sessionId, steerQueueItem], - ); + const pendingSendActions = usePendingSendActions({ + beginQueueEdit, moveQueueItem, queueBusy, queueSelectedClientId, + removeQueueItem, removeOutboxItem, retryOutboxItem, sessionId, + setQueueSelectedClientId, steerQueueItem, + }); // ⚠️ 临时取证(定位「气泡凭空消失」,定位完成后删):会话页实例身份 + 挂载/卸载。 // settlingDiag 只在两次「首帧」打出 prev=0,之后再没跑过 —— 强烈指向组件被换了实例 @@ -9125,14 +9065,6 @@ function SessionScreenContent({ beforeSessionSwitchRef, newSessionActionRef }: S // chip 长按菜单(浮动面板,ContextSheet/模型选择面板同款):target 即开关。 const [chipMenuTarget, setChipMenuTarget] = useState(null); - // 会话页的短暂提示(复制路径等),与文件浏览器「已复制路径」同口径。 - const [transientNotice, setTransientNotice] = useState(null); - const transientNoticeSeqRef = useRef(0); - const showTransientNotice = useCallback((message: string) => { - transientNoticeSeqRef.current += 1; - setTransientNotice({ id: transientNoticeSeqRef.current, message }); - }, []); - const dismissTransientNotice = useCallback(() => setTransientNotice(null), []); const [chipShareBusy, setChipShareBusy] = useState(false); // 聊天正文文件 chip 上下文(消息树内 inline chip 经 context 消费,不走多层 prop): diff --git a/apps/mobile/babel-transformer.js b/apps/mobile/babel-transformer.js new file mode 100644 index 00000000000..1769f1ef5c2 --- /dev/null +++ b/apps/mobile/babel-transformer.js @@ -0,0 +1,23 @@ +const expoTransformer = require('@expo/metro-config/babel-transformer'); + +module.exports = { + ...expoTransformer, + transform(input) { + // Expo preserves the parent NODE_ENV/BABEL_ENV even for --dev false. + // Worklets reads those variables and otherwise emits Error stacks in a + // Release bundle. Use the same mode as Metro's transform/cache request. + const previousNodeEnv = process.env.NODE_ENV; + const previousBabelEnv = process.env.BABEL_ENV; + const mode = input.options.dev ? 'development' : 'production'; + process.env.NODE_ENV = mode; + process.env.BABEL_ENV = mode; + try { + return expoTransformer.transform(input); + } finally { + if (previousNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = previousNodeEnv; + if (previousBabelEnv === undefined) delete process.env.BABEL_ENV; + else process.env.BABEL_ENV = previousBabelEnv; + } + }, +}; diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index 04e6afdefb9..86fbaadc724 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -2,6 +2,7 @@ const path = require('path'); const { getDefaultConfig } = require('expo/metro-config'); const config = getDefaultConfig(__dirname); +config.transformer.babelTransformerPath = require.resolve('./babel-transformer'); const workspaceRoot = path.resolve(__dirname, '../..'); // Shared endpoint discovery bundles the public manifests from the repository config directory. config.watchFolders = [...config.watchFolders, path.join(workspaceRoot, 'config')]; diff --git a/apps/mobile/src/__tests__/PendingSendBubble.touch.test.tsx b/apps/mobile/src/__tests__/PendingSendBubble.touch.test.tsx index 9630c70a199..147c5747089 100644 --- a/apps/mobile/src/__tests__/PendingSendBubble.touch.test.tsx +++ b/apps/mobile/src/__tests__/PendingSendBubble.touch.test.tsx @@ -9,6 +9,16 @@ import { MessageBodyText } from '@/session/ShareMessageCheckbox'; import { buildQueueRowPresentation, EMPTY_INPUT_PROJECTION } from '@/session/inputProjection'; import type { MobilePendingSendItem } from '@/session/pendingSendItems'; import { collectMobileMessageGalleryImages, lightboxImagesForPayload } from '@/session/messageGallery'; +import { usePendingOutboxActions, usePendingSendActions } from '@/session/usePendingSendActions'; +import { remoteSessionStore } from '@/session/remoteSessionStore'; +import type { DurableOutboxRecord } from '@/session/durableOutbox'; +import type { TFunction } from 'i18next'; + +const outbox = vi.hoisted(() => ({ records: [] as DurableOutboxRecord[], update: vi.fn().mockResolvedValue(undefined) })); +vi.mock('@/session/mobileDurableOutbox', () => ({ + getCurrentMobileOutboxRecords: () => outbox.records, + mobileDurableOutbox: { update: outbox.update }, +})); const { handlers, frames, durableThumbs } = vi.hoisted(() => ({ handlers: new Map>(), @@ -154,6 +164,66 @@ it('lets a real body link consume the pending tap before the frame commits', () act(() => host.querySelector('[data-testid="link"]')!.click()); flush(); expect(openLink).toHaveBeenCalledOnce(); expect(select).not.toHaveBeenCalled(); }); + +// #7083 unit regression: the production hooks feed the real bubble. Existing +// touch tests inject static callbacks, so they cannot catch callback churn or +// a retry/edit routed to the previous task after a task switch. Minimal input: +// two tasks with the same client ID, plus an unrelated parent render. +it('keeps pending actions stable and routes bubble actions to the current task', async () => { + outbox.records = ['task-a', 'task-b'].map((sessionId): DurableOutboxRecord => ({ + version: 1, accountId: 'test', deviceId: 'pc', createdAt: 1, state: 'failed', uploads: [], + item: { clientId: 'a', sessionId, text: sessionId, quotesEncoded: false, + agentReferences: [], pastedTextRanges: [], slashCommandRanges: [], permissionModeAtSend: 'plan', + attachmentSlots: [], slotMeta: [], slotByLocalId: {}, waitingIds: [], failedIds: [], + enqueueError: null, phase: 'uploading' }, + })); + const bindings = { + deviceId: 'pc', deviceName: 'Computer', router: { push: vi.fn() }, + outboxSessionAliveRef: { current: 'task-a' }, setError: vi.fn(), + setQueueSelectedClientId: select, t: ((key: string) => key) as TFunction, + }; + const renderText = vi.fn(() => pending message); + const renderMedia = () => null; + const failedItem: MobilePendingSendItem = { ...item, phase: 'failed', actions: null }; + let actions!: PendingSendBubbleActions; + function Screen({ sessionId, queueBusy = false, queued = false }: { + sessionId: string; queueBusy?: boolean; queued?: boolean; + }) { + const { retryOutboxItem, removeOutboxItem } = usePendingOutboxActions({ ...bindings, sessionId }); + actions = usePendingSendActions({ + retryOutboxItem, removeOutboxItem, sessionId, queueBusy, queueSelectedClientId: null, + setQueueSelectedClientId: select, beginQueueEdit: edit, steerQueueItem: steer, + removeQueueItem: remove, moveQueueItem: move, + }); + return ; + } + const showScreen = (sessionId = 'task-a', queueBusy = false, queued = false) => + act(() => root.render()); + try { + showScreen(); + const initial = actions; + for (let n = 0; n < 30; n++) showScreen(); + expect(actions).toBe(initial); + await act(async () => host.querySelector('[data-testid="pendingSend.outboxRetry.a"]')!.click()); + expect(outbox.update).toHaveBeenLastCalledWith(outbox.records[0], expect.objectContaining({ state: 'queued' })); + showScreen('task-b'); + await act(async () => host.querySelector('[data-testid="pendingSend.outboxRetry.a"]')!.click()); + expect(outbox.update).toHaveBeenLastCalledWith(outbox.records[1], expect.objectContaining({ state: 'queued' })); + const queued = { clientId: 'a', text: 'new task message', persistedContent: 'new task message', + model: 'model', workingDir: '/synthetic', createOpts: {}, chatMessage: { role: 'user' as const } }; + remoteSessionStore.setInputProjection('task-b', { ...EMPTY_INPUT_PROJECTION, pendingQueue: [queued] }); + showScreen('task-b', false, true); + const menu = handlers.get('pendingSend.queueMenu.a')!; + act(() => (menu.onPressAction as (e: { nativeEvent: { event: string } }) => void)({ nativeEvent: { event: 'edit' } })); + expect(edit).toHaveBeenLastCalledWith(queued); + showScreen('task-b', true, true); + expect(actions.busy).toBe(true); + } finally { + outbox.records = []; + remoteSessionStore.clear(); + } +}); it.each(['queued', 'uploading', 'sending', 'settling', 'editing', 'failed'] as const)( 'previews a %s image without changing queue selection or cancelling the send', (phase) => { show({ phase, thumbs: [{ key: 'a-slot-0', uri: 'file:///photo.jpg', ossRef: null, diff --git a/apps/mobile/src/__tests__/homeAutomationWindow.test.tsx b/apps/mobile/src/__tests__/homeAutomationWindow.test.tsx index 3f18c69c730..5dfac722bd0 100644 --- a/apps/mobile/src/__tests__/homeAutomationWindow.test.tsx +++ b/apps/mobile/src/__tests__/homeAutomationWindow.test.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; import { resolve } from 'node:path'; import * as jsxRuntime from 'react/jsx-runtime'; import { act, Children, createContext, forwardRef, Fragment, isValidElement, useContext, useRef, useState, type ReactElement, type ReactNode } from 'react'; @@ -20,6 +21,59 @@ const compiled = ts.transpileModule(declaration.getText(source), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX }, }).outputText; +// Unit regression for #7083: the native drag callback must transfer only its +// small payload, not the project's task/history tree. Existing window tests +// count mounted rows but never exercise Babel's actual worklet closure. +it('keeps task data out of the project drag worklet while preserving drag actions', () => { + const projectRow = source.statements.find((node): node is ts.FunctionDeclaration => ts.isFunctionDeclaration(node) + && node.name?.text === 'ProjectRow')!; + let gesture!: ts.VariableDeclaration; + function visit(node: ts.Node) { + if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'dragGesture') gesture = node; + ts.forEachChild(node, visit); + } + visit(projectRow); + const require = createRequire(import.meta.url); + const { transformSync } = require('@babel/core'); + const code = transformSync(`function build(project, onDragStart, onDragMove, onDragEnd) { + const kind = 'project', displayTitle = project.title; + return ${gesture.initializer!.getText(source)}; + }`, { + filename: __filename, babelrc: false, configFile: false, + plugins: [require.resolve('react-native-worklets/plugin')], + }).code; + const callbacks: Record void) & { __closure?: object }> = {}; + const pan: Record unknown> = {}; + for (const method of ['activateAfterLongPress', 'activeOffsetY', 'failOffsetX', 'onStart', 'onUpdate', 'onFinalize']) { + pan[method] = (value: any) => { + if (method.startsWith('on')) callbacks[method] = value; + return pan; + }; + } + const build = new Function('Gesture', 'useMemo', 'runOnJS', `${code}; return build;`)( + { Pan: () => pan }, (factory: () => unknown) => factory(), (callback: unknown) => callback, + ); + const start = vi.fn(), move = vi.fn(), finish = vi.fn(); + const project = { key: 'project', title: 'Project', sessionCount: 1, + get sessions(): never { throw new Error('task data crossed into a gesture worklet'); } }; + build(project, start, move, finish); + // Serializable conversion enumerates captured objects, including their + // nested fields. A single sentinel task collection catches the old transfer. + const visited = new Set(); + function inspect(value: unknown) { + if (!value || typeof value !== 'object' || visited.has(value)) return; + visited.add(value); + Object.values(value).forEach(inspect); + } + expect(() => Object.values(callbacks).forEach(callback => inspect(callback.__closure))).not.toThrow(); + callbacks.onStart({ absoluteY: 140 }); + callbacks.onUpdate({ absoluteY: 160 }); + callbacks.onFinalize(); + expect(start).toHaveBeenCalledWith({ absoluteY: 140, count: 1, key: 'project', title: 'Project' }); + expect(move).toHaveBeenCalledWith(160); + expect(finish).toHaveBeenCalledOnce(); +}); + function renderChildren(count: number, anchor: number, old = false) { const now = Date.now(); const items = Array.from({ length: count }, (_, i) => toRemoteSessionListItem({ diff --git a/apps/mobile/src/__tests__/homeListPatchWhileCovered.test.tsx b/apps/mobile/src/__tests__/homeListPatchWhileCovered.test.tsx index 560fdf04f5f..2b132758166 100644 --- a/apps/mobile/src/__tests__/homeListPatchWhileCovered.test.tsx +++ b/apps/mobile/src/__tests__/homeListPatchWhileCovered.test.tsx @@ -2,7 +2,7 @@ import { act, createElement } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; -import { RemoteSessionStoreSubscriptionGate, remoteSessionStore, useRemoteSessions } from '@/session/remoteSessionStore'; +import { RemoteSessionStoreSubscriptionGate, remoteSessionStore, useRemoteSessions, useRemoteSessionTitles } from '@/session/remoteSessionStore'; import type { RemoteSession } from '@/session/types'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); @@ -51,3 +51,31 @@ it('shows list patches received while Home was covered as soon as Home returns', render(true); expect(container.textContent).toBe('renamed'); }); + +it('freezes linked titles while covered and catches up on resume without a list pull', () => { + remoteSessionStore.setDeviceSessions('mac', 'Mac', [row('a'), row('b')]); + const ids = ['a']; + const renders = vi.fn(); + function Link() { + renders(); + return useRemoteSessionTitles(ids)?.a ?? 'missing'; + } + root = createRoot(container); + const render = (enabled: boolean) => act(() => root!.render( + createElement(RemoteSessionStoreSubscriptionGate, { enabled, children: createElement(Link) }), + )); + render(true); + expect(container.textContent).toBe('a'); + render(false); + renders.mockClear(); + act(() => remoteSessionStore.applySessionPatch('mac', 'a', { title: 'renamed' })); + expect(renders).not.toHaveBeenCalled(); + expect(container.textContent).toBe('a'); + render(true); + expect(container.textContent).toBe('renamed'); + let cancelPreview!: () => void; + act(() => { cancelPreview = remoteSessionStore.beginSessionListRemovalPreview('a'); }); + expect(container.textContent).toBe('missing'); + act(() => cancelPreview()); + expect(container.textContent).toBe('renamed'); +}); diff --git a/apps/mobile/src/__tests__/markdownBlockContent.test.tsx b/apps/mobile/src/__tests__/markdownBlockContent.test.tsx index 3f919649880..2170ea48b4d 100644 --- a/apps/mobile/src/__tests__/markdownBlockContent.test.tsx +++ b/apps/mobile/src/__tests__/markdownBlockContent.test.tsx @@ -4,6 +4,8 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MarkdownBlockContent } from '@/session/MarkdownBlockContent'; import { useMarkdownSessionLinkTitles } from '@/session/useMarkdownSessionLinkTitles'; +import { remoteSessionStore } from '@/session/remoteSessionStore'; +import type { RemoteSession } from '@/session/types'; import { groupMobileMarkdownSelectableBlocks, parseMobileMarkdownIncremental, @@ -49,6 +51,7 @@ function Body({ parse, renderBlock, baseline = false, options }: { let host: HTMLDivElement; let root: Root; beforeEach(() => { + remoteSessionStore.clear(); Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); host = document.createElement('div'); document.body.append(host); @@ -162,30 +165,74 @@ describe('streaming Markdown React block boundaries', () => { const linkedText = `[task](cindy://session/${sessionId})\n\ntail`; let parse = parseMobileMarkdownIncremental(linkedText); const renders = vi.fn(); - function LinkedBody({ document, sessions }: { + const bodyRenders = vi.fn(); + function LinkedBody({ document }: { document: MobileMarkdownParseResult; - sessions: { id: string; title: string }[]; }) { - const titles = useMarkdownSessionLinkTitles(document.source, sessions); + bodyRenders(); + const titles = useMarkdownSessionLinkTitles(document.source); const renderBlock = useCallback((block, gap) => { renders(); return {renderText(block, gap)}; }, [titles]); return ; } - const sessions = [{ id: sessionId, title: 'First title' }]; - act(() => root.render()); + const sessions = [{ id: sessionId, title: 'First title' }, { id: 'other', title: 'Other' }] as RemoteSession[]; + remoteSessionStore.setDeviceSessions('computer', 'Computer', sessions); + act(() => root.render()); + bodyRenders.mockClear(); + // Unit regression: a title-only consumer must not render on other task activity. + for (let n = 0; n < 30; n += 1) { + act(() => remoteSessionStore.applySessionPatch('computer', 'other', { updatedAt: String(n) })); + } + expect(bodyRenders).not.toHaveBeenCalled(); renders.mockClear(); for (let n = 0; n < 10; n += 1) { parse = parseMobileMarkdownIncremental(`${parse.source}x`, parse); - act(() => root.render()); + act(() => root.render()); } expect(renders).toHaveBeenCalledTimes(10); renders.mockClear(); - act(() => root.render()); + act(() => remoteSessionStore.applySessionPatch('computer', sessionId, { title: 'Renamed' })); expect(renders).toHaveBeenCalledTimes(2); expect(host.querySelectorAll('[data-title="Renamed"]')).toHaveLength(2); - act(() => root.render()); + act(() => remoteSessionStore.setDeviceSessions('computer', 'Computer', [])); expect(host.querySelector('[data-title]')).toBeNull(); + act(() => remoteSessionStore.setDeviceSessions('computer', 'Computer', sessions)); + expect(host.querySelectorAll('[data-title="First title"]')).toHaveLength(2); + }); + + it('does not subscribe plain text to task changes and follows a changed link target', () => { + const first = '03e0c22d-19db-4ac5-814f-1ea04040b471'; + const second = '03e0c22d-19db-4ac5-814f-1ea04040b472'; + remoteSessionStore.setDeviceSessions('computer', 'Computer', [ + { id: first, title: 'First' }, { id: second, title: 'Second' }, + ] as RemoteSession[]); + const subscribe = vi.spyOn(remoteSessionStore, 'subscribe'); + const renders = vi.fn(); + function Titles({ text }: { text: string }) { + const titles = useMarkdownSessionLinkTitles(text); + renders(); + return {JSON.stringify(titles)}; + } + try { + act(() => root.render()); + expect(subscribe).not.toHaveBeenCalled(); + act(() => root.render()); + expect(host.textContent).toContain('First'); + act(() => root.render()); + renders.mockClear(); + act(() => remoteSessionStore.applySessionPatch('computer', first, { title: 'Old link renamed' })); + expect(renders).not.toHaveBeenCalled(); + expect(host.textContent).toContain('Second'); + act(() => remoteSessionStore.applySessionPatch('computer', second, { title: 'New title' })); + expect(host.textContent).toContain('New title'); + act(() => root.render()); + renders.mockClear(); + act(() => remoteSessionStore.applySessionPatch('computer', second, { title: 'Later' })); + expect(renders).not.toHaveBeenCalled(); + } finally { + subscribe.mockRestore(); + } }); }); diff --git a/apps/mobile/src/__tests__/mobileBabelTransform.test.ts b/apps/mobile/src/__tests__/mobileBabelTransform.test.ts new file mode 100644 index 00000000000..070b0ddf771 --- /dev/null +++ b/apps/mobile/src/__tests__/mobileBabelTransform.test.ts @@ -0,0 +1,48 @@ +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { afterEach, expect, it, vi } from 'vitest'; + +const require = createRequire(import.meta.url); +const config = require('../../metro.config.js'); +const transformer = require(config.transformer.babelTransformerPath); +const generate = require('@babel/generator').default; +const projectRoot = path.resolve(__dirname, '../..'); + +afterEach(() => vi.unstubAllEnvs()); + +function compileWorklet(dev: boolean) { + const { ast } = transformer.transform({ + // The Worklets development transform reads this file for its source map. + filename: __filename, + src: "function calculate() { 'worklet'; return 42; }", + options: { + dev, platform: 'ios', projectRoot, enableBabelRCLookup: true, + customTransformOptions: { engine: 'hermes' }, + }, + plugins: [], + }); + return new Function(`${generate(ast).code}; return calculate;`)(); +} + +it('release worklets omit debug allocations even when the build inherits development variables', () => { + vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('BABEL_ENV', 'development'); + const worklet = compileWorklet(false); + expect(worklet()).toBe(42); + expect(worklet.__workletHash).toEqual(expect.any(Number)); + expect(worklet.__stackDetails).toBeUndefined(); + expect(worklet.__initData.sourceMap).toBeUndefined(); + expect(process.env.NODE_ENV).toBe('development'); + expect(process.env.BABEL_ENV).toBe('development'); +}); + +it('development worklets retain debugging even when the parent environment says production', () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('BABEL_ENV', 'production'); + const worklet = compileWorklet(true); + expect(worklet()).toBe(42); + expect(worklet.__stackDetails[0]).toBeInstanceOf(Error); + expect(worklet.__initData.sourceMap).toEqual(expect.any(String)); + expect(process.env.NODE_ENV).toBe('production'); + expect(process.env.BABEL_ENV).toBe('production'); +}); diff --git a/apps/mobile/src/__tests__/optimisticSessionComposer.test.ts b/apps/mobile/src/__tests__/optimisticSessionComposer.test.ts index a4f47afe630..9d52c328ba2 100644 --- a/apps/mobile/src/__tests__/optimisticSessionComposer.test.ts +++ b/apps/mobile/src/__tests__/optimisticSessionComposer.test.ts @@ -23,8 +23,8 @@ const SCREEN = 'app/sessions/[sessionId].tsx'; describe('mobile optimistic composer while session is not ready', () => { it('uses the shared unsent proof to cancel locally before requiring a remote receipt', () => { - const source = readSource(SCREEN); - const remove = source.slice(source.indexOf('const removeOutboxItem ='), source.indexOf('const outboxDisplayItems =')); + const source = readSource('src/session/usePendingSendActions.ts'); + const remove = source.slice(source.indexOf('const removeOutboxItem ='), source.indexOf('return { findDurableOutboxRecord')); expect(remove).toContain('if (isDurableOutboxUnsent(record))'); expect(remove).toContain("cleanupOutcome: 'cancelled'"); expect(remove).toContain("else await mobileDurableOutbox.update(record, { cancelRequested: true, state: 'confirming' })"); diff --git a/apps/mobile/src/__tests__/remoteSessionStore.test.ts b/apps/mobile/src/__tests__/remoteSessionStore.test.ts index 5ea18cd7d89..b9e9883643b 100644 --- a/apps/mobile/src/__tests__/remoteSessionStore.test.ts +++ b/apps/mobile/src/__tests__/remoteSessionStore.test.ts @@ -685,6 +685,29 @@ describe('remoteSessionStore', () => { expect(remoteSessionStore.getHomeSessions()[0].title).toBe('Restored title'); }); + it('keeps metadata patches on the canonical shard and preserves hidden rows and usage projection', () => { + remoteSessionStore.setDeviceIdentity([{ deviceId: 'current', name: 'Mac' }]); + remoteSessionStore.setDeviceSessions('stale', 'Mac', [session('s1', { title: 'Stale' })]); + remoteSessionStore.setDeviceSessions('current', 'Mac', [session('s1'), session('s2')]); + const other = remoteSessionStore.getHomeSessions()[1]; + const before = remoteSessionStore.getSessions()[0]; + remoteSessionStore.applySessionPatch('stale', 's1', { title: 'Wrong', updatedAt: '2026-02-01T00:00:00Z' }); + expect(remoteSessionStore.getSessions()[0]).toBe(before); + expect(remoteSessionStore.getSessionDeviceId('s1')).toBe('current'); + const finishPreview = remoteSessionStore.beginSessionListRemovalPreview('s1'); + remoteSessionStore.applySessionPatch('current', 's1', { + title: 'Renamed', updatedAt: '2026-02-02T00:00:00Z', totalTokenUsage: 500, + }); + expect(remoteSessionStore.getHomeSessions()).toEqual([other]); + expect(remoteSessionStore.getSessions()[0]).toMatchObject({ title: 'Renamed', totalTokenUsage: 500 }); + finishPreview(); + expect(remoteSessionStore.getHomeSessions()[0]).toMatchObject({ + title: 'Renamed', updatedAt: '2026-02-02T00:00:00Z', canonicalDeviceId: 'current', + }); + expect(remoteSessionStore.getHomeSessions()[0]).not.toHaveProperty('totalTokenUsage'); + expect(remoteSessionStore.getHomeSessions()[1]).toBe(other); + }); + it('removes archived sessions from the active mirror', () => { remoteSessionStore.setDeviceSessions('dev-1', 'Mac', [session('s1'), session('s2')]); remoteSessionStore.applySessionPatch('dev-1', 's1', { status: 'archived' }); diff --git a/apps/mobile/src/__tests__/swipeableProjectHeader.test.tsx b/apps/mobile/src/__tests__/swipeableProjectHeader.test.tsx index 9129063a529..a6a4399c1de 100644 --- a/apps/mobile/src/__tests__/swipeableProjectHeader.test.tsx +++ b/apps/mobile/src/__tests__/swipeableProjectHeader.test.tsx @@ -6,6 +6,7 @@ import { SwipeableProjectHeader } from "@/session/SwipeableProjectHeader"; import { createSwipeRowRegistry } from "@/session/swipeRowRegistry"; const native = vi.hoisted(() => ({ + width: 400, props: null as any, offset: { value: 0 }, close: vi.fn(), @@ -22,7 +23,7 @@ vi.mock("react-native", async () => { children, ), StyleSheet: { create: (value: unknown) => value }, - useWindowDimensions: () => ({ width: 400 }), + useWindowDimensions: () => ({ width: native.width }), }; }); vi.mock("react-native-reanimated", async () => { @@ -109,6 +110,7 @@ function render(projectKey = "project-a") { } beforeEach(() => { vi.clearAllMocks(); + native.width = 400; native.offset.value = 0; registry = createSwipeRowRegistry(); destination = "home"; @@ -124,6 +126,24 @@ afterEach(() => act(() => root.unmount())); // The requested side swap moves new-task and its full-swipe trigger to the right // panel; clicking the revealed left panel must still open tasks/search (unit). describe("project header navigation and swipe lifecycle", () => { + // #7083 unit: repeated parent renders/rotation must retain the native gesture + // inputs; full-swipe still uses the latest width and current project action. + it("does not re-register gestures on refresh or rotation", () => { + render(); + const initial = native.props; + native.width = 800; + render("project-b"); + for (const key of ["onSwipeableOpenStartDrag", "onSwipeableClose", "onSwipeableWillOpen", "hitSlop"]) { + expect(native.props[key]).toBe(initial[key]); + } + native.offset.value = -260; + act(() => native.props.onSwipeableWillOpen("left")); + expect(destination).toBe("home"); + native.offset.value = -500; + act(() => native.props.onSwipeableWillOpen("left")); + expect(destination).toBe("new"); + expect(registry.closeOpenRow()).toBe(false); + }); it.each([ ["left", -260, "new"], ["left", -100, "home"], diff --git a/apps/mobile/src/__tests__/swipeableSessionRowIos.test.tsx b/apps/mobile/src/__tests__/swipeableSessionRowIos.test.tsx index f1b5ec109f5..df2ff148e4c 100644 --- a/apps/mobile/src/__tests__/swipeableSessionRowIos.test.tsx +++ b/apps/mobile/src/__tests__/swipeableSessionRowIos.test.tsx @@ -7,6 +7,7 @@ import type { RemoteSession } from "@/session/types"; import { SwipeableSessionRow } from "@/session/SwipeableSessionRow.ios"; const runtime = vi.hoisted(() => ({ + width: 400, translation: { value: 0 }, props: null as SwipeableProps | null, reset: vi.fn(), @@ -31,7 +32,7 @@ vi.mock("react-native", () => ({ ), StyleSheet: { create: (value: unknown) => value }, - useWindowDimensions: () => ({ width: 400 }), + useWindowDimensions: () => ({ width: runtime.width }), })); vi.mock("@/platform/gestureHandler", async () => { const { forwardRef, useImperativeHandle } = await import("react"); @@ -168,6 +169,7 @@ function release(direction: "left" | "right") { } beforeEach(() => { vi.clearAllMocks(); + runtime.width = 400; runtime.translation.value = 0; runtime.styles = []; runtime.reactions.clear(); @@ -177,6 +179,25 @@ beforeEach(() => { afterEach(() => act(() => root.unmount())); describe("iOS swipe runtime contracts (mocked native boundary)", () => { + // #7083: refreshed row objects and rotation must not rebuild RNGH's worklet + // graph. Existing release tests do not compare the native callback boundary. + it("keeps gesture callbacks stable while using the current width and recycled task", () => { + render("a"); + const initial = runtime.props!; + render("a"); + expect(runtime.props!.onSwipeableWillOpen).toBe(initial.onSwipeableWillOpen); + runtime.width = 800; + render("b"); + expect(runtime.props!.onSwipeableWillOpen).toBe(initial.onSwipeableWillOpen); + expect(runtime.props!.onSwipeableOpenStartDrag).toBe(initial.onSwipeableOpenStartDrag); + expect(runtime.props!.onSwipeableClose).toBe(initial.onSwipeableClose); + drag(-260); + release("left"); + expect(controls.onArchive).not.toHaveBeenCalled(); + drag(-500); + release("left"); + expect(controls.onArchive).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ id: "b" })); + }); it.each([ ["left", -260, "onArchive"], ["right", 260, "onMarkRead"], diff --git a/apps/mobile/src/session/HomeSurface.tsx b/apps/mobile/src/session/HomeSurface.tsx index 43f69a73947..4b13537b046 100644 --- a/apps/mobile/src/session/HomeSurface.tsx +++ b/apps/mobile/src/session/HomeSurface.tsx @@ -3961,6 +3961,9 @@ function ProjectRow({ const start = onDragStart; const move = onDragMove; const finish = onDragEnd; + // Worklets serialize captured objects recursively. Capture the two scalar + // fields here so registering a drag never copies all project task data. + const { key: projectKey, sessionCount } = project; return Gesture.Pan() .activateAfterLongPress(380) .activeOffsetY([-8, 8]) @@ -3968,8 +3971,8 @@ function ProjectRow({ .onStart((event) => { runOnJS(start)({ absoluteY: event.absoluteY, - count: project.sessionCount, - key: project.key, + count: sessionCount, + key: projectKey, title: displayTitle, }); }) diff --git a/apps/mobile/src/session/MessageRenderer.tsx b/apps/mobile/src/session/MessageRenderer.tsx index 5813ff8ae25..af4ac273194 100644 --- a/apps/mobile/src/session/MessageRenderer.tsx +++ b/apps/mobile/src/session/MessageRenderer.tsx @@ -263,7 +263,6 @@ import { ChatPathChipSpan } from '@/session/ChatPathChipSpan'; import { useRemoteDeviceIdentity, useRemoteSessionMessages, - useRemoteSessions, } from '@/session/remoteSessionStore'; import { isCrossDeviceSourceSessionId, @@ -5997,8 +5996,7 @@ function MarkdownBodyBase({ }, [layout, managedImagePreviewUrl, onOpenPayload, openMarkdownImage, resolveMarkdownMedia]); // Preserve the inline renderer while streaming or unrelated task metadata // changes; referenced task title changes still refresh every affected chip. - const remoteSessions = useRemoteSessions(); - const sessionLinkTitles = useMarkdownSessionLinkTitles(text, remoteSessions); + const sessionLinkTitles = useMarkdownSessionLinkTitles(text); const renderInlines = useCallback( ( inlines: readonly MobileMarkdownInline[], diff --git a/apps/mobile/src/session/SwipeableProjectHeader.tsx b/apps/mobile/src/session/SwipeableProjectHeader.tsx index 94fc47239e7..8e840717622 100644 --- a/apps/mobile/src/session/SwipeableProjectHeader.tsx +++ b/apps/mobile/src/session/SwipeableProjectHeader.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useRef, type ReactNode } from "react"; +import { memo, useCallback, useEffect, useLayoutEffect, useRef, type ReactNode } from "react"; import { Pressable, StyleSheet, View, useWindowDimensions } from "react-native"; import Animated, { interpolate, @@ -27,6 +27,7 @@ const ACTION_SIZE = 56; const GAP = spacing.md; const LEADING_WIDTH = ACTION_SIZE * 2 + GAP * 3; const TRAILING_WIDTH = ACTION_SIZE + GAP * 2; +const EDGE_HIT_SLOP = { left: -spacing.xl }; interface Props { projectKey: string; @@ -66,6 +67,27 @@ export const SwipeableProjectHeader = memo(function SwipeableProjectHeader({ }, [close, registry, rowKey], ); + // Keep RNGH's worklet graph registered across list updates and rotation. + // Release actions read the current committed project and full-swipe width. + const gestureInputs = useRef({ registry, rowKey, width, run, onNewTask }); + useLayoutEffect(() => { + gestureInputs.current = { registry, rowKey, width, run, onNewTask }; + }); + const handleOpenStartDrag = useCallback(() => { + const { registry, rowKey } = gestureInputs.current; + registry.onRowOpen(rowKey, close); + }, [close]); + const handleClose = useCallback(() => { + const { registry, rowKey } = gestureInputs.current; + registry.onRowClose(rowKey); + }, []); + const handleWillOpen = useCallback((direction: (typeof SwipeDirection)[keyof typeof SwipeDirection]) => { + const { width, run, onNewTask } = gestureInputs.current; + // Release only. Right swipes always require an explicit action tap. + if (direction === SwipeDirection.LEFT && -(translation.current?.value ?? 0) >= width * 0.55) { + run(onNewTask); + } + }, []); return ( registry.onRowOpen(rowKey, close)} - onSwipeableClose={() => registry.onRowClose(rowKey)} - onSwipeableWillOpen={(direction) => { - // Release only. Right swipes always require an explicit action tap. - if ( - direction === SwipeDirection.LEFT && - -(translation.current?.value ?? 0) >= width * 0.55 - ) { - run(onNewTask); - } - }} + onSwipeableOpenStartDrag={handleOpenStartDrag} + onSwipeableClose={handleClose} + onSwipeableWillOpen={handleWillOpen} renderLeftActions={(_progress, offset) => { translation.current = offset; return ( diff --git a/apps/mobile/src/session/SwipeableSessionRow.ios.tsx b/apps/mobile/src/session/SwipeableSessionRow.ios.tsx index bd9512694e9..2b8ffdd8e07 100644 --- a/apps/mobile/src/session/SwipeableSessionRow.ios.tsx +++ b/apps/mobile/src/session/SwipeableSessionRow.ios.tsx @@ -20,7 +20,7 @@ * - 全滑判定在 onSwipeableWillOpen(松手后的 JS 回调)同步读 translation,不在 * worklet 拖动中触发 —— 手指还按着时行被移除会拆手势树。 */ -import { memo, useCallback, useEffect, useRef, type ReactNode } from "react"; +import { memo, useCallback, useEffect, useLayoutEffect, useRef, type ReactNode } from "react"; import { Pressable, StyleSheet, View, useWindowDimensions } from "react-native"; import { ReanimatedSwipeable as Swipeable, @@ -155,18 +155,27 @@ function SwipeableSessionRowInner({ methodsRef.current?.close(); }, []); + // RNGH rebuilds its serialized worklet graph when these JS callbacks change. + // Row refreshes and rotation only update the action's committed inputs. + const gestureInputs = useRef({ registry, rowKey, fullSwipeThreshold, onMarkRead, onArchive, session }); + useLayoutEffect(() => { + gestureInputs.current = { registry, rowKey, fullSwipeThreshold, onMarkRead, onArchive, session }; + }); const handleOpenStartDrag = useCallback(() => { + const { registry, rowKey } = gestureInputs.current; registry.onRowOpen(rowKey, close); - }, [registry, rowKey, close]); + }, [close]); const handleClose = useCallback(() => { + const { registry, rowKey } = gestureInputs.current; registry.onRowClose(rowKey); - }, [registry, rowKey]); + }, []); // 全滑判定:松手瞬间(库在 release 时派发 WillOpen)读当前位移,超阈值即触发。 // direction 语义:RIGHT = 右滑(露出左面板/置顶),LEFT = 左滑(露出右面板/归档)。 const handleWillOpen = useCallback( (direction: (typeof SwipeDirection)[keyof typeof SwipeDirection]) => { + const { fullSwipeThreshold, onMarkRead, onArchive, session } = gestureInputs.current; const translation = translationRef.current; if (!translation) return; if (direction === SwipeDirection.RIGHT) { @@ -182,7 +191,7 @@ function SwipeableSessionRowInner({ onArchive(session); } }, - [fullSwipeThreshold, close, onMarkRead, onArchive, session], + [close], ); const handleReadPress = useCallback(() => { diff --git a/apps/mobile/src/session/remoteSessionStore.ts b/apps/mobile/src/session/remoteSessionStore.ts index aa71053370f..1b93e86f842 100644 --- a/apps/mobile/src/session/remoteSessionStore.ts +++ b/apps/mobile/src/session/remoteSessionStore.ts @@ -1436,6 +1436,9 @@ const mergedSessionById = new Map(); // live usage. Usage pushes must not rebuild Home's grouping/localization tree. let homeSessions: RemoteSession[] = []; const SESSION_USAGE_FIELDS = new Set(['totalMoney', 'totalCostUsd', 'totalTokenUsage']); +// These fields cannot change shard ownership, visibility or retention. Timestamps +// still update this task's live-row anchor; titles still reconcile local previews. +const SESSION_METADATA_FIELDS = new Set([...SESSION_USAGE_FIELDS, 'title', 'updatedAt', 'userSendAt']); const homeSessionProjectionCache = new Map SESSION_METADATA_FIELDS.has(key))) { + const previous = mergedSessionById.get(sessionId); + if (previous && sessionDeviceIndex.get(sessionId) === deviceId) { + const updated = applyPendingTitlePreview({ ...patched, canonicalDeviceId: previous.canonicalDeviceId }); + if (!remoteSessionEqual(previous, updated)) { + mergedSessions = mergedSessions.map((session) => session.id === sessionId ? updated : session); + mergedSessionById.set(sessionId, updated); + pendingMessagePreviewSessionIds.add(sessionId); + reconcileVisibleSessions(); + reconcileHomeSessions(); + } + bindPendingHostAnchorSendAt(sessionId, updated.userSendAt); + if (reanchorPendingLiveAssistantRows(sessionId, + updated.userSendAt ?? updated.updatedAt ?? updated.createdAt, { consumePending: false })) { + bumpMessageVersion(); + } + } + emit(); + return; + } shouldReseedAfterPatch = wasPinned && unpinned; } recomputeSessions(); @@ -6287,6 +6310,26 @@ export function useRemoteSessions(): RemoteSession[] { return usePausableRemoteSessionStoreSnapshot('sessions', remoteSessionStore.getVisibleSessions); } +/** Link chips observe only their visible targets, not every task's activity. */ +export function useRemoteSessionTitles(ids: readonly string[]): Record | undefined { + const read = useCallback(() => { + if (ids.length === 0) return undefined; + const sessions = remoteSessionStore.getVisibleSessions(); + const titles: Record = {}; + for (const id of ids) { + const title = sessions.find((session) => session.id === id)?.title?.trim(); + if (title) titles[id] = title; + } + return Object.keys(titles).length > 0 ? JSON.stringify(titles) : undefined; + }, [ids]); + // A primitive snapshot stays equal on unrelated pushes. Reuse the route's + // subscription gate so covered conversations remain frozen until resumed. + const json = usePausableRemoteSessionStoreSnapshot( + read, read, ids.length > 0 ? remoteSessionStore.subscribe : NOOP_SUBSCRIBE, + ); + return useMemo(() => json === undefined ? undefined : JSON.parse(json) as Record, [json]); +} + /** A hidden child can be opened without making it an ordinary Home list row. */ export function useRemoteSessionDetail(deviceId: string, sessionId: string): RemoteSession | null { const read = useCallback(() => remoteSessionStore.getSessions().find(session => diff --git a/apps/mobile/src/session/useMarkdownSessionLinkTitles.ts b/apps/mobile/src/session/useMarkdownSessionLinkTitles.ts index 1a41d68a206..ca042971ad8 100644 --- a/apps/mobile/src/session/useMarkdownSessionLinkTitles.ts +++ b/apps/mobile/src/session/useMarkdownSessionLinkTitles.ts @@ -1,23 +1,12 @@ import { useMemo } from 'react'; import { extractSessionLinkIds } from '@/session/sessionLinks'; +import { useRemoteSessionTitles } from '@/session/remoteSessionStore'; /** Stable render context: only a referenced title changing invalidates spans. */ export function useMarkdownSessionLinkTitles( text: string, - sessions: readonly { id: string; title?: string | null }[], ): Record | undefined { const idsJson = useMemo(() => JSON.stringify(extractSessionLinkIds(text)), [text]); - const titlesJson = useMemo(() => { - const ids = JSON.parse(idsJson) as string[]; - if (ids.length === 0) return undefined; - const titles: Record = {}; - for (const id of ids) { - const title = sessions.find((session) => session.id === id)?.title?.trim(); - if (title) titles[id] = title; - } - return Object.keys(titles).length > 0 ? JSON.stringify(titles) : undefined; - }, [idsJson, sessions]); - return useMemo(() => titlesJson === undefined - ? undefined - : JSON.parse(titlesJson) as Record, [titlesJson]); + const ids = useMemo(() => JSON.parse(idsJson) as string[], [idsJson]); + return useRemoteSessionTitles(ids); } diff --git a/apps/mobile/src/session/usePendingSendActions.ts b/apps/mobile/src/session/usePendingSendActions.ts new file mode 100644 index 00000000000..51fff63f8ed --- /dev/null +++ b/apps/mobile/src/session/usePendingSendActions.ts @@ -0,0 +1,123 @@ +import { useCallback, useMemo, type RefObject } from 'react'; +import { Alert } from 'react-native'; +import type { useRouter } from 'expo-router'; +import type { TFunction } from 'i18next'; +import { getMobileAuthOwner, isMobileAuthOwnerCurrent } from '@/auth/authOwnerGeneration'; +import { formatRemoteError } from '@/device-link/remoteStatus'; +import { getCurrentMobileOutboxRecords, mobileDurableOutbox } from './mobileDurableOutbox'; +import { isDurableOutboxUnsent } from './durableOutbox'; +import { remoteSessionStore } from './remoteSessionStore'; +import type { PendingSendBubbleActions } from './PendingSendBubble'; +import type { QueuedRemoteMessage } from './types'; + +/** Keep outbox callbacks stable without capturing records from an earlier task. */ +export function usePendingOutboxActions({ + deviceId, deviceName, sessionId, router, outboxSessionAliveRef, + setError, setQueueSelectedClientId, t, +}: { + deviceId: string; + deviceName: string; + sessionId: string; + router: Pick, 'push'>; + outboxSessionAliveRef: RefObject; + setError(error: string): void; + setQueueSelectedClientId(clientId: string | null): void; + t: TFunction; +}) { + const findDurableOutboxRecord = useCallback((clientId: string) => getCurrentMobileOutboxRecords().find((r) => + r.deviceId === deviceId && r.item.sessionId === sessionId && r.item.clientId === clientId), [deviceId, sessionId]); + const retryOutboxItem = useCallback((clientId: string) => { + const record = findDurableOutboxRecord(clientId); + if (!record || record.state !== 'failed') return; + if (record.creation && !record.prepared && !record.template) { + const owner = getMobileAuthOwner(); + void mobileDurableOutbox.update(record, { suspended: true }).then(() => { + if (isMobileAuthOwnerCurrent(owner) && outboxSessionAliveRef.current === sessionId) { + router.push({ pathname: '/sessions/new', params: { recoverySessionId: sessionId, deviceId, deviceName } }); + } + }).catch((err) => setError(formatRemoteError(err))); + return; + } + setQueueSelectedClientId(null); + void mobileDurableOutbox.update(record, { + state: record.prepared ? 'confirming' : 'queued', error: undefined, + }).catch((err) => setError(formatRemoteError(err))); + }, [deviceId, deviceName, findDurableOutboxRecord, outboxSessionAliveRef, router, sessionId, setError, setQueueSelectedClientId]); + const removeOutboxItem = useCallback((clientId: string) => { + const record = findDurableOutboxRecord(clientId); + if (!record) return; + // Until creation succeeds, this row also owns the recovery form for every later message. + if (record.creation && !record.prepared && !record.template) { + setError(t('session.outbox.creationRecoveryRequired')); + return; + } + // 删除发送失败的消息不可撤销:先确认(破坏性按钮),确认后才真正移除。 + const confirmRemove = () => { + setQueueSelectedClientId(null); + const owner = getMobileAuthOwner(); + const remove = async () => { + if (isDurableOutboxUnsent(record)) { + await mobileDurableOutbox.update(record, { state: 'host-owned', cleanupOutcome: 'cancelled', cancelRequested: true, error: undefined }); + } else await mobileDurableOutbox.update(record, { cancelRequested: true, state: 'confirming' }); + }; + void remove().catch((err) => { + if (isMobileAuthOwnerCurrent(owner) && outboxSessionAliveRef.current === sessionId) setError(formatRemoteError(err)); + }); + }; + Alert.alert( + t('session.outbox.deleteConfirmTitle'), + t('session.outbox.deleteConfirmBody'), + [ + { text: t('session.common.cancel'), style: 'cancel' }, + { text: t('session.common.delete'), style: 'destructive', onPress: confirmRemove }, + ], + ); + }, [findDurableOutboxRecord, outboxSessionAliveRef, sessionId, setError, setQueueSelectedClientId, t]); + return { findDurableOutboxRecord, retryOutboxItem, removeOutboxItem }; +} + +/** Shared action object for message rows; look up queue entries when clicked. */ +export function usePendingSendActions({ + beginQueueEdit, moveQueueItem, queueBusy, queueSelectedClientId, + removeQueueItem, removeOutboxItem, retryOutboxItem, sessionId, + setQueueSelectedClientId, steerQueueItem, +}: { + beginQueueEdit(item: QueuedRemoteMessage): void; + moveQueueItem: PendingSendBubbleActions['onMove']; + queueBusy: boolean; + queueSelectedClientId: string | null; + removeQueueItem: PendingSendBubbleActions['onRemove']; + removeOutboxItem: PendingSendBubbleActions['onRemoveOutbox']; + retryOutboxItem: PendingSendBubbleActions['onRetryOutbox']; + sessionId: string; + setQueueSelectedClientId: PendingSendBubbleActions['onSelect']; + steerQueueItem(item: QueuedRemoteMessage): void; +}) { + return useMemo( + () => ({ + busy: queueBusy, + selectedClientId: queueSelectedClientId, + onSelect: setQueueSelectedClientId, + onRemove: (clientId: string) => { + setQueueSelectedClientId(null); + removeQueueItem(clientId); + }, + onBeginEdit: (clientId: string) => { + const target = remoteSessionStore.getInputProjection(sessionId).pendingQueue + .find((entry) => entry.clientId === clientId); + if (target) beginQueueEdit(target); + }, + onSteer: (clientId: string) => { + const target = remoteSessionStore.getInputProjection(sessionId).pendingQueue + .find((entry) => entry.clientId === clientId); + if (!target) return; + setQueueSelectedClientId(null); + steerQueueItem(target); + }, + onMove: moveQueueItem, + onRetryOutbox: retryOutboxItem, + onRemoveOutbox: removeOutboxItem, + }), + [beginQueueEdit, setQueueSelectedClientId, moveQueueItem, queueBusy, queueSelectedClientId, removeQueueItem, removeOutboxItem, retryOutboxItem, sessionId, steerQueueItem], + ); +} diff --git a/docs/dev-rules/mobile-development.md b/docs/dev-rules/mobile-development.md index 26398db5e10..de98af658ed 100644 --- a/docs/dev-rules/mobile-development.md +++ b/docs/dev-rules/mobile-development.md @@ -91,6 +91,12 @@ JSON 已包含 `worktree` / `expectedSource`、`region` / `bundleId`、目标设 ## 分层验证 +Release 构建必须使用 `NODE_ENV=production BABEL_ENV=production`,包括手动调用 +`xcodebuild`、Gradle 或 `expo export:embed --dev false`。Expo 会保留父进程的环境变量, +因此 `__DEV__=false` 或 Xcode 的 Release 配置本身不能证明 Worklets 已按生产模式编译。 +仓库 iOS/Android 构建脚本会固定这两个变量;Metro 的 Babel transformer 另按每次请求的 +`dev` 标志统一编译模式,防止外部开发环境把动画调试堆栈编进 Release 包,同时保留开发包调试能力。 + ```bash pnpm --filter mobile typecheck pnpm --filter mobile exec vitest run <测试文件路径> diff --git a/scripts/__tests__/client-endpoint-build-env.test.mjs b/scripts/__tests__/client-endpoint-build-env.test.mjs index 299932159ed..2db9fd1f04d 100644 --- a/scripts/__tests__/client-endpoint-build-env.test.mjs +++ b/scripts/__tests__/client-endpoint-build-env.test.mjs @@ -66,6 +66,8 @@ test('Mobile bundling 进程环境只在 CindyDev 保留 Release 清单基址', const repoRoot = writeRepoFixtures(); const staleBaseEnv = { KEEP_ME: '1', + NODE_ENV: 'development', + BABEL_ENV: 'development', EXPO_PUBLIC_CINDY_DEV_RELEASE_ENDPOINT_MANIFEST_BASE_URL: 'https://stale-release.example.invalid/app', }; @@ -76,6 +78,9 @@ test('Mobile bundling 进程环境只在 CindyDev 保留 Release 清单基址', repoRoot, }); assert.equal(cn.KEEP_ME, '1'); + assert.equal(cn.NODE_ENV, 'production'); + assert.equal(cn.BABEL_ENV, 'production'); + assert.equal(staleBaseEnv.NODE_ENV, 'development'); assert.equal( Object.hasOwn( cn, @@ -89,6 +94,8 @@ test('Mobile bundling 进程环境只在 CindyDev 保留 Release 清单基址', baseEnv: staleBaseEnv, repoRoot, }); + assert.equal(dev.NODE_ENV, 'production'); + assert.equal(dev.BABEL_ENV, 'production'); assert.equal( dev.EXPO_PUBLIC_CINDY_DEV_RELEASE_ENDPOINT_MANIFEST_BASE_URL, 'https://hotfix-cn.example.invalid/app', diff --git a/scripts/shared/client-endpoint-build-env.mjs b/scripts/shared/client-endpoint-build-env.mjs index 99124c6a93e..5d433b33948 100644 --- a/scripts/shared/client-endpoint-build-env.mjs +++ b/scripts/shared/client-endpoint-build-env.mjs @@ -156,7 +156,10 @@ export function mobileClientBundleProcessEnv({ repoRoot, } = {}) { const bundleEnv = mobileClientBundleEnv({ authRegion, repoRoot }); - const env = { ...baseEnv, ...bundleEnv }; + // These callers build installable iOS/Android Release bundles. Region "dev" + // selects endpoints, not Babel's build mode. Do not inherit an agent/server's + // development environment into the production Worklets transform. + const env = { ...baseEnv, ...bundleEnv, NODE_ENV: 'production', BABEL_ENV: 'production' }; if (bundleEnv.EXPO_PUBLIC_CINDY_AUTH_REGION !== 'dev') { delete env.EXPO_PUBLIC_CINDY_DEV_RELEASE_ENDPOINT_MANIFEST_BASE_URL; }