authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-06 22:24:11-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 22:24:11-07:00
loge1230d6031bc55a21668818d0b585d8887b88579
tree8568e6dd1f7eeaa5f95a3966aef5f4e681e583ba
parent6abdf37a50ce6c1c9fabc499f4d0e159a6182d90
signature Signed by PGP key B5690EEEBB952194

fix(mobile): keep pending messages in the chat timeline (#10449)


12 files changed, 469 insertions(+), 67 deletions(-)

apps/mobile/src/features/threads/ThreadComposer.tsx+2-19
......@@ -35,9 +35,7 @@ import {
3535} from "../../state/composer-attachment-uploads";
3636import Animated, {
3737 FadeIn,
38 FadeInDown,
3938 FadeOut,
40 FadeOutDown,
4139 LinearTransition,
4240 ReduceMotion,
4341 useAnimatedStyle,
......@@ -272,12 +270,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
272270}) {
273271 const isReconnecting = props.status.kind === "reconnecting";
274272 return (
275 <Animated.View
276 className="absolute inset-x-0 bottom-full items-center pb-2"
277 entering={FadeInDown.duration(180)}
278 exiting={FadeOutDown.duration(140)}
279 pointerEvents="box-none"
280 >
273 <View className="absolute inset-x-0 bottom-full items-center pb-2" pointerEvents="box-none">
281274 <Pressable
282275 accessibilityRole="button"
283276 onPress={props.onPress}
......@@ -295,7 +288,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
295288 {props.status.label}
296289 </Text>
297290 </Pressable>
298 </Animated.View>
291 </View>
299292 );
300293});
301294
......@@ -891,16 +884,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
891884 </ComposerDictationToolbar>
892885 </Animated.View>
893886 </ComposerSurface>
894
895 {/* Queue count */}
896 {props.queueCount > 0 ? (
897 <Animated.View entering={FadeIn.duration(180)} exiting={FadeOut.duration(120)}>
898 <Text className="pt-2 text-xs text-foreground-muted">
899 {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send
900 automatically.
901 </Text>
902 </Animated.View>
903 ) : null}
904887 </Animated.View>
905888
906889 <VideoPreviewModal source={previewVideo} onRequestClose={closePreview} />
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+47-10
......@@ -37,6 +37,7 @@ import {
3737 useState,
3838} from "react";
3939import {
40 Alert,
4041 AppState,
4142 Keyboard,
4243 Platform,
......@@ -67,6 +68,8 @@ import type { StatusTone } from "../../components/StatusPill";
6768import type { DraftComposerAttachment } from "../../lib/composerImages";
6869import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
6970import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
71import { editPendingThreadMessage } from "../../state/edit-pending-thread-message";
72import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
7073import { scopedThreadKey } from "../../lib/scopedEntities";
7174import type {
7275 PendingApproval,
......@@ -127,6 +130,8 @@ export interface ThreadDetailScreenProps {
127130 readonly projectWorkspaceRoot: string | null;
128131 readonly threadCwd: string | null;
129132 readonly selectedThreadQueueCount: number;
133 readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;
134 readonly dispatchingMessageId: MessageId | null;
130135 readonly serverConfig: T3ServerConfig | null;
131136 readonly layoutVariant?: LayoutVariant;
132137 readonly usesAutomaticContentInsets?: boolean;
......@@ -348,6 +353,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
348353 return null;
349354 })();
350355 const showWorkingControl = floatingStatus !== null;
356 // Connection and working status occupy the same space. Keep the feed inset
357 // stable when reconnecting hands off to syncing and then to a running turn.
358 const showFloatingStatus =
359 showWorkingControl ||
360 props.connectionStateLabel !== "connected" ||
361 props.queuedMessages.length > 0 ||
362 props.selectedThreadFeed.some(
363 (entry) => "acknowledged" in entry && entry.acknowledged === true,
364 );
351365 const selectedThreadFeed = props.selectedThreadFeed;
352366 const hasCompactableConversation =
353367 selectedThreadFeed.some(
......@@ -487,14 +501,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
487501 const userInputInsetProgress = useSharedValue(1);
488502 const userInputCardCoverage = useSharedValue(0);
489503 const floatingControlCoverage = useSharedValue(
490 showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0,
504 showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0,
491505 );
492506 useEffect(() => {
493507 floatingControlCoverage.value = withTiming(
494 showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0,
508 showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0,
495509 { duration: 180, reduceMotion: ReduceMotion.System },
496510 );
497 }, [floatingControlCoverage, showWorkingControl]);
511 }, [floatingControlCoverage, showFloatingStatus]);
498512 // Android renders the expanded card in-flow (it cannot hit-test the iOS
499513 // overlay outside the bar's bounds), so its measured overlay height already
500514 // includes the card — the coverage extra is iOS-only.
......@@ -554,12 +568,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
554568 useEffect(() => {
555569 const previous = previousWorkingControlStateRef.current;
556570 const threadChanged = previous.threadKey !== selectedThreadKey;
557 const visibilityChanged = previous.visible !== showWorkingControl;
571 const visibilityChanged = previous.visible !== showFloatingStatus;
558572 previousWorkingControlStateRef.current = {
559573 threadKey: selectedThreadKey,
560 visible: showWorkingControl,
574 visible: showFloatingStatus,
561575 };
562 if ((!threadChanged && !visibilityChanged) || (threadChanged && !showWorkingControl)) {
576 if ((!threadChanged && !visibilityChanged) || (threadChanged && !showFloatingStatus)) {
563577 return;
564578 }
565579 // LegendList applies the larger inset but does not re-anchor short
......@@ -567,7 +581,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
567581 // initial load. Re-pin after the finite inset transition; the callback
568582 // checks follow state again so a user who scrolled up stays put.
569583 scheduleOverlayRepin(230);
570 }, [scheduleOverlayRepin, selectedThreadKey, showWorkingControl]);
584 }, [scheduleOverlayRepin, selectedThreadKey, showFloatingStatus]);
571585 const handleToggleUserInputCollapsed = useCallback(() => {
572586 if (activeUserInputRequestId === null) {
573587 return;
......@@ -634,11 +648,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
634648 useEffect(() => {
635649 if (
636650 submittedMessageId === null ||
651 anchorMessageId !== submittedMessageId ||
637652 lastScrolledSubmittedMessageIdRef.current === submittedMessageId ||
638653 contentPresentationKind !== "ready" ||
639 !selectedThreadFeed.some(
654 (!selectedThreadFeed.some(
640655 (entry) => entry.type === "message" && entry.id === submittedMessageId,
641 )
656 ) &&
657 !props.queuedMessages.some((message) => message.messageId === submittedMessageId))
642658 ) {
643659 return;
644660 }
......@@ -677,9 +693,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
677693 });
678694 return () => cancelAnimationFrame(frame);
679695 }, [
696 anchorMessageId,
680697 submittedMessageId,
681698 freeze,
682699 contentPresentationKind,
700 props.queuedMessages,
683701 selectedThreadFeed,
684702 scrollMessageToEnd,
685703 selectedThreadKey,
......@@ -720,6 +738,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
720738 selectedThreadKey,
721739 ]);
722740
741 const handleEditPendingMessage = useCallback(async (message: QueuedThreadMessage) => {
742 try {
743 if (
744 (await editPendingThreadMessage(message)) &&
745 selectedThreadKeyRef.current === scopedThreadKey(message.environmentId, message.threadId)
746 ) {
747 composerEditorRef.current?.focus();
748 }
749 } catch (error) {
750 Alert.alert(
751 "Could not edit message",
752 error instanceof Error ? error.message : "Please try again.",
753 );
754 }
755 }, []);
756
723757 const collapseComposer = useCallback(() => {
724758 composerEditorRef.current?.blur();
725759 }, []);
......@@ -797,6 +831,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
797831 threadId={props.selectedThread.id}
798832 workspaceRoot={props.threadCwd}
799833 feed={props.selectedThreadFeed}
834 queuedMessages={props.queuedMessages}
835 dispatchingMessageId={props.dispatchingMessageId}
836 onEditPendingMessage={handleEditPendingMessage}
800837 contentPresentation={props.contentPresentation}
801838 agentLabel={agentLabel}
802839 latestTurn={props.selectedThread.latestTurn}
......@@ -808,7 +845,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
808845 contentInsetEndAdjustment={combinedContentInsetEndAdjustment}
809846 contentTopInset={0}
810847 contentBottomInset={
811 estimatedOverlayHeight + (showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0)
848 estimatedOverlayHeight + (showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0)
812849 }
813850 contentMaxWidth={contentMaxWidth}
814851 layoutVariant={layoutVariant}
apps/mobile/src/features/threads/ThreadFeed.tsx+85-33
......@@ -72,12 +72,7 @@ import { FilePreviewModal, type FilePreviewSource } from "../../components/FileP
7272import { isPdfFile } from "../../lib/filePreview";
7373import { PresentationSource } from "../../components/NativePresentation";
7474import { useSafeAreaInsets } from "react-native-safe-area-context";
75import Animated, {
76 FadeIn,
77 FadeInUp,
78 LinearTransition,
79 type SharedValue,
80} from "react-native-reanimated";
75import Animated, { FadeIn, LinearTransition, type SharedValue } from "react-native-reanimated";
8176import { useUniwindTheme } from "../../lib/useUniwindTheme";
8277import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
8378import { useFontFamily } from "../../lib/useFontFamily";
......@@ -163,6 +158,8 @@ import {
163158 THREAD_DISCLOSURE_TRANSITION_MS,
164159 WORK_GROUP_TOGGLE_HEIGHT,
165160} from "./thread-work-log";
161import { appendPendingThreadMessages, type PendingThreadFeedEntry } from "./pending-thread-feed";
162import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
166163import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
167164import {
168165 assetEnvironment,
......@@ -231,6 +228,9 @@ function isFreshTimestamp(input: string): boolean {
231228}
232229
233230export interface ThreadFeedProps {
231 readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;
232 readonly dispatchingMessageId: MessageId | null;
233 readonly onEditPendingMessage: (message: QueuedThreadMessage) => void;
234234 readonly environmentId: EnvironmentId;
235235 readonly threadId: ThreadId;
236236 readonly workspaceRoot?: string | null;
......@@ -1324,8 +1324,15 @@ function useMarkdownStyles(
13241324}
13251325
13261326function renderFeedEntry(
1327 info: { item: ThreadFeedEntry; index: number },
1328 props: Pick<ThreadFeedProps, "environmentId" | "onUseArtifactTemplate" | "skills"> & {
1327 info: { item: PendingThreadFeedEntry; index: number },
1328 props: Pick<
1329 ThreadFeedProps,
1330 | "environmentId"
1331 | "onUseArtifactTemplate"
1332 | "skills"
1333 | "dispatchingMessageId"
1334 | "onEditPendingMessage"
1335 > & {
13291336 readonly copiedRowId: string | null;
13301337 readonly expandedWorkRows: Record<string, boolean>;
13311338 readonly workRowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
......@@ -1470,12 +1477,8 @@ function renderFeedEntry(
14701477 !message.streaming;
14711478
14721479 if (isUser) {
1473 const enterAnimated = isFreshTimestamp(message.createdAt);
14741480 return (
1475 <Animated.View
1476 className="mb-5 items-end"
1477 {...(enterAnimated ? { entering: FadeInUp.duration(220) } : {})}
1478 >
1481 <View className="mb-5 items-end">
14791482 <View
14801483 className="min-w-0 gap-2 rounded-[20px] px-3.5 py-2.5"
14811484 style={{
......@@ -1502,6 +1505,28 @@ function renderFeedEntry(
15021505 />
15031506 </MarkdownImageAvailableWidthContext>
15041507 ) : null}
1508 {entry.pendingMessage?.attachments.map((attachment) =>
1509 attachment.type === "image" && attachment.uploadedAttachmentId ? (
1510 <MessageAttachmentImage
1511 key={attachment.id}
1512 environmentId={props.environmentId}
1513 attachmentId={attachment.uploadedAttachmentId}
1514 name={attachment.name}
1515 mimeType={attachment.mimeType}
1516 className="h-[140px] w-[180px] rounded-[14px]"
1517 onPressPreview={props.onPressPreview}
1518 />
1519 ) : attachment.type === "image" ? (
1520 <Image
1521 key={attachment.id}
1522 source={{ uri: attachment.previewUri }}
1523 accessibilityLabel={attachment.name}
1524 style={{ width: 180, height: 140, borderRadius: 14 }}
1525 />
1526 ) : (
1527 <MessageAttachmentUnknown key={attachment.id} name={attachment.name} />
1528 ),
1529 )}
15051530 {attachments.map((attachment) => {
15061531 return isImageAttachment(attachment) ? (
15071532 <MessageAttachmentImage
......@@ -1528,8 +1553,24 @@ function renderFeedEntry(
15281553 </View>
15291554 <View className="mt-1 flex-row items-center justify-end gap-1 pr-0.5">
15301555 <Text className="font-t3-medium text-xs tabular-nums text-adaptive-neutral-600-400">
1531 {timestampLabel}
1556 {entry.pendingMessage && !entry.acknowledged ? "Pending" : timestampLabel}
15321557 </Text>
1558 {entry.pendingMessage &&
1559 !entry.acknowledged &&
1560 !entry.pendingMessage.creation &&
1561 entry.pendingMessage.messageId !== props.dispatchingMessageId ? (
1562 <Pressable
1563 accessibilityRole="button"
1564 accessibilityLabel="Edit pending message"
1565 hitSlop={8}
1566 className="size-7 items-center justify-center"
1567 onPress={() => {
1568 if (entry.pendingMessage) props.onEditPendingMessage(entry.pendingMessage);
1569 }}
1570 >
1571 <SymbolView name="pencil" size={14} tintColor={iconSubtleColor} />
1572 </Pressable>
1573 ) : null}
15331574 {message.text.trim().length > 0 ? (
15341575 <CopyTextButton
15351576 accessibilityLabel="Copy message"
......@@ -1540,7 +1581,7 @@ function renderFeedEntry(
15401581 />
15411582 ) : null}
15421583 </View>
1543 </Animated.View>
1584 </View>
15441585 );
15451586 }
15461587
......@@ -2227,6 +2268,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22272268 // Keep row-local interaction props in extraData so disclosures and copy feedback repaint.
22282269 const listAppearanceData = useMemo(
22292270 () => ({
2271 dispatchingMessageId: props.dispatchingMessageId,
22302272 copiedRowId,
22312273 expandedWorkRows,
22322274 workRowSizing,
......@@ -2238,6 +2280,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22382280 viewportWidth,
22392281 }),
22402282 [
2283 props.dispatchingMessageId,
22412284 copiedRowId,
22422285 expandedWorkRows,
22432286 workRowSizing,
......@@ -2382,14 +2425,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
23822425 }, [expandedWorkGroups]);
23832426 const presentedFeed = useMemo(
23842427 () =>
2385 deriveThreadFeedPresentation(
2428 appendPendingThreadMessages(
2429 deriveThreadFeedPresentation(
2430 props.feed,
2431 props.latestTurn,
2432 expandedTurnIds,
2433 expandedWorkGroupIds,
2434 props.activeWorkStartedAt,
2435 ),
23862436 props.feed,
2387 props.latestTurn,
2388 expandedTurnIds,
2389 expandedWorkGroupIds,
2390 props.activeWorkStartedAt,
2437 props.queuedMessages,
23912438 ),
23922439 [
2440 props.queuedMessages,
23932441 expandedTurnIds,
23942442 expandedWorkGroupIds,
23952443 props.activeWorkStartedAt,
......@@ -2401,7 +2449,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
24012449 // content-inset override. Seed the fresh instance synchronously with the
24022450 // current overlay height before the scroll integration's next reaction;
24032451 // on Android the declarative contentInset floor covers this same window.
2404 const listMountKey = `${feedThreadKey}:${props.feed.length === 0 ? "empty" : "filled"}`;
2452 const listMountKey = `${feedThreadKey}:${presentedFeed.length === 0 ? "empty" : "filled"}`;
24052453 useLayoutEffect(() => {
24062454 const bottom = props.contentInsetEndAdjustment.value;
24072455 if (bottom > 0) {
......@@ -2646,7 +2694,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
26462694 // Disclosures can mount existing offscreen rows as well as new work rows.
26472695 // Fade those in after movement; never retain removed rows over replacements.
26482696 const renderItem = useCallback(
2649 (info: { item: ThreadFeedEntry; index: number }) => (
2697 (info: { item: PendingThreadFeedEntry; index: number }) => (
26502698 <Animated.View
26512699 key={info.item.id}
26522700 entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined}
......@@ -2654,6 +2702,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
26542702 <ThreadMediaVisibility>
26552703 {renderFeedEntry(info, {
26562704 environmentId: props.environmentId,
2705 dispatchingMessageId: props.dispatchingMessageId,
2706 onEditPendingMessage: props.onEditPendingMessage,
26572707 copiedRowId,
26582708 expandedWorkRows,
26592709 workRowSizing,
......@@ -2685,6 +2735,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
26852735 </Animated.View>
26862736 ),
26872737 [
2738 props.dispatchingMessageId,
2739 props.onEditPendingMessage,
26882740 copiedRowId,
26892741 disclosureToggleSettling,
26902742 expandedWorkRows,
......@@ -2716,7 +2768,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
27162768 ],
27172769 );
27182770
2719 if (props.contentPresentation.kind === "unavailable") {
2771 if (props.contentPresentation.kind === "unavailable" && props.queuedMessages.length === 0) {
27202772 return (
27212773 <ThreadFeedPlaceholder
27222774 title={props.contentPresentation.title}
......@@ -2789,17 +2841,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
27892841 // targets land one safe-area short of the true resting offset.
27902842 adjustedInsetCompensation={usesNativeAutomaticInsets ? insets.bottom : 0}
27912843 freeze={props.freeze}
2792 // Animated: on send, the optimistic message's dataChange fires
2793 // maintainScrollAtEnd before any render-cycle suppression could
2794 // engage — an instant snap there teleports the feed to the anchor
2795 // instead of scrolling to it. Keeping it enabled (animated) during
2796 // anchor scrolls also lets it correct a scroll that landed on a
2797 // stale end target once the anchor row finishes measuring.
2844 // Follow the measured end immediately. Animating toward an estimated
2845 // end races row measurement when a pending message is acknowledged.
27982846 maintainScrollAtEnd={
27992847 disclosureToggleSettling || !endFollowEnabled
28002848 ? false
28012849 : {
2802 animated: true,
2850 animated: false,
28032851 on: {
28042852 dataChange: true,
28052853 itemLayout: true,
......@@ -2807,7 +2855,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
28072855 },
28082856 }
28092857 }
2810 maintainVisibleContentPosition={maintainVisibleContentPosition}
2858 maintainVisibleContentPosition={
2859 endFollowEnabled && !disclosureToggleSettling ? false : maintainVisibleContentPosition
2860 }
28112861 data={presentedFeed}
28122862 extraData={listAppearanceData}
28132863 renderItem={renderItem}
......@@ -2817,7 +2867,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
28172867 entry.type === "message" ? `message:${entry.message.role}` : entry.type
28182868 }
28192869 getFixedItemSize={getFixedItemSize}
2820 itemLayoutAnimation={THREAD_FEED_LAYOUT_TRANSITION}
2870 itemLayoutAnimation={
2871 disclosureToggleSettling ? THREAD_FEED_LAYOUT_TRANSITION : undefined
2872 }
28212873 onItemSizeChanged={handleItemSizeChanged}
28222874 // Measure rows well before they scroll into view so estimate→actual
28232875 // corrections land offscreen instead of under the user's finger.
......@@ -2875,7 +2927,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
28752927 }}
28762928 />
28772929 </View>
2878 {props.feed.length === 0 &&
2930 {presentedFeed.length === 0 &&
28792931 props.activeWorkStartedAt === null &&
28802932 props.contentPresentation.kind === "ready" ? (
28812933 <View pointerEvents="none" style={StyleSheet.absoluteFill}>
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+2
......@@ -807,6 +807,8 @@ function ThreadRouteContent(
807807 projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
808808 threadCwd={selectedThreadCwd}
809809 selectedThreadQueueCount={composer.selectedThreadQueueCount}
810 queuedMessages={composer.selectedThreadQueuedMessages}
811 dispatchingMessageId={composer.dispatchingQueuedMessageId}
810812 layoutVariant={layout.variant}
811813 usesAutomaticContentInsets={usesNativeHeaderGlass}
812814 onOpenConnectionEditor={handleOpenConnectionEditor}
apps/mobile/src/features/threads/pending-thread-feed.test.ts created+41
......@@ -0,0 +1,41 @@
1import { describe, expect, it } from "vite-plus/test";
2import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
3import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
4import { appendPendingThreadMessages } from "./pending-thread-feed";
5
6const pending = (id: string): QueuedThreadMessage => ({
7 environmentId: EnvironmentId.make("env"),
8 threadId: ThreadId.make("thread"),
9 messageId: MessageId.make(id),
10 commandId: CommandId.make(id),
11 text: id,
12 attachments: [],
13 createdAt: "2026-09-06T10:00:00.000Z",
14});
15
16describe("pending timeline messages", () => {
17 it("keeps pending messages after newer agent activity in queue order", () => {
18 const activity = {
19 type: "thinking",
20 turnId: null,
21 id: "thinking",
22 createdAt: "2026-09-06T11:00:00.000Z",
23 } as const;
24 const entries = appendPendingThreadMessages(
25 [activity],
26 [],
27 [pending("first"), pending("second")],
28 );
29 expect(entries.map((entry) => entry.id)).toEqual(["thinking", "first", "second"]);
30 expect(entries[1]?.pendingMessage?.text).toBe("first");
31 });
32
33 it("reuses the message id and suppresses the pending copy when delivery appears", () => {
34 const queued = pending("sent");
35 const optimistic = appendPendingThreadMessages([], [], [queued])[0]!;
36 const delivered = { ...optimistic, pendingMessage: undefined };
37 expect(appendPendingThreadMessages([delivered], [delivered], [queued])).toEqual([delivered]);
38 // Folded messages still count as delivered even when absent from the presented rows.
39 expect(appendPendingThreadMessages([], [delivered], [queued])).toEqual([]);
40 });
41});
apps/mobile/src/features/threads/pending-thread-feed.ts created+39
......@@ -0,0 +1,39 @@
1import type { ThreadFeedEntry } from "../../lib/threadActivity";
2import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
3
4export type PendingThreadFeedEntry = ThreadFeedEntry & {
5 readonly pendingMessage?: QueuedThreadMessage;
6 readonly acknowledged?: boolean;
7};
8
9/** Append the outbox after all presented activity, until the server echoes each message. */
10export function appendPendingThreadMessages(
11 presentedFeed: ReadonlyArray<ThreadFeedEntry>,
12 feed: ReadonlyArray<ThreadFeedEntry>,
13 queuedMessages: ReadonlyArray<QueuedThreadMessage>,
14): ReadonlyArray<PendingThreadFeedEntry> {
15 if (queuedMessages.length === 0) return presentedFeed;
16 const deliveredIds = new Set(
17 feed.flatMap((entry) => (entry.type === "message" ? [entry.message.id] : [])),
18 );
19 return [
20 ...presentedFeed,
21 ...queuedMessages
22 .filter((message) => !deliveredIds.has(message.messageId))
23 .map((pendingMessage): PendingThreadFeedEntry => ({
24 type: "message",
25 id: pendingMessage.messageId,
26 createdAt: pendingMessage.createdAt,
27 pendingMessage,
28 message: {
29 id: pendingMessage.messageId,
30 role: "user",
31 text: pendingMessage.text,
32 createdAt: pendingMessage.createdAt,
33 updatedAt: pendingMessage.createdAt,
34 turnId: null,
35 streaming: false,
36 },
37 })),
38 ];
39}
apps/mobile/src/state/acknowledged-thread-messages.ts created+27
......@@ -0,0 +1,27 @@
1import { Atom } from "effect/unstable/reactivity";
2import { appAtomRegistry } from "./atom-registry";
3import type { QueuedThreadMessage } from "./thread-outbox-model";
4
5// A command acknowledgment can precede its message in the subscribed timeline.
6// Keep the visible row until that projection arrives, independently of outbox cleanup.
7export const acknowledgedThreadMessagesAtom = Atom.make<ReadonlyArray<QueuedThreadMessage>>(
8 [],
9).pipe(Atom.keepAlive);
10
11export function retainAcknowledgedThreadMessage(message: QueuedThreadMessage) {
12 const current = appAtomRegistry.get(acknowledgedThreadMessagesAtom);
13 appAtomRegistry.set(
14 acknowledgedThreadMessagesAtom,
15 current.some((entry) => entry.messageId === message.messageId)
16 ? current
17 : [...current, message],
18 );
19}
20
21export function forgetAcknowledgedThreadMessage(message: QueuedThreadMessage) {
22 const current = appAtomRegistry.get(acknowledgedThreadMessagesAtom);
23 appAtomRegistry.set(
24 acknowledgedThreadMessagesAtom,
25 current.filter((entry) => entry.messageId !== message.messageId),
26 );
27}
apps/mobile/src/state/edit-pending-thread-message.test.ts created+109
......@@ -0,0 +1,109 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
3import type { QueuedThreadMessage } from "./thread-outbox-model";
4
5const state = vi.hoisted(() => ({
6 dispatching: null as string | null,
7 held: {} as Record<string, true>,
8 draft: { text: "Existing draft", attachments: [] as { id: string }[] },
9 confirm: vi.fn(async () => true),
10 remove: vi.fn(async () => true),
11 flush: vi.fn(async () => {}),
12}));
13vi.mock("./atom-registry", () => ({
14 appAtomRegistry: {
15 get: (atom: string) => (atom === "dispatching" ? state.dispatching : state.held),
16 },
17}));
18vi.mock("./use-thread-outbox", () => ({
19 dispatchingQueuedMessageIdAtom: "dispatching",
20 editingQueuedMessageIdsAtom: "editing",
21 holdEditingQueuedMessage: (id: string) => {
22 state.held[id] = true;
23 },
24 releaseEditingQueuedMessage: (id: string) => {
25 delete state.held[id];
26 },
27}));
28vi.mock("./thread-outbox", () => ({
29 confirmThreadOutboxMessageQueued: state.confirm,
30 threadOutboxRevision: () => 1,
31}));
32vi.mock("./thread-outbox-removal", () => ({ removeThreadOutboxMessage: state.remove }));
33vi.mock("./use-composer-drafts", () => ({
34 waitForComposerDraftsLoaded: async () => {},
35 getComposerDraftSnapshot: () => state.draft,
36 mergeComposerDraftContent: async (_key: string, message: QueuedThreadMessage) => {
37 state.draft = {
38 text: `${state.draft.text}\n\n${message.text}`,
39 attachments: [...state.draft.attachments, ...message.attachments],
40 };
41 },
42 updateComposerDraftSettings: () => {},
43 flushComposerDrafts: state.flush,
44 undoComposerDraftMerge: async (_key: string, snapshot: typeof state.draft) => {
45 state.draft = snapshot;
46 },
47}));
48import { editPendingThreadMessage } from "./edit-pending-thread-message";
49
50const message: QueuedThreadMessage = {
51 environmentId: EnvironmentId.make("env"),
52 threadId: ThreadId.make("thread"),
53 messageId: MessageId.make("message"),
54 commandId: CommandId.make("command"),
55 text: "Queued task",
56 createdAt: "2026-09-06T10:00:00.000Z",
57 attachments: [
58 {
59 id: "file",
60 type: "file",
61 name: "notes.txt",
62 mimeType: "text/plain",
63 sizeBytes: 10,
64 fileUri: "file:///notes.txt",
65 },
66 ],
67};
68beforeEach(() => {
69 vi.clearAllMocks();
70 state.dispatching = null;
71 state.held = {};
72 state.draft = { text: "Existing draft", attachments: [] };
73 state.confirm.mockResolvedValue(true);
74 state.remove.mockResolvedValue(true);
75 state.flush.mockResolvedValue(undefined);
76});
77describe("editing a pending message", () => {
78 it("locks delivery and persists text and attachments before removing the queued copy", async () => {
79 state.confirm.mockImplementationOnce(async () => {
80 expect(state.held[message.messageId]).toBe(true);
81 return true;
82 });
83 state.remove.mockImplementationOnce(async () => {
84 expect(state.flush).toHaveBeenCalled();
85 expect(state.draft.text).toBe("Existing draft\n\nQueued task");
86 expect(state.draft.attachments).toEqual(message.attachments);
87 return true;
88 });
89 expect(await editPendingThreadMessage(message)).toBe(true);
90 expect(state.held).toEqual({});
91 });
92 it("does not reclaim a message already being dispatched", async () => {
93 state.dispatching = message.messageId;
94 expect(await editPendingThreadMessage(message)).toBe(false);
95 expect(state.confirm).not.toHaveBeenCalled();
96 expect(state.draft.text).toBe("Existing draft");
97 });
98 it("rolls back the draft if removing the queued message fails", async () => {
99 state.remove.mockRejectedValueOnce(new Error("disk error"));
100 await expect(editPendingThreadMessage(message)).rejects.toThrow("disk error");
101 expect(state.draft).toEqual({ text: "Existing draft", attachments: [] });
102 expect(state.held).toEqual({});
103 });
104 it("rolls back when a newer queue revision wins", async () => {
105 state.remove.mockResolvedValueOnce(false);
106 expect(await editPendingThreadMessage(message)).toBe(false);
107 expect(state.draft.text).toBe("Existing draft");
108 });
109});
apps/mobile/src/state/edit-pending-thread-message.ts created+72
......@@ -0,0 +1,72 @@
1import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts";
2import { scopedThreadKey } from "../lib/scopedEntities";
3import { appAtomRegistry } from "./atom-registry";
4import {
5 confirmThreadOutboxMessageQueued,
6 threadOutboxRevision,
7 type QueuedThreadMessage,
8} from "./thread-outbox";
9import { removeThreadOutboxMessage } from "./thread-outbox-removal";
10import {
11 flushComposerDrafts,
12 getComposerDraftSnapshot,
13 mergeComposerDraftContent,
14 undoComposerDraftMerge,
15 updateComposerDraftSettings,
16 waitForComposerDraftsLoaded,
17} from "./use-composer-drafts";
18import {
19 dispatchingQueuedMessageIdAtom,
20 editingQueuedMessageIdsAtom,
21 holdEditingQueuedMessage,
22 releaseEditingQueuedMessage,
23} from "./use-thread-outbox";
24
25/** Take delivery ownership before any await; the durable draft then takes ownership of the files. */
26export async function editPendingThreadMessage(message: QueuedThreadMessage): Promise<boolean> {
27 if (
28 message.creation ||
29 appAtomRegistry.get(dispatchingQueuedMessageIdAtom) === message.messageId ||
30 appAtomRegistry.get(editingQueuedMessageIdsAtom)[message.messageId]
31 ) {
32 return false;
33 }
34 holdEditingQueuedMessage(message.messageId);
35 const draftKey = scopedThreadKey(message.environmentId, message.threadId);
36 let rollback: {
37 snapshot: ReturnType<typeof getComposerDraftSnapshot>;
38 merged: ReturnType<typeof getComposerDraftSnapshot>;
39 } | null = null;
40 try {
41 if (!(await confirmThreadOutboxMessageQueued(message))) return false;
42 const revision = threadOutboxRevision(message.messageId);
43 await waitForComposerDraftsLoaded();
44 const snapshot = getComposerDraftSnapshot(draftKey);
45 const attachmentIds = new Set(snapshot.attachments.map((attachment) => attachment.id));
46 for (const attachment of message.attachments) attachmentIds.add(attachment.id);
47 if (attachmentIds.size > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
48 throw new Error("Remove attachments from the composer before editing this message.");
49 }
50 try {
51 await mergeComposerDraftContent(draftKey, message);
52 } finally {
53 rollback = { snapshot, merged: getComposerDraftSnapshot(draftKey) };
54 }
55 updateComposerDraftSettings(draftKey, {
56 ...(message.modelSelection ? { modelSelection: message.modelSelection } : {}),
57 ...(message.runtimeMode ? { runtimeMode: message.runtimeMode } : {}),
58 ...(message.interactionMode ? { interactionMode: message.interactionMode } : {}),
59 });
60 rollback = { snapshot, merged: getComposerDraftSnapshot(draftKey) };
61 await flushComposerDrafts();
62 if (!(await removeThreadOutboxMessage(message, revision))) return false;
63 rollback = null;
64 return true;
65 } finally {
66 try {
67 if (rollback) await undoComposerDraftMerge(draftKey, rollback.snapshot, rollback.merged);
68 } finally {
69 releaseEditingQueuedMessage(message.messageId);
70 }
71 }
72}
apps/mobile/src/state/use-thread-composer-state.ts+35-5
......@@ -33,6 +33,8 @@ import {
3333import type { DraftComposerImageAttachment } from "../lib/composerImages";
3434import { scopedThreadKey } from "../lib/scopedEntities";
3535import { buildThreadFeed } from "../lib/threadActivity";
36import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages";
37import { appendPendingThreadMessages } from "../features/threads/pending-thread-feed";
3638import { appAtomRegistry } from "../state/atom-registry";
3739import {
3840 appendComposerDraftAttachments,
......@@ -102,6 +104,7 @@ export function useThreadComposerState() {
102104 const { selectedThread: selectedThreadShell, selectedEnvironmentRuntime } = useThreadSelection();
103105 const selectedThreadDetail = useSelectedThreadDetail();
104106 const composerDrafts = useAtomValue(composerDraftsAtom);
107 const acknowledgedMessages = useAtomValue(acknowledgedThreadMessagesAtom);
105108 const queuedMessagesByThreadKey = useThreadOutboxMessages();
106109 const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom);
107110 const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState<
......@@ -138,16 +141,41 @@ export function useThreadComposerState() {
138141 );
139142 const selectedThreadMessages = selectedThreadDetail?.messages;
140143 const selectedThreadActivities = selectedThreadDetail?.activities;
141 const selectedThreadFeed = useMemo(
142 () =>
144 const selectedThreadFeed = useMemo(() => {
145 const feed =
143146 selectedThreadMessages && selectedThreadActivities
144147 ? buildThreadFeed({
145148 messages: selectedThreadMessages,
146149 activities: selectedThreadActivities,
147150 })
148 : [],
149 [selectedThreadActivities, selectedThreadMessages],
150 );
151 : [];
152 const pendingAcknowledgments = acknowledgedMessages.filter(
153 (message) =>
154 scopedThreadKey(message.environmentId, message.threadId) === selectedThreadKey &&
155 !selectedThreadQueuedMessages.some((queued) => queued.messageId === message.messageId),
156 );
157 if (pendingAcknowledgments.length === 0) return feed;
158 return appendPendingThreadMessages(feed, feed, pendingAcknowledgments).map((entry) =>
159 entry.pendingMessage ? { ...entry, acknowledged: true } : entry,
160 );
161 }, [
162 selectedThreadActivities,
163 selectedThreadMessages,
164 selectedThreadKey,
165 selectedThreadQueuedMessages,
166 acknowledgedMessages,
167 ]);
168 useEffect(() => {
169 const echoedIds = new Set(selectedThreadMessages?.map((message) => message.id));
170 if (acknowledgedMessages.some((message) => echoedIds.has(message.messageId))) {
171 appAtomRegistry.set(
172 acknowledgedThreadMessagesAtom,
173 appAtomRegistry
174 .get(acknowledgedThreadMessagesAtom)
175 .filter((message) => !echoedIds.has(message.messageId)),
176 );
177 }
178 }, [acknowledgedMessages, selectedThreadMessages]);
151179
152180 const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null;
153181 const draftMessage = selectedDraft?.text ?? "";
......@@ -558,6 +586,8 @@ export function useThreadComposerState() {
558586 dismissFeedback,
559587 selectedThreadFeed,
560588 selectedThreadQueueCount,
589 selectedThreadQueuedMessages,
590 dispatchingQueuedMessageId,
561591 activeWorkStartedAt,
562592 isCompacting,
563593 draftMessage,
apps/mobile/src/state/use-thread-outbox-drain.test.ts+3
......@@ -1,3 +1,4 @@
1import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages";
12import {
23 CommandId,
34 EnvironmentId,
......@@ -193,6 +194,7 @@ function remainingMessages(): ReadonlyArray<QueuedThreadMessage> {
193194}
194195
195196beforeEach(() => {
197 appAtomRegistry.set(acknowledgedThreadMessagesAtom, []);
196198 harness.draftFile.setDocument({ schemaVersion: 1, drafts: {} });
197199});
198200
......@@ -431,6 +433,7 @@ describe("thread outbox drain delivery cleanup", () => {
431433 await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("removed");
432434
433435 expect(remainingMessages()).toEqual([]);
436 expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([message]);
434437 });
435438
436439 it("keeps a delivered message when its editor opens during storage removal", async () => {
apps/mobile/src/state/use-thread-outbox-drain.ts+7
......@@ -22,6 +22,10 @@ import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"
2222import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload";
2323import { randomHex } from "../lib/uuid";
2424import { isModelSelectionUnavailable } from "../lib/modelOptions";
25import {
26 retainAcknowledgedThreadMessage,
27 forgetAcknowledgedThreadMessage,
28} from "./acknowledged-thread-messages";
2529import { appAtomRegistry } from "./atom-registry";
2630import { useProjects, useServerConfigs, useThreadShells } from "./entities";
2731import { serverEnvironment } from "./server";
......@@ -196,6 +200,7 @@ export async function completeQueuedMessageDelivery(
196200 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) {
197201 return "edited";
198202 }
203 retainAcknowledgedThreadMessage(queuedMessage);
199204 // Removal also releases the message's local attachment files.
200205 const removed = await removeThreadOutboxMessage(
201206 queuedMessage,
......@@ -203,6 +208,7 @@ export async function completeQueuedMessageDelivery(
203208 () => !appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId],
204209 );
205210 if (!removed) {
211 forgetAcknowledgedThreadMessage(queuedMessage);
206212 console.warn(
207213 "[thread-outbox] delivered message was edited before cleanup; keeping the newer message",
208214 {
......@@ -215,6 +221,7 @@ export async function completeQueuedMessageDelivery(
215221 }
216222 return "removed";
217223 } catch (error) {
224 forgetAcknowledgedThreadMessage(queuedMessage);
218225 console.warn("[thread-outbox] failed to remove delivered queued message", {
219226 environmentId: queuedMessage.environmentId,
220227 threadId: queuedMessage.threadId,