Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";
import type { PendingBackgroundWorkTask } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
import type { LegendListRef } from "@legendapp/list/react-native";
import type {
Expand Down Expand Up @@ -56,6 +57,7 @@ export interface ThreadDetailScreenProps {
readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;
readonly activityRun: ThreadFeedLatestRun | null;
readonly activeWorkStartedAt: string | null;
readonly pendingBackgroundTasks: ReadonlyArray<PendingBackgroundWorkTask>;
readonly activePendingApproval: PendingApproval | null;
readonly respondingApprovalId: RuntimeRequestId | null;
readonly activePendingUserInput: PendingUserInput | null;
Expand Down Expand Up @@ -401,6 +403,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
threadTitle={props.selectedThread.title}
latestRun={props.activityRun}
activeWorkStartedAt={props.activeWorkStartedAt}
pendingBackgroundTasks={props.pendingBackgroundTasks}
listRef={listRef}
freeze={freeze}
anchorMessageId={anchorMessageId}
Expand Down
61 changes: 60 additions & 1 deletion apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "@t3tools/contracts";
import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
import type { PendingBackgroundWorkTask } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import { SymbolView } from "../../components/AppSymbol";
import { HeaderHeightContext } from "@react-navigation/elements";
import { useNavigation } from "@react-navigation/native";
Expand Down Expand Up @@ -55,10 +56,16 @@ import { TouchableOpacity } from "react-native-gesture-handler";
import ImageViewing from "react-native-image-viewing";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
cancelAnimation,
FadeIn,
FadeInUp,
LinearTransition,
type SharedValue,
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { useThemeColor } from "../../lib/useThemeColor";
import { useFontFamily } from "../../lib/useFontFamily";
Expand Down Expand Up @@ -148,6 +155,7 @@ export interface ThreadFeedProps {
readonly agentLabel: string;
readonly latestRun: ThreadFeedLatestRun | null;
readonly activeWorkStartedAt: string | null;
readonly pendingBackgroundTasks: ReadonlyArray<PendingBackgroundWorkTask>;
readonly listRef: RefObject<LegendListRef | null>;
readonly freeze: SharedValue<boolean>;
readonly anchorMessageId: MessageId | null;
Expand Down Expand Up @@ -927,6 +935,10 @@ function renderFeedEntry(
return <WorkingTimelineRow startedAt={entry.createdAt} />;
}

if (entry.type === "waiting-background") {
return <WaitingBackgroundTimelineRow label={entry.label} />;
}

if (entry.type === "run-fold") {
return (
<Pressable
Expand Down Expand Up @@ -1171,6 +1183,44 @@ const WorkingTimelineRow = memo(function WorkingTimelineRow(props: { readonly st
);
});

// Unlike the working row there is no elapsed timer to carry liveness, so the
// dots pulse instead. Web animates both rows the same way.
const WaitingBackgroundTimelineRow = memo(function WaitingBackgroundTimelineRow(props: {
readonly label: string;
}) {
return (
<View className="mb-4 flex-row items-center gap-2 px-1.5 py-1">
<View className="flex-row items-center gap-1">
<WaitingBackgroundDot delayMs={0} className="bg-neutral-400 dark:bg-neutral-500" />
<WaitingBackgroundDot delayMs={200} className="bg-neutral-400/80 dark:bg-neutral-500/80" />
<WaitingBackgroundDot delayMs={400} className="bg-neutral-400/60 dark:bg-neutral-500/60" />
</View>
<Text
numberOfLines={2}
className="flex-1 font-t3-medium text-xs text-neutral-600 dark:text-neutral-400"
>
{props.label}
</Text>
</View>
);
});

function WaitingBackgroundDot(props: { readonly className: string; readonly delayMs: number }) {
const opacity = useSharedValue(1);
useEffect(() => {
opacity.value = withDelay(
props.delayMs,
withRepeat(withTiming(0.3, { duration: 600 }), -1, true),
);
return () => cancelAnimation(opacity);
}, [opacity, props.delayMs]);

const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
return (
<Animated.View className={`h-1 w-1 rounded-full ${props.className}`} style={animatedStyle} />
);
}

function UserMessageContent(props: {
readonly text: string;
readonly markdownStyles: MarkdownStyleSet;
Expand Down Expand Up @@ -1591,8 +1641,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
.map(([groupId]) => groupId),
),
props.activeWorkStartedAt,
props.pendingBackgroundTasks,
),
[expandedTurnIds, expandedWorkGroups, props.activeWorkStartedAt, props.feed, props.latestRun],
[
expandedTurnIds,
expandedWorkGroups,
props.activeWorkStartedAt,
props.feed,
props.latestRun,
props.pendingBackgroundTasks,
],
);

// The empty↔filled key below remounts the list, which resets its imperative
Expand Down Expand Up @@ -1946,6 +2004,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
</View>
{props.feed.length === 0 &&
props.activeWorkStartedAt === null &&
props.pendingBackgroundTasks.length === 0 &&
props.contentPresentation.kind === "ready" ? (
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
<ThreadFeedPlaceholder
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,7 @@ function ThreadRouteContent(
selectedThreadFeed={composer.selectedThreadFeed}
activityRun={composer.selectedThreadActivityRun}
activeWorkStartedAt={composer.activeWorkStartedAt}
pendingBackgroundTasks={composer.pendingBackgroundTasks}
activePendingApproval={requests.activePendingApproval}
respondingApprovalId={requests.respondingApprovalId}
activePendingUserInput={requests.activePendingUserInput}
Expand Down
127 changes: 127 additions & 0 deletions apps/mobile/src/features/threads/threadPresentation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { EnvironmentId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import { makeThreadShellFixture } from "../../test-fixtures";
import { resolveThreadStatus } from "./threadPresentation";

const environmentId = EnvironmentId.make("environment-1");
const NOW = "2026-06-02T00:00:00.000Z";

function makeThread(input: Partial<EnvironmentThreadShell> = {}): EnvironmentThreadShell {
return makeThreadShellFixture({
environmentId,
id: ThreadId.make("t"),
title: "t",
...input,
});
}

type ThreadRuntime = NonNullable<EnvironmentThreadShell["runtime"]>;
type ThreadRun = NonNullable<EnvironmentThreadShell["latestRun"]>;

function latestRun(status: ThreadRun["status"], completedAt: string | null): ThreadRun {
return {
runId: RunId.make("run-t"),
status,
requestedAt: NOW,
startedAt: NOW,
completedAt,
assistantMessageId: null,
};
}

function runtime(status: ThreadRuntime["status"]): ThreadRuntime {
return {
status,
activeRunId: null,
providerInstanceId: ProviderInstanceId.make("codex"),
providerName: "Codex",
lastError: null,
updatedAt: NOW,
};
}

describe("resolveThreadStatus waiting", () => {
it("reports a static muted Waiting pill for a nonempty background roster", () => {
const status = resolveThreadStatus(
makeThread({
pendingBackgroundTasks: [{ taskId: "bg-1", description: "Run Codex review" }],
runtime: runtime("idle"),
}),
);

expect(status?.kind).toBe("waiting");
expect(status?.label).toBe("Waiting");
expect(status?.pulse).toBe(false);
});

it("reports nothing once the roster drains", () => {
expect(
resolveThreadStatus(makeThread({ pendingBackgroundTasks: [], runtime: runtime("idle") })),
).toBeNull();
});

it("wins over a stale non-terminal latestRun once the bridge parks runtime idle", () => {
// The shape the presentation bridge actually emits post-settlement: the
// roster parks runtime at idle while latestRun still reads as active.
const status = resolveThreadStatus(
makeThread({
pendingBackgroundTasks: [{ taskId: "bg-1", description: "Run Codex review" }],
runtime: runtime("idle"),
latestRun: latestRun("running", null),
}),
);

expect(status?.kind).toBe("waiting");
});

it("wins over Plan Ready", () => {
const status = resolveThreadStatus(
makeThread({
pendingBackgroundTasks: [{ taskId: "bg-1", description: "Run Codex review" }],
runtime: runtime("idle"),
interactionMode: "plan",
hasActionableProposedPlan: true,
latestRun: latestRun("completed", NOW),
}),
);

expect(status?.kind).toBe("waiting");
});

// Guards the resolver itself, not a shape the bridge emits: parking means a
// nonempty roster arrives with runtime idle. If parking is ever dropped,
// active work must still win.
it("keeps active work ahead of Waiting", () => {
const status = resolveThreadStatus(
makeThread({
pendingBackgroundTasks: [{ taskId: "bg-1", description: "Run Codex review" }],
runtime: {
...runtime("running"),
activeRunId: RunId.make("run-t"),
},
}),
);

expect(status?.kind).toBe("working");
});

it("keeps approvals, input, and failures ahead of Waiting", () => {
const pendingBackgroundTasks = [{ taskId: "bg-1", description: "Run Codex review" }];

expect(
resolveThreadStatus(
makeThread({ pendingBackgroundTasks, hasPendingApprovals: true, runtime: runtime("idle") }),
)?.kind,
).toBe("pending-approval");
expect(
resolveThreadStatus(
makeThread({ pendingBackgroundTasks, hasPendingUserInput: true, runtime: runtime("idle") }),
)?.kind,
).toBe("awaiting-input");
expect(
resolveThreadStatus(makeThread({ pendingBackgroundTasks, runtime: runtime("failed") }))?.kind,
).toBe("error");
});
});
21 changes: 20 additions & 1 deletion apps/mobile/src/features/threads/threadPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type ThreadStatusKind =
| "working"
| "connecting"
| "error"
| "waiting"
| "plan-ready";

export interface ThreadStatusPresentation extends StatusTone {
Expand Down Expand Up @@ -42,7 +43,12 @@ function isLatestRunSettled(thread: EnvironmentThreadShell): boolean {
/**
* Resolves the user-facing status of a thread, in priority order. Returns
* `null` for quiescent threads so rows stay free of "Idle"-style noise.
* Mirrors `resolveThreadStatusPill` in apps/web/src/components/Sidebar.logic.ts.
*
* Follows `resolveThreadStatusPill` in apps/web/src/components/Sidebar.logic.ts
* for the shared pills, but is not a mirror of it: mobile additionally ranks
* Error above Waiting, and the web classic sidebar has no Error pill at all. So
* a failed run holding a nonempty background roster reads Error here and
* Waiting on desktop.
*/
export function resolveThreadStatus(
thread: EnvironmentThreadShell,
Expand Down Expand Up @@ -109,6 +115,19 @@ export function resolveThreadStatus(
};
}

// Settled root turn with finite provider work still pending. Static and
// muted: in motion, but nothing to act on. Mirrors the web sidebar pill.
if ((thread.pendingBackgroundTasks?.length ?? 0) > 0) {
return {
kind: "waiting",
label: "Waiting",
pillClassName: "bg-neutral-500/12 dark:bg-neutral-500/16",
textClassName: "text-neutral-600 dark:text-neutral-400",
...THREAD_STATUS_NEUTRAL_ICON,
pulse: false,
};
}

const hasPlanReadyPrompt =
thread.interactionMode === "plan" &&
isLatestRunSettled(thread) &&
Expand Down
Loading
Loading