Files
paseo/packages/app/src/subagents/track.tsx
T
Mohamed Boudra 25fbc939e0 feat(app): give composer track pills a segment per state
A mixed fan-out used to collapse the pill to its single most urgent
state, so a red dot beside "1 failed" over a child that was still
working read as the whole fan-out having stopped. Every state present
now gets its own mark and count, in the sidebar's status-group order.
2026-08-18 21:51:25 +02:00

376 lines
11 KiB
TypeScript

import { useCallback, useMemo, type ReactElement } from "react";
import { Pressable, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { Archive, Unlink } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { getProviderIcon } from "@/components/provider-icons";
import { ComposerTrackActions, ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import {
WorkspaceTabIcon,
type WorkspaceTabPresentation,
} from "@/screens/workspace/workspace-tab-presentation";
import type { Theme } from "@/styles/theme";
import type { SubagentRow } from "./select";
import type { ArchiveFinishedStatus } from "./use-archive-finished";
import {
buildSubagentPillPresentation,
buildSubagentRowPresentationData,
countFinishedSubagents,
} from "./track-presentation";
const ThemedArchive = withUnistyles(Archive);
const ThemedUnlink = withUnistyles(Unlink);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
export interface SubagentsTrackProps {
rows: SubagentRow[];
onOpenSubagent: (id: string) => void;
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
onArchiveSubagent: (id: string) => void;
onArchiveFinished?: () => void;
archiveFinishedStatus?: ArchiveFinishedStatus;
onDetachSubagent?: (id: string) => void;
}
const IDLE_ARCHIVE_FINISHED_STATUS: ArchiveFinishedStatus = { kind: "idle" };
/** Leading and action glyphs share one size so rows keep a single icon column. */
const ROW_ICON_SIZE = 14;
function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation {
const data = buildSubagentRowPresentationData(row);
return {
...data,
tooltip: data.label,
modified: false,
icon: getProviderIcon(row.provider),
};
}
export function SubagentsTrack({
rows,
onOpenSubagent,
onOpenProviderSubagent,
onArchiveSubagent,
onArchiveFinished,
archiveFinishedStatus = IDLE_ARCHIVE_FINISHED_STATUS,
onDetachSubagent,
}: SubagentsTrackProps): ReactElement | null {
const { t } = useTranslation();
const isArchivingFinished = archiveFinishedStatus.kind === "archiving";
const isArchiveFinishedFailed = archiveFinishedStatus.kind === "failed";
if (rows.length === 0 && !isArchivingFinished && !isArchiveFinishedFailed) {
return null;
}
const pill = buildSubagentPillPresentation(t, rows);
const finishedCount = countFinishedSubagents(rows);
const showArchiveFinished = finishedCount > 0 || isArchivingFinished || isArchiveFinishedFailed;
return (
<ComposerTrackPill
testID="subagents-track-header"
segments={pill.segments}
accessibilityLabel={pill.accessibilityLabel}
panelTitle={t("subagents.title")}
>
{showArchiveFinished && onArchiveFinished ? (
<ComposerTrackActions divided={rows.length > 0}>
<ArchiveFinishedRow
status={archiveFinishedStatus}
disabled={isArchivingFinished}
onPress={onArchiveFinished}
/>
</ComposerTrackActions>
) : null}
{rows.map((row) => (
<SubagentsTrackRow
key={row.id}
row={row}
onOpenSubagent={onOpenSubagent}
onOpenProviderSubagent={onOpenProviderSubagent}
onArchiveSubagent={onArchiveSubagent}
onDetachSubagent={onDetachSubagent}
/>
))}
</ComposerTrackPill>
);
}
/**
* Bulk archive, as a row above the list rather than an icon next to the count. The pill has no
* header to hang an icon off, and a destructive-ish action reads better with its name attached.
*/
function ArchiveFinishedRow({
status,
disabled,
onPress,
}: {
status: ArchiveFinishedStatus;
disabled: boolean;
onPress: () => void;
}): ReactElement {
const { t } = useTranslation();
const renderRow = useCallback(
({ active }: { active: boolean }) => (
<>
<ThemedArchive
size={ROW_ICON_SIZE}
uniProps={active ? foregroundColorMapping : foregroundMutedColorMapping}
/>
<Text style={styles.rowLabel} numberOfLines={1}>
{t("subagents.archiveFinishedAction")}
</Text>
{status.kind === "archiving" ? (
<Text style={styles.rowTrailing} testID="subagents-track-archive-progress">
{status.completedCount}/{status.totalCount}
</Text>
) : null}
{status.kind === "failed" ? (
<Text style={styles.rowTrailing} testID="subagents-track-archive-failed">
{t("subagents.archiveFinishedRetry", {
failed: status.failedCount,
total: status.totalCount,
})}
</Text>
) : null}
</>
),
[status, t],
);
return (
<ComposerTrackRow
accessibilityLabel={t("subagents.archiveFinishedAction")}
testID="subagents-track-archive-finished"
disabled={disabled}
// Progress and the retry count land on this row, so the panel is where the result of
// pressing it shows up. Dismissing would hide the thing the press produces.
closeOnSelect={false}
onPress={onPress}
>
{renderRow}
</ComposerTrackRow>
);
}
interface SubagentsTrackRowProps {
row: SubagentRow;
onOpenSubagent: (id: string) => void;
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
onArchiveSubagent: (id: string) => void;
onDetachSubagent?: (id: string) => void;
}
function SubagentsTrackRow({
row,
onOpenSubagent,
onOpenProviderSubagent,
onArchiveSubagent,
onDetachSubagent,
}: SubagentsTrackRowProps): ReactElement {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const presentation = useMemo(() => buildRowPresentation(row), [row]);
const displayLabel =
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
const handlePress = useCallback(() => {
if (row.kind === "provider") {
onOpenProviderSubagent(row.parentAgentId, row.id);
} else {
onOpenSubagent(row.id);
}
}, [onOpenProviderSubagent, onOpenSubagent, row]);
const handleArchivePress = useCallback(() => {
onArchiveSubagent(row.id);
}, [onArchiveSubagent, row.id]);
const handleDetachPress = useCallback(() => {
onDetachSubagent?.(row.id);
}, [onDetachSubagent, row.id]);
const actionsAlwaysVisible = isNative || isCompact;
const renderRow = useCallback(
({ active }: { active: boolean }) => (
<>
<WorkspaceTabIcon presentation={presentation} backdrop={active ? "surface2" : "surface1"} />
<Text style={styles.rowLabel} numberOfLines={1}>
{displayLabel}
</Text>
{presentation.subtitle ? (
<Text style={styles.rowTrailing} numberOfLines={1}>
{presentation.subtitle}
</Text>
) : null}
{row.kind === "paseo" ? (
<SubagentRowActions
rowId={row.id}
displayLabel={displayLabel}
visible={actionsAlwaysVisible || active}
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
onArchivePress={handleArchivePress}
/>
) : null}
</>
),
[
actionsAlwaysVisible,
displayLabel,
handleArchivePress,
handleDetachPress,
onDetachSubagent,
presentation,
row.kind,
row.id,
],
);
return (
<ComposerTrackRow
accessibilityLabel={displayLabel}
testID={`subagents-track-row-${row.id}`}
onPress={handlePress}
>
{renderRow}
</ComposerTrackRow>
);
}
function SubagentRowActions({
rowId,
displayLabel,
visible,
onDetachPress,
onArchivePress,
}: {
rowId: string;
displayLabel: string;
visible: boolean;
onDetachPress?: () => void;
onArchivePress: () => void;
}): ReactElement {
const { t } = useTranslation();
return (
<View
style={visible ? styles.actionClusterVisible : styles.actionClusterHidden}
pointerEvents={visible ? "auto" : "none"}
>
{onDetachPress ? (
<SubagentActionButton
accessibilityLabel={t("subagents.detachAction", { label: displayLabel })}
testID={`subagents-track-detach-${rowId}`}
tooltipLabel={t("subagents.detachTooltip")}
icon="detach"
visible={visible}
onPress={onDetachPress}
/>
) : null}
<SubagentActionButton
accessibilityLabel={t("subagents.archiveAction", { label: displayLabel })}
testID={`subagents-track-archive-${rowId}`}
tooltipLabel={t("subagents.archiveTooltip")}
icon="archive"
visible={visible}
onPress={onArchivePress}
/>
</View>
);
}
type SubagentActionIcon = "archive" | "detach";
function renderSubagentActionIcon(icon: SubagentActionIcon, isActive: boolean): ReactElement {
const uniProps = isActive ? foregroundColorMapping : foregroundMutedColorMapping;
if (icon === "detach") {
return <ThemedUnlink size={ROW_ICON_SIZE} uniProps={uniProps} />;
}
return <ThemedArchive size={ROW_ICON_SIZE} uniProps={uniProps} />;
}
function SubagentActionButton({
accessibilityLabel,
testID,
tooltipLabel,
icon,
visible,
onPress,
}: {
accessibilityLabel: string;
testID: string;
tooltipLabel: string;
icon: SubagentActionIcon;
visible: boolean;
onPress: () => void;
}): ReactElement {
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild disabled={!visible}>
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
testID={testID}
onPress={onPress}
style={styles.actionButton}
hitSlop={8}
>
{({ hovered, pressed }) => renderSubagentActionIcon(icon, hovered || pressed)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
// `flexBasis: "auto"` rather than `flex: 1`: a zero-basis label contributes nothing to the row's
// intrinsic width, so the panel measures itself at its floor and truncates every label at once.
rowLabel: {
flexGrow: 1,
flexShrink: 1,
flexBasis: "auto",
minWidth: 0,
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
},
// Trailing metadata — provider context on a subagent row, progress on the archive row. No width
// cap: the panel's own ceiling bounds it. It shrinks twice as fast as the label, so a wordy
// provider subtitle gives way first instead of squeezing the thing that names the row.
rowTrailing: {
flexShrink: 2,
minWidth: 0,
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
actionClusterVisible: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
opacity: 1,
},
actionClusterHidden: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
opacity: 0,
},
actionButton: {
padding: theme.spacing[1],
alignItems: "center",
justifyContent: "center",
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
}));