Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 27 additions & 95 deletions apps/mobile/app/sessions/[sessionId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -672,7 +672,6 @@ import {
sessionMetaWriteQueue,
sessionPendingWrites,
type RemoteSessionRunStatus,
useRemoteSessions,
useRemoteSessionDetail,
useSessionGoalStatus,
useSessionInputProjection,
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -7744,11 +7697,20 @@ function SessionScreenContent({ beforeSessionSwitchRef, newSessionActionRef }: S
};
};

// Shared by queue receipts and file actions; declared before memoized consumers.
const [transientNotice, setTransientNotice] = useState<SessionTransientNoticeValue | null>(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.
Expand Down Expand Up @@ -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') => {
// 菜单打开期间队列可能变化:按最新位置重新判断方向和目标。
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -7843,7 +7805,7 @@ function SessionScreenContent({ beforeSessionSwitchRef, newSessionActionRef }: S
},
action: () => maker.input.remove(sessionId, clientId),
});
};
}, [maker, runOptimisticQueueAction, runQueueAction, sessionId]);

/**
* 回收某次排队编辑期间"新增"的附件(不在 stash、也不是该队列条目自身 files):
Expand Down Expand Up @@ -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 竞争——桌面端可能用旧内容抢先派发,或
Expand Down Expand Up @@ -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<PendingSendBubbleActions>(
() => ({
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,之后再没跑过 —— 强烈指向组件被换了实例
Expand Down Expand Up @@ -9125,14 +9065,6 @@ function SessionScreenContent({ beforeSessionSwitchRef, newSessionActionRef }: S

// chip 长按菜单(浮动面板,ContextSheet/模型选择面板同款):target 即开关。
const [chipMenuTarget, setChipMenuTarget] = useState<ChatFilePathTarget | null>(null);
// 会话页的短暂提示(复制路径等),与文件浏览器「已复制路径」同口径。
const [transientNotice, setTransientNotice] = useState<SessionTransientNoticeValue | null>(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):
Expand Down
23 changes: 23 additions & 0 deletions apps/mobile/babel-transformer.js
Original file line number Diff line number Diff line change
@@ -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;
}
},
};
1 change: 1 addition & 0 deletions apps/mobile/metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')];
Expand Down
70 changes: 70 additions & 0 deletions apps/mobile/src/__tests__/PendingSendBubble.touch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, unknown>>(),
Expand Down Expand Up @@ -154,6 +164,66 @@ it('lets a real body link consume the pending tap before the frame commits', ()
act(() => host.querySelector<HTMLElement>('[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(() => <span>pending message</span>);
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 <PendingSendBubble item={queued ? item : failedItem} actions={actions}
renderText={renderText} renderFile={renderMedia} renderImage={renderMedia} />;
}
const showScreen = (sessionId = 'task-a', queueBusy = false, queued = false) =>
act(() => root.render(<Screen {...{ sessionId, queueBusy, queued }} />));
try {
showScreen();
const initial = actions;
for (let n = 0; n < 30; n++) showScreen();
expect(actions).toBe(initial);
await act(async () => host.querySelector<HTMLElement>('[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<HTMLElement>('[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,
Expand Down
Loading
Loading