Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
146 changes: 146 additions & 0 deletions apps/mobile/src/features/threads/SubagentRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
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 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 }) => (
// Not its own accessibility element: the enclosing row reads this
// label in place of the icon, keeping each agent a single stop.
<View
key={label}
accessibilityLabel={`${label}: ${value}`}
className="min-w-0 shrink flex-row items-center gap-1.5"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium threads/SubagentRow.tsx:126

Workspace details are announced without their Branch:, Worktree:, or Project: label, so values such as a branch name are ambiguous to screen-reader users. The View carrying accessibilityLabel is not accessible, so its label is ignored while the containing accessible row aggregates only descendant text; mark this view as accessible so the type is announced.

Suggested change
// Not its own accessibility element: the enclosing row reads this
// label in place of the icon, keeping each agent a single stop.
<View
key={label}
accessibilityLabel={`${label}: ${value}`}
className="min-w-0 shrink flex-row items-center gap-1.5"
<View
key={label}
accessible
accessibilityLabel={`${label}: ${value}`}
className="min-w-0 shrink flex-row items-center gap-1.5"
>
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/SubagentRow.tsx around lines 126-131:

Workspace details are announced without their `Branch:`, `Worktree:`, or `Project:` label, so values such as a branch name are ambiguous to screen-reader users. The `View` carrying `accessibilityLabel` is not `accessible`, so its label is ignored while the containing accessible row aggregates only descendant text; mark this view as `accessible` so the type is announced.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. Fabric flattens a View whose only distinguishing prop is accessibilityLabel, so 81395aa dropped the label: in the iOS simulator's accessibility tree, the row read "·, divide, fix/subagent-lifecycle". Rather than restoring accessible, which can give the segment its own focus stop, 4269ca4 uses collapsable={false} to keep the native view. The row now reads "…, Codex · GPT-6.1-Sol, Branch: fix/subagent-lifecycle, …" as a single element. The commit also hides the decorative title separator from the label.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

>
<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