Skip to content
Merged
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
154 changes: 154 additions & 0 deletions apps/mobile/src/features/threads/SubagentRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
import { resolveSubagentMetadata } from "@t3tools/client-runtime/state/subagent-display";
import type { EnvironmentId, OrchestrationV2Subagent } from "@t3tools/contracts";
import type { ReactNode } from "react";
import { View } 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 { useEnvironmentServerConfig, useProject, useThreadShell } from "../../state/entities";
import { subagentCardDetail } from "./subagent-card-presentation";
import { SUBAGENT_TONE_TEXT_CLASS, SubagentStatusDot } from "./SubagentStatusDot";
import { resolveSubagentRowPresentation } from "./threadAgentsPresentation";

type SubagentRowSubagent = Pick<
OrchestrationV2Subagent,
| "threadId"
| "childThreadId"
| "model"
| "driver"
| "providerInstanceId"
| "title"
| "prompt"
| "status"
| "progress"
| "result"
>;

/**
* One agent as shown in the Agents sheet and transcript cards: status, model,
* workspace changes, and recent progress or result. Callers own the press
* target and pass their own elapsed timer so ticking stays isolated.
*/
export function SubagentRow(props: {
readonly environmentId: EnvironmentId;
readonly subagent: SubagentRowSubagent;
readonly elapsed: ReactNode;
}) {
const presentation = resolveSubagentRowPresentation(props.subagent);
const detail = subagentCardDetail(presentation.detail);
return (
<View className="flex-row gap-3">
<View className="h-5 justify-center">
<SubagentStatusDot tone={presentation.tone} placement="sheet" />
</View>
<View className="min-w-0 flex-1 gap-1">
<View className="min-h-5 flex-row items-center gap-2">
<View className="min-w-0 flex-1 flex-row items-baseline gap-1.5">
<Text
numberOfLines={1}
className="min-w-0 shrink font-t3-medium text-sm text-foreground"
>
{presentation.title}
</Text>
<Text
accessibilityElementsHidden
importantForAccessibility="no"
className="shrink-0 text-xs text-foreground-muted"
>
·
</Text>
<Text
className={cn(
"shrink-0 text-xs font-t3-medium",
SUBAGENT_TONE_TEXT_CLASS[presentation.tone],
)}
>
{presentation.statusLabel}
</Text>
</View>
{props.elapsed}
{presentation.canOpenThread ? (
<SymbolView name="chevron.right" size={12} tintColorClassName="accent-icon-subtle" />
) : null}
</View>
<SubagentMetadata environmentId={props.environmentId} subagent={props.subagent} />
{detail ? (
<Text
numberOfLines={3}
className={cn(
"text-xs text-foreground-muted",
presentation.tone === "failed" && SUBAGENT_TONE_TEXT_CLASS.failed,
)}
>
{detail}
</Text>
) : null}
</View>
</View>
);
}

/** Uses shell data already held by the client, without loading child transcripts. */
function SubagentMetadata(props: {
readonly environmentId: EnvironmentId;
readonly subagent: SubagentRowSubagent;
}) {
const { environmentId, subagent } = props;
const config = useEnvironmentServerConfig(environmentId);
const provider = config?.providers.find(
(candidate) => candidate.instanceId === subagent.providerInstanceId,
);
const parent = useThreadShell(scopeThreadRef(environmentId, subagent.threadId))?.source;
const child = useThreadShell(
subagent.childThreadId === null ? null : scopeThreadRef(environmentId, subagent.childThreadId),
)?.source;
const parentProject = useProject(
parent ? scopeProjectRef(environmentId, parent.projectId) : null,
);
const childProject = useProject(child ? scopeProjectRef(environmentId, child.projectId) : null);
const { modelLabel, workspace } = resolveSubagentMetadata({
model: subagent.model,
provider,
parentThread: parent,
childThread: child,
parentProject,
childProject,
});
return (
<View className="min-w-0 flex-row items-center gap-1.5">
<ProviderIcon
provider={provider?.driver ?? subagent.driver}
iconUrl={provider?.iconUrl}
size={12}
/>
<Text className="min-w-0 shrink text-xs text-foreground-muted" numberOfLines={1}>
{provider?.displayName ? `${provider.displayName} · ` : ""}
{modelLabel}
</Text>
{workspace.map(({ label, value }) => (
// The row reads this label in place of the icon. collapsable keeps the
// view (and label) from being flattened away without making it a
// separate accessibility stop.
<View
key={label}
collapsable={false}
accessibilityLabel={`${label}: ${value}`}
className="min-w-0 shrink flex-row items-center gap-1.5"
>
<Text className="text-xs text-foreground-muted">·</Text>
<SymbolView
name={label === "Branch" ? "arrow.triangle.branch" : "folder"}
size={11}
tintColorClassName="accent-icon-muted"
/>
<Text className="min-w-0 shrink text-xs text-foreground-muted" numberOfLines={1}>
{value}
</Text>
</View>
))}
</View>
);
}
10 changes: 8 additions & 2 deletions apps/mobile/src/features/threads/SubagentStatusDot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,25 @@ const TONE_CLASS = {
stopped: "bg-foreground-muted",
} as const satisfies Record<SubagentRowTone, string>;

