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
25 changes: 25 additions & 0 deletions apps/mobile/src/components/ComposerContextSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,31 @@ export function ComposerContextSheet(props: {
) : null}
</View>
) : null}
{record.kind === "subagent" ? (
<View className="gap-3">
<ContextField label="Agent" value={`@${record.handle}`} />
<ContextField label="Title" value={record.title} />
<ContextField label="Status when referenced" value={record.status} />
{/* Exactly what the agent receives about the referenced agent. */}
<ContextField label="Task" value={record.prompt} />
<ContextField label="Result" value={record.result} />
{props.onOpenThread && record.childThreadId !== null ? (
<Pressable
accessibilityRole="button"
onPress={() =>
record.childThreadId !== null &&
props.onOpenThread?.({
environmentId: record.environmentId,
threadId: record.childThreadId,
})
}
className="rounded-xl bg-subtle p-4"
>
<Text className="text-foreground">Open agent</Text>
</Pressable>
) : null}
</View>
) : null}
{record.kind === "skill" ? (
<View className="gap-3">
<ContextField label="Skill" value={record.name} />
Expand Down
159 changes: 146 additions & 13 deletions apps/mobile/src/features/threads/ComposerCommandPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,38 @@ import {
resolveProviderSkillSourceKind,
type ProviderSkillSourceKind,
} from "@t3tools/client-runtime/providerSkills";
import type { AgentFleetEntry } from "@t3tools/client-runtime/state/agent-fleet";
import { resolveProviderInstanceDisplayName } from "@t3tools/client-runtime/state/provider-instance-display";
import {
formatSubagentModelLabel,
formatSubagentTokenCount,
isActiveSubagentStatus,
} from "@t3tools/client-runtime/state/subagentRuntime";
import type {
PullRequestContextMetadata,
ScopedThreadRef,
ServerProvider,
ServerProviderSkill,
ServerProviderSlashCommand,
} from "@t3tools/contracts";
import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger";
import { memo } from "react";
import { deriveSubagentElapsedMs, formatDuration } from "@t3tools/shared/orchestrationTiming";
import { memo, useEffect, useState } from "react";
import { Pressable, ScrollView, StyleSheet, View, type ViewStyle } from "react-native";

import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { GlassSurface } from "../../components/GlassSurface";
import { PierreEntryIcon } from "../../components/PierreEntryIcon";
import { ProviderIcon } from "../../components/ProviderIcon";
import { SegmentedControl } from "../../components/SegmentedControl";
import { cn } from "../../lib/cn";
import { SUBAGENT_TONE_TEXT_CLASS, SubagentStatusDot } from "./SubagentStatusDot";
import { subagentStatusLabel, subagentStatusTone } from "./threadAgentsPresentation";

/** The `@` menu's tabs, shown when the thread has agents to reference. */
export type ComposerPathTab = "files" | "agents";

export type ComposerCommandItem =
| {
readonly id: string;
Expand All @@ -39,6 +57,14 @@ export type ComposerCommandItem =
readonly label: string;
readonly description: string;
}
| {
readonly id: string;
readonly type: "subagent";
readonly entry: AgentFleetEntry;
readonly provider: ServerProvider | undefined;
readonly label: string;
readonly description: string;
}
| {
readonly id: string;
readonly type: "slash-command";
Expand Down Expand Up @@ -66,9 +92,19 @@ interface ComposerCommandPopoverProps {
readonly triggerKind: ComposerTriggerKind | null;
readonly isLoading: boolean;
readonly error?: string | null;
/** Files/Agents tabs for the `@` menu; null when the thread has no agents. */
readonly pathTab?: {
readonly active: ComposerPathTab;
readonly onChange: (tab: ComposerPathTab) => void;
} | null;
readonly onSelect: (item: ComposerCommandItem) => void;
}

const PATH_TAB_OPTIONS = [
{ value: "files", label: "Files" },
{ value: "agents", label: "Agents" },
] as const satisfies ReadonlyArray<{ value: ComposerPathTab; label: string }>;

function PopoverSurface(props: { readonly children: React.ReactNode; readonly style?: ViewStyle }) {
const baseStyle: ViewStyle = {
borderRadius: 16,
Expand Down Expand Up @@ -109,6 +145,8 @@ function itemIcon(item: ComposerCommandItem): AppSymbolName | null {
return null;
case "thread":
return "text.bubble";
case "subagent":
return null;
}
}

Expand All @@ -127,7 +165,12 @@ function groupLabel(triggerKind: ComposerTriggerKind | null): string | null {
}
}

function emptyText(triggerKind: ComposerTriggerKind | null, isLoading: boolean): string {
function emptyText(
triggerKind: ComposerTriggerKind | null,
isLoading: boolean,
pathTab: ComposerPathTab | null,
): string {
if (pathTab === "agents") return "No matching agents.";
if (isLoading) {
return triggerKind === "path" ? "Searching files…" : "Loading…";
}
Expand All @@ -145,6 +188,75 @@ function emptyText(triggerKind: ComposerTriggerKind | null, isLoading: boolean):
}
}

/** Ticks once a second while the agent works, so a settled row never repaints. */
function useAgentClock(live: boolean): number {
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
if (!live) return;
const intervalId = setInterval(() => setNowMs(Date.now()), 1_000);
return () => clearInterval(intervalId);
}, [live]);
return nowMs;
}

/** An agent in the `@` menu: enough to tell apart agents with similar titles. */
const AgentCommandRow = memo(function AgentCommandRow(props: {
readonly entry: AgentFleetEntry;
readonly provider: ServerProvider | undefined;
readonly onPress: () => void;
readonly isLast: boolean;
}) {
const { entry, provider } = props;
const { agent } = entry;
const tone = subagentStatusTone(agent.status);
const nowMs = useAgentClock(isActiveSubagentStatus(agent.status));
const elapsedMs = deriveSubagentElapsedMs(agent, nowMs);
const details = [
provider ? resolveProviderInstanceDisplayName(provider) : null,
formatSubagentModelLabel(agent.model, agent.effort),
elapsedMs ? formatDuration(elapsedMs) : null,
agent.usage ? `${formatSubagentTokenCount(agent.usage.totalTokens)} tokens` : null,
].filter((part) => part !== null);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`@${entry.handle}, ${entry.title}, ${subagentStatusLabel(agent.status)}`}
onPress={props.onPress}
className="flex-row gap-2.5 border-border px-3.5 py-2.5 active:opacity-60"
style={{ borderBottomWidth: props.isLast ? 0 : StyleSheet.hairlineWidth }}
>
<View className="h-5 justify-center">
<SubagentStatusDot tone={tone} placement="sheet" />
</View>
<View className="min-w-0 flex-1 gap-0.5">
<View className="min-w-0 flex-row items-baseline gap-1.5">
<Text className="shrink-0 text-base font-t3-medium text-foreground" numberOfLines={1}>
@{entry.handle}
</Text>
<Text className={cn("shrink-0 text-xs font-t3-medium", SUBAGENT_TONE_TEXT_CLASS[tone])}>
{subagentStatusLabel(agent.status)}
</Text>
</View>
<Text className="text-xs text-foreground" numberOfLines={1}>
{entry.title}
</Text>
{details.length > 0 ? (
<View className="min-w-0 flex-row items-center gap-1.5">
<ProviderIcon
provider={provider?.driver ?? entry.subagent?.driver ?? ""}
iconUrl={provider?.iconUrl}
size={11}
/>
<Text className="min-w-0 shrink text-xs text-foreground-muted" numberOfLines={1}>
{details.join(" · ")}
</Text>
</View>
) : null}
</View>
</Pressable>
);
});

