From 0eab0b78283e57f8baf21a9acb429079af573dbf Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 17 Aug 2026 12:36:28 +0200 Subject: [PATCH] feat(app): float composer trackers as pills over the transcript (#3482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/agent-lifecycle.md | 8 +- docs/glossary.md | 2 +- .../browser/provider-subagents.real.spec.ts | 2 + packages/app/e2e/support/helpers/subagents.ts | 11 +- .../app/src/components/task-list-row.test.tsx | 6 +- packages/app/src/components/task-list-row.tsx | 14 +- packages/app/src/composer/task-list/index.tsx | 110 ++--- packages/app/src/composer/tracks.tsx | 316 +++++++++++++++ packages/app/src/i18n/resources.test.ts | 2 +- packages/app/src/i18n/resources/ar.ts | 5 +- packages/app/src/i18n/resources/en.ts | 5 +- packages/app/src/i18n/resources/es.ts | 5 +- packages/app/src/i18n/resources/fr.ts | 5 +- packages/app/src/i18n/resources/ja.ts | 5 +- packages/app/src/i18n/resources/ko.ts | 5 +- packages/app/src/i18n/resources/pt-BR.ts | 5 +- packages/app/src/i18n/resources/ru.ts | 5 +- packages/app/src/i18n/resources/zh-CN.ts | 5 +- packages/app/src/panels/agent-panel.tsx | 57 +-- packages/app/src/panels/agent-tracks.tsx | 74 ++++ .../src/subagents/track-presentation.test.ts | 53 +-- .../app/src/subagents/track-presentation.ts | 35 +- packages/app/src/subagents/track.tsx | 379 +++++++----------- 23 files changed, 672 insertions(+), 442 deletions(-) create mode 100644 packages/app/src/composer/tracks.tsx create mode 100644 packages/app/src/panels/agent-tracks.tsx diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index a607873ad..a84037874 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -135,7 +135,9 @@ Running provider-native subagents contribute `running` to the workspace owned by ## The subagents track -The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`) combines two kinds of children: +The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`. + +The rows combine two kinds of children: - **Paseo subagents** are full managed agents. Their membership rule (`packages/app/src/subagents/select.ts`) is: @@ -165,7 +167,7 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls. -The track header's **Archive finished** action covers every finished row. It archives idle or errored managed Paseo subagents one at a time, and hides completed, failed, or canceled provider-owned rows in the current app session. Native sessions and timelines are untouched. Running and initializing children remain in the track. If a hidden provider child starts running again, the app brings it back to the track. +The **Archive finished** row at the foot of the panel covers every finished row. It archives idle or errored managed Paseo subagents one at a time, and hides completed, failed, or canceled provider-owned rows in the current app session. Native sessions and timelines are untouched. Running and initializing children remain in the track. If a hidden provider child starts running again, the app brings it back to the track. To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the relationship lifecycle labels, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot. @@ -185,7 +187,7 @@ We considered universal decoupling (no tab close ever archives, archive is alway ### Subagent accumulation under long-lived parents -A parent that spawns many subagents will see the track grow. Managed Paseo subagents can be archived individually or with **Archive finished**. That action hides finished provider-owned rows locally; this presentation state resets when the app restarts. +A parent that spawns many subagents will see the panel's list grow; the pill only counts them. Managed Paseo subagents can be archived individually or with **Archive finished**. That action hides finished provider-owned rows locally; this presentation state resets when the app restarts. ### Cross-client tab dismissal diff --git a/docs/glossary.md b/docs/glossary.md index 4dd156761..dd0d9fe4d 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -42,7 +42,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar". - **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar". - **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`). -- **Composer track** — A contextual lane above the composer input. Specific tracks use the ` track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`). +- **Composer track** — A contextual lane at the foot of a pane, by the composer input. Specific tracks use the ` track` form: **Queue track**, **Subagents track**, **Tasks track**. The subagents and tasks tracks are pills floating over the transcript, each opening a panel with its rows; the queue track is a lane inside the composer itself. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), pill and panel frame in `packages/app/src/composer/tracks.tsx`, `SubagentsTrack` (`packages/app/src/subagents/track.tsx`), `AgentTaskList` (`packages/app/src/composer/task-list/index.tsx`). - **Subagent** — User-facing term for an agent session related to a parent agent session. Use **subagents** in UI copy and docs. Internal daemon/provider plumbing may say "child agent" or `child_session`, especially for provider-managed imports; do not surface "child agent" as a product term. - **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar". - **Fork** — Starting a NEW agent session seeded with a copy of another session's curated chat history. The history rides along as a `chat_history` composer attachment on a draft; the fork does **not** resume or branch the provider-side session, and the source session keeps running untouched. Two targets, one surface, one RPC (`agent.fork_context`, gated on the `agentForkContext` host feature). UI: the fork menu lives in the assistant turn footer and offers "Fork in a new tab" / "Fork in a new workspace". On a **completed** turn it sits beside the copy button and pins a boundary to that turn, so the fork stops where the turn ended. On the **in-flight** turn it sits beside the progress loader and elapsed timer, and omits the boundary entirely — `selectForkContextRows` projects the whole timeline when no boundary is given, so the fork captures everything **up to now**, including the response still streaming. That omission is what makes mid-run forking work; pinning a boundary there would silently drop the live response. Forbidden on provider-subagent (read-only) panes. Code: `useForkAgent` (`packages/app/src/hooks/use-fork-agent.ts`), `selectForkContextRows` (`packages/server/src/server/agent/activity-curator.ts`). Don't confuse with: git fork (unrelated; Paseo has no such concept), **Worktree** (filesystem isolation, not history copying). diff --git a/packages/app/e2e/browser/provider-subagents.real.spec.ts b/packages/app/e2e/browser/provider-subagents.real.spec.ts index 09da77f7a..0b735c308 100644 --- a/packages/app/e2e/browser/provider-subagents.real.spec.ts +++ b/packages/app/e2e/browser/provider-subagents.real.spec.ts @@ -133,6 +133,8 @@ test.describe("real provider subagent timelines", () => { await expect( page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(), ).toBeVisible({ timeout: 60_000 }); + // Opening the subagent's tab closed the panel with the parent's pane. + await openSubagentsTrack(page); const archiveFinished = page.getByTestId("subagents-track-archive-finished"); await expect(archiveFinished).toBeVisible({ timeout: 30_000 }); await archiveFinished.click(); diff --git a/packages/app/e2e/support/helpers/subagents.ts b/packages/app/e2e/support/helpers/subagents.ts index 68fd5f732..d9c59e702 100644 --- a/packages/app/e2e/support/helpers/subagents.ts +++ b/packages/app/e2e/support/helpers/subagents.ts @@ -128,8 +128,17 @@ export async function seedParentWithCrossWorkspaceSubagent( }; } +/** + * Opens the subagents panel, and leaves it open if it already is — the pill is a toggle, and a + * second click would close the thing the caller is about to assert on. The panel also closes on + * its own whenever a row navigates away, so a flow that comes back to the parent reopens it. + */ export async function openSubagentsTrack(page: Page): Promise { - await page.getByTestId("subagents-track-header").click(); + const panel = page.getByTestId("subagents-track-header-panel"); + if ((await panel.count()) === 0) { + await page.getByTestId("subagents-track-header").click(); + } + await expect(panel).toBeVisible({ timeout: 30_000 }); } export async function expectSubagentRowVisible(page: Page, childId: string): Promise { diff --git a/packages/app/src/components/task-list-row.test.tsx b/packages/app/src/components/task-list-row.test.tsx index 94ed47bc7..9849964f8 100644 --- a/packages/app/src/components/task-list-row.test.tsx +++ b/packages/app/src/components/task-list-row.test.tsx @@ -85,10 +85,12 @@ describe("TaskListRow", () => { container.remove(); }); - it("keeps pending tasks muted", () => { + it("keeps a pending task's ring quieter than its label, so it does not read as a checkbox", () => { act(() => root.render()); - expect(container.querySelector('[data-icon="Circle"]')?.getAttribute("color")).toBe("#muted"); + expect(container.querySelector('[data-icon="Circle"]')?.getAttribute("color")).toBe( + "#extra-muted", + ); expect(container.querySelector("[data-color]")?.getAttribute("data-color")).toBe("#muted"); }); diff --git a/packages/app/src/components/task-list-row.tsx b/packages/app/src/components/task-list-row.tsx index 5dc834b61..7517afffa 100644 --- a/packages/app/src/components/task-list-row.tsx +++ b/packages/app/src/components/task-list-row.tsx @@ -10,7 +10,6 @@ const ThemedCircleCheck = withUnistyles(CircleCheck); const ThemedCircleDot = withUnistyles(CircleDot); const extraMutedIcon = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted }); -const mutedIcon = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const runningIcon = (theme: Theme) => ({ color: theme.colors.statusDotRunning }); function TaskStatusIcon({ isCompleted, isRunning }: { isCompleted: boolean; isRunning: boolean }) { @@ -20,7 +19,10 @@ function TaskStatusIcon({ isCompleted, isRunning }: { isCompleted: boolean; isRu if (isRunning) { return ; } - return ; + // A pending task's ring is a status mark, not a checkbox. At the muted step it carries the + // weight of an enabled control and invites a click that does nothing, so it sits one step back + // from the text it marks. + return ; } export const TaskListRow = memo(function TaskListRow({ task }: { task: TodoEntry }) { @@ -47,8 +49,14 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[2], }, + // Grows and shrinks, but keeps an `auto` basis: a zero-basis label reports no intrinsic width, + // and a container that sizes itself to its content — the composer track panel — measures the + // row as empty and truncates it against a surface that had room to spare. text: { - flex: 1, + flexGrow: 1, + flexShrink: 1, + flexBasis: "auto", + minWidth: 0, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, diff --git a/packages/app/src/composer/task-list/index.tsx b/packages/app/src/composer/task-list/index.tsx index 4a991abdc..81b9d7cc9 100644 --- a/packages/app/src/composer/task-list/index.tsx +++ b/packages/app/src/composer/task-list/index.tsx @@ -1,11 +1,9 @@ -import { memo, useCallback, useMemo, useState } from "react"; +import { memo, useMemo } from "react"; import { View } from "react-native"; -import { ChevronDown, ChevronRight } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; -import { Button } from "@/components/ui/button"; +import { ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks"; import { TaskListRow } from "@/components/task-list-row"; -import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { useSessionStore } from "@/stores/session-store"; import type { TodoEntry } from "@/types/stream"; @@ -23,94 +21,38 @@ export const AgentTaskList = memo(function AgentTaskList({ const TaskListCard = memo(function TaskListCard({ tasks }: { tasks: TodoEntry[] }) { const { t } = useTranslation(); - const [expanded, setExpanded] = useState(false); const completed = useMemo( () => tasks.filter((task) => task.completed || task.status === "completed").length, [tasks], ); - const currentTask = useMemo( - () => - tasks.find((task) => task.status === "in_progress") ?? - tasks.find((task) => !task.completed && task.status !== "completed"), - [tasks], - ); - const currentTaskText = - currentTask?.status === "in_progress" && currentTask.activeForm - ? currentTask.activeForm - : currentTask?.text; - const label = currentTaskText - ? t("message.todo.tasksProgressCurrent", { - completed, - total: tasks.length, - task: currentTaskText, - }) - : t("message.todo.tasksProgress", { completed, total: tasks.length }); - const toggle = useCallback(() => setExpanded((current) => !current), []); - const accessibilityState = useMemo(() => ({ expanded }), [expanded]); + // Counts only. The active task used to ride along in the header, where it was the first thing + // truncated on a phone; the panel shows it in full, in place, with the rest of the list. + const label = t("message.todo.tasksProgress", { completed, total: tasks.length }); return ( - - - - - {expanded ? ( - - {tasks.map((task, index) => ( - - ))} - - ) : null} - - - + + {tasks.map((task, index) => ( + + + + + + ))} + ); }); -const styles = StyleSheet.create((theme) => ({ - container: { - alignItems: "center", - paddingHorizontal: theme.spacing[4], - }, - track: { - width: "100%", - maxWidth: MAX_CONTENT_WIDTH, - marginBottom: -theme.spacing[4], - }, - card: { - backgroundColor: theme.colors.surface1, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - borderBottomWidth: 0, - borderTopLeftRadius: theme.borderRadius["2xl"], - borderTopRightRadius: theme.borderRadius["2xl"], - overflow: "hidden", - paddingBottom: theme.spacing[4], - }, - header: { - width: "100%", - justifyContent: "flex-start", - borderRadius: 0, - paddingHorizontal: theme.spacing[3], - }, - headerText: { - color: theme.colors.foregroundMuted, - }, - list: { - borderTopWidth: theme.borderWidth[1], - borderTopColor: theme.colors.border, - paddingHorizontal: theme.spacing[3], - paddingTop: theme.spacing[2], - gap: theme.spacing[1], +const styles = StyleSheet.create(() => ({ + // The task row draws its own icon and text; this only lets it span the shared row frame. + // Basis stays `auto` so the text's width reaches the panel's measurement — see track.tsx. + taskRow: { + flexGrow: 1, + flexShrink: 1, + flexBasis: "auto", + minWidth: 0, }, })); diff --git a/packages/app/src/composer/tracks.tsx b/packages/app/src/composer/tracks.tsx new file mode 100644 index 000000000..c85caa86b --- /dev/null +++ b/packages/app/src/composer/tracks.tsx @@ -0,0 +1,316 @@ +import { useCallback, useMemo, useState, type ReactElement, type ReactNode } from "react"; +import { Pressable, Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { + MenuRoot, + MenuSurface, + MenuTrigger, + useMenuContext, + type MenuTriggerState, +} from "@/components/ui/menu"; +import { MAX_CONTENT_WIDTH } from "@/constants/layout"; +import { isWeb } from "@/constants/platform"; +import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry"; +import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; + +/** + * The strip of pills where a pane's ambient trackers live — subagents and tasks today. + * + * Everything in it is a pill: a count you can read without opening anything, and a panel behind + * it for the detail. Trackers used to be stacked cards, so every one of them pushed the composer + * further down the pane; a row of pills costs one line no matter how many there are. + * + * **Mount it in a container that spans the transcript.** The bar pins itself to that container's + * bottom edge and paints over it, with no background of its own and `pointerEvents="box-none"` + * so the gaps between pills still belong to the timeline scrolling underneath. Absolute, not in + * flow: in flow it reserves a strip the transcript cannot use, which is the band this replaced. + * + * It stays a child of the transcript rather than a sibling floating above the composer because + * on Android a view outside its parent's bounds paints but takes no touches — see + * docs/floating-panels.md — and every pill here is pressable. + */ +export function ComposerTrackBar({ children }: { children: ReactNode }): ReactElement { + return ( + + + {children} + + + ); +} + +export interface ComposerTrackPillProps { + /** The whole pill, and short enough to stay on one line: "3 subagents", "2/7 tasks". */ + label: string; + /** Sheet header on compact. Popovers never show one. */ + panelTitle: string; + testID: string; + accessibilityLabel?: string; + /** Drawn as a leading dot. `null` leaves the pill text-only. */ + statusBucket?: SidebarStateBucket | null; + /** Panel body. Rendered into a popover on wide screens and a sheet on compact ones. */ + children: ReactNode; +} + +/** + * Size of the panel, not the pill: the pill is as wide as its label, the panel is not. The + * ceiling is generous because the rows carry real content — a subagent's task description, a + * task's active form — and there is nothing above the composer competing for the space. The + * surface still shrinks to its content and clamps to the viewport. + */ +const PANEL_MIN_WIDTH = 280; +const PANEL_MAX_WIDTH = 620; +const PANEL_MAX_HEIGHT = 440; +/** + * Gap between the pill and its panel. Wider than a dropdown's, because the panel is not a menu + * hanging off a control — it is a surface parked over the composer, and it needs to read as + * separate from the pill that opened it. + */ +const PANEL_OFFSET = 12; + +export function ComposerTrackPill({ + label, + panelTitle, + testID, + accessibilityLabel, + statusBucket = null, + children, +}: ComposerTrackPillProps): ReactElement { + return ( + + + + {children} + + + ); +} + +function ComposerTrackPillTrigger({ + label, + testID, + accessibilityLabel, + statusBucket, +}: { + label: string; + testID: string; + accessibilityLabel: string; + statusBucket: SidebarStateBucket | null; +}): ReactElement { + const { open } = useMenuContext("ComposerTrackPill"); + const accessibilityState = useMemo(() => ({ expanded: open }), [open]); + // React Native Web does not map `accessibilityState.expanded` to `aria-expanded`, so the web + // attribute is set by hand — the same workaround header toggles use. + const ariaExpandedProps = isWeb ? { "aria-expanded": open } : null; + const pillStyle = useCallback( + ({ hovered, pressed, open: isOpen }: MenuTriggerState) => [ + styles.pill, + (hovered || pressed || isOpen) && styles.pillActive, + ], + [], + ); + const labelStyle = useMemo(() => [styles.pillLabel, open && styles.pillLabelActive], [open]); + + return ( + + + + {label} + + + ); +} + +export interface ComposerTrackRowProps { + /** A function child receives the row's own hover/press state, for hover-revealed actions. */ + children: ReactNode | ((state: { active: boolean }) => ReactNode); + /** Rows that open something are pressable and fill on press or hover. A read-only row is not. */ + onPress?: () => void; + disabled?: boolean; + accessibilityLabel?: string; + testID?: string; +} + +/** + * One line in a track panel. Every track uses it, so a subagent and a task sit on the same rail + * and the same rhythm however different their contents are — the panels are one surface family, + * not one design per tracker. + * + * Hover lives on the outer plain View and press on the inner Pressable, per docs/hover.md: rows + * carry action buttons, and a Pressable tracking its own hover fights every Pressable inside it. + */ +export function ComposerTrackRow({ + children, + onPress, + disabled = false, + accessibilityLabel, + testID, +}: ComposerTrackRowProps): ReactElement { + const [hovered, setHovered] = useState(false); + const handlePointerEnter = useCallback(() => setHovered(true), []); + const handlePointerLeave = useCallback(() => setHovered(false), []); + + const renderRow = useCallback( + (active: boolean) => ( + + {typeof children === "function" ? children({ active }) : children} + + ), + [children], + ); + const renderPressed = useCallback( + ({ pressed }: { pressed: boolean }) => renderRow(hovered || pressed), + [hovered, renderRow], + ); + + if (!onPress) { + return {renderRow(false)}; + } + + return ( + + + {renderPressed} + + + ); +} + +function ComposerTrackDot({ bucket }: { bucket: SidebarStateBucket | null }): ReactElement | null { + const colorStyle = bucket ? dotColorStyle(bucket) : null; + const dotStyle = useMemo(() => [styles.dot, colorStyle], [colorStyle]); + if (!colorStyle) { + return null; + } + return ; +} + +function dotColorStyle(bucket: SidebarStateBucket) { + switch (bucket) { + case "needs_input": + return styles.dotNeedsInput; + case "failed": + return styles.dotFailed; + case "running": + return styles.dotRunning; + case "attention": + return styles.dotAttention; + case "done": + return styles.dotDone; + } +} + +const styles = StyleSheet.create((theme) => ({ + bar: { + position: "absolute", + left: 0, + right: 0, + bottom: 0, + alignItems: "center", + paddingHorizontal: theme.spacing[4], + }, + track: { + width: "100%", + maxWidth: MAX_CONTENT_WIDTH, + flexDirection: "row", + flexWrap: "wrap", + alignItems: "center", + gap: theme.spacing[1], + }, + pill: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + marginBottom: theme.spacing[2], + // One step inside the composer's corner, not a stadium: the pills sit directly on top of it + // and read as part of the same stack. See composer/input/input.tsx `inputWrapper`. + borderRadius: theme.borderRadius.xl, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface1, + }, + pillActive: { + backgroundColor: theme.colors.surface2, + borderColor: theme.colors.borderAccent, + }, + pillLabel: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + pillLabelActive: { + color: theme.colors.foreground, + }, + // The rail every panel row sits on: inset from the panel edge so the fill is a rounded block + // inside it, and tall enough that revealing an action button cannot resize the row. + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + minHeight: 32, + marginHorizontal: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + borderRadius: theme.borderRadius.md, + }, + rowActive: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + minHeight: 32, + marginHorizontal: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface2, + }, + dot: { + width: STATUS_INDICATOR_FILLED_DOT_SIZE, + height: STATUS_INDICATOR_FILLED_DOT_SIZE, + borderRadius: theme.borderRadius.full, + }, + dotNeedsInput: { + backgroundColor: theme.colors.statusDotWarning, + }, + dotFailed: { + backgroundColor: theme.colors.statusDotDanger, + }, + dotRunning: { + backgroundColor: theme.colors.statusDotRunning, + }, + dotAttention: { + backgroundColor: theme.colors.statusDotSuccess, + }, + dotDone: { + backgroundColor: theme.colors.border, + }, +})); diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 37f916dab..c672c3e15 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -402,7 +402,7 @@ describe("translation resources", () => { expect(en.message.question.otherPlaceholder).toBe("Other..."); expect(en.message.todo.title).toBe("Tasks"); expect(en.message.todo.empty).toBe("No tasks yet."); - expect(en.message.todo.tasksProgressCurrent).toBe("{{completed}}/{{total}} tasks · {{task}}"); + expect(en.message.todo.tasksProgress).toBe("{{completed}}/{{total}} tasks"); expect(en.message.todo.activity).toEqual({ created: "Created {{count}} tasks", added: "Added", diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index db2891516..1993cd2fa 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -336,7 +336,6 @@ export const ar: TranslationResources = { title: "المهام", empty: "لا توجد مهام حتى الآن.", tasksProgress: "{{completed}}/{{total}} مهام", - tasksProgressCurrent: "{{completed}}/{{total}} مهام · {{task}}", activity: { created: "تم إنشاء {{count}} مهام", added: "أُضيفت", @@ -1620,12 +1619,14 @@ export const ar: TranslationResources = { backdrop: "خلفية القائمة", }, subagents: { + title: "الوكلاء الفرعيون", + pillLabelOne: "وكيل فرعي واحد", + pillLabelMany: "{{count}} وكلاء فرعيين", detachAction: "فصل {{label}}", detachTooltip: "فصل الوكيل الفرعي", archiveAction: "أرشيف{{label}}", archiveTooltip: "أرشفة الوكيل الفرعي", archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين", - archiveFinishedTooltip: "أرشفة المكتملين", archiveFinishedRetry: "إعادة المحاولة ({{failed}}/{{total}})", }, panels: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 6e74ca8d5..9b14e32d3 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -334,7 +334,6 @@ export const en = { title: "Tasks", empty: "No tasks yet.", tasksProgress: "{{completed}}/{{total}} tasks", - tasksProgressCurrent: "{{completed}}/{{total}} tasks · {{task}}", activity: { created: "Created {{count}} tasks", added: "Added", @@ -1630,12 +1629,14 @@ export const en = { backdrop: "Menu backdrop", }, subagents: { + title: "Subagents", + pillLabelOne: "1 subagent", + pillLabelMany: "{{count}} subagents", detachAction: "Detach {{label}}", detachTooltip: "Detach subagent", archiveAction: "Archive {{label}}", archiveTooltip: "Archive subagent", archiveFinishedAction: "Archive finished subagents", - archiveFinishedTooltip: "Archive finished", archiveFinishedRetry: "Retry ({{failed}}/{{total}})", }, panels: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 1e1269adb..74757e827 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -339,7 +339,6 @@ export const es: TranslationResources = { title: "Tareas", empty: "Aún no hay tareas.", tasksProgress: "{{completed}}/{{total}} tareas", - tasksProgressCurrent: "{{completed}}/{{total}} tareas · {{task}}", activity: { created: "Se crearon {{count}} tareas", added: "Añadida", @@ -1663,12 +1662,14 @@ export const es: TranslationResources = { backdrop: "Fondo del menú", }, subagents: { + title: "Subagentes", + pillLabelOne: "1 subagente", + pillLabelMany: "{{count}} subagentes", detachAction: "Separar {{label}}", detachTooltip: "Separar subagente", archiveAction: "Archivo{{label}}", archiveTooltip: "Subagente de archivo", archiveFinishedAction: "Archivar subagentes finalizados", - archiveFinishedTooltip: "Archivar finalizados", archiveFinishedRetry: "Reintentar ({{failed}}/{{total}})", }, panels: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index a464c1a27..170633d31 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -340,7 +340,6 @@ export const fr: TranslationResources = { title: "Tâches", empty: "Aucune tâche pour l'instant.", tasksProgress: "{{completed}}/{{total}} tâches", - tasksProgressCurrent: "{{completed}}/{{total}} tâches · {{task}}", activity: { created: "{{count}} tâches créées", added: "Ajoutée", @@ -1667,12 +1666,14 @@ export const fr: TranslationResources = { backdrop: "Toile de fond du menu", }, subagents: { + title: "Sous-agents", + pillLabelOne: "1 sous-agent", + pillLabelMany: "{{count}} sous-agents", detachAction: "Detacher {{label}}", detachTooltip: "Detacher le sous-agent", archiveAction: "Archiver{{label}}", archiveTooltip: "Sous-agent d'archivage", archiveFinishedAction: "Archiver les sous-agents terminés", - archiveFinishedTooltip: "Archiver les terminés", archiveFinishedRetry: "Réessayer ({{failed}}/{{total}})", }, panels: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index b4bde32fd..c6e72af87 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -339,7 +339,6 @@ export const ja: TranslationResources = { title: "タスク", empty: "タスクがまだありません。", tasksProgress: "{{completed}}/{{total}}タスク", - tasksProgressCurrent: "{{completed}}/{{total}}タスク · {{task}}", activity: { created: "{{count}}件のタスクを作成", added: "追加", @@ -1636,12 +1635,14 @@ export const ja: TranslationResources = { backdrop: "メニューの背景", }, subagents: { + title: "サブエージェント", + pillLabelOne: "サブエージェント 1 件", + pillLabelMany: "サブエージェント {{count}} 件", detachAction: "{{label}}を切り離す", detachTooltip: "サブエージェントを切り離す", archiveAction: "{{label}}をアーカイブ", archiveTooltip: "サブエージェントをアーカイブ", archiveFinishedAction: "完了したサブエージェントをアーカイブ", - archiveFinishedTooltip: "完了した項目をアーカイブ", archiveFinishedRetry: "再試行 ({{failed}}/{{total}})", }, panels: { diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 54c8c324f..dcf1cc88a 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -337,7 +337,6 @@ export const ko: TranslationResources = { title: "작업", empty: "아직 작업이 없습니다.", tasksProgress: "작업 {{completed}}/{{total}}개", - tasksProgressCurrent: "작업 {{completed}}/{{total}}개 · {{task}}", activity: { created: "작업 {{count}}개 생성", added: "추가됨", @@ -1630,12 +1629,14 @@ export const ko: TranslationResources = { backdrop: "메뉴 배경", }, subagents: { + title: "하위 에이전트", + pillLabelOne: "하위 에이전트 1개", + pillLabelMany: "하위 에이전트 {{count}}개", detachAction: "{{label}} 분리", detachTooltip: "하위 에이전트 분리", archiveAction: "{{label}} 보관", archiveTooltip: "서브에이전트 보관", archiveFinishedAction: "완료된 하위 에이전트 보관", - archiveFinishedTooltip: "아카이브 완료", archiveFinishedRetry: "다시 시도 ({{failed}}/{{total}})", }, panels: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index b32d524b7..df5243ef8 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -339,7 +339,6 @@ export const ptBR: TranslationResources = { title: "Tarefas", empty: "Nenhuma tarefa ainda.", tasksProgress: "{{completed}}/{{total}} tarefas", - tasksProgressCurrent: "{{completed}}/{{total}} tarefas · {{task}}", activity: { created: "{{count}} tarefas criadas", added: "Adicionada", @@ -1649,12 +1648,14 @@ export const ptBR: TranslationResources = { backdrop: "Fundo do menu", }, subagents: { + title: "Subagentes", + pillLabelOne: "1 subagente", + pillLabelMany: "{{count}} subagentes", detachAction: "Desanexar {{label}}", detachTooltip: "Desanexar subagente", archiveAction: "Arquivar {{label}}", archiveTooltip: "Arquivar subagente", archiveFinishedAction: "Arquivar subagentes concluídos", - archiveFinishedTooltip: "Arquivar concluídos", archiveFinishedRetry: "Tentar novamente ({{failed}}/{{total}})", }, panels: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 9c1285d1b..437692783 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -338,7 +338,6 @@ export const ru: TranslationResources = { title: "Задачи", empty: "Заданий пока нет.", tasksProgress: "{{completed}}/{{total}} задач", - tasksProgressCurrent: "{{completed}}/{{total}} задач · {{task}}", activity: { created: "Создано задач: {{count}}", added: "Добавлена", @@ -1654,12 +1653,14 @@ export const ru: TranslationResources = { backdrop: "Фон меню", }, subagents: { + title: "Субагенты", + pillLabelOne: "1 субагент", + pillLabelMany: "{{count}} субагентов", detachAction: "Отсоединить {{label}}", detachTooltip: "Отсоединить субагент", archiveAction: "Архив{{label}}", archiveTooltip: "Архивный субагент", archiveFinishedAction: "Архивировать завершенные субагенты", - archiveFinishedTooltip: "Архивировать завершенные", archiveFinishedRetry: "Повторить ({{failed}}/{{total}})", }, panels: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 43ecef1c0..5acaf0838 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -336,7 +336,6 @@ export const zhCN: TranslationResources = { title: "任务", empty: "还没有任务。", tasksProgress: "{{completed}}/{{total}} 项任务", - tasksProgressCurrent: "{{completed}}/{{total}} 项任务 · {{task}}", activity: { created: "已创建 {{count}} 项任务", added: "已添加", @@ -1600,12 +1599,14 @@ export const zhCN: TranslationResources = { backdrop: "菜单背景", }, subagents: { + title: "Subagent", + pillLabelOne: "1 个 subagent", + pillLabelMany: "{{count}} 个 subagent", detachAction: "分离 {{label}}", detachTooltip: "分离 subagent", archiveAction: "归档 {{label}}", archiveTooltip: "归档 subagent", archiveFinishedAction: "归档已完成的 subagent", - archiveFinishedTooltip: "归档已完成项", archiveFinishedRetry: "重试 ({{failed}}/{{total}})", }, panels: { diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index fe5ae2b82..5b4776f32 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -74,7 +74,7 @@ import { deriveRouteBottomAnchorRequest, } from "@/screens/agent/agent-ready-screen-bottom-anchor"; import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab"; -import { AgentTaskList } from "@/composer/task-list"; +import { AgentTracks } from "@/panels/agent-tracks"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys"; import { usePanelStore } from "@/stores/panel-store"; @@ -87,20 +87,12 @@ import { import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import type { Theme } from "@/styles/theme"; -import { - useArchiveFinishedSubagents, - useArchiveSubagent, - useDetachSubagent, - useSubagentsForParent, -} from "@/subagents"; -import { SubagentsTrack } from "@/subagents/track"; import type { PendingPermission } from "@/types/shared"; import type { StreamItem } from "@/types/stream"; import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; -import { navigateToAgent } from "@/utils/navigate-to-agent"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; import { buildDraftAgentSetup, type ClientSlashCommand } from "@/client-slash-commands"; @@ -1296,8 +1288,15 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ /> ); + // The tracks float at the foot of the transcript, inside the same transform the composer uses, + // so the pills sit on its top edge with the timeline running under them. They follow the + // composer: an archived or archiving agent has none, and its children can no longer be acted on. + const hasComposer = !agentState.archivedAt && !isArchivingCurrentAgent; const streamContent = ( - {streamSection} + + {streamSection} + {hasComposer ? : null} + ); const contentContainer = {streamContent}; @@ -1506,37 +1505,11 @@ function ActiveAgentComposer({ { initialIsBelow: isCompactFormFactor }, ); const paneContext = usePaneContext(); - const { workspaceId, tabId, retargetCurrentTab, openTab } = paneContext; + const { workspaceId, tabId, retargetCurrentTab } = paneContext; const { archiveAgent } = useArchiveAgent(); const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent); const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent); - const subagentRows = useSubagentsForParent({ - serverId, - parentAgentId: agentId, - }); - const canDetachSubagents = useSessionStore( - (state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true, - ); - const handleOpenSubagent = useCallback( - (subagentId: string) => { - navigateToAgent({ serverId, agentId: subagentId }); - }, - [serverId], - ); - const handleOpenProviderSubagent = useCallback( - (parentAgentId: string, subagentId: string) => { - openTab({ kind: "provider_subagent", parentAgentId, subagentId }); - }, - [openTab], - ); - const handleArchiveSubagent = useArchiveSubagent({ serverId }); - const handleDetachSubagent = useDetachSubagent({ serverId }); - const archiveFinishedSubagents = useArchiveFinishedSubagents({ - serverId, - parentAgentId: agentId, - rows: subagentRows, - }); const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({ serverId, cwd, @@ -1623,16 +1596,6 @@ function ActiveAgentComposer({ return ( - - state.sessions[serverId]?.serverInfo?.features?.agentDetach === true, + ); + const archiveSubagent = useArchiveSubagent({ serverId }); + const detachSubagent = useDetachSubagent({ serverId }); + const archiveFinishedSubagents = useArchiveFinishedSubagents({ + serverId, + parentAgentId: agentId, + rows: subagentRows, + }); + + const handleOpenSubagent = useCallback( + (subagentId: string) => { + navigateToAgent({ serverId, agentId: subagentId }); + }, + [serverId], + ); + const handleOpenProviderSubagent = useCallback( + (parentAgentId: string, subagentId: string) => { + openTab({ kind: "provider_subagent", parentAgentId, subagentId }); + }, + [openTab], + ); + + return ( + + + + + ); +}); diff --git a/packages/app/src/subagents/track-presentation.test.ts b/packages/app/src/subagents/track-presentation.test.ts index 75574a182..d7a51daab 100644 --- a/packages/app/src/subagents/track-presentation.test.ts +++ b/packages/app/src/subagents/track-presentation.test.ts @@ -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"); }); }); diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts index a78c1ecdf..c79f93c06 100644 --- a/packages/app/src/subagents/track-presentation.ts +++ b/packages/app/src/subagents/track-presentation.ts @@ -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 { diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index 33f9efe57..23e6b1d2f 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -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 ( - - - - - - {showArchiveFinished && onArchiveFinished ? ( - - - {archiveFinishedStatus.kind === "archiving" ? ( - - {archiveFinishedStatus.completedCount}/{archiveFinishedStatus.totalCount} - - ) : null} - {archiveFinishedStatus.kind === "failed" ? ( - - {t("subagents.archiveFinishedRetry", { - failed: archiveFinishedStatus.failedCount, - total: archiveFinishedStatus.totalCount, - })} - - ) : null} - - ) : null} - - {expanded ? ( - - {rows.map((row) => ( - - ))} - - ) : null} - - - + + {rows.map((row) => ( + + ))} + {showArchiveFinished && onArchiveFinished ? ( + + ) : null} + + ); +} + +/** + * 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 }) => ( + <> + + + {t("subagents.archiveFinishedAction")} + + {status.kind === "archiving" ? ( + + {status.completedCount}/{status.totalCount} + + ) : null} + {status.kind === "failed" ? ( + + {t("subagents.archiveFinishedRetry", { + failed: status.failedCount, + total: status.totalCount, + })} + + ) : null} + + ), + [status, t], + ); + + return ( + + {renderRow} + ); } @@ -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 }) => ( + <> + + + {displayLabel} + + {presentation.subtitle ? ( + + {presentation.subtitle} + + ) : null} + {row.kind === "paseo" ? ( + + ) : 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. - - - {({ pressed }) => ( - - - - {displayLabel} - - {presentation.subtitle ? ( - - {presentation.subtitle} - - ) : null} - {row.kind === "paseo" ? ( - - ) : null} - - )} - - + + {renderRow} + ); } @@ -290,9 +289,9 @@ type SubagentActionIcon = "archive" | "detach"; function renderSubagentActionIcon(icon: SubagentActionIcon, isActive: boolean): ReactElement { const uniProps = isActive ? foregroundColorMapping : foregroundMutedColorMapping; if (icon === "detach") { - return ; + return ; } - return ; + return ; } 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 ( - + ({ - 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, },