| author | |
| committer | |
| log | 7e2bb475042ccfd196779831e69f5b19a38a2423 |
| tree | fa82633bb968dae6f94dd333d3e56595aecd0816 |
| parent | 18fa89c4adaae722e619f6abde093becf80efc08 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 92 insertions(+), 71 deletions(-)
apps/mobile/src/features/threads/ThreadComposer.tsx+14-3| ... | ... | @@ -26,7 +26,13 @@ import { |
| 26 | 26 | type ViewStyle, |
| 27 | 27 | } from "react-native"; |
| 28 | 28 | import ImageViewing from "react-native-image-viewing"; |
| 29 | import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; | |
| 29 | import Animated, { | |
| 30 | FadeIn, | |
| 31 | FadeInDown, | |
| 32 | FadeOut, | |
| 33 | FadeOutDown, | |
| 34 | LinearTransition, | |
| 35 | } from "react-native-reanimated"; | |
| 30 | 36 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 31 | 37 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; |
| 32 | 38 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| ... | ... | @@ -232,7 +238,12 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( |
| 232 | 238 | const isReconnecting = props.status.kind !== "unavailable"; |
| 233 | 239 | |
| 234 | 240 | return ( |
| 235 | <View className="items-center pb-2"> | |
| 241 | <Animated.View | |
| 242 | className="absolute inset-x-0 bottom-full items-center pb-2" | |
| 243 | entering={FadeInDown.duration(180)} | |
| 244 | exiting={FadeOutDown.duration(140)} | |
| 245 | pointerEvents="box-none" | |
| 246 | > | |
| 236 | 247 | <Pressable |
| 237 | 248 | accessibilityRole="button" |
| 238 | 249 | onPress={props.onPress} |
| ... | ... | @@ -250,7 +261,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( |
| 250 | 261 | {props.status.label} |
| 251 | 262 | </Text> |
| 252 | 263 | </Pressable> |
| 253 | </View> | |
| 264 | </Animated.View> | |
| 254 | 265 | ); |
| 255 | 266 | }); |
| 256 | 267 |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+5-64| ... | ... | @@ -14,21 +14,18 @@ import type { |
| 14 | 14 | ServerConfig as T3ServerConfig, |
| 15 | 15 | ThreadId, |
| 16 | 16 | } from "@t3tools/contracts"; |
| 17 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; | |
| 18 | 17 | import * as Haptics from "expo-haptics"; |
| 19 | 18 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 20 | 19 | import { Platform, View, type GestureResponderEvent } from "react-native"; |
| 21 | 20 | import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller"; |
| 22 | import Animated, { FadeInDown, FadeOut, LinearTransition } from "react-native-reanimated"; | |
| 21 | import Animated, { FadeInDown, FadeOut } from "react-native-reanimated"; | |
| 23 | 22 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 24 | 23 | |
| 25 | import { AppText as Text } from "../../components/AppText"; | |
| 26 | 24 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; |
| 27 | 25 | import type { StatusTone } from "../../components/StatusPill"; |
| 28 | 26 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 29 | 27 | import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; |
| 30 | 28 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 31 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | |
| 32 | 29 | import type { |
| 33 | 30 | PendingApproval, |
| 34 | 31 | PendingUserInput, |
| ... | ... | @@ -172,54 +169,6 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedE |
| 172 | 169 | }, [threadId, feed]); |
| 173 | 170 | } |
| 174 | 171 | |
| 175 | // Pre-measurement estimate for the working pill's slot (the real height is | |
| 176 | // measured via onComposerLayout since the pill lives inside the composer | |
| 177 | // overlay). Matches the rendered pill: pt-2 + pb-2 (16) wrapping a bordered | |
| 178 | // px-3/py-2 row (~36), so ~52 — keep it in sync with WorkingDurationPill. | |
| 179 | const WORKING_INDICATOR_HEIGHT = 52; | |
| 180 | ||
| 181 | const WorkingDurationPill = memo(function WorkingDurationPill(props: { | |
| 182 | readonly startedAt: string; | |
| 183 | }) { | |
| 184 | const [nowMs, setNowMs] = useState(() => Date.now()); | |
| 185 | ||
| 186 | useEffect(() => { | |
| 187 | const intervalId = setInterval(() => { | |
| 188 | setNowMs(Date.now()); | |
| 189 | }, 1_000); | |
| 190 | return () => clearInterval(intervalId); | |
| 191 | }, [props.startedAt]); | |
| 192 | ||
| 193 | const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s"; | |
| 194 | ||
| 195 | return ( | |
| 196 | <Animated.View | |
| 197 | className="shrink-0 px-4 pb-2 pt-2" | |
| 198 | entering={FadeInDown.duration(200)} | |
| 199 | exiting={FadeOut.duration(140)} | |
| 200 | > | |
| 201 | <View | |
| 202 | className={ | |
| 203 | Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED | |
| 204 | ? "self-start rounded-full border border-border bg-card px-3 py-2" | |
| 205 | : "self-start rounded-full border border-neutral-200/80 bg-neutral-50/90 px-3 py-2 dark:border-white/[0.08] dark:bg-white/[0.04]" | |
| 206 | } | |
| 207 | > | |
| 208 | <View className="flex-row items-center gap-2"> | |
| 209 | <View className="flex-row items-center gap-1"> | |
| 210 | <View className="h-1.5 w-1.5 rounded-full bg-neutral-400 dark:bg-neutral-500" /> | |
| 211 | <View className="h-1.5 w-1.5 rounded-full bg-neutral-400/80 dark:bg-neutral-500/80" /> | |
| 212 | <View className="h-1.5 w-1.5 rounded-full bg-neutral-400/60 dark:bg-neutral-500/60" /> | |
| 213 | </View> | |
| 214 | <Text className="font-t3-medium text-xs text-neutral-600 dark:text-neutral-400"> | |
| 215 | Working for {durationLabel} | |
| 216 | </Text> | |
| 217 | </View> | |
| 218 | </View> | |
| 219 | </Animated.View> | |
| 220 | ); | |
| 221 | }); | |
| 222 | ||
| 223 | 172 | export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { |
| 224 | 173 | const insets = useSafeAreaInsets(); |
| 225 | 174 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; |
| ... | ... | @@ -253,8 +202,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 253 | 202 | const selectedThreadFeed = props.selectedThreadFeed; |
| 254 | 203 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; |
| 255 | 204 | const composerOverlapHeight = composerChrome + composerBottomInset; |
| 256 | const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; | |
| 257 | const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight; | |
| 205 | const estimatedOverlayHeight = composerOverlapHeight; | |
| 258 | 206 | // The overlay's measured height includes the home-indicator inset (the |
| 259 | 207 | // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes |
| 260 | 208 | // UIKit add the safe-area bottom to the content inset AGAIN — leaving a |
| ... | ... | @@ -411,6 +359,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 411 | 359 | contentPresentation={props.contentPresentation} |
| 412 | 360 | agentLabel={agentLabel} |
| 413 | 361 | latestTurn={props.selectedThread.latestTurn} |
| 362 | activeWorkStartedAt={props.activeWorkStartedAt} | |
| 414 | 363 | listRef={listRef} |
| 415 | 364 | freeze={freeze} |
| 416 | 365 | anchorMessageId={anchorMessageId} |
| ... | ... | @@ -438,15 +387,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 438 | 387 | list's bottom inset, so any padding above the pill/composer |
| 439 | 388 | pushes the resting content floor up by the same amount. */} |
| 440 | 389 | <View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full"> |
| 441 | <Animated.View | |
| 442 | className="w-full self-center" | |
| 443 | layout={LinearTransition.duration(220)} | |
| 444 | style={{ maxWidth: contentMaxWidth }} | |
| 445 | > | |
| 446 | {props.activeWorkStartedAt ? ( | |
| 447 | <WorkingDurationPill startedAt={props.activeWorkStartedAt} /> | |
| 448 | ) : null} | |
| 449 | ||
| 390 | <View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}> | |
| 450 | 391 | {props.activePendingApproval || props.activePendingUserInput ? ( |
| 451 | 392 | <Animated.View |
| 452 | 393 | className="shrink-0 gap-3 px-4 pb-3" |
| ... | ... | @@ -473,7 +414,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 473 | 414 | ) : null} |
| 474 | 415 | </Animated.View> |
| 475 | 416 | ) : null} |
| 476 | </Animated.View> | |
| 417 | </View> | |
| 477 | 418 | |
| 478 | 419 | <ThreadComposer |
| 479 | 420 | editorRef={composerEditorRef} |
apps/mobile/src/features/threads/ThreadFeed.tsx+43-2| ... | ... | @@ -3,6 +3,7 @@ import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; |
| 3 | 3 | import { type LegendListRef } from "@legendapp/list/react-native"; |
| 4 | 4 | import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; |
| 5 | 5 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 6 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; | |
| 6 | 7 | import { SymbolView } from "../../components/AppSymbol"; |
| 7 | 8 | import { HeaderHeightContext } from "@react-navigation/elements"; |
| 8 | 9 | import { useNavigation } from "@react-navigation/native"; |
| ... | ... | @@ -128,6 +129,7 @@ export interface ThreadFeedProps { |
| 128 | 129 | readonly contentPresentation: ThreadContentPresentation; |
| 129 | 130 | readonly agentLabel: string; |
| 130 | 131 | readonly latestTurn: ThreadFeedLatestTurn | null; |
| 132 | readonly activeWorkStartedAt: string | null; | |
| 131 | 133 | readonly listRef: RefObject<LegendListRef | null>; |
| 132 | 134 | readonly freeze: SharedValue<boolean>; |
| 133 | 135 | readonly anchorMessageId: MessageId | null; |
| ... | ... | @@ -816,6 +818,10 @@ function renderFeedEntry( |
| 816 | 818 | const entry = info.item; |
| 817 | 819 | const { markdownStyles, iconSubtleColor, userBubbleColor } = props; |
| 818 | 820 | |
| 821 | if (entry.type === "working") { | |
| 822 | return <WorkingTimelineRow startedAt={entry.createdAt} />; | |
| 823 | } | |
| 824 | ||
| 819 | 825 | if (entry.type === "turn-fold") { |
| 820 | 826 | return ( |
| 821 | 827 | <Pressable |
| ... | ... | @@ -993,6 +999,32 @@ function renderFeedEntry( |
| 993 | 999 | ); |
| 994 | 1000 | } |
| 995 | 1001 | |
| 1002 | const WorkingTimelineRow = memo(function WorkingTimelineRow(props: { readonly startedAt: string }) { | |
| 1003 | const [nowMs, setNowMs] = useState(() => Date.now()); | |
| 1004 | ||
| 1005 | useEffect(() => { | |
| 1006 | const intervalId = setInterval(() => { | |
| 1007 | setNowMs(Date.now()); | |
| 1008 | }, 1_000); | |
| 1009 | return () => clearInterval(intervalId); | |
| 1010 | }, [props.startedAt]); | |
| 1011 | ||
| 1012 | const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s"; | |
| 1013 | ||
| 1014 | return ( | |
| 1015 | <View className="mb-4 flex-row items-center gap-2 px-1.5 py-1"> | |
| 1016 | <View className="flex-row items-center gap-1"> | |
| 1017 | <View className="h-1 w-1 rounded-full bg-neutral-400 dark:bg-neutral-500" /> | |
| 1018 | <View className="h-1 w-1 rounded-full bg-neutral-400/80 dark:bg-neutral-500/80" /> | |
| 1019 | <View className="h-1 w-1 rounded-full bg-neutral-400/60 dark:bg-neutral-500/60" /> | |
| 1020 | </View> | |
| 1021 | <Text className="font-t3-medium text-xs tabular-nums text-neutral-600 dark:text-neutral-400"> | |
| 1022 | Working for {durationLabel} | |
| 1023 | </Text> | |
| 1024 | </View> | |
| 1025 | ); | |
| 1026 | }); | |
| 1027 | ||
| 996 | 1028 | function UserMessageContent(props: { |
| 997 | 1029 | readonly text: string; |
| 998 | 1030 | readonly markdownStyles: MarkdownStyleSet; |
| ... | ... | @@ -1415,8 +1447,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1415 | 1447 | props.latestTurn, |
| 1416 | 1448 | expandedTurnIds, |
| 1417 | 1449 | expandedWorkGroupIds, |
| 1450 | props.activeWorkStartedAt, | |
| 1418 | 1451 | ), |
| 1419 | [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn], | |
| 1452 | [ | |
| 1453 | expandedTurnIds, | |
| 1454 | expandedWorkGroupIds, | |
| 1455 | props.activeWorkStartedAt, | |
| 1456 | props.feed, | |
| 1457 | props.latestTurn, | |
| 1458 | ], | |
| 1420 | 1459 | ); |
| 1421 | 1460 | |
| 1422 | 1461 | // The empty↔filled key below remounts the list, which resets its imperative |
| ... | ... | @@ -1761,7 +1800,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1761 | 1800 | }} |
| 1762 | 1801 | /> |
| 1763 | 1802 | </View> |
| 1764 | {props.feed.length === 0 && props.contentPresentation.kind === "ready" ? ( | |
| 1803 | {props.feed.length === 0 && | |
| 1804 | props.activeWorkStartedAt === null && | |
| 1805 | props.contentPresentation.kind === "ready" ? ( | |
| 1765 | 1806 | <View pointerEvents="none" style={StyleSheet.absoluteFill}> |
| 1766 | 1807 | <ThreadFeedPlaceholder |
| 1767 | 1808 | title="No conversation yet" |
apps/mobile/src/lib/threadActivity.test.ts+14| ... | ... | @@ -414,6 +414,20 @@ describe("buildThreadFeed", () => { |
| 414 | 414 | }); |
| 415 | 415 | }); |
| 416 | 416 | |
| 417 | it("appends active work as a normal timeline row", () => { | |
| 418 | const startedAt = "2026-04-01T00:00:01.000Z"; | |
| 419 | const presented = deriveThreadFeedPresentation([], null, new Set(), new Set(), startedAt); | |
| 420 | ||
| 421 | expect(presented).toEqual([ | |
| 422 | { | |
| 423 | type: "working", | |
| 424 | id: "working-indicator-row", | |
| 425 | createdAt: startedAt, | |
| 426 | }, | |
| 427 | ]); | |
| 428 | expect(deriveThreadFeedPresentation(presented, null, new Set())).toEqual([]); | |
| 429 | }); | |
| 430 | ||
| 417 | 431 | it("models work-log overflow as list rows", () => { |
| 418 | 432 | const activity = ( |
| 419 | 433 | id: string, |
apps/mobile/src/lib/threadActivity.ts+16-2| ... | ... | @@ -98,6 +98,11 @@ type RawThreadFeedEntry = |
| 98 | 98 | |
| 99 | 99 | export type ThreadFeedEntry = |
| 100 | 100 | | Extract<RawThreadFeedEntry, { type: "message" }> |
| 101 | | { | |
| 102 | readonly type: "working"; | |
| 103 | readonly id: string; | |
| 104 | readonly createdAt: string; | |
| 105 | } | |
| 101 | 106 | | { |
| 102 | 107 | readonly type: "activity-group"; |
| 103 | 108 | readonly id: string; |
| ... | ... | @@ -1105,9 +1110,11 @@ export function deriveThreadFeedPresentation( |
| 1105 | 1110 | latestTurn: ThreadFeedLatestTurn | null, |
| 1106 | 1111 | expandedTurnIds: ReadonlySet<TurnId>, |
| 1107 | 1112 | expandedWorkGroupIds: ReadonlySet<string> = new Set(), |
| 1113 | activeWorkStartedAt: string | null = null, | |
| 1108 | 1114 | ): ThreadFeedEntry[] { |
| 1109 | 1115 | const sourceFeed = feed.filter( |
| 1110 | (entry) => entry.type !== "turn-fold" && entry.type !== "work-toggle", | |
| 1116 | (entry) => | |
| 1117 | entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "working", | |
| 1111 | 1118 | ); |
| 1112 | 1119 | const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn); |
| 1113 | 1120 | const collapsedEntryIds = new Set<string>(); |
| ... | ... | @@ -1136,12 +1143,19 @@ export function deriveThreadFeedPresentation( |
| 1136 | 1143 | appendPresentedFeedEntry(result, entry, expandedWorkGroupIds); |
| 1137 | 1144 | } |
| 1138 | 1145 | } |
| 1146 | if (activeWorkStartedAt !== null) { | |
| 1147 | result.push({ | |
| 1148 | type: "working", | |
| 1149 | id: "working-indicator-row", | |
| 1150 | createdAt: activeWorkStartedAt, | |
| 1151 | }); | |
| 1152 | } | |
| 1139 | 1153 | return result; |
| 1140 | 1154 | } |
| 1141 | 1155 | |
| 1142 | 1156 | function appendPresentedFeedEntry( |
| 1143 | 1157 | result: ThreadFeedEntry[], |
| 1144 | entry: Exclude<ThreadFeedEntry, { readonly type: "turn-fold" | "work-toggle" }>, | |
| 1158 | entry: Exclude<ThreadFeedEntry, { readonly type: "turn-fold" | "work-toggle" | "working" }>, | |
| 1145 | 1159 | expandedWorkGroupIds: ReadonlySet<string>, |
| 1146 | 1160 | ): void { |
| 1147 | 1161 | if (entry.type !== "activity-group") { |