const CommandRow = memo(function CommandRow(props: {
readonly item: ComposerCommandItem;
readonly onPress: () => void;
Expand Down Expand Up @@ -193,10 +305,21 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover(
props: ComposerCommandPopoverProps,
) {
const label = groupLabel(props.triggerKind);
const pathTab = props.triggerKind === "path" ? (props.pathTab ?? null) : null;

return (
<PopoverSurface>
{label ? (
{pathTab ? (
<View className="px-2.5 pt-2.5 pb-1">
<SegmentedControl
size="compact"
role="tab"
options={PATH_TAB_OPTIONS}
selected={pathTab.active}
onSelect={pathTab.onChange}
/>
</View>
) : label ? (
<View className="px-3.5 pt-2.5 pb-1">
<Text className="text-3xs font-t3-bold tracking-[0.8px] uppercase text-foreground-muted">
{label}
Expand All @@ -209,20 +332,30 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover(
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{props.items.map((item, index) => (
<CommandRow
key={item.id}
item={item}
onPress={() => props.onSelect(item)}
isLast={index === props.items.length - 1}
isSlashSkill={props.triggerKind === "slash-command" && item.type === "skill"}
/>
))}
{props.items.map((item, index) =>
item.type === "subagent" ? (
<AgentCommandRow
key={item.id}
entry={item.entry}
provider={item.provider}
onPress={() => props.onSelect(item)}
isLast={index === props.items.length - 1}
/>
) : (
<CommandRow
key={item.id}
item={item}
onPress={() => props.onSelect(item)}
isLast={index === props.items.length - 1}
isSlashSkill={props.triggerKind === "slash-command" && item.type === "skill"}
/>
),
)}
</ScrollView>
) : (
<View className="px-3.5 py-2.5">
<Text className="text-xs text-foreground-tertiary">
{props.error ?? emptyText(props.triggerKind, props.isLoading)}
{props.error ?? emptyText(props.triggerKind, props.isLoading, pathTab?.active ?? null)}
</Text>
</View>
)}
Expand Down
15 changes: 13 additions & 2 deletions apps/mobile/src/features/threads/ProviderSubagentBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import {
formatProviderSubagentStatus,
type ProviderSubagentStatus,
} from "@t3tools/client-runtime/state/thread-execution";
import { isOrchestrationV2WorkActive } from "@t3tools/contracts";
import { isOrchestrationV2WorkActive, type EnvironmentId, type ThreadId } from "@t3tools/contracts";
import { View } from "react-native";

import { AppText as Text } from "../../components/AppText";
import { ProviderIcon } from "../../components/ProviderIcon";
import { useAgentReferences } from "./agent-references";
import { RequestActionButton } from "./RequestActionButton";
import { useVisibleSecondClock } from "./use-visible-second-clock";

Expand All @@ -15,7 +16,13 @@ import { useVisibleSecondClock } from "./use-visible-second-clock";
* runs that conversation, so there is nothing to send; the bar says which
* model is working, for how long, and leads back to the parent.
*/
const NO_SUBAGENTS: [] = [];

export function ProviderSubagentBar(props: {
/** The subagent's own thread and the thread that started it, to name it by its handle. */
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
readonly parentThreadId: ThreadId | null;
/** Driver and catalog icon of the provider running the subagent. */
readonly provider: { readonly driver: string; readonly iconUrl?: string | undefined } | null;
readonly modelLabel: string;
Expand All @@ -28,6 +35,9 @@ export function ProviderSubagentBar(props: {
const live = props.status !== null && isOrchestrationV2WorkActive(props.status.status);
const nowMs = useVisibleSecondClock(live);
const statusLabel = formatProviderSubagentStatus(props.status, nowMs);
const handle =
useAgentReferences(props.environmentId, props.parentThreadId, NO_SUBAGENTS).get(props.threadId)
?.handle ?? null;
const modelDescription =
props.effortLabel === null ? props.modelLabel : `${props.modelLabel}, ${props.effortLabel}`;

Expand All @@ -36,7 +46,7 @@ export function ProviderSubagentBar(props: {
{/* Only the text is one element, so "Open parent" stays reachable. */}
<View
accessible
accessibilityLabel={`${modelDescription} subagent, ${statusLabel}. It runs on its own and cannot take messages.`}
accessibilityLabel={`${modelDescription} subagent${handle ? ` @${handle}` : ""}, ${statusLabel}. It runs on its own and cannot take messages.`}
className="min-w-0 flex-1 gap-0.5"
>
<View className="min-w-0 flex-row items-center gap-1.5">
Expand Down Expand Up @@ -64,6 +74,7 @@ export function ProviderSubagentBar(props: {
className="font-sans text-xs text-foreground-secondary"
style={{ fontVariant: ["tabular-nums"] }}
>
{handle ? `@${handle} · ` : ""}
{statusLabel} · Runs on its own
</Text>
</View>
Expand Down
20 changes: 19 additions & 1 deletion apps/mobile/src/features/threads/SubagentRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export function SubagentRow(props: {
readonly environmentId: EnvironmentId;
readonly subagent: SubagentRowSubagent;
readonly elapsed: ReactNode;
/** What the composer's `@` menu calls the agent, without the `@`. */
readonly handle?: string | null;
}) {
const presentation = resolveSubagentRowPresentation(props.subagent);
const detail = subagentCardDetail(presentation.detail);
Expand Down Expand Up @@ -75,7 +77,11 @@ export function SubagentRow(props: {
<SymbolView name="chevron.right" size={12} tintColorClassName="accent-icon-subtle" />
) : null}
</View>
<SubagentMetadata environmentId={props.environmentId} subagent={props.subagent} />
<SubagentMetadata
environmentId={props.environmentId}
subagent={props.subagent}
handle={props.handle ?? null}
/>
{detail ? (
<Text
numberOfLines={3}
Expand All @@ -96,6 +102,7 @@ export function SubagentRow(props: {
function SubagentMetadata(props: {
readonly environmentId: EnvironmentId;
readonly subagent: SubagentRowSubagent;
readonly handle: string | null;
}) {
const { environmentId, subagent } = props;
const config = useEnvironmentServerConfig(environmentId);
Expand All @@ -120,6 +127,17 @@ function SubagentMetadata(props: {
});
return (
<View className="min-w-0 flex-row items-center gap-1.5">
{props.handle ? (
<>
<Text
numberOfLines={1}
className="min-w-0 shrink font-t3-medium text-xs text-foreground-secondary"
>
@{props.handle}
</Text>
<Text className="text-xs text-foreground-muted">·</Text>
</>
) : null}
<ProviderIcon
provider={provider?.driver ?? subagent.driver}
iconUrl={provider?.iconUrl}
Expand Down
Loading