Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2818,6 +2818,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
case "thinking":
return WORK_GROUP_TOGGLE_HEIGHT;
case "activity-group":
if (entry.activities[0]?.projectedItem.item.type === "subagent") {
return undefined;
}
if (isContextCompactionActivityGroup(entry) || isContextHandoffActivityGroup(entry)) {
return undefined;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import * as DateTime from "effect/DateTime";
import { describe, expect, it } from "vite-plus/test";
import { subagentCardDetail, subagentCardElapsed } from "./subagent-card-presentation";

describe("subagent card", () => {
it("shows readable result text and suppresses generic completion messages", () => {
expect(
subagentCardDetail(
"- Updated `app.ts` with [the fix](https://example.com).\n- Checked tests.",
),
).toBe("Updated app.ts with the fix. Checked tests.");
expect(subagentCardDetail("Child task ended with status failed.")).toBeNull();
expect(subagentCardDetail(" ")).toBeNull();
});

const start = DateTime.makeUnsafe("2026-09-21T12:00:00Z");
const end = DateTime.makeUnsafe("2026-09-21T12:01:00Z");
const later = DateTime.makeUnsafe("2026-09-21T12:02:00Z");
const done = { status: "completed" as const, startedAt: start, completedAt: end };

it("freezes settled durations and spans the group's wall time", () => {
expect(subagentCardElapsed([done], DateTime.toEpochMillis(later))).toBe("1m");
expect(
subagentCardElapsed(
[done, { ...done, startedAt: end, completedAt: later }],
DateTime.toEpochMillis(later),
),
).toBe("2m");
});

it("counts live work but never uses a settled agent's age as its duration", () => {
const unfinished = { ...done, completedAt: null };
expect(subagentCardElapsed([unfinished], DateTime.toEpochMillis(later))).toBeNull();
expect(
subagentCardElapsed([{ ...unfinished, status: "running" }], DateTime.toEpochMillis(later)),
).toBe("2m");
expect(
subagentCardElapsed([{ ...unfinished, status: "idle" }], DateTime.toEpochMillis(later)),
).toBeNull();
expect(
subagentCardElapsed([{ ...done, startedAt: null }], DateTime.toEpochMillis(later)),
).toBeNull();
});
});
35 changes: 35 additions & 0 deletions apps/mobile/src/features/threads/subagent-card-presentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { isActiveSubagentStatus } from "@t3tools/client-runtime/state/subagentRuntime";
import type { OrchestrationV2Subagent } from "@t3tools/contracts";
import { formatDuration } from "@t3tools/shared/orchestrationTiming";
import * as DateTime from "effect/DateTime";

export function subagentCardDetail(detail: string | null): string | null {
if (!detail || /^Child task ended with status\b/i.test(detail)) return null;
return (
detail
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/`/g, "")
.replace(/^[ \t]*[-*][ \t]+/gm, "")
.replace(/\s+/g, " ")
.trim() || null
);
}

/** A group's wall time spans its first launch to its last completion. */
export function subagentCardElapsed(
agents: ReadonlyArray<Pick<OrchestrationV2Subagent, "status" | "startedAt" | "completedAt">>,
nowMs: number,
): string | null {
const starts = agents.flatMap((agent) =>
agent.startedAt ? [DateTime.toEpochMillis(agent.startedAt)] : [],
);
if (starts.length === 0) return null;
const live = agents.some((agent) => isActiveSubagentStatus(agent.status));
// Settled agents without a completion timestamp must not keep counting their age.
if (!live && agents.some((agent) => agent.completedAt === null)) return null;
const ends = agents.flatMap((agent) =>
agent.completedAt ? [DateTime.toEpochMillis(agent.completedAt)] : [],
);
const duration = (live ? nowMs : Math.max(...ends)) - Math.min(...starts);
return duration > 0 ? formatDuration(duration) : null;
}
235 changes: 235 additions & 0 deletions apps/mobile/src/features/threads/thread-subagent-group.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { useAtomValue } from "@effect/atom-react";
import { useIsFocused, useNavigation } from "@react-navigation/native";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { summarizeSubagentStatuses } from "@t3tools/client-runtime/state/subagent-display";
import {
isActiveSubagentStatus,
isTerminalSubagentStatus,
} from "@t3tools/client-runtime/state/subagentRuntime";
import type {
EnvironmentId,
OrchestrationV2Subagent,
OrchestrationV2TurnItem,
} from "@t3tools/contracts";
import { useEffect, useState } from "react";
import { AppState, Pressable, View, type ColorValue } from "react-native";

import { AppText as Text } from "../../components/AppText";
import { SymbolView } from "../../components/AppSymbol";
import { ProviderIcon } from "../../components/ProviderIcon";
import { cn } from "../../lib/cn";
import type { ThreadFeedActivity } from "../../lib/threadActivity";
import { serverEnvironment } from "../../state/server";
import { environmentThreadDetails } from "../../state/threads";
import { SubagentStatusDot } from "./SubagentStatusDot";
import { subagentCardDetail, subagentCardElapsed } from "./subagent-card-presentation";
import { resolveSubagentRowPresentation } from "./threadAgentsPresentation";
import { WorkLogBlock } from "./work-log-layout";

type SubagentItem = Extract<OrchestrationV2TurnItem, { type: "subagent" }>;
type AgentTiming = Pick<OrchestrationV2Subagent, "status" | "startedAt" | "completedAt">;

function SubagentElapsed({ agents }: { readonly agents: ReadonlyArray<AgentTiming> }) {
const focused = useIsFocused();
const live = agents.some((agent) => isActiveSubagentStatus(agent.status));
const [nowMs, setNowMs] = useState(() => Date.now());
const [appActive, setAppActive] = useState(() => AppState.currentState === "active");
useEffect(() => {
const subscription = AppState.addEventListener("change", (state) =>
setAppActive(state === "active"),
);
return () => subscription.remove();
}, []);
useEffect(() => {
if (!live || !focused || !appActive) return;
const intervalId = setInterval(() => setNowMs(Date.now()), 1_000);
return () => clearInterval(intervalId);
}, [appActive, focused, live]);
const elapsed = subagentCardElapsed(agents, nowMs);
return elapsed ? (
<Text className="shrink-0 text-2xs tabular-nums text-foreground-muted">{elapsed}</Text>
) : null;
}

function SubagentAvatar(props: {
readonly item: SubagentItem;
readonly iconUrl?: string | null | undefined;
readonly status?: OrchestrationV2Subagent["status"];
}) {
return (
<View
accessible={false}
className="relative h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border bg-card"
>
<ProviderIcon provider={props.item.driver} iconUrl={props.iconUrl} size={15} />
{props.status ? (
<SubagentStatusDot
placement="provider"
tone={resolveSubagentRowPresentation({ ...props.item, status: props.status }).tone}
/>
) : null}
</View>
);
}

export function ThreadSubagentGroup(props: {
readonly activities: ReadonlyArray<ThreadFeedActivity>;
readonly environmentId: EnvironmentId;
readonly anchorKey: string;
readonly expandedRows: Readonly<Record<string, boolean>>;
readonly iconSubtleColor: ColorValue;
readonly onToggleRow: (rowId: string, anchorKey: string) => void;
}) {
const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId));
const navigation = useNavigation();
const members = props.activities.flatMap(({ projectedItem }) =>
projectedItem.item.type === "subagent" ? [projectedItem.item] : [],
);
const liveAgents = useAtomValue(
environmentThreadDetails.threadAtom(scopeThreadRef(props.environmentId, members[0]!.threadId)),
(thread) => thread?.projection.subagents,
);
const agents = members.map((item) => {
const live = liveAgents?.find((agent) => agent.id === item.subagentId);
return {
...item,
item,
status: live?.status ?? item.status,
startedAt: live?.startedAt ?? item.startedAt,
completedAt: live?.completedAt ?? item.completedAt,
result: live?.result ?? item.result,
progress: live?.progress ?? item.progress,
};
});
const label = `${agents.length} ${agents.length === 1 ? "subagent" : "subagents"}`;
const summary = summarizeSubagentStatuses(agents.map((agent) => agent.status));
const expanded = props.expandedRows[props.anchorKey] ?? false;
const iconUrl = (item: SubagentItem) =>
config?.providers.find((provider) => provider.instanceId === item.providerInstanceId)?.iconUrl;
return (
<WorkLogBlock>
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
<Pressable
accessibilityRole="button"
accessibilityLabel={`${label}, ${summary}`}
accessibilityState={{ expanded }}
onPress={() => props.onToggleRow(props.anchorKey, props.anchorKey)}
className="min-h-14 flex-row items-center gap-3 rounded-lg py-2 active:bg-subtle"
>
<View className="flex-row items-center">
{agents.slice(0, 3).map((agent, index) => (
<View key={agent.item.id} style={{ marginLeft: index === 0 ? 0 : -7 }}>
<SubagentAvatar
item={agent.item}
iconUrl={iconUrl(agent.item)}
{...(agents.length === 1 ? { status: agent.status } : {})}
/>
</View>
))}
{agents.length > 3 ? (
<View className="-ml-2 h-7 w-7 items-center justify-center rounded-full border border-border bg-card">
<Text className="text-2xs text-foreground-muted">+{agents.length - 3}</Text>
</View>
) : null}
</View>
<View className="min-w-0 flex-1 gap-0.5">
<Text numberOfLines={1} className="font-t3-medium text-sm text-foreground">
{label}
</Text>
<Text
numberOfLines={1}
className={cn(
"text-2xs text-foreground-muted",
agents.some((agent) => isActiveSubagentStatus(agent.status))
? "text-adaptive-sky-600-400"
: agents.some((agent) => agent.status === "failed") && "text-adaptive-rose-600-400",
)}
>
{summary}
</Text>
</View>
<SubagentElapsed agents={agents} />
<SymbolView
name={expanded ? "chevron.up" : "chevron.down"}
size={11}
tintColor={props.iconSubtleColor}
/>
</Pressable>
{expanded ? (
<View className="mb-1 gap-px rounded-xl border border-border bg-card/30 p-1">
{agents.map((agent) => {
const presentation = resolveSubagentRowPresentation(agent);
const detail = subagentCardDetail(
isTerminalSubagentStatus(agent.status)
? agent.result?.trim() || agent.progress || null
: agent.progress?.trim() || agent.result,
);
const threadId = agent.childThreadId;
return (
<Pressable
key={agent.item.id}
accessible
accessibilityRole={threadId === null ? undefined : "link"}
accessibilityLabel={`${presentation.title}, ${presentation.statusLabel}${detail ? `, ${detail}` : ""}`}
accessibilityHint={
threadId === null ? "Provider-managed agent" : "Opens this agent's thread"
}
disabled={threadId === null}
onPress={() => {
if (threadId !== null)
navigation.navigate("Thread", {
environmentId: String(props.environmentId),
threadId: String(threadId),
});
}}
className="min-h-14 flex-row items-center gap-3 rounded-lg px-2 py-2 active:bg-subtle"
>
<SubagentAvatar
item={agent.item}
iconUrl={iconUrl(agent.item)}
status={agent.status}
/>
<View className="min-w-0 flex-1 gap-0.5">
<View className="flex-row items-baseline gap-2">
<Text
numberOfLines={1}
className="min-w-0 shrink font-t3-medium text-sm text-foreground"
>
{presentation.title}
</Text>
{detail && agent.status !== "completed" ? (
<Text
className={cn(
"shrink-0 text-2xs text-foreground-muted",
agent.status === "failed" && "text-adaptive-rose-600-400",
)}
>
{presentation.statusLabel}
</Text>
) : null}
</View>
<Text
numberOfLines={1}
className={cn(
"text-xs text-foreground-muted",
agent.status === "failed" && "text-adaptive-rose-600-400",
)}
>
{detail ?? presentation.statusLabel}
</Text>
</View>
<SubagentElapsed agents={[agent]} />
{threadId !== null ? (
<SymbolView
name="chevron.right"
size={12}
tintColorClassName="accent-icon-subtle"
/>
) : null}
</Pressable>
);
})}
</View>
) : null}
</WorkLogBlock>
);
}
Loading
Loading