| 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,7 +26,13 @@ import { |
| 26 | type ViewStyle, | 26 | type ViewStyle, |
| 27 | } from "react-native"; | 27 | } from "react-native"; |
| 28 | import ImageViewing from "react-native-image-viewing"; | 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 | import { useThemeColor } from "../../lib/useThemeColor"; | 36 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 31 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; | 37 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; |
| 32 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 38 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| ... | @@ -232,7 +238,12 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( | ... | @@ -232,7 +238,12 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( |
| 232 | const isReconnecting = props.status.kind !== "unavailable"; | 238 | const isReconnecting = props.status.kind !== "unavailable"; |
| 233 | 239 | ||
| 234 | return ( | 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 | <Pressable | 247 | <Pressable |
| 237 | accessibilityRole="button" | 248 | accessibilityRole="button" |
| 238 | onPress={props.onPress} | 249 | onPress={props.onPress} |
| ... | @@ -250,7 +261,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( | ... | @@ -250,7 +261,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( |
| 250 | {props.status.label} | 261 | {props.status.label} |
| 251 | </Text> | 262 | </Text> |
| 252 | </Pressable> | 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,21 +14,18 @@ import type { |
| 14 | ServerConfig as T3ServerConfig, | 14 | ServerConfig as T3ServerConfig, |
| 15 | ThreadId, | 15 | ThreadId, |
| 16 | } from "@t3tools/contracts"; | 16 | } from "@t3tools/contracts"; |
| 17 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; | ||
| 18 | import * as Haptics from "expo-haptics"; | 17 | import * as Haptics from "expo-haptics"; |
| 19 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | 18 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 20 | import { Platform, View, type GestureResponderEvent } from "react-native"; | 19 | import { Platform, View, type GestureResponderEvent } from "react-native"; |
| 21 | import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller"; | 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 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 22 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 24 | 23 | ||
| 25 | import { AppText as Text } from "../../components/AppText"; | ||
| 26 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; | 24 | import type { ComposerEditorHandle } from "../../components/ComposerEditor"; |
| 27 | import type { StatusTone } from "../../components/StatusPill"; | 25 | import type { StatusTone } from "../../components/StatusPill"; |
| 28 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 26 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| 29 | import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; | 27 | import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; |
| 30 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 28 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 31 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | ||
| 32 | import type { | 29 | import type { |
| 33 | PendingApproval, | 30 | PendingApproval, |
| 34 | PendingUserInput, | 31 | PendingUserInput, |
| ... | @@ -172,54 +169,6 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedE | ... | @@ -172,54 +169,6 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedE |
| 172 | }, [threadId, feed]); | 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 | export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { | 172 | export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { |
| 224 | const insets = useSafeAreaInsets(); | 173 | const insets = useSafeAreaInsets(); |
| 225 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; | 174 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; |
| ... | @@ -253,8 +202,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -253,8 +202,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 253 | const selectedThreadFeed = props.selectedThreadFeed; | 202 | const selectedThreadFeed = props.selectedThreadFeed; |
| 254 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; | 203 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; |
| 255 | const composerOverlapHeight = composerChrome + composerBottomInset; | 204 | const composerOverlapHeight = composerChrome + composerBottomInset; |
| 256 | const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; | 205 | const estimatedOverlayHeight = composerOverlapHeight; |
| 257 | const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight; | ||
| 258 | // The overlay's measured height includes the home-indicator inset (the | 206 | // The overlay's measured height includes the home-indicator inset (the |
| 259 | // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes | 207 | // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes |
| 260 | // UIKit add the safe-area bottom to the content inset AGAIN — leaving a | 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,6 +359,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 411 | contentPresentation={props.contentPresentation} | 359 | contentPresentation={props.contentPresentation} |
| 412 | agentLabel={agentLabel} | 360 | agentLabel={agentLabel} |
| 413 | latestTurn={props.selectedThread.latestTurn} | 361 | latestTurn={props.selectedThread.latestTurn} |
| 362 | activeWorkStartedAt={props.activeWorkStartedAt} | ||
| 414 | listRef={listRef} | 363 | listRef={listRef} |
| 415 | freeze={freeze} | 364 | freeze={freeze} |
| 416 | anchorMessageId={anchorMessageId} | 365 | anchorMessageId={anchorMessageId} |
| ... | @@ -438,15 +387,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -438,15 +387,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 438 | list's bottom inset, so any padding above the pill/composer | 387 | list's bottom inset, so any padding above the pill/composer |
| 439 | pushes the resting content floor up by the same amount. */} | 388 | pushes the resting content floor up by the same amount. */} |
| 440 | <View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full"> | 389 | <View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full"> |
| 441 | <Animated.View | 390 | <View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}> |
| 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 | |||
| 450 | {props.activePendingApproval || props.activePendingUserInput ? ( | 391 | {props.activePendingApproval || props.activePendingUserInput ? ( |
| 451 | <Animated.View | 392 | <Animated.View |
| 452 | className="shrink-0 gap-3 px-4 pb-3" | 393 | className="shrink-0 gap-3 px-4 pb-3" |
| ... | @@ -473,7 +414,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -473,7 +414,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 473 | ) : null} | 414 | ) : null} |
| 474 | </Animated.View> | 415 | </Animated.View> |
| 475 | ) : null} | 416 | ) : null} |
| 476 | </Animated.View> | 417 | </View> |
| 477 | 418 | ||
| 478 | <ThreadComposer | 419 | <ThreadComposer |
| 479 | editorRef={composerEditorRef} | 420 | editorRef={composerEditorRef} |
apps/mobile/src/features/threads/ThreadFeed.tsx+43-2| ... | @@ -3,6 +3,7 @@ import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; | ... | @@ -3,6 +3,7 @@ import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; |
| 3 | import { type LegendListRef } from "@legendapp/list/react-native"; | 3 | import { type LegendListRef } from "@legendapp/list/react-native"; |
| 4 | import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; | 4 | import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; |
| 5 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; | 5 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 6 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; | ||
| 6 | import { SymbolView } from "../../components/AppSymbol"; | 7 | import { SymbolView } from "../../components/AppSymbol"; |
| 7 | import { HeaderHeightContext } from "@react-navigation/elements"; | 8 | import { HeaderHeightContext } from "@react-navigation/elements"; |
| 8 | import { useNavigation } from "@react-navigation/native"; | 9 | import { useNavigation } from "@react-navigation/native"; |
| ... | @@ -128,6 +129,7 @@ export interface ThreadFeedProps { | ... | @@ -128,6 +129,7 @@ export interface ThreadFeedProps { |
| 128 | readonly contentPresentation: ThreadContentPresentation; | 129 | readonly contentPresentation: ThreadContentPresentation; |
| 129 | readonly agentLabel: string; | 130 | readonly agentLabel: string; |
| 130 | readonly latestTurn: ThreadFeedLatestTurn | null; | 131 | readonly latestTurn: ThreadFeedLatestTurn | null; |
| 132 | readonly activeWorkStartedAt: string | null; | ||
| 131 | readonly listRef: RefObject<LegendListRef | null>; | 133 | readonly listRef: RefObject<LegendListRef | null>; |
| 132 | readonly freeze: SharedValue<boolean>; | 134 | readonly freeze: SharedValue<boolean>; |
| 133 | readonly anchorMessageId: MessageId | null; | 135 | readonly anchorMessageId: MessageId | null; |
| ... | @@ -816,6 +818,10 @@ function renderFeedEntry( | ... | @@ -816,6 +818,10 @@ function renderFeedEntry( |
| 816 | const entry = info.item; | 818 | const entry = info.item; |
| 817 | const { markdownStyles, iconSubtleColor, userBubbleColor } = props; | 819 | const { markdownStyles, iconSubtleColor, userBubbleColor } = props; |
| 818 | 820 | ||
| 821 | if (entry.type === "working") { | ||
| 822 | return <WorkingTimelineRow startedAt={entry.createdAt} />; | ||
| 823 | } | ||
| 824 | |||
| 819 | if (entry.type === "turn-fold") { | 825 | if (entry.type === "turn-fold") { |
| 820 | return ( | 826 | return ( |
| 821 | <Pressable | 827 | <Pressable |
| ... | @@ -993,6 +999,32 @@ function renderFeedEntry( | ... | @@ -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 | function UserMessageContent(props: { | 1028 | function UserMessageContent(props: { |
| 997 | readonly text: string; | 1029 | readonly text: string; |
| 998 | readonly markdownStyles: MarkdownStyleSet; | 1030 | readonly markdownStyles: MarkdownStyleSet; |
| ... | @@ -1415,8 +1447,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1415,8 +1447,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1415 | props.latestTurn, | 1447 | props.latestTurn, |
| 1416 | expandedTurnIds, | 1448 | expandedTurnIds, |
| 1417 | expandedWorkGroupIds, | 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 | // The empty↔filled key below remounts the list, which resets its imperative | 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,7 +1800,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1761 | }} | 1800 | }} |
| 1762 | /> | 1801 | /> |
| 1763 | </View> | 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 | <View pointerEvents="none" style={StyleSheet.absoluteFill}> | 1806 | <View pointerEvents="none" style={StyleSheet.absoluteFill}> |
| 1766 | <ThreadFeedPlaceholder | 1807 | <ThreadFeedPlaceholder |
| 1767 | title="No conversation yet" | 1808 | title="No conversation yet" |
apps/mobile/src/lib/threadActivity.test.ts+14| ... | @@ -414,6 +414,20 @@ describe("buildThreadFeed", () => { | ... | @@ -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 | it("models work-log overflow as list rows", () => { | 431 | it("models work-log overflow as list rows", () => { |
| 418 | const activity = ( | 432 | const activity = ( |
| 419 | id: string, | 433 | id: string, |
apps/mobile/src/lib/threadActivity.ts+16-2| ... | @@ -98,6 +98,11 @@ type RawThreadFeedEntry = | ... | @@ -98,6 +98,11 @@ type RawThreadFeedEntry = |
| 98 | 98 | ||
| 99 | export type ThreadFeedEntry = | 99 | export type ThreadFeedEntry = |
| 100 | | Extract<RawThreadFeedEntry, { type: "message" }> | 100 | | Extract<RawThreadFeedEntry, { type: "message" }> |
| 101 | | { | ||
| 102 | readonly type: "working"; | ||
| 103 | readonly id: string; | ||
| 104 | readonly createdAt: string; | ||
| 105 | } | ||
| 101 | | { | 106 | | { |
| 102 | readonly type: "activity-group"; | 107 | readonly type: "activity-group"; |
| 103 | readonly id: string; | 108 | readonly id: string; |
| ... | @@ -1105,9 +1110,11 @@ export function deriveThreadFeedPresentation( | ... | @@ -1105,9 +1110,11 @@ export function deriveThreadFeedPresentation( |
| 1105 | latestTurn: ThreadFeedLatestTurn | null, | 1110 | latestTurn: ThreadFeedLatestTurn | null, |
| 1106 | expandedTurnIds: ReadonlySet<TurnId>, | 1111 | expandedTurnIds: ReadonlySet<TurnId>, |
| 1107 | expandedWorkGroupIds: ReadonlySet<string> = new Set(), | 1112 | expandedWorkGroupIds: ReadonlySet<string> = new Set(), |
| 1113 | activeWorkStartedAt: string | null = null, | ||
| 1108 | ): ThreadFeedEntry[] { | 1114 | ): ThreadFeedEntry[] { |
| 1109 | const sourceFeed = feed.filter( | 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 | const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn); | 1119 | const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn); |
| 1113 | const collapsedEntryIds = new Set<string>(); | 1120 | const collapsedEntryIds = new Set<string>(); |
| ... | @@ -1136,12 +1143,19 @@ export function deriveThreadFeedPresentation( | ... | @@ -1136,12 +1143,19 @@ export function deriveThreadFeedPresentation( |
| 1136 | appendPresentedFeedEntry(result, entry, expandedWorkGroupIds); | 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 | return result; | 1153 | return result; |
| 1140 | } | 1154 | } |
| 1141 | 1155 | ||
| 1142 | function appendPresentedFeedEntry( | 1156 | function appendPresentedFeedEntry( |
| 1143 | result: ThreadFeedEntry[], | 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 | expandedWorkGroupIds: ReadonlySet<string>, | 1159 | expandedWorkGroupIds: ReadonlySet<string>, |
| 1146 | ): void { | 1160 | ): void { |
| 1147 | if (entry.type !== "activity-group") { | 1161 | if (entry.type !== "activity-group") { |