authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-22 17:36:22+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-22 17:36:22+02:00
log7e2bb475042ccfd196779831e69f5b19a38a2423
treefa82633bb968dae6f94dd333d3e56595aecd0816
parent18fa89c4adaae722e619f6abde093becf80efc08
signature Signed by PGP key B5690EEEBB952194

Move mobile working timer into the thread timeline (#4285)


5 files changed, 92 insertions(+), 71 deletions(-)

apps/mobile/src/features/threads/ThreadComposer.tsx+14-3
......@@ -26,7 +26,13 @@ import {
2626 type ViewStyle,
2727} from "react-native";
2828import ImageViewing from "react-native-image-viewing";
29import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
29import Animated, {
30 FadeIn,
31 FadeInDown,
32 FadeOut,
33 FadeOutDown,
34 LinearTransition,
35} from "react-native-reanimated";
3036import { useThemeColor } from "../../lib/useThemeColor";
3137import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
3238import { scopedThreadKey } from "../../lib/scopedEntities";
......@@ -232,7 +238,12 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
232238 const isReconnecting = props.status.kind !== "unavailable";
233239
234240 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 >
236247 <Pressable
237248 accessibilityRole="button"
238249 onPress={props.onPress}
......@@ -250,7 +261,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
250261 {props.status.label}
251262 </Text>
252263 </Pressable>
253 </View>
264 </Animated.View>
254265 );
255266});
256267
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+5-64
......@@ -14,21 +14,18 @@ import type {
1414 ServerConfig as T3ServerConfig,
1515 ThreadId,
1616} from "@t3tools/contracts";
17import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
1817import * as Haptics from "expo-haptics";
1918import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2019import { Platform, View, type GestureResponderEvent } from "react-native";
2120import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller";
22import Animated, { FadeInDown, FadeOut, LinearTransition } from "react-native-reanimated";
21import Animated, { FadeInDown, FadeOut } from "react-native-reanimated";
2322import { useSafeAreaInsets } from "react-native-safe-area-context";
2423
25import { AppText as Text } from "../../components/AppText";
2624import type { ComposerEditorHandle } from "../../components/ComposerEditor";
2725import type { StatusTone } from "../../components/StatusPill";
2826import type { DraftComposerImageAttachment } from "../../lib/composerImages";
2927import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
3028import { scopedThreadKey } from "../../lib/scopedEntities";
31import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
3229import type {
3330 PendingApproval,
3431 PendingUserInput,
......@@ -172,54 +169,6 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedE
172169 }, [threadId, feed]);
173170}
174171
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.
179const WORKING_INDICATOR_HEIGHT = 52;
180
181const 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
223172export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
224173 const insets = useSafeAreaInsets();
225174 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
......@@ -253,8 +202,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
253202 const selectedThreadFeed = props.selectedThreadFeed;
254203 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
255204 const composerOverlapHeight = composerChrome + composerBottomInset;
256 const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0;
257 const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight;
205 const estimatedOverlayHeight = composerOverlapHeight;
258206 // The overlay's measured height includes the home-indicator inset (the
259207 // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes
260208 // 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
411359 contentPresentation={props.contentPresentation}
412360 agentLabel={agentLabel}
413361 latestTurn={props.selectedThread.latestTurn}
362 activeWorkStartedAt={props.activeWorkStartedAt}
414363 listRef={listRef}
415364 freeze={freeze}
416365 anchorMessageId={anchorMessageId}
......@@ -438,15 +387,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
438387 list's bottom inset, so any padding above the pill/composer
439388 pushes the resting content floor up by the same amount. */}
440389 <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 }}>
450391 {props.activePendingApproval || props.activePendingUserInput ? (
451392 <Animated.View
452393 className="shrink-0 gap-3 px-4 pb-3"
......@@ -473,7 +414,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
473414 ) : null}
474415 </Animated.View>
475416 ) : null}
476 </Animated.View>
417 </View>
477418
478419 <ThreadComposer
479420 editorRef={composerEditorRef}
apps/mobile/src/features/threads/ThreadFeed.tsx+43-2
......@@ -3,6 +3,7 @@ import { KeyboardAwareLegendList } from "@legendapp/list/keyboard";
33import { type LegendListRef } from "@legendapp/list/react-native";
44import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";
55import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
6import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
67import { SymbolView } from "../../components/AppSymbol";
78import { HeaderHeightContext } from "@react-navigation/elements";
89import { useNavigation } from "@react-navigation/native";
......@@ -128,6 +129,7 @@ export interface ThreadFeedProps {
128129 readonly contentPresentation: ThreadContentPresentation;
129130 readonly agentLabel: string;
130131 readonly latestTurn: ThreadFeedLatestTurn | null;
132 readonly activeWorkStartedAt: string | null;
131133 readonly listRef: RefObject<LegendListRef | null>;
132134 readonly freeze: SharedValue<boolean>;
133135 readonly anchorMessageId: MessageId | null;
......@@ -816,6 +818,10 @@ function renderFeedEntry(
816818 const entry = info.item;
817819 const { markdownStyles, iconSubtleColor, userBubbleColor } = props;
818820
821 if (entry.type === "working") {
822 return <WorkingTimelineRow startedAt={entry.createdAt} />;
823 }
824
819825 if (entry.type === "turn-fold") {
820826 return (
821827 <Pressable
......@@ -993,6 +999,32 @@ function renderFeedEntry(
993999 );
9941000}
9951001
1002const 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
9961028function UserMessageContent(props: {
9971029 readonly text: string;
9981030 readonly markdownStyles: MarkdownStyleSet;
......@@ -1415,8 +1447,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
14151447 props.latestTurn,
14161448 expandedTurnIds,
14171449 expandedWorkGroupIds,
1450 props.activeWorkStartedAt,
14181451 ),
1419 [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn],
1452 [
1453 expandedTurnIds,
1454 expandedWorkGroupIds,
1455 props.activeWorkStartedAt,
1456 props.feed,
1457 props.latestTurn,
1458 ],
14201459 );
14211460
14221461 // The empty↔filled key below remounts the list, which resets its imperative
......@@ -1761,7 +1800,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
17611800 }}
17621801 />
17631802 </View>
1764 {props.feed.length === 0 && props.contentPresentation.kind === "ready" ? (
1803 {props.feed.length === 0 &&
1804 props.activeWorkStartedAt === null &&
1805 props.contentPresentation.kind === "ready" ? (
17651806 <View pointerEvents="none" style={StyleSheet.absoluteFill}>
17661807 <ThreadFeedPlaceholder
17671808 title="No conversation yet"
apps/mobile/src/lib/threadActivity.test.ts+14
......@@ -414,6 +414,20 @@ describe("buildThreadFeed", () => {
414414 });
415415 });
416416
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
417431 it("models work-log overflow as list rows", () => {
418432 const activity = (
419433 id: string,
apps/mobile/src/lib/threadActivity.ts+16-2
......@@ -98,6 +98,11 @@ type RawThreadFeedEntry =
9898
9999export type ThreadFeedEntry =
100100 | Extract<RawThreadFeedEntry, { type: "message" }>
101 | {
102 readonly type: "working";
103 readonly id: string;
104 readonly createdAt: string;
105 }
101106 | {
102107 readonly type: "activity-group";
103108 readonly id: string;
......@@ -1105,9 +1110,11 @@ export function deriveThreadFeedPresentation(
11051110 latestTurn: ThreadFeedLatestTurn | null,
11061111 expandedTurnIds: ReadonlySet<TurnId>,
11071112 expandedWorkGroupIds: ReadonlySet<string> = new Set(),
1113 activeWorkStartedAt: string | null = null,
11081114): ThreadFeedEntry[] {
11091115 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",
11111118 );
11121119 const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn);
11131120 const collapsedEntryIds = new Set<string>();
......@@ -1136,12 +1143,19 @@ export function deriveThreadFeedPresentation(
11361143 appendPresentedFeedEntry(result, entry, expandedWorkGroupIds);
11371144 }
11381145 }
1146 if (activeWorkStartedAt !== null) {
1147 result.push({
1148 type: "working",
1149 id: "working-indicator-row",
1150 createdAt: activeWorkStartedAt,
1151 });
1152 }
11391153 return result;
11401154}
11411155
11421156function appendPresentedFeedEntry(
11431157 result: ThreadFeedEntry[],
1144 entry: Exclude<ThreadFeedEntry, { readonly type: "turn-fold" | "work-toggle" }>,
1158 entry: Exclude<ThreadFeedEntry, { readonly type: "turn-fold" | "work-toggle" | "working" }>,
11451159 expandedWorkGroupIds: ReadonlySet<string>,
11461160): void {
11471161 if (entry.type !== "activity-group") {