export const SUBAGENT_TONE_TEXT_CLASS = {
working: "text-adaptive-sky-600-400",
completed: "text-adaptive-emerald-600-400",
failed: "text-adaptive-rose-600-400",
stopped: "text-foreground-muted",
} as const satisfies Record<SubagentRowTone, string>;

export function SubagentStatusDot({
tone,
placement = "inline",
}: {
readonly tone: SubagentRowTone;
readonly placement?: "inline" | "provider" | "sheet";
readonly placement?: "inline" | "sheet";
}) {
return (
<View
className={cn(
"shrink-0 rounded-full",
placement === "sheet" ? "h-2 w-2" : "h-1.5 w-1.5",
placement === "provider" && "absolute bottom-0.5 right-0.5",
TONE_CLASS[tone],
)}
/>
Expand Down
47 changes: 21 additions & 26 deletions apps/mobile/src/features/threads/ThreadAgentsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";

import { AndroidSheetHeader } from "../../components/AndroidScreenHeader";
import { AppText as Text } from "../../components/AppText";
import { SymbolView } from "../../components/AppSymbol";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { environmentThreadDetails } from "../../state/threads";
import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
import { resolveSubagentRowPresentation } from "./threadAgentsPresentation";

import { SubagentStatusDot } from "./SubagentStatusDot";
import { SubagentRow } from "./SubagentRow";

const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);

Expand Down Expand Up @@ -72,6 +69,7 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps<AgentsTarget>) {
<AgentRow
key={subagent.id}
subagent={subagent}
environmentId={target.environmentId}
tickSeconds={hasLiveAgent}
onOpen={openChildThread}
/>
Expand Down Expand Up @@ -121,40 +119,28 @@ export function ThreadAgentsSheet({ route }: StaticScreenProps<AgentsTarget>) {
}

function AgentRow(props: {
readonly environmentId: EnvironmentId;
readonly subagent: OrchestrationV2Subagent;
readonly tickSeconds: boolean;
readonly onOpen: (childThreadId: ThreadId) => void;
}) {
const { subagent } = props;
const presentation = resolveSubagentRowPresentation(subagent);
const childThreadId = subagent.childThreadId;
const elapsed = useSubagentElapsed(subagent, props.tickSeconds);

const row = (
<View className="min-h-14 flex-row items-center gap-3 border-b border-border py-3">
<SubagentStatusDot tone={presentation.tone} placement="sheet" />
<View className="min-w-0 flex-1 gap-0.5">
<Text className="font-t3-medium text-sm text-foreground" numberOfLines={1}>
{presentation.title}
</Text>
<Text className="text-xs text-foreground-muted" numberOfLines={1}>
{presentation.detail ?? presentation.statusLabel}
</Text>
</View>
{elapsed === null ? null : (
<Text className="shrink-0 text-2xs tabular-nums text-foreground-muted">{elapsed}</Text>
)}
{presentation.canOpenThread ? (
<SymbolView name="chevron.right" size={12} tintColorClassName="accent-icon-subtle" />
) : null}
<View className="border-b border-border py-3.5">
<SubagentRow
environmentId={props.environmentId}
subagent={subagent}
elapsed={<AgentElapsed subagent={subagent} tickSeconds={props.tickSeconds} />}
/>
</View>
);

if (childThreadId === null) {
return (
<View
accessible
accessibilityLabel={`${presentation.title}, ${presentation.statusLabel}`}
accessibilityHint="Provider-managed agent. Its work appears in the transcript."
>
{row}
Expand All @@ -165,7 +151,6 @@ function AgentRow(props: {
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={`${presentation.title}, ${presentation.statusLabel}`}
accessibilityHint="Opens this agent's thread"
onPress={() => props.onOpen(childThreadId)}
className="active:opacity-70"
Expand All @@ -175,9 +160,19 @@ function AgentRow(props: {
);
}

function AgentElapsed(props: {
readonly subagent: OrchestrationV2Subagent;
readonly tickSeconds: boolean;
}) {
const elapsed = useSubagentElapsed(props.subagent, props.tickSeconds);
return elapsed === null ? null : (
<Text className="shrink-0 text-xs tabular-nums text-foreground-muted">{elapsed}</Text>
);
}

/**
* Elapsed time for one agent. Only a roster with live work subscribes to the
* shared second tick, so a settled sheet never repaints.
* Elapsed time for one agent. Only live work ticks, inside AgentElapsed,
* so the timer never repaints the metadata or a settled sheet.
*/
function useSubagentElapsed(
subagent: Pick<OrchestrationV2Subagent, "status" | "startedAt" | "completedAt">,
Expand Down
Loading
Loading