feat(app): float composer trackers as pills over the transcript (#3482)
The subagents track and the task list were stacked cards above the composer. Each one pushed the composer further down the pane and painted an opaque band the timeline stopped at, and both spent a full row on a count that fits in a pill. Both are now pills that open a panel — a popover on wide screens, a sheet on compact ones, from the existing menu engine rather than a fourth floating-panel implementation. The row frame is shared, so a subagent row and a task row sit on the same rail whatever their contents. The bar mounts inside the transcript's animated container and pins itself to the bottom edge, so scrolled content passes under the pills instead of stopping above them. It stays a child of that container rather than a sibling floating over the composer: on Android a view outside its parent's bounds paints but takes no touches, and every pill is pressable. The container carries the same keyboard transform the composer does, so the pills track the composer's top edge when the keyboard moves. The tracker state moves out of the composer with it. None of it was composer state; it lived there because that is where it used to render.
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
|
||||
@@ -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(<TaskListRow task={pendingTask} />));
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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 <ThemedCircleDot size={16} uniProps={runningIcon} />;
|
||||
}
|
||||
return <ThemedCircle size={16} uniProps={mutedIcon} />;
|
||||
// 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 <ThemedCircle size={16} uniProps={extraMutedIcon} />;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.container} accessibilityLabel={t("message.todo.title")}>
|
||||
<View style={styles.track}>
|
||||
<View style={styles.card}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onPress={toggle}
|
||||
accessibilityLabel={label}
|
||||
accessibilityState={accessibilityState}
|
||||
leftIcon={expanded ? ChevronDown : ChevronRight}
|
||||
style={styles.header}
|
||||
textStyle={styles.headerText}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
{expanded ? (
|
||||
<View style={styles.list}>
|
||||
{tasks.map((task, index) => (
|
||||
<TaskListRow key={task.id ?? `${index}:${task.text}`} task={task} />
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<ComposerTrackPill
|
||||
testID="agent-task-list-header"
|
||||
label={label}
|
||||
panelTitle={t("message.todo.title")}
|
||||
>
|
||||
{tasks.map((task, index) => (
|
||||
<ComposerTrackRow key={task.id ?? `${index}:${task.text}`}>
|
||||
<View style={styles.taskRow}>
|
||||
<TaskListRow task={task} />
|
||||
</View>
|
||||
</ComposerTrackRow>
|
||||
))}
|
||||
</ComposerTrackPill>
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.bar} pointerEvents="box-none">
|
||||
<View style={styles.track} pointerEvents="box-none">
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<MenuRoot compactMode="sheet">
|
||||
<ComposerTrackPillTrigger
|
||||
label={label}
|
||||
testID={testID}
|
||||
accessibilityLabel={accessibilityLabel ?? label}
|
||||
statusBucket={statusBucket}
|
||||
/>
|
||||
<MenuSurface
|
||||
side="top"
|
||||
align="start"
|
||||
offset={PANEL_OFFSET}
|
||||
sheetTitle={panelTitle}
|
||||
minWidth={PANEL_MIN_WIDTH}
|
||||
maxWidth={PANEL_MAX_WIDTH}
|
||||
maxHeight={PANEL_MAX_HEIGHT}
|
||||
scrollable
|
||||
testID={`${testID}-panel`}
|
||||
>
|
||||
{children}
|
||||
</MenuSurface>
|
||||
</MenuRoot>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<MenuTrigger
|
||||
testID={testID}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityState={accessibilityState}
|
||||
{...ariaExpandedProps}
|
||||
style={pillStyle}
|
||||
>
|
||||
<ComposerTrackDot bucket={statusBucket} />
|
||||
<Text style={labelStyle} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</MenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
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) => (
|
||||
<View style={active ? styles.rowActive : styles.row}>
|
||||
{typeof children === "function" ? children({ active }) : children}
|
||||
</View>
|
||||
),
|
||||
[children],
|
||||
);
|
||||
const renderPressed = useCallback(
|
||||
({ pressed }: { pressed: boolean }) => renderRow(hovered || pressed),
|
||||
[hovered, renderRow],
|
||||
);
|
||||
|
||||
if (!onPress) {
|
||||
return <View accessibilityLabel={accessibilityLabel}>{renderRow(false)}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
>
|
||||
{renderPressed}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 <View style={dotStyle} />;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
</RenderProfile>
|
||||
);
|
||||
// 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 = (
|
||||
<ReanimatedAnimated.View style={animatedContentStyle}>{streamSection}</ReanimatedAnimated.View>
|
||||
<ReanimatedAnimated.View style={animatedContentStyle}>
|
||||
{streamSection}
|
||||
{hasComposer ? <AgentTracks serverId={serverId} agentId={agentId} /> : null}
|
||||
</ReanimatedAnimated.View>
|
||||
);
|
||||
const contentContainer = <View style={styles.contentContainer}>{streamContent}</View>;
|
||||
|
||||
@@ -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 (
|
||||
<ReanimatedAnimated.View style={inputAreaStyle} onLayout={onInputAreaLayout}>
|
||||
<AgentTaskList serverId={serverId} agentId={agentId} />
|
||||
<SubagentsTrack
|
||||
rows={subagentRows}
|
||||
onOpenSubagent={handleOpenSubagent}
|
||||
onOpenProviderSubagent={handleOpenProviderSubagent}
|
||||
onArchiveSubagent={handleArchiveSubagent}
|
||||
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
|
||||
archiveFinishedStatus={archiveFinishedSubagents.status}
|
||||
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
|
||||
/>
|
||||
<Composer
|
||||
agentId={agentId}
|
||||
serverId={serverId}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { memo, useCallback, type ReactElement } from "react";
|
||||
import { AgentTaskList } from "@/composer/task-list";
|
||||
import { ComposerTrackBar } from "@/composer/tracks";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
useArchiveFinishedSubagents,
|
||||
useArchiveSubagent,
|
||||
useDetachSubagent,
|
||||
useSubagentsForParent,
|
||||
} from "@/subagents";
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
|
||||
/**
|
||||
* The pane's trackers — its subagents and its task list — as a row of pills over the foot of
|
||||
* the transcript.
|
||||
*
|
||||
* It is mounted inside the transcript's animated container rather than above the composer, and
|
||||
* that placement is the whole design: the pills paint over the timeline, so scrolled content
|
||||
* passes under them instead of stopping at a band, and the container carries the same keyboard
|
||||
* transform the composer does, so the pills stay glued to its top edge while the keyboard moves.
|
||||
*
|
||||
* Its state was living in the composer only because that is where it used to render. None of it
|
||||
* is composer state — a subagent row opens a tab, the task list reads the agent's stream.
|
||||
*/
|
||||
export const AgentTracks = memo(function AgentTracks({
|
||||
serverId,
|
||||
agentId,
|
||||
}: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
}): ReactElement {
|
||||
const { openTab } = usePaneContext();
|
||||
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
|
||||
const canDetachSubagents = useSessionStore(
|
||||
(state) => 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 (
|
||||
<ComposerTrackBar>
|
||||
<SubagentsTrack
|
||||
rows={subagentRows}
|
||||
onOpenSubagent={handleOpenSubagent}
|
||||
onOpenProviderSubagent={handleOpenProviderSubagent}
|
||||
onArchiveSubagent={archiveSubagent}
|
||||
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
|
||||
archiveFinishedStatus={archiveFinishedSubagents.status}
|
||||
onDetachSubagent={canDetachSubagents ? detachSubagent : undefined}
|
||||
/>
|
||||
<AgentTaskList serverId={serverId} agentId={agentId} />
|
||||
</ComposerTrackBar>
|
||||
);
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PaseoSubagentRow, ProviderSubagentRow, SubagentRow } from "./select";
|
||||
import {
|
||||
aggregateSubagentStatusBucket,
|
||||
buildSubagentRowPresentationData,
|
||||
countFinishedSubagents,
|
||||
formatHeaderLabel,
|
||||
resolveRowLabel,
|
||||
} from "./track-presentation";
|
||||
|
||||
@@ -23,55 +23,28 @@ function row(
|
||||
};
|
||||
}
|
||||
|
||||
describe("formatHeaderLabel", () => {
|
||||
it("uses singular 'subagent' for a single row", () => {
|
||||
expect(formatHeaderLabel([row({ id: "a" })])).toBe("1 subagent");
|
||||
describe("aggregateSubagentStatusBucket", () => {
|
||||
it("has no bucket without rows", () => {
|
||||
expect(aggregateSubagentStatusBucket([])).toBeNull();
|
||||
});
|
||||
|
||||
it("uses plural 'subagents' for two rows with no running rows", () => {
|
||||
expect(formatHeaderLabel([row({ id: "a" }), row({ id: "b" })])).toBe("2 subagents");
|
||||
it("has no bucket when every child is done", () => {
|
||||
expect(aggregateSubagentStatusBucket([row({ id: "a" }), row({ id: "b" })])).toBeNull();
|
||||
});
|
||||
|
||||
it("appends the running count when at least one row is running", () => {
|
||||
it("reports running when any child is running", () => {
|
||||
expect(
|
||||
formatHeaderLabel([row({ id: "a", status: "running" }), row({ id: "b" }), row({ id: "c" })]),
|
||||
).toBe("3 subagents · 1 running");
|
||||
aggregateSubagentStatusBucket([row({ id: "a" }), row({ id: "b", status: "running" })]),
|
||||
).toBe("running");
|
||||
});
|
||||
|
||||
it("counts every running row in the suffix", () => {
|
||||
it("ranks a failed child above a running one", () => {
|
||||
expect(
|
||||
formatHeaderLabel([
|
||||
aggregateSubagentStatusBucket([
|
||||
row({ id: "a", status: "running" }),
|
||||
row({ id: "b", status: "running" }),
|
||||
row({ id: "c", requiresAttention: true }),
|
||||
row({ id: "d" }),
|
||||
row({ id: "e" }),
|
||||
row({ id: "b", status: "error", requiresAttention: true }),
|
||||
]),
|
||||
).toBe("5 subagents · 2 running");
|
||||
});
|
||||
|
||||
it("ignores requiresAttention on non-running rows in the header copy", () => {
|
||||
expect(
|
||||
formatHeaderLabel([
|
||||
row({ id: "a", status: "error", requiresAttention: false }),
|
||||
row({ id: "b", status: "idle", requiresAttention: false }),
|
||||
row({ id: "c", status: "idle", requiresAttention: true }),
|
||||
]),
|
||||
).toBe("3 subagents");
|
||||
});
|
||||
|
||||
it("still counts running rows even when they require attention", () => {
|
||||
expect(
|
||||
formatHeaderLabel([
|
||||
row({ id: "a", status: "error", requiresAttention: true }),
|
||||
row({ id: "b", status: "running", requiresAttention: true }),
|
||||
row({ id: "c", status: "idle", requiresAttention: true }),
|
||||
]),
|
||||
).toBe("3 subagents · 1 running");
|
||||
});
|
||||
|
||||
it("uses singular 'subagent' for a single row that requires attention upstream", () => {
|
||||
expect(formatHeaderLabel([row({ id: "a", requiresAttention: true })])).toBe("1 subagent");
|
||||
).toBe("failed");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import {
|
||||
aggregateSidebarStateBuckets,
|
||||
deriveSidebarStateBucket,
|
||||
} from "@/utils/sidebar-agent-state";
|
||||
import type { SubagentRow } from "./select";
|
||||
import { isFinishedSubagent } from "./archive-finished";
|
||||
import { providerSubagentLifecycleStatus } from "./provider-store";
|
||||
@@ -40,19 +43,25 @@ export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowP
|
||||
};
|
||||
}
|
||||
|
||||
export function formatHeaderLabel(rows: readonly SubagentRow[]): string {
|
||||
let runningCount = 0;
|
||||
for (const row of rows) {
|
||||
if (row.status === "running") {
|
||||
runningCount += 1;
|
||||
}
|
||||
/**
|
||||
* The one state the collapsed pill can show. The pill has room for a dot and a count, so the
|
||||
* children collapse into the most urgent bucket among them — the same rule a collapsed project
|
||||
* row in the sidebar uses, and for the same reason.
|
||||
*
|
||||
* `null` when every child is done: a finished fan-out is not worth a colour above the composer.
|
||||
*/
|
||||
export function aggregateSubagentStatusBucket(
|
||||
rows: readonly SubagentRow[],
|
||||
): SidebarStateBucket | null {
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = [`${rows.length} ${rows.length === 1 ? "subagent" : "subagents"}`];
|
||||
if (runningCount > 0) {
|
||||
parts.push(`${runningCount} running`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
const buckets = rows.flatMap((row) => {
|
||||
const bucket = buildSubagentRowPresentationData(row).statusBucket;
|
||||
return bucket ? [bucket] : [];
|
||||
});
|
||||
const aggregate = aggregateSidebarStateBuckets(buckets);
|
||||
return aggregate === "done" ? null : aggregate;
|
||||
}
|
||||
|
||||
export function countFinishedSubagents(rows: readonly SubagentRow[]): number {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Archive, ChevronDown, ChevronRight, Unlink } from "lucide-react-native";
|
||||
import { Archive, Unlink } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useIsCompactFormFactor, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import {
|
||||
WorkspaceTabIcon,
|
||||
@@ -16,9 +16,9 @@ import type { Theme } from "@/styles/theme";
|
||||
import type { SubagentRow } from "./select";
|
||||
import type { ArchiveFinishedStatus } from "./use-archive-finished";
|
||||
import {
|
||||
aggregateSubagentStatusBucket,
|
||||
buildSubagentRowPresentationData,
|
||||
countFinishedSubagents,
|
||||
formatHeaderLabel,
|
||||
} from "./track-presentation";
|
||||
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
@@ -39,7 +39,10 @@ export interface SubagentsTrackProps {
|
||||
onDetachSubagent?: (id: string) => void;
|
||||
}
|
||||
|
||||
const SUBAGENTS_LIST_MAX_HEIGHT = 200;
|
||||
const IDLE_ARCHIVE_FINISHED_STATUS: ArchiveFinishedStatus = { kind: "idle" };
|
||||
|
||||
/** Leading and action glyphs share one size so rows keep a single icon column. */
|
||||
const ROW_ICON_SIZE = 14;
|
||||
|
||||
function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation {
|
||||
const data = buildSubagentRowPresentationData(row);
|
||||
@@ -57,26 +60,10 @@ export function SubagentsTrack({
|
||||
onOpenProviderSubagent,
|
||||
onArchiveSubagent,
|
||||
onArchiveFinished,
|
||||
archiveFinishedStatus = { kind: "idle" },
|
||||
archiveFinishedStatus = IDLE_ARCHIVE_FINISHED_STATUS,
|
||||
onDetachSubagent,
|
||||
}: SubagentsTrackProps): ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setExpanded((current) => !current);
|
||||
}, []);
|
||||
|
||||
const surfaceStyle = useMemo(
|
||||
() => [styles.surface, expanded && styles.surfaceExpanded],
|
||||
[expanded],
|
||||
);
|
||||
|
||||
const headerContainerStyle = useMemo(
|
||||
() => [styles.header, expanded ? styles.headerDivider : styles.headerCollapsed],
|
||||
[expanded],
|
||||
);
|
||||
const headerAccessibilityState = useMemo(() => ({ expanded }), [expanded]);
|
||||
|
||||
const isArchivingFinished = archiveFinishedStatus.kind === "archiving";
|
||||
const isArchiveFinishedFailed = archiveFinishedStatus.kind === "failed";
|
||||
@@ -84,83 +71,94 @@ export function SubagentsTrack({
|
||||
return null;
|
||||
}
|
||||
|
||||
const headerLabel = formatHeaderLabel(rows);
|
||||
const pillLabel =
|
||||
rows.length === 1
|
||||
? t("subagents.pillLabelOne")
|
||||
: t("subagents.pillLabelMany", { count: rows.length });
|
||||
const finishedCount = countFinishedSubagents(rows);
|
||||
const showArchiveFinished = finishedCount > 0 || isArchivingFinished || isArchiveFinishedFailed;
|
||||
|
||||
return (
|
||||
<View style={styles.outer} testID="subagents-track">
|
||||
<View style={styles.track}>
|
||||
<View style={surfaceStyle}>
|
||||
<View style={headerContainerStyle}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
accessibilityLabel={headerLabel}
|
||||
accessibilityState={headerAccessibilityState}
|
||||
testID="subagents-track-header"
|
||||
onPress={toggleExpanded}
|
||||
leftIcon={expanded ? ChevronDown : ChevronRight}
|
||||
style={styles.headerToggle}
|
||||
textStyle={styles.headerLabel}
|
||||
>
|
||||
{headerLabel}
|
||||
</Button>
|
||||
{showArchiveFinished && onArchiveFinished ? (
|
||||
<View style={styles.headerAction}>
|
||||
<SubagentActionButton
|
||||
accessibilityLabel={t("subagents.archiveFinishedAction")}
|
||||
testID="subagents-track-archive-finished"
|
||||
tooltipLabel={t("subagents.archiveFinishedTooltip")}
|
||||
icon="archive"
|
||||
visible
|
||||
disabled={isArchivingFinished}
|
||||
onPress={onArchiveFinished}
|
||||
/>
|
||||
{archiveFinishedStatus.kind === "archiving" ? (
|
||||
<Text
|
||||
style={styles.archiveFinishedStatus}
|
||||
testID="subagents-track-archive-progress"
|
||||
>
|
||||
{archiveFinishedStatus.completedCount}/{archiveFinishedStatus.totalCount}
|
||||
</Text>
|
||||
) : null}
|
||||
{archiveFinishedStatus.kind === "failed" ? (
|
||||
<Text
|
||||
style={styles.archiveFinishedStatus}
|
||||
testID="subagents-track-archive-failed"
|
||||
>
|
||||
{t("subagents.archiveFinishedRetry", {
|
||||
failed: archiveFinishedStatus.failedCount,
|
||||
total: archiveFinishedStatus.totalCount,
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
{expanded ? (
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
nestedScrollEnabled
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<SubagentsTrackRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
onOpenProviderSubagent={onOpenProviderSubagent}
|
||||
onArchiveSubagent={onArchiveSubagent}
|
||||
onDetachSubagent={onDetachSubagent}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<ComposerTrackPill
|
||||
testID="subagents-track-header"
|
||||
label={pillLabel}
|
||||
panelTitle={t("subagents.title")}
|
||||
statusBucket={aggregateSubagentStatusBucket(rows)}
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<SubagentsTrackRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
onOpenProviderSubagent={onOpenProviderSubagent}
|
||||
onArchiveSubagent={onArchiveSubagent}
|
||||
onDetachSubagent={onDetachSubagent}
|
||||
/>
|
||||
))}
|
||||
{showArchiveFinished && onArchiveFinished ? (
|
||||
<ArchiveFinishedRow
|
||||
status={archiveFinishedStatus}
|
||||
disabled={isArchivingFinished}
|
||||
onPress={onArchiveFinished}
|
||||
/>
|
||||
) : null}
|
||||
</ComposerTrackPill>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk archive, as a row at the foot of the panel rather than an icon next to the count. The
|
||||
* pill has no header to hang an icon off, and a destructive-ish action reads better with its
|
||||
* name attached.
|
||||
*/
|
||||
function ArchiveFinishedRow({
|
||||
status,
|
||||
disabled,
|
||||
onPress,
|
||||
}: {
|
||||
status: ArchiveFinishedStatus;
|
||||
disabled: boolean;
|
||||
onPress: () => void;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renderRow = useCallback(
|
||||
({ active }: { active: boolean }) => (
|
||||
<>
|
||||
<ThemedArchive
|
||||
size={ROW_ICON_SIZE}
|
||||
uniProps={active ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
<Text style={styles.rowLabel} numberOfLines={1}>
|
||||
{t("subagents.archiveFinishedAction")}
|
||||
</Text>
|
||||
{status.kind === "archiving" ? (
|
||||
<Text style={styles.rowTrailing} testID="subagents-track-archive-progress">
|
||||
{status.completedCount}/{status.totalCount}
|
||||
</Text>
|
||||
) : null}
|
||||
{status.kind === "failed" ? (
|
||||
<Text style={styles.rowTrailing} testID="subagents-track-archive-failed">
|
||||
{t("subagents.archiveFinishedRetry", {
|
||||
failed: status.failedCount,
|
||||
total: status.totalCount,
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
),
|
||||
[status, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<ComposerTrackRow
|
||||
accessibilityLabel={t("subagents.archiveFinishedAction")}
|
||||
testID="subagents-track-archive-finished"
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
>
|
||||
{renderRow}
|
||||
</ComposerTrackRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -181,7 +179,6 @@ function SubagentsTrackRow({
|
||||
}: SubagentsTrackRowProps): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const presentation = useMemo(() => buildRowPresentation(row), [row]);
|
||||
const displayLabel =
|
||||
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
|
||||
@@ -198,49 +195,51 @@ function SubagentsTrackRow({
|
||||
const handleDetachPress = useCallback(() => {
|
||||
onDetachSubagent?.(row.id);
|
||||
}, [onDetachSubagent, row.id]);
|
||||
const handlePointerEnter = useCallback(() => setHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setHovered(false), []);
|
||||
const actionsAlwaysVisible = isNative || isCompact;
|
||||
const actionsVisible = actionsAlwaysVisible || hovered;
|
||||
|
||||
const renderRow = useCallback(
|
||||
({ active }: { active: boolean }) => (
|
||||
<>
|
||||
<WorkspaceTabIcon presentation={presentation} backdrop={active ? "surface2" : "surface1"} />
|
||||
<Text style={styles.rowLabel} numberOfLines={1}>
|
||||
{displayLabel}
|
||||
</Text>
|
||||
{presentation.subtitle ? (
|
||||
<Text style={styles.rowTrailing} numberOfLines={1}>
|
||||
{presentation.subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
{row.kind === "paseo" ? (
|
||||
<SubagentRowActions
|
||||
rowId={row.id}
|
||||
displayLabel={displayLabel}
|
||||
visible={actionsAlwaysVisible || active}
|
||||
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
|
||||
onArchivePress={handleArchivePress}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
),
|
||||
[
|
||||
actionsAlwaysVisible,
|
||||
displayLabel,
|
||||
handleArchivePress,
|
||||
handleDetachPress,
|
||||
onDetachSubagent,
|
||||
presentation,
|
||||
row.kind,
|
||||
row.id,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
// Wrapper View handles hover so moving the pointer between the row and
|
||||
// the archive button doesn't drop the hover state — the same pattern
|
||||
// used by sidebar workspace rows.
|
||||
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={displayLabel}
|
||||
testID={`subagents-track-row-${row.id}`}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{({ pressed }) => (
|
||||
<View style={hovered || pressed ? styles.rowActive : styles.row}>
|
||||
<WorkspaceTabIcon
|
||||
presentation={presentation}
|
||||
backdrop={hovered || pressed ? "surface2" : "surface1"}
|
||||
/>
|
||||
<Text style={styles.rowLabel} numberOfLines={1}>
|
||||
{displayLabel}
|
||||
</Text>
|
||||
{presentation.subtitle ? (
|
||||
<Text style={styles.rowSubtitle} numberOfLines={1}>
|
||||
{presentation.subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
{row.kind === "paseo" ? (
|
||||
<SubagentRowActions
|
||||
rowId={row.id}
|
||||
displayLabel={displayLabel}
|
||||
visible={actionsVisible}
|
||||
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
|
||||
onArchivePress={handleArchivePress}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
<ComposerTrackRow
|
||||
accessibilityLabel={displayLabel}
|
||||
testID={`subagents-track-row-${row.id}`}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{renderRow}
|
||||
</ComposerTrackRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -290,9 +289,9 @@ type SubagentActionIcon = "archive" | "detach";
|
||||
function renderSubagentActionIcon(icon: SubagentActionIcon, isActive: boolean): ReactElement {
|
||||
const uniProps = isActive ? foregroundColorMapping : foregroundMutedColorMapping;
|
||||
if (icon === "detach") {
|
||||
return <ThemedUnlink size={14} uniProps={uniProps} />;
|
||||
return <ThemedUnlink size={ROW_ICON_SIZE} uniProps={uniProps} />;
|
||||
}
|
||||
return <ThemedArchive size={14} uniProps={uniProps} />;
|
||||
return <ThemedArchive size={ROW_ICON_SIZE} uniProps={uniProps} />;
|
||||
}
|
||||
|
||||
function SubagentActionButton({
|
||||
@@ -301,7 +300,6 @@ function SubagentActionButton({
|
||||
tooltipLabel,
|
||||
icon,
|
||||
visible,
|
||||
disabled = false,
|
||||
onPress,
|
||||
}: {
|
||||
accessibilityLabel: string;
|
||||
@@ -309,17 +307,15 @@ function SubagentActionButton({
|
||||
tooltipLabel: string;
|
||||
icon: SubagentActionIcon;
|
||||
visible: boolean;
|
||||
disabled?: boolean;
|
||||
onPress: () => void;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild disabled={!visible || disabled}>
|
||||
<TooltipTrigger asChild disabled={!visible}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={styles.actionButton}
|
||||
hitSlop={8}
|
||||
@@ -335,97 +331,22 @@ function SubagentActionButton({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
outer: {
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
track: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
marginBottom: -theme.spacing[4],
|
||||
},
|
||||
surface: {
|
||||
alignSelf: "stretch",
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderBottomWidth: 0,
|
||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||
overflow: "hidden",
|
||||
},
|
||||
surfaceExpanded: {
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
headerToggle: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
justifyContent: "flex-start",
|
||||
borderRadius: 0,
|
||||
paddingLeft: theme.spacing[3],
|
||||
paddingRight: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
headerAction: {
|
||||
paddingRight: theme.spacing[2],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
archiveFinishedStatus: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
headerCollapsed: {
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
headerDivider: {
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
headerLabel: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
scroll: {
|
||||
maxHeight: SUBAGENTS_LIST_MAX_HEIGHT,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
rowActive: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
// `flexBasis: "auto"` rather than `flex: 1`: a zero-basis label contributes nothing to the row's
|
||||
// intrinsic width, so the panel measures itself at its floor and truncates every label at once.
|
||||
rowLabel: {
|
||||
flex: 1,
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
flexBasis: "auto",
|
||||
minWidth: 0,
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
// Keep provider context secondary and bounded so the task remains readable on compact screens.
|
||||
rowSubtitle: {
|
||||
flexShrink: 1,
|
||||
// Trailing metadata — provider context on a subagent row, progress on the archive row. No width
|
||||
// cap: the panel's own ceiling bounds it. It shrinks twice as fast as the label, so a wordy
|
||||
// provider subtitle gives way first instead of squeezing the thing that names the row.
|
||||
rowTrailing: {
|
||||
flexShrink: 2,
|
||||
minWidth: 0,
|
||||
maxWidth: "45%",
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user