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 {...@@ -26,7 +26,13 @@ import {
26 type ViewStyle,26 type ViewStyle,
27} from "react-native";27} from "react-native";
28import ImageViewing from "react-native-image-viewing";28import 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";
30import { useThemeColor } from "../../lib/useThemeColor";36import { useThemeColor } from "../../lib/useThemeColor";
31import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";37import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
32import { scopedThreadKey } from "../../lib/scopedEntities";38import { 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";
233239
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 <Pressable247 <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});
256267
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";
17import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
18import * as Haptics from "expo-haptics";17import * as Haptics from "expo-haptics";
19import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";18import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
20import { Platform, View, type GestureResponderEvent } from "react-native";19import { Platform, View, type GestureResponderEvent } from "react-native";
21import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller";20import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller";
22import Animated, { FadeInDown, FadeOut, LinearTransition } from "react-native-reanimated";21import Animated, { FadeInDown, FadeOut } from "react-native-reanimated";
23import { useSafeAreaInsets } from "react-native-safe-area-context";22import { useSafeAreaInsets } from "react-native-safe-area-context";
2423
25import { AppText as Text } from "../../components/AppText";
26import type { ComposerEditorHandle } from "../../components/ComposerEditor";24import type { ComposerEditorHandle } from "../../components/ComposerEditor";
27import type { StatusTone } from "../../components/StatusPill";25import type { StatusTone } from "../../components/StatusPill";
28import type { DraftComposerImageAttachment } from "../../lib/composerImages";26import type { DraftComposerImageAttachment } from "../../lib/composerImages";
29import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";27import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
30import { scopedThreadKey } from "../../lib/scopedEntities";28import { scopedThreadKey } from "../../lib/scopedEntities";
31import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
32import type {29import 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}
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
223export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {172export 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 (the206 // The overlay's measured height includes the home-indicator inset (the
259 // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes207 // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes
260 // UIKit add the safe-area bottom to the content inset AGAIN — leaving a208 // 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/composer387 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.View390 <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.View392 <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>
477418
478 <ThreadComposer419 <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";
3import { type LegendListRef } from "@legendapp/list/react-native";3import { type LegendListRef } from "@legendapp/list/react-native";
4import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";4import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts";
5import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";5import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
6import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
6import { SymbolView } from "../../components/AppSymbol";7import { SymbolView } from "../../components/AppSymbol";
7import { HeaderHeightContext } from "@react-navigation/elements";8import { HeaderHeightContext } from "@react-navigation/elements";
8import { useNavigation } from "@react-navigation/native";9import { 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;
818820
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 <Pressable827 <Pressable
...@@ -993,6 +999,32 @@ function renderFeedEntry(...@@ -993,6 +999,32 @@ function renderFeedEntry(
993 );999 );
994}1000}
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
996function UserMessageContent(props: {1028function 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 );
14211460
1422 // The empty↔filled key below remounts the list, which resets its imperative1461 // 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 <ThreadFeedPlaceholder1807 <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 });
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
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 =
9898
99export type ThreadFeedEntry =99export 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}
11411155
1142function appendPresentedFeedEntry(1156function 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") {