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