| 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,9 +35,7 @@ import { |
| 35 | } from "../../state/composer-attachment-uploads"; | 35 | } from "../../state/composer-attachment-uploads"; |
| 36 | import Animated, { | 36 | import 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.View | 273 | <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 | <Pressable | 274 | <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 | }); |
| 301 | 294 | ||
| ... | @@ -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> |
| 905 | 888 | ||
| 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"; |
| 39 | import { | 39 | import { |
| 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"; |
| 67 | import type { DraftComposerAttachment } from "../../lib/composerImages"; | 68 | import type { DraftComposerAttachment } from "../../lib/composerImages"; |
| 68 | import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; | 69 | import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; |
| 69 | import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; | 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 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 73 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 71 | import type { | 74 | import 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 iOS | 512 | // 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 already | 513 | // 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 short | 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,7 +581,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 567 | // initial load. Re-pin after the finite inset transition; the callback | 581 | // 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 | ]); |
| 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 | 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 |
| 72 | import { isPdfFile } from "../../lib/filePreview"; | 72 | import { isPdfFile } from "../../lib/filePreview"; |
| 73 | import { PresentationSource } from "../../components/NativePresentation"; | 73 | import { PresentationSource } from "../../components/NativePresentation"; |
| 74 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 74 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 75 | import Animated, { | 75 | import Animated, { FadeIn, LinearTransition, type SharedValue } from "react-native-reanimated"; |
| 76 | FadeIn, | ||
| 77 | FadeInUp, | ||
| 78 | LinearTransition, | ||
| 79 | type SharedValue, | ||
| 80 | } from "react-native-reanimated"; | ||
| 81 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; | 76 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 82 | import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; | 77 | import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; |
| 83 | import { useFontFamily } from "../../lib/useFontFamily"; | 78 | import { 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"; |
| 161 | import { appendPendingThreadMessages, type PendingThreadFeedEntry } from "./pending-thread-feed"; | ||
| 162 | import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; | ||
| 166 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; | 163 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; |
| 167 | import { | 164 | import { |
| 168 | assetEnvironment, | 165 | assetEnvironment, |
| ... | @@ -231,6 +228,9 @@ function isFreshTimestamp(input: string): boolean { | ... | @@ -231,6 +228,9 @@ function isFreshTimestamp(input: string): boolean { |
| 231 | } | 228 | } |
| 232 | 229 | ||
| 233 | export interface ThreadFeedProps { | 230 | export 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 | } |
| 1325 | 1325 | ||
| 1326 | function renderFeedEntry( | 1326 | function 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; |
| 1471 | 1478 | ||
| 1472 | if (isUser) { | 1479 | if (isUser) { |
| 1473 | const enterAnimated = isFreshTimestamp(message.createdAt); | ||
| 1474 | return ( | 1480 | return ( |
| 1475 | <Animated.View | 1481 | <View className="mb-5 items-end"> |
| 1476 | className="mb-5 items-end" | ||
| 1477 | {...(enterAnimated ? { entering: FadeInUp.duration(220) } : {})} | ||
| 1478 | > | ||
| 1479 | <View | 1482 | <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 | <MessageAttachmentImage | 1532 | <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 | <CopyTextButton | 1575 | <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 | } |
| 1546 | 1587 | ||
| ... | @@ -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 the | 2449 | // 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.View | 2698 | <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 | ); |
| 2718 | 2770 | ||
| 2719 | if (props.contentPresentation.kind === "unavailable") { | 2771 | if (props.contentPresentation.kind === "unavailable" && props.queuedMessages.length === 0) { |
| 2720 | return ( | 2772 | return ( |
| 2721 | <ThreadFeedPlaceholder | 2773 | <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 fires | 2844 | // Follow the measured end immediately. Animating toward an estimated |
| 2793 | // maintainScrollAtEnd before any render-cycle suppression could | 2845 | // 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 || !endFollowEnabled | 2847 | disclosureToggleSettling || !endFollowEnabled |
| 2800 | ? false | 2848 | ? 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.type | 2867 | 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→actual | 2874 | // 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 @@ | ||
| 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,6 +33,8 @@ import { |
| 33 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; | 33 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; |
| 34 | import { scopedThreadKey } from "../lib/scopedEntities"; | 34 | import { scopedThreadKey } from "../lib/scopedEntities"; |
| 35 | import { buildThreadFeed } from "../lib/threadActivity"; | 35 | import { buildThreadFeed } from "../lib/threadActivity"; |
| 36 | import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages"; | ||
| 37 | import { appendPendingThreadMessages } from "../features/threads/pending-thread-feed"; | ||
| 36 | import { appAtomRegistry } from "../state/atom-registry"; | 38 | import { appAtomRegistry } from "../state/atom-registry"; |
| 37 | import { | 39 | import { |
| 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 && selectedThreadActivities | 146 | 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]); | ||
| 151 | 179 | ||
| 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 @@ |
| 1 | import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages"; | ||
| 1 | import { | 2 | import { |
| 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 | } |
| 194 | 195 | ||
| 195 | beforeEach(() => { | 196 | beforeEach(() => { |
| 197 | appAtomRegistry.set(acknowledgedThreadMessagesAtom, []); | ||
| 196 | harness.draftFile.setDocument({ schemaVersion: 1, drafts: {} }); | 198 | harness.draftFile.setDocument({ schemaVersion: 1, drafts: {} }); |
| 197 | }); | 199 | }); |
| 198 | 200 | ||
| ... | @@ -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"); |
| 432 | 434 | ||
| 433 | expect(remainingMessages()).toEqual([]); | 435 | expect(remainingMessages()).toEqual([]); |
| 436 | expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([message]); | ||
| 434 | }); | 437 | }); |
| 435 | 438 | ||
| 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" |
| 22 | import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload"; | 22 | import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload"; |
| 23 | import { randomHex } from "../lib/uuid"; | 23 | import { randomHex } from "../lib/uuid"; |
| 24 | import { isModelSelectionUnavailable } from "../lib/modelOptions"; | 24 | import { isModelSelectionUnavailable } from "../lib/modelOptions"; |
| 25 | import { | ||
| 26 | retainAcknowledgedThreadMessage, | ||
| 27 | forgetAcknowledgedThreadMessage, | ||
| 28 | } from "./acknowledged-thread-messages"; | ||
| 25 | import { appAtomRegistry } from "./atom-registry"; | 29 | import { appAtomRegistry } from "./atom-registry"; |
| 26 | import { useProjects, useServerConfigs, useThreadShells } from "./entities"; | 30 | import { useProjects, useServerConfigs, useThreadShells } from "./entities"; |
| 27 | import { serverEnvironment } from "./server"; | 31 | import { 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, |