diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx
index e712664d30a..c1a6f2c235e 100644
--- a/apps/mobile/src/features/threads/ThreadComposer.tsx
+++ b/apps/mobile/src/features/threads/ThreadComposer.tsx
@@ -26,7 +26,13 @@ import {
type ViewStyle,
} from "react-native";
import ImageViewing from "react-native-image-viewing";
-import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
+import Animated, {
+ FadeIn,
+ FadeInDown,
+ FadeOut,
+ FadeOutDown,
+ LinearTransition,
+} from "react-native-reanimated";
import { useThemeColor } from "../../lib/useThemeColor";
import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
import { scopedThreadKey } from "../../lib/scopedEntities";
@@ -232,7 +238,12 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
const isReconnecting = props.status.kind !== "unavailable";
return (
-
+
-
+
);
});
diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
index 32527b0b719..5cb04290f66 100644
--- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@@ -14,21 +14,18 @@ import type {
ServerConfig as T3ServerConfig,
ThreadId,
} from "@t3tools/contracts";
-import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
import * as Haptics from "expo-haptics";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Platform, View, type GestureResponderEvent } from "react-native";
import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller";
-import Animated, { FadeInDown, FadeOut, LinearTransition } from "react-native-reanimated";
+import Animated, { FadeInDown, FadeOut } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { AppText as Text } from "../../components/AppText";
import type { ComposerEditorHandle } from "../../components/ComposerEditor";
import type { StatusTone } from "../../components/StatusPill";
import type { DraftComposerImageAttachment } from "../../lib/composerImages";
import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
import { scopedThreadKey } from "../../lib/scopedEntities";
-import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import type {
PendingApproval,
PendingUserInput,
@@ -172,54 +169,6 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray Date.now());
-
- useEffect(() => {
- const intervalId = setInterval(() => {
- setNowMs(Date.now());
- }, 1_000);
- return () => clearInterval(intervalId);
- }, [props.startedAt]);
-
- const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s";
-
- return (
-
-
-
-
-
-
-
-
-
- Working for {durationLabel}
-
-
-
-
- );
-});
-
export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
const insets = useSafeAreaInsets();
const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
@@ -253,8 +202,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const selectedThreadFeed = props.selectedThreadFeed;
const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
const composerOverlapHeight = composerChrome + composerBottomInset;
- const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0;
- const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight;
+ const estimatedOverlayHeight = composerOverlapHeight;
// The overlay's measured height includes the home-indicator inset (the
// composer pads it), but contentInsetAdjustmentBehavior="automatic" makes
// 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
contentPresentation={props.contentPresentation}
agentLabel={agentLabel}
latestTurn={props.selectedThread.latestTurn}
+ activeWorkStartedAt={props.activeWorkStartedAt}
listRef={listRef}
freeze={freeze}
anchorMessageId={anchorMessageId}
@@ -438,15 +387,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
list's bottom inset, so any padding above the pill/composer
pushes the resting content floor up by the same amount. */}
-
- {props.activeWorkStartedAt ? (
-
- ) : null}
-
+
{props.activePendingApproval || props.activePendingUserInput ? (
) : null}
-
+
;
readonly freeze: SharedValue;
readonly anchorMessageId: MessageId | null;
@@ -816,6 +818,10 @@ function renderFeedEntry(
const entry = info.item;
const { markdownStyles, iconSubtleColor, userBubbleColor } = props;
+ if (entry.type === "working") {
+ return ;
+ }
+
if (entry.type === "turn-fold") {
return (
Date.now());
+
+ useEffect(() => {
+ const intervalId = setInterval(() => {
+ setNowMs(Date.now());
+ }, 1_000);
+ return () => clearInterval(intervalId);
+ }, [props.startedAt]);
+
+ const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s";
+
+ return (
+
+
+
+
+
+
+
+ Working for {durationLabel}
+
+
+ );
+});
+
function UserMessageContent(props: {
readonly text: string;
readonly markdownStyles: MarkdownStyleSet;
@@ -1415,8 +1447,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
props.latestTurn,
expandedTurnIds,
expandedWorkGroupIds,
+ props.activeWorkStartedAt,
),
- [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn],
+ [
+ expandedTurnIds,
+ expandedWorkGroupIds,
+ props.activeWorkStartedAt,
+ props.feed,
+ props.latestTurn,
+ ],
);
// The empty↔filled key below remounts the list, which resets its imperative
@@ -1761,7 +1800,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}}
/>
- {props.feed.length === 0 && props.contentPresentation.kind === "ready" ? (
+ {props.feed.length === 0 &&
+ props.activeWorkStartedAt === null &&
+ props.contentPresentation.kind === "ready" ? (
{
});
});
+ it("appends active work as a normal timeline row", () => {
+ const startedAt = "2026-04-01T00:00:01.000Z";
+ const presented = deriveThreadFeedPresentation([], null, new Set(), new Set(), startedAt);
+
+ expect(presented).toEqual([
+ {
+ type: "working",
+ id: "working-indicator-row",
+ createdAt: startedAt,
+ },
+ ]);
+ expect(deriveThreadFeedPresentation(presented, null, new Set())).toEqual([]);
+ });
+
it("models work-log overflow as list rows", () => {
const activity = (
id: string,
diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts
index 9f79a90550d..6278247dc69 100644
--- a/apps/mobile/src/lib/threadActivity.ts
+++ b/apps/mobile/src/lib/threadActivity.ts
@@ -98,6 +98,11 @@ type RawThreadFeedEntry =
export type ThreadFeedEntry =
| Extract
+ | {
+ readonly type: "working";
+ readonly id: string;
+ readonly createdAt: string;
+ }
| {
readonly type: "activity-group";
readonly id: string;
@@ -1105,9 +1110,11 @@ export function deriveThreadFeedPresentation(
latestTurn: ThreadFeedLatestTurn | null,
expandedTurnIds: ReadonlySet,
expandedWorkGroupIds: ReadonlySet = new Set(),
+ activeWorkStartedAt: string | null = null,
): ThreadFeedEntry[] {
const sourceFeed = feed.filter(
- (entry) => entry.type !== "turn-fold" && entry.type !== "work-toggle",
+ (entry) =>
+ entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "working",
);
const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn);
const collapsedEntryIds = new Set();
@@ -1136,12 +1143,19 @@ export function deriveThreadFeedPresentation(
appendPresentedFeedEntry(result, entry, expandedWorkGroupIds);
}
}
+ if (activeWorkStartedAt !== null) {
+ result.push({
+ type: "working",
+ id: "working-indicator-row",
+ createdAt: activeWorkStartedAt,
+ });
+ }
return result;
}
function appendPresentedFeedEntry(
result: ThreadFeedEntry[],
- entry: Exclude,
+ entry: Exclude,
expandedWorkGroupIds: ReadonlySet,
): void {
if (entry.type !== "activity-group") {