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