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 {...@@ -35,9 +35,7 @@ import {
35} from "../../state/composer-attachment-uploads";35} from "../../state/composer-attachment-uploads";
36import Animated, {36import Animated, {
37 FadeIn,37 FadeIn,
38 FadeInDown,
39 FadeOut,38 FadeOut,
40 FadeOutDown,
41 LinearTransition,39 LinearTransition,
42 ReduceMotion,40 ReduceMotion,
43 useAnimatedStyle,41 useAnimatedStyle,
...@@ -272,12 +270,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(...@@ -272,12 +270,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
272}) {270}) {
273 const isReconnecting = props.status.kind === "reconnecting";271 const isReconnecting = props.status.kind === "reconnecting";
274 return (272 return (
275 <Animated.View273 <View className="absolute inset-x-0 bottom-full items-center pb-2" pointerEvents="box-none">
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 >
281 <Pressable274 <Pressable
282 accessibilityRole="button"275 accessibilityRole="button"
283 onPress={props.onPress}276 onPress={props.onPress}
...@@ -295,7 +288,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(...@@ -295,7 +288,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
295 {props.status.label}288 {props.status.label}
296 </Text>289 </Text>
297 </Pressable>290 </Pressable>
298 </Animated.View>291 </View>
299 );292 );
300});293});
301294
...@@ -891,16 +884,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -891,16 +884,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
891 </ComposerDictationToolbar>884 </ComposerDictationToolbar>
892 </Animated.View>885 </Animated.View>
893 </ComposerSurface>886 </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}
904 </Animated.View>887 </Animated.View>
905888
906 <VideoPreviewModal source={previewVideo} onRequestClose={closePreview} />889 <VideoPreviewModal source={previewVideo} onRequestClose={closePreview} />
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+47-10
...@@ -37,6 +37,7 @@ import {...@@ -37,6 +37,7 @@ import {
37 useState,37 useState,
38} from "react";38} from "react";
39import {39import {
40 Alert,
40 AppState,41 AppState,
41 Keyboard,42 Keyboard,
42 Platform,43 Platform,
...@@ -67,6 +68,8 @@ import type { StatusTone } from "../../components/StatusPill";...@@ -67,6 +68,8 @@ import type { StatusTone } from "../../components/StatusPill";
67import type { DraftComposerAttachment } from "../../lib/composerImages";68import type { DraftComposerAttachment } from "../../lib/composerImages";
68import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";69import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
69import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";70import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
71import { editPendingThreadMessage } from "../../state/edit-pending-thread-message";
72import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
70import { scopedThreadKey } from "../../lib/scopedEntities";73import { scopedThreadKey } from "../../lib/scopedEntities";
71import type {74import type {
72 PendingApproval,75 PendingApproval,
...@@ -127,6 +130,8 @@ export interface ThreadDetailScreenProps {...@@ -127,6 +130,8 @@ export interface ThreadDetailScreenProps {
127 readonly projectWorkspaceRoot: string | null;130 readonly projectWorkspaceRoot: string | null;
128 readonly threadCwd: string | null;131 readonly threadCwd: string | null;
129 readonly selectedThreadQueueCount: number;132 readonly selectedThreadQueueCount: number;
133 readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;
134 readonly dispatchingMessageId: MessageId | null;
130 readonly serverConfig: T3ServerConfig | null;135 readonly serverConfig: T3ServerConfig | null;
131 readonly layoutVariant?: LayoutVariant;136 readonly layoutVariant?: LayoutVariant;
132 readonly usesAutomaticContentInsets?: boolean;137 readonly usesAutomaticContentInsets?: boolean;
...@@ -348,6 +353,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -348,6 +353,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
348 return null;353 return null;
349 })();354 })();
350 const showWorkingControl = floatingStatus !== null;355 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 );
351 const selectedThreadFeed = props.selectedThreadFeed;365 const selectedThreadFeed = props.selectedThreadFeed;
352 const hasCompactableConversation =366 const hasCompactableConversation =
353 selectedThreadFeed.some(367 selectedThreadFeed.some(
...@@ -487,14 +501,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -487,14 +501,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
487 const userInputInsetProgress = useSharedValue(1);501 const userInputInsetProgress = useSharedValue(1);
488 const userInputCardCoverage = useSharedValue(0);502 const userInputCardCoverage = useSharedValue(0);
489 const floatingControlCoverage = useSharedValue(503 const floatingControlCoverage = useSharedValue(
490 showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0,504 showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0,
491 );505 );
492 useEffect(() => {506 useEffect(() => {
493 floatingControlCoverage.value = withTiming(507 floatingControlCoverage.value = withTiming(
494 showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0,508 showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0,
495 { duration: 180, reduceMotion: ReduceMotion.System },509 { duration: 180, reduceMotion: ReduceMotion.System },
496 );510 );
497 }, [floatingControlCoverage, showWorkingControl]);511 }, [floatingControlCoverage, showFloatingStatus]);
498 // Android renders the expanded card in-flow (it cannot hit-test the iOS512 // Android renders the expanded card in-flow (it cannot hit-test the iOS
499 // overlay outside the bar's bounds), so its measured overlay height already513 // overlay outside the bar's bounds), so its measured overlay height already
500 // includes the card — the coverage extra is iOS-only.514 // includes the card — the coverage extra is iOS-only.
...@@ -554,12 +568,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -554,12 +568,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
554 useEffect(() => {568 useEffect(() => {
555 const previous = previousWorkingControlStateRef.current;569 const previous = previousWorkingControlStateRef.current;
556 const threadChanged = previous.threadKey !== selectedThreadKey;570 const threadChanged = previous.threadKey !== selectedThreadKey;
557 const visibilityChanged = previous.visible !== showWorkingControl;571 const visibilityChanged = previous.visible !== showFloatingStatus;
558 previousWorkingControlStateRef.current = {572 previousWorkingControlStateRef.current = {
559 threadKey: selectedThreadKey,573 threadKey: selectedThreadKey,
560 visible: showWorkingControl,574 visible: showFloatingStatus,
561 };575 };
562 if ((!threadChanged && !visibilityChanged) || (threadChanged && !showWorkingControl)) {576 if ((!threadChanged && !visibilityChanged) || (threadChanged && !showFloatingStatus)) {
563 return;577 return;
564 }578 }
565 // LegendList applies the larger inset but does not re-anchor short579 // LegendList applies the larger inset but does not re-anchor short
...@@ -567,7 +581,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -567,7 +581,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
567 // initial load. Re-pin after the finite inset transition; the callback581 // initial load. Re-pin after the finite inset transition; the callback
568 // checks follow state again so a user who scrolled up stays put.582 // checks follow state again so a user who scrolled up stays put.
569 scheduleOverlayRepin(230);583 scheduleOverlayRepin(230);
570 }, [scheduleOverlayRepin, selectedThreadKey, showWorkingControl]);584 }, [scheduleOverlayRepin, selectedThreadKey, showFloatingStatus]);
571 const handleToggleUserInputCollapsed = useCallback(() => {585 const handleToggleUserInputCollapsed = useCallback(() => {
572 if (activeUserInputRequestId === null) {586 if (activeUserInputRequestId === null) {
573 return;587 return;
...@@ -634,11 +648,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -634,11 +648,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
634 useEffect(() => {648 useEffect(() => {
635 if (649 if (
636 submittedMessageId === null ||650 submittedMessageId === null ||
651 anchorMessageId !== submittedMessageId ||
637 lastScrolledSubmittedMessageIdRef.current === submittedMessageId ||652 lastScrolledSubmittedMessageIdRef.current === submittedMessageId ||
638 contentPresentationKind !== "ready" ||653 contentPresentationKind !== "ready" ||
639 !selectedThreadFeed.some(654 (!selectedThreadFeed.some(
640 (entry) => entry.type === "message" && entry.id === submittedMessageId,655 (entry) => entry.type === "message" && entry.id === submittedMessageId,
641 )656 ) &&
657 !props.queuedMessages.some((message) => message.messageId === submittedMessageId))
642 ) {658 ) {
643 return;659 return;
644 }660 }
...@@ -677,9 +693,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -677,9 +693,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
677 });693 });
678 return () => cancelAnimationFrame(frame);694 return () => cancelAnimationFrame(frame);
679 }, [695 }, [
696 anchorMessageId,
680 submittedMessageId,697 submittedMessageId,
681 freeze,698 freeze,
682 contentPresentationKind,699 contentPresentationKind,
700 props.queuedMessages,
683 selectedThreadFeed,701 selectedThreadFeed,
684 scrollMessageToEnd,702 scrollMessageToEnd,
685 selectedThreadKey,703 selectedThreadKey,
...@@ -720,6 +738,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -720,6 +738,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
720 selectedThreadKey,738 selectedThreadKey,
721 ]);739 ]);
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
723 const collapseComposer = useCallback(() => {757 const collapseComposer = useCallback(() => {
724 composerEditorRef.current?.blur();758 composerEditorRef.current?.blur();
725 }, []);759 }, []);
...@@ -797,6 +831,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -797,6 +831,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
797 threadId={props.selectedThread.id}831 threadId={props.selectedThread.id}
798 workspaceRoot={props.threadCwd}832 workspaceRoot={props.threadCwd}
799 feed={props.selectedThreadFeed}833 feed={props.selectedThreadFeed}
834 queuedMessages={props.queuedMessages}
835 dispatchingMessageId={props.dispatchingMessageId}
836 onEditPendingMessage={handleEditPendingMessage}
800 contentPresentation={props.contentPresentation}837 contentPresentation={props.contentPresentation}
801 agentLabel={agentLabel}838 agentLabel={agentLabel}
802 latestTurn={props.selectedThread.latestTurn}839 latestTurn={props.selectedThread.latestTurn}
...@@ -808,7 +845,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -808,7 +845,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
808 contentInsetEndAdjustment={combinedContentInsetEndAdjustment}845 contentInsetEndAdjustment={combinedContentInsetEndAdjustment}
809 contentTopInset={0}846 contentTopInset={0}
810 contentBottomInset={847 contentBottomInset={
811 estimatedOverlayHeight + (showWorkingControl ? FLOATING_WORKING_CONTROL_COVERAGE : 0)848 estimatedOverlayHeight + (showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0)
812 }849 }
813 contentMaxWidth={contentMaxWidth}850 contentMaxWidth={contentMaxWidth}
814 layoutVariant={layoutVariant}851 layoutVariant={layoutVariant}
apps/mobile/src/features/threads/ThreadFeed.tsx+85-33
...@@ -72,12 +72,7 @@ import { FilePreviewModal, type FilePreviewSource } from "../../components/FileP...@@ -72,12 +72,7 @@ import { FilePreviewModal, type FilePreviewSource } from "../../components/FileP
72import { isPdfFile } from "../../lib/filePreview";72import { isPdfFile } from "../../lib/filePreview";
73import { PresentationSource } from "../../components/NativePresentation";73import { PresentationSource } from "../../components/NativePresentation";
74import { useSafeAreaInsets } from "react-native-safe-area-context";74import { useSafeAreaInsets } from "react-native-safe-area-context";
75import Animated, {75import Animated, { FadeIn, LinearTransition, type SharedValue } from "react-native-reanimated";
76 FadeIn,
77 FadeInUp,
78 LinearTransition,
79 type SharedValue,
80} from "react-native-reanimated";
81import { useUniwindTheme } from "../../lib/useUniwindTheme";76import { useUniwindTheme } from "../../lib/useUniwindTheme";
82import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";77import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
83import { useFontFamily } from "../../lib/useFontFamily";78import { useFontFamily } from "../../lib/useFontFamily";
...@@ -163,6 +158,8 @@ import {...@@ -163,6 +158,8 @@ import {
163 THREAD_DISCLOSURE_TRANSITION_MS,158 THREAD_DISCLOSURE_TRANSITION_MS,
164 WORK_GROUP_TOGGLE_HEIGHT,159 WORK_GROUP_TOGGLE_HEIGHT,
165} from "./thread-work-log";160} from "./thread-work-log";
161import { appendPendingThreadMessages, type PendingThreadFeedEntry } from "./pending-thread-feed";
162import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
166import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";163import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
167import {164import {
168 assetEnvironment,165 assetEnvironment,
...@@ -231,6 +228,9 @@ function isFreshTimestamp(input: string): boolean {...@@ -231,6 +228,9 @@ function isFreshTimestamp(input: string): boolean {
231}228}
232229
233export interface ThreadFeedProps {230export interface ThreadFeedProps {
231 readonly queuedMessages: ReadonlyArray<QueuedThreadMessage>;
232 readonly dispatchingMessageId: MessageId | null;
233 readonly onEditPendingMessage: (message: QueuedThreadMessage) => void;
234 readonly environmentId: EnvironmentId;234 readonly environmentId: EnvironmentId;
235 readonly threadId: ThreadId;235 readonly threadId: ThreadId;
236 readonly workspaceRoot?: string | null;236 readonly workspaceRoot?: string | null;
...@@ -1324,8 +1324,15 @@ function useMarkdownStyles(...@@ -1324,8 +1324,15 @@ function useMarkdownStyles(
1324}1324}
13251325
1326function renderFeedEntry(1326function renderFeedEntry(
1327 info: { item: ThreadFeedEntry; index: number },1327 info: { item: PendingThreadFeedEntry; index: number },
1328 props: Pick<ThreadFeedProps, "environmentId" | "onUseArtifactTemplate" | "skills"> & {1328 props: Pick<
1329 ThreadFeedProps,
1330 | "environmentId"
1331 | "onUseArtifactTemplate"
1332 | "skills"
1333 | "dispatchingMessageId"
1334 | "onEditPendingMessage"
1335 > & {
1329 readonly copiedRowId: string | null;1336 readonly copiedRowId: string | null;
1330 readonly expandedWorkRows: Record<string, boolean>;1337 readonly expandedWorkRows: Record<string, boolean>;
1331 readonly workRowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;1338 readonly workRowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
...@@ -1470,12 +1477,8 @@ function renderFeedEntry(...@@ -1470,12 +1477,8 @@ function renderFeedEntry(
1470 !message.streaming;1477 !message.streaming;
14711478
1472 if (isUser) {1479 if (isUser) {
1473 const enterAnimated = isFreshTimestamp(message.createdAt);
1474 return (1480 return (
1475 <Animated.View1481 <View className="mb-5 items-end">
1476 className="mb-5 items-end"
1477 {...(enterAnimated ? { entering: FadeInUp.duration(220) } : {})}
1478 >
1479 <View1482 <View
1480 className="min-w-0 gap-2 rounded-[20px] px-3.5 py-2.5"1483 className="min-w-0 gap-2 rounded-[20px] px-3.5 py-2.5"
1481 style={{1484 style={{
...@@ -1502,6 +1505,28 @@ function renderFeedEntry(...@@ -1502,6 +1505,28 @@ function renderFeedEntry(
1502 />1505 />
1503 </MarkdownImageAvailableWidthContext>1506 </MarkdownImageAvailableWidthContext>
1504 ) : null}1507 ) : 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 )}
1505 {attachments.map((attachment) => {1530 {attachments.map((attachment) => {
1506 return isImageAttachment(attachment) ? (1531 return isImageAttachment(attachment) ? (
1507 <MessageAttachmentImage1532 <MessageAttachmentImage
...@@ -1528,8 +1553,24 @@ function renderFeedEntry(...@@ -1528,8 +1553,24 @@ function renderFeedEntry(
1528 </View>1553 </View>
1529 <View className="mt-1 flex-row items-center justify-end gap-1 pr-0.5">1554 <View className="mt-1 flex-row items-center justify-end gap-1 pr-0.5">
1530 <Text className="font-t3-medium text-xs tabular-nums text-adaptive-neutral-600-400">1555 <Text className="font-t3-medium text-xs tabular-nums text-adaptive-neutral-600-400">
1531 {timestampLabel}1556 {entry.pendingMessage && !entry.acknowledged ? "Pending" : timestampLabel}
1532 </Text>1557 </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}
1533 {message.text.trim().length > 0 ? (1574 {message.text.trim().length > 0 ? (
1534 <CopyTextButton1575 <CopyTextButton
1535 accessibilityLabel="Copy message"1576 accessibilityLabel="Copy message"
...@@ -1540,7 +1581,7 @@ function renderFeedEntry(...@@ -1540,7 +1581,7 @@ function renderFeedEntry(
1540 />1581 />
1541 ) : null}1582 ) : null}
1542 </View>1583 </View>
1543 </Animated.View>1584 </View>
1544 );1585 );
1545 }1586 }
15461587
...@@ -2227,6 +2268,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2227,6 +2268,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2227 // Keep row-local interaction props in extraData so disclosures and copy feedback repaint.2268 // Keep row-local interaction props in extraData so disclosures and copy feedback repaint.
2228 const listAppearanceData = useMemo(2269 const listAppearanceData = useMemo(
2229 () => ({2270 () => ({
2271 dispatchingMessageId: props.dispatchingMessageId,
2230 copiedRowId,2272 copiedRowId,
2231 expandedWorkRows,2273 expandedWorkRows,
2232 workRowSizing,2274 workRowSizing,
...@@ -2238,6 +2280,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2238,6 +2280,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2238 viewportWidth,2280 viewportWidth,
2239 }),2281 }),
2240 [2282 [
2283 props.dispatchingMessageId,
2241 copiedRowId,2284 copiedRowId,
2242 expandedWorkRows,2285 expandedWorkRows,
2243 workRowSizing,2286 workRowSizing,
...@@ -2382,14 +2425,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2382,14 +2425,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2382 }, [expandedWorkGroups]);2425 }, [expandedWorkGroups]);
2383 const presentedFeed = useMemo(2426 const presentedFeed = useMemo(
2384 () =>2427 () =>
2385 deriveThreadFeedPresentation(2428 appendPendingThreadMessages(
2429 deriveThreadFeedPresentation(
2430 props.feed,
2431 props.latestTurn,
2432 expandedTurnIds,
2433 expandedWorkGroupIds,
2434 props.activeWorkStartedAt,
2435 ),
2386 props.feed,2436 props.feed,
2387 props.latestTurn,2437 props.queuedMessages,
2388 expandedTurnIds,
2389 expandedWorkGroupIds,
2390 props.activeWorkStartedAt,
2391 ),2438 ),
2392 [2439 [
2440 props.queuedMessages,
2393 expandedTurnIds,2441 expandedTurnIds,
2394 expandedWorkGroupIds,2442 expandedWorkGroupIds,
2395 props.activeWorkStartedAt,2443 props.activeWorkStartedAt,
...@@ -2401,7 +2449,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2401,7 +2449,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2401 // content-inset override. Seed the fresh instance synchronously with the2449 // content-inset override. Seed the fresh instance synchronously with the
2402 // current overlay height before the scroll integration's next reaction;2450 // current overlay height before the scroll integration's next reaction;
2403 // on Android the declarative contentInset floor covers this same window.2451 // 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"}`;
2405 useLayoutEffect(() => {2453 useLayoutEffect(() => {
2406 const bottom = props.contentInsetEndAdjustment.value;2454 const bottom = props.contentInsetEndAdjustment.value;
2407 if (bottom > 0) {2455 if (bottom > 0) {
...@@ -2646,7 +2694,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2646,7 +2694,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2646 // Disclosures can mount existing offscreen rows as well as new work rows.2694 // Disclosures can mount existing offscreen rows as well as new work rows.
2647 // Fade those in after movement; never retain removed rows over replacements.2695 // Fade those in after movement; never retain removed rows over replacements.
2648 const renderItem = useCallback(2696 const renderItem = useCallback(
2649 (info: { item: ThreadFeedEntry; index: number }) => (2697 (info: { item: PendingThreadFeedEntry; index: number }) => (
2650 <Animated.View2698 <Animated.View
2651 key={info.item.id}2699 key={info.item.id}
2652 entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined}2700 entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined}
...@@ -2654,6 +2702,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2654,6 +2702,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2654 <ThreadMediaVisibility>2702 <ThreadMediaVisibility>
2655 {renderFeedEntry(info, {2703 {renderFeedEntry(info, {
2656 environmentId: props.environmentId,2704 environmentId: props.environmentId,
2705 dispatchingMessageId: props.dispatchingMessageId,
2706 onEditPendingMessage: props.onEditPendingMessage,
2657 copiedRowId,2707 copiedRowId,
2658 expandedWorkRows,2708 expandedWorkRows,
2659 workRowSizing,2709 workRowSizing,
...@@ -2685,6 +2735,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2685,6 +2735,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2685 </Animated.View>2735 </Animated.View>
2686 ),2736 ),
2687 [2737 [
2738 props.dispatchingMessageId,
2739 props.onEditPendingMessage,
2688 copiedRowId,2740 copiedRowId,
2689 disclosureToggleSettling,2741 disclosureToggleSettling,
2690 expandedWorkRows,2742 expandedWorkRows,
...@@ -2716,7 +2768,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2716,7 +2768,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2716 ],2768 ],
2717 );2769 );
27182770
2719 if (props.contentPresentation.kind === "unavailable") {2771 if (props.contentPresentation.kind === "unavailable" && props.queuedMessages.length === 0) {
2720 return (2772 return (
2721 <ThreadFeedPlaceholder2773 <ThreadFeedPlaceholder
2722 title={props.contentPresentation.title}2774 title={props.contentPresentation.title}
...@@ -2789,17 +2841,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2789,17 +2841,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2789 // targets land one safe-area short of the true resting offset.2841 // targets land one safe-area short of the true resting offset.
2790 adjustedInsetCompensation={usesNativeAutomaticInsets ? insets.bottom : 0}2842 adjustedInsetCompensation={usesNativeAutomaticInsets ? insets.bottom : 0}
2791 freeze={props.freeze}2843 freeze={props.freeze}
2792 // Animated: on send, the optimistic message's dataChange fires2844 // Follow the measured end immediately. Animating toward an estimated
2793 // maintainScrollAtEnd before any render-cycle suppression could2845 // end races row measurement when a pending message is acknowledged.
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.
2798 maintainScrollAtEnd={2846 maintainScrollAtEnd={
2799 disclosureToggleSettling || !endFollowEnabled2847 disclosureToggleSettling || !endFollowEnabled
2800 ? false2848 ? false
2801 : {2849 : {
2802 animated: true,2850 animated: false,
2803 on: {2851 on: {
2804 dataChange: true,2852 dataChange: true,
2805 itemLayout: true,2853 itemLayout: true,
...@@ -2807,7 +2855,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2807,7 +2855,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2807 },2855 },
2808 }2856 }
2809 }2857 }
2810 maintainVisibleContentPosition={maintainVisibleContentPosition}2858 maintainVisibleContentPosition={
2859 endFollowEnabled && !disclosureToggleSettling ? false : maintainVisibleContentPosition
2860 }
2811 data={presentedFeed}2861 data={presentedFeed}
2812 extraData={listAppearanceData}2862 extraData={listAppearanceData}
2813 renderItem={renderItem}2863 renderItem={renderItem}
...@@ -2817,7 +2867,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2817,7 +2867,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2817 entry.type === "message" ? `message:${entry.message.role}` : entry.type2867 entry.type === "message" ? `message:${entry.message.role}` : entry.type
2818 }2868 }
2819 getFixedItemSize={getFixedItemSize}2869 getFixedItemSize={getFixedItemSize}
2820 itemLayoutAnimation={THREAD_FEED_LAYOUT_TRANSITION}2870 itemLayoutAnimation={
2871 disclosureToggleSettling ? THREAD_FEED_LAYOUT_TRANSITION : undefined
2872 }
2821 onItemSizeChanged={handleItemSizeChanged}2873 onItemSizeChanged={handleItemSizeChanged}
2822 // Measure rows well before they scroll into view so estimate→actual2874 // Measure rows well before they scroll into view so estimate→actual
2823 // corrections land offscreen instead of under the user's finger.2875 // corrections land offscreen instead of under the user's finger.
...@@ -2875,7 +2927,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2875,7 +2927,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2875 }}2927 }}
2876 />2928 />
2877 </View>2929 </View>
2878 {props.feed.length === 0 &&2930 {presentedFeed.length === 0 &&
2879 props.activeWorkStartedAt === null &&2931 props.activeWorkStartedAt === null &&
2880 props.contentPresentation.kind === "ready" ? (2932 props.contentPresentation.kind === "ready" ? (
2881 <View pointerEvents="none" style={StyleSheet.absoluteFill}>2933 <View pointerEvents="none" style={StyleSheet.absoluteFill}>
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+2
...@@ -807,6 +807,8 @@ function ThreadRouteContent(...@@ -807,6 +807,8 @@ function ThreadRouteContent(
807 projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}807 projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
808 threadCwd={selectedThreadCwd}808 threadCwd={selectedThreadCwd}
809 selectedThreadQueueCount={composer.selectedThreadQueueCount}809 selectedThreadQueueCount={composer.selectedThreadQueueCount}
810 queuedMessages={composer.selectedThreadQueuedMessages}
811 dispatchingMessageId={composer.dispatchingQueuedMessageId}
810 layoutVariant={layout.variant}812 layoutVariant={layout.variant}
811 usesAutomaticContentInsets={usesNativeHeaderGlass}813 usesAutomaticContentInsets={usesNativeHeaderGlass}
812 onOpenConnectionEditor={handleOpenConnectionEditor}814 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 {...@@ -33,6 +33,8 @@ import {
33import type { DraftComposerImageAttachment } from "../lib/composerImages";33import type { DraftComposerImageAttachment } from "../lib/composerImages";
34import { scopedThreadKey } from "../lib/scopedEntities";34import { scopedThreadKey } from "../lib/scopedEntities";
35import { buildThreadFeed } from "../lib/threadActivity";35import { buildThreadFeed } from "../lib/threadActivity";
36import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages";
37import { appendPendingThreadMessages } from "../features/threads/pending-thread-feed";
36import { appAtomRegistry } from "../state/atom-registry";38import { appAtomRegistry } from "../state/atom-registry";
37import {39import {
38 appendComposerDraftAttachments,40 appendComposerDraftAttachments,
...@@ -102,6 +104,7 @@ export function useThreadComposerState() {...@@ -102,6 +104,7 @@ export function useThreadComposerState() {
102 const { selectedThread: selectedThreadShell, selectedEnvironmentRuntime } = useThreadSelection();104 const { selectedThread: selectedThreadShell, selectedEnvironmentRuntime } = useThreadSelection();
103 const selectedThreadDetail = useSelectedThreadDetail();105 const selectedThreadDetail = useSelectedThreadDetail();
104 const composerDrafts = useAtomValue(composerDraftsAtom);106 const composerDrafts = useAtomValue(composerDraftsAtom);
107 const acknowledgedMessages = useAtomValue(acknowledgedThreadMessagesAtom);
105 const queuedMessagesByThreadKey = useThreadOutboxMessages();108 const queuedMessagesByThreadKey = useThreadOutboxMessages();
106 const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom);109 const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom);
107 const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState<110 const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState<
...@@ -138,16 +141,41 @@ export function useThreadComposerState() {...@@ -138,16 +141,41 @@ export function useThreadComposerState() {
138 );141 );
139 const selectedThreadMessages = selectedThreadDetail?.messages;142 const selectedThreadMessages = selectedThreadDetail?.messages;
140 const selectedThreadActivities = selectedThreadDetail?.activities;143 const selectedThreadActivities = selectedThreadDetail?.activities;
141 const selectedThreadFeed = useMemo(144 const selectedThreadFeed = useMemo(() => {
142 () =>145 const feed =
143 selectedThreadMessages && selectedThreadActivities146 selectedThreadMessages && selectedThreadActivities
144 ? buildThreadFeed({147 ? buildThreadFeed({
145 messages: selectedThreadMessages,148 messages: selectedThreadMessages,
146 activities: selectedThreadActivities,149 activities: selectedThreadActivities,
147 })150 })
148 : [],151 : [];
149 [selectedThreadActivities, selectedThreadMessages],152 const pendingAcknowledgments = acknowledgedMessages.filter(
150 );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
152 const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null;180 const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null;
153 const draftMessage = selectedDraft?.text ?? "";181 const draftMessage = selectedDraft?.text ?? "";
...@@ -558,6 +586,8 @@ export function useThreadComposerState() {...@@ -558,6 +586,8 @@ export function useThreadComposerState() {
558 dismissFeedback,586 dismissFeedback,
559 selectedThreadFeed,587 selectedThreadFeed,
560 selectedThreadQueueCount,588 selectedThreadQueueCount,
589 selectedThreadQueuedMessages,
590 dispatchingQueuedMessageId,
561 activeWorkStartedAt,591 activeWorkStartedAt,
562 isCompacting,592 isCompacting,
563 draftMessage,593 draftMessage,
apps/mobile/src/state/use-thread-outbox-drain.test.ts+3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages";
1import {2import {
2 CommandId,3 CommandId,
3 EnvironmentId,4 EnvironmentId,
...@@ -193,6 +194,7 @@ function remainingMessages(): ReadonlyArray<QueuedThreadMessage> {...@@ -193,6 +194,7 @@ function remainingMessages(): ReadonlyArray<QueuedThreadMessage> {
193}194}
194195
195beforeEach(() => {196beforeEach(() => {
197 appAtomRegistry.set(acknowledgedThreadMessagesAtom, []);
196 harness.draftFile.setDocument({ schemaVersion: 1, drafts: {} });198 harness.draftFile.setDocument({ schemaVersion: 1, drafts: {} });
197});199});
198200
...@@ -431,6 +433,7 @@ describe("thread outbox drain delivery cleanup", () => {...@@ -431,6 +433,7 @@ describe("thread outbox drain delivery cleanup", () => {
431 await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("removed");433 await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("removed");
432434
433 expect(remainingMessages()).toEqual([]);435 expect(remainingMessages()).toEqual([]);
436 expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([message]);
434 });437 });
435438
436 it("keeps a delivered message when its editor opens during storage removal", async () => {439 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"...@@ -22,6 +22,10 @@ import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"
22import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload";22import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload";
23import { randomHex } from "../lib/uuid";23import { randomHex } from "../lib/uuid";
24import { isModelSelectionUnavailable } from "../lib/modelOptions";24import { isModelSelectionUnavailable } from "../lib/modelOptions";
25import {
26 retainAcknowledgedThreadMessage,
27 forgetAcknowledgedThreadMessage,
28} from "./acknowledged-thread-messages";
25import { appAtomRegistry } from "./atom-registry";29import { appAtomRegistry } from "./atom-registry";
26import { useProjects, useServerConfigs, useThreadShells } from "./entities";30import { useProjects, useServerConfigs, useThreadShells } from "./entities";
27import { serverEnvironment } from "./server";31import { serverEnvironment } from "./server";
...@@ -196,6 +200,7 @@ export async function completeQueuedMessageDelivery(...@@ -196,6 +200,7 @@ export async function completeQueuedMessageDelivery(
196 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) {200 if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) {
197 return "edited";201 return "edited";
198 }202 }
203 retainAcknowledgedThreadMessage(queuedMessage);
199 // Removal also releases the message's local attachment files.204 // Removal also releases the message's local attachment files.
200 const removed = await removeThreadOutboxMessage(205 const removed = await removeThreadOutboxMessage(
201 queuedMessage,206 queuedMessage,
...@@ -203,6 +208,7 @@ export async function completeQueuedMessageDelivery(...@@ -203,6 +208,7 @@ export async function completeQueuedMessageDelivery(
203 () => !appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId],208 () => !appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId],
204 );209 );
205 if (!removed) {210 if (!removed) {
211 forgetAcknowledgedThreadMessage(queuedMessage);
206 console.warn(212 console.warn(
207 "[thread-outbox] delivered message was edited before cleanup; keeping the newer message",213 "[thread-outbox] delivered message was edited before cleanup; keeping the newer message",
208 {214 {
...@@ -215,6 +221,7 @@ export async function completeQueuedMessageDelivery(...@@ -215,6 +221,7 @@ export async function completeQueuedMessageDelivery(
215 }221 }
216 return "removed";222 return "removed";
217 } catch (error) {223 } catch (error) {
224 forgetAcknowledgedThreadMessage(queuedMessage);
218 console.warn("[thread-outbox] failed to remove delivered queued message", {225 console.warn("[thread-outbox] failed to remove delivered queued message", {
219 environmentId: queuedMessage.environmentId,226 environmentId: queuedMessage.environmentId,
220 threadId: queuedMessage.threadId,227 threadId: queuedMessage.threadId,