feat(app): float composer trackers as pills over the transcript (#3482)

The subagents track and the task list were stacked cards above the
composer. Each one pushed the composer further down the pane and painted
an opaque band the timeline stopped at, and both spent a full row on a
count that fits in a pill.

Both are now pills that open a panel — a popover on wide screens, a sheet
on compact ones, from the existing menu engine rather than a fourth
floating-panel implementation. The row frame is shared, so a subagent row
and a task row sit on the same rail whatever their contents.

The bar mounts inside the transcript's animated container and pins itself
to the bottom edge, so scrolled content passes under the pills instead of
stopping above them. It stays a child of that container rather than a
sibling floating over the composer: on Android a view outside its parent's
bounds paints but takes no touches, and every pill is pressable. The
container carries the same keyboard transform the composer does, so the
pills track the composer's top edge when the keyboard moves.

The tracker state moves out of the composer with it. None of it was
composer state; it lived there because that is where it used to render.
This commit is contained in:
Mohamed Boudra
2026-08-17 12:36:28 +02:00
committed by GitHub
parent ac4331456c
commit 0eab0b7828
23 changed files with 672 additions and 442 deletions
@@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest";
import type { PaseoSubagentRow, ProviderSubagentRow, SubagentRow } from "./select";
import {
aggregateSubagentStatusBucket,
buildSubagentRowPresentationData,
countFinishedSubagents,
formatHeaderLabel,
resolveRowLabel,
} from "./track-presentation";
@@ -23,55 +23,28 @@ function row(
};
}
describe("formatHeaderLabel", () => {
it("uses singular 'subagent' for a single row", () => {
expect(formatHeaderLabel([row({ id: "a" })])).toBe("1 subagent");
describe("aggregateSubagentStatusBucket", () => {
it("has no bucket without rows", () => {
expect(aggregateSubagentStatusBucket([])).toBeNull();
});
it("uses plural 'subagents' for two rows with no running rows", () => {
expect(formatHeaderLabel([row({ id: "a" }), row({ id: "b" })])).toBe("2 subagents");
it("has no bucket when every child is done", () => {
expect(aggregateSubagentStatusBucket([row({ id: "a" }), row({ id: "b" })])).toBeNull();
});
it("appends the running count when at least one row is running", () => {
it("reports running when any child is running", () => {
expect(
formatHeaderLabel([row({ id: "a", status: "running" }), row({ id: "b" }), row({ id: "c" })]),
).toBe("3 subagents · 1 running");
aggregateSubagentStatusBucket([row({ id: "a" }), row({ id: "b", status: "running" })]),
).toBe("running");
});
it("counts every running row in the suffix", () => {
it("ranks a failed child above a running one", () => {
expect(
formatHeaderLabel([
aggregateSubagentStatusBucket([
row({ id: "a", status: "running" }),
row({ id: "b", status: "running" }),
row({ id: "c", requiresAttention: true }),
row({ id: "d" }),
row({ id: "e" }),
row({ id: "b", status: "error", requiresAttention: true }),
]),
).toBe("5 subagents · 2 running");
});
it("ignores requiresAttention on non-running rows in the header copy", () => {
expect(
formatHeaderLabel([
row({ id: "a", status: "error", requiresAttention: false }),
row({ id: "b", status: "idle", requiresAttention: false }),
row({ id: "c", status: "idle", requiresAttention: true }),
]),
).toBe("3 subagents");
});
it("still counts running rows even when they require attention", () => {
expect(
formatHeaderLabel([
row({ id: "a", status: "error", requiresAttention: true }),
row({ id: "b", status: "running", requiresAttention: true }),
row({ id: "c", status: "idle", requiresAttention: true }),
]),
).toBe("3 subagents · 1 running");
});
it("uses singular 'subagent' for a single row that requires attention upstream", () => {
expect(formatHeaderLabel([row({ id: "a", requiresAttention: true })])).toBe("1 subagent");
).toBe("failed");
});
});
@@ -1,5 +1,8 @@
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import {
aggregateSidebarStateBuckets,
deriveSidebarStateBucket,
} from "@/utils/sidebar-agent-state";
import type { SubagentRow } from "./select";
import { isFinishedSubagent } from "./archive-finished";
import { providerSubagentLifecycleStatus } from "./provider-store";
@@ -40,19 +43,25 @@ export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowP
};
}
export function formatHeaderLabel(rows: readonly SubagentRow[]): string {
let runningCount = 0;
for (const row of rows) {
if (row.status === "running") {
runningCount += 1;
}
/**
* The one state the collapsed pill can show. The pill has room for a dot and a count, so the
* children collapse into the most urgent bucket among them — the same rule a collapsed project
* row in the sidebar uses, and for the same reason.
*
* `null` when every child is done: a finished fan-out is not worth a colour above the composer.
*/
export function aggregateSubagentStatusBucket(
rows: readonly SubagentRow[],
): SidebarStateBucket | null {
if (rows.length === 0) {
return null;
}
const parts = [`${rows.length} ${rows.length === 1 ? "subagent" : "subagents"}`];
if (runningCount > 0) {
parts.push(`${runningCount} running`);
}
return parts.join(" · ");
const buckets = rows.flatMap((row) => {
const bucket = buildSubagentRowPresentationData(row).statusBucket;
return bucket ? [bucket] : [];
});
const aggregate = aggregateSidebarStateBuckets(buckets);
return aggregate === "done" ? null : aggregate;
}
export function countFinishedSubagents(rows: readonly SubagentRow[]): number {
+150 -229
View File
@@ -1,12 +1,12 @@
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import { useCallback, useMemo, type ReactElement } from "react";
import { Pressable, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { Archive, ChevronDown, ChevronRight, Unlink } from "lucide-react-native";
import { Archive, Unlink } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { getProviderIcon } from "@/components/provider-icons";
import { Button } from "@/components/ui/button";
import { ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsCompactFormFactor, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import {
WorkspaceTabIcon,
@@ -16,9 +16,9 @@ import type { Theme } from "@/styles/theme";
import type { SubagentRow } from "./select";
import type { ArchiveFinishedStatus } from "./use-archive-finished";
import {
aggregateSubagentStatusBucket,
buildSubagentRowPresentationData,
countFinishedSubagents,
formatHeaderLabel,
} from "./track-presentation";
const ThemedArchive = withUnistyles(Archive);
@@ -39,7 +39,10 @@ export interface SubagentsTrackProps {
onDetachSubagent?: (id: string) => void;
}
const SUBAGENTS_LIST_MAX_HEIGHT = 200;
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);
@@ -57,26 +60,10 @@ export function SubagentsTrack({
onOpenProviderSubagent,
onArchiveSubagent,
onArchiveFinished,
archiveFinishedStatus = { kind: "idle" },
archiveFinishedStatus = IDLE_ARCHIVE_FINISHED_STATUS,
onDetachSubagent,
}: SubagentsTrackProps): ReactElement | null {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const toggleExpanded = useCallback(() => {
setExpanded((current) => !current);
}, []);
const surfaceStyle = useMemo(
() => [styles.surface, expanded && styles.surfaceExpanded],
[expanded],
);
const headerContainerStyle = useMemo(
() => [styles.header, expanded ? styles.headerDivider : styles.headerCollapsed],
[expanded],
);
const headerAccessibilityState = useMemo(() => ({ expanded }), [expanded]);
const isArchivingFinished = archiveFinishedStatus.kind === "archiving";
const isArchiveFinishedFailed = archiveFinishedStatus.kind === "failed";
@@ -84,83 +71,94 @@ export function SubagentsTrack({
return null;
}
const headerLabel = formatHeaderLabel(rows);
const pillLabel =
rows.length === 1
? t("subagents.pillLabelOne")
: t("subagents.pillLabelMany", { count: rows.length });
const finishedCount = countFinishedSubagents(rows);
const showArchiveFinished = finishedCount > 0 || isArchivingFinished || isArchiveFinishedFailed;
return (
<View style={styles.outer} testID="subagents-track">
<View style={styles.track}>
<View style={surfaceStyle}>
<View style={headerContainerStyle}>
<Button
variant="ghost"
size="xs"
accessibilityLabel={headerLabel}
accessibilityState={headerAccessibilityState}
testID="subagents-track-header"
onPress={toggleExpanded}
leftIcon={expanded ? ChevronDown : ChevronRight}
style={styles.headerToggle}
textStyle={styles.headerLabel}
>
{headerLabel}
</Button>
{showArchiveFinished && onArchiveFinished ? (
<View style={styles.headerAction}>
<SubagentActionButton
accessibilityLabel={t("subagents.archiveFinishedAction")}
testID="subagents-track-archive-finished"
tooltipLabel={t("subagents.archiveFinishedTooltip")}
icon="archive"
visible
disabled={isArchivingFinished}
onPress={onArchiveFinished}
/>
{archiveFinishedStatus.kind === "archiving" ? (
<Text
style={styles.archiveFinishedStatus}
testID="subagents-track-archive-progress"
>
{archiveFinishedStatus.completedCount}/{archiveFinishedStatus.totalCount}
</Text>
) : null}
{archiveFinishedStatus.kind === "failed" ? (
<Text
style={styles.archiveFinishedStatus}
testID="subagents-track-archive-failed"
>
{t("subagents.archiveFinishedRetry", {
failed: archiveFinishedStatus.failedCount,
total: archiveFinishedStatus.totalCount,
})}
</Text>
) : null}
</View>
) : null}
</View>
{expanded ? (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
nestedScrollEnabled
>
{rows.map((row) => (
<SubagentsTrackRow
key={row.id}
row={row}
onOpenSubagent={onOpenSubagent}
onOpenProviderSubagent={onOpenProviderSubagent}
onArchiveSubagent={onArchiveSubagent}
onDetachSubagent={onDetachSubagent}
/>
))}
</ScrollView>
) : null}
</View>
</View>
</View>
<ComposerTrackPill
testID="subagents-track-header"
label={pillLabel}
panelTitle={t("subagents.title")}
statusBucket={aggregateSubagentStatusBucket(rows)}
>
{rows.map((row) => (
<SubagentsTrackRow
key={row.id}
row={row}
onOpenSubagent={onOpenSubagent}
onOpenProviderSubagent={onOpenProviderSubagent}
onArchiveSubagent={onArchiveSubagent}
onDetachSubagent={onDetachSubagent}
/>
))}
{showArchiveFinished && onArchiveFinished ? (
<ArchiveFinishedRow
status={archiveFinishedStatus}
disabled={isArchivingFinished}
onPress={onArchiveFinished}
/>
) : null}
</ComposerTrackPill>
);
}
/**
* Bulk archive, as a row at the foot of the panel 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}
onPress={onPress}
>
{renderRow}
</ComposerTrackRow>
);
}
@@ -181,7 +179,6 @@ function SubagentsTrackRow({
}: SubagentsTrackRowProps): ReactElement {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const [hovered, setHovered] = useState(false);
const presentation = useMemo(() => buildRowPresentation(row), [row]);
const displayLabel =
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
@@ -198,49 +195,51 @@ function SubagentsTrackRow({
const handleDetachPress = useCallback(() => {
onDetachSubagent?.(row.id);
}, [onDetachSubagent, row.id]);
const handlePointerEnter = useCallback(() => setHovered(true), []);
const handlePointerLeave = useCallback(() => setHovered(false), []);
const actionsAlwaysVisible = isNative || isCompact;
const actionsVisible = actionsAlwaysVisible || hovered;
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 (
// Wrapper View handles hover so moving the pointer between the row and
// the archive button doesn't drop the hover state — the same pattern
// used by sidebar workspace rows.
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
<Pressable
accessibilityRole="button"
accessibilityLabel={displayLabel}
testID={`subagents-track-row-${row.id}`}
onPress={handlePress}
>
{({ pressed }) => (
<View style={hovered || pressed ? styles.rowActive : styles.row}>
<WorkspaceTabIcon
presentation={presentation}
backdrop={hovered || pressed ? "surface2" : "surface1"}
/>
<Text style={styles.rowLabel} numberOfLines={1}>
{displayLabel}
</Text>
{presentation.subtitle ? (
<Text style={styles.rowSubtitle} numberOfLines={1}>
{presentation.subtitle}
</Text>
) : null}
{row.kind === "paseo" ? (
<SubagentRowActions
rowId={row.id}
displayLabel={displayLabel}
visible={actionsVisible}
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
onArchivePress={handleArchivePress}
/>
) : null}
</View>
)}
</Pressable>
</View>
<ComposerTrackRow
accessibilityLabel={displayLabel}
testID={`subagents-track-row-${row.id}`}
onPress={handlePress}
>
{renderRow}
</ComposerTrackRow>
);
}
@@ -290,9 +289,9 @@ type SubagentActionIcon = "archive" | "detach";
function renderSubagentActionIcon(icon: SubagentActionIcon, isActive: boolean): ReactElement {
const uniProps = isActive ? foregroundColorMapping : foregroundMutedColorMapping;
if (icon === "detach") {
return <ThemedUnlink size={14} uniProps={uniProps} />;
return <ThemedUnlink size={ROW_ICON_SIZE} uniProps={uniProps} />;
}
return <ThemedArchive size={14} uniProps={uniProps} />;
return <ThemedArchive size={ROW_ICON_SIZE} uniProps={uniProps} />;
}
function SubagentActionButton({
@@ -301,7 +300,6 @@ function SubagentActionButton({
tooltipLabel,
icon,
visible,
disabled = false,
onPress,
}: {
accessibilityLabel: string;
@@ -309,17 +307,15 @@ function SubagentActionButton({
tooltipLabel: string;
icon: SubagentActionIcon;
visible: boolean;
disabled?: boolean;
onPress: () => void;
}): ReactElement {
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild disabled={!visible || disabled}>
<TooltipTrigger asChild disabled={!visible}>
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
testID={testID}
disabled={disabled}
onPress={onPress}
style={styles.actionButton}
hitSlop={8}
@@ -335,97 +331,22 @@ function SubagentActionButton({
}
const styles = StyleSheet.create((theme) => ({
outer: {
width: "100%",
alignItems: "center",
paddingHorizontal: theme.spacing[4],
},
track: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
marginBottom: -theme.spacing[4],
},
surface: {
alignSelf: "stretch",
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
borderBottomWidth: 0,
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
overflow: "hidden",
},
surfaceExpanded: {
paddingBottom: theme.spacing[4],
},
header: {
flexDirection: "row",
alignItems: "center",
},
headerToggle: {
flex: 1,
minWidth: 0,
justifyContent: "flex-start",
borderRadius: 0,
paddingLeft: theme.spacing[3],
paddingRight: theme.spacing[1],
paddingVertical: theme.spacing[2],
},
headerAction: {
paddingRight: theme.spacing[2],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
archiveFinishedStatus: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
headerCollapsed: {
paddingBottom: theme.spacing[4],
},
headerDivider: {
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
headerLabel: {
flexShrink: 1,
minWidth: 0,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
scroll: {
maxHeight: SUBAGENTS_LIST_MAX_HEIGHT,
},
scrollContent: {
paddingVertical: theme.spacing[1],
},
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
rowActive: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.surface2,
},
// `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: {
flex: 1,
flexGrow: 1,
flexShrink: 1,
flexBasis: "auto",
minWidth: 0,
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
// Keep provider context secondary and bounded so the task remains readable on compact screens.
rowSubtitle: {
flexShrink: 1,
// 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,
maxWidth: "45%",
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},