AgentTree

Show an agent and the subagents and commands it started as a tree, with status, last activity and live elapsed time on every node. Nodes with children can be collapsed; all are expanded by default unless listed in defaultCollapsedIds. Build the nested structure from a flat list with buildTaskTree.

Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-react
Examples
Agent with subagents
Source

The component source as it ships in the package. Useful as a reference, or as a starting point if you want to fork a piece.

tasks/AgentTree/AgentTree.tsx
import React, { memo, useCallback, useMemo, useState } from 'react';
import {
  ActionIcon,
  Group,
  Stack,
  Text,
  Tree,
  useTree,
  type RenderTreeNodePayload,
  type TreeNodeData,
} from '@mantine/core';
import { IconChevronRight } from '@tabler/icons-react';
import { StatusBadge } from '../../primitives/StatusBadge/StatusBadge';
import { cx } from '../../utils/cx';
import { TaskElapsed, TaskKindIcon } from '../TaskMeta';
import {
  DEFAULT_BACKGROUND_TASK_LABELS,
  findTask,
  flattenTaskTree,
  getTaskStatusLabel,
  getTaskUiStatus,
} from '../task-utils';
import type { BackgroundTask, BackgroundTaskLabels } from '../types';
import classes from './AgentTree.module.css';

type TreeExpandedState = Record<string, boolean>;

export interface AgentTreeProps {
  /** Root tasks with nested `children` */
  tasks: BackgroundTask[];
  /** Id of the selected task */
  selectedId?: string | null;
  /** Called when a node is clicked or chosen with Enter */
  onSelect?: (task: BackgroundTask) => void;
  /** Ids of collapsed nodes when uncontrolled, every node is expanded by default */
  defaultCollapsedIds?: string[];
  /** Overrides of the default English labels */
  labels?: Partial<BackgroundTaskLabels>;
  /** Class name added to the root element */
  className?: string;
  /** Inline styles added to the root element */
  style?: React.CSSProperties;
}

function toTreeData(tasks: BackgroundTask[]): TreeNodeData[] {
  return tasks.map((task) => ({
    value: task.id,
    label: task.title,
    children: task.children?.length ? toTreeData(task.children) : undefined,
  }));
}

/** Tree of an agent and its subagents with status, last activity and live elapsed time */
export const AgentTree = memo(function AgentTree({
  tasks,
  selectedId,
  onSelect,
  defaultCollapsedIds,
  labels: labelsProp,
  className,
  style,
}: AgentTreeProps) {
  const labels = { ...DEFAULT_BACKGROUND_TASK_LABELS, ...labelsProp };
  const data = useMemo(() => toTreeData(tasks), [tasks]);
  const [collapsed, setCollapsed] = useState(() => new Set(defaultCollapsedIds));

  const expandedState = useMemo<TreeExpandedState>(() => {
    const state: TreeExpandedState = {};
    for (const task of flattenTaskTree(tasks)) {
      state[task.id] = !collapsed.has(task.id);
    }
    return state;
  }, [tasks, collapsed]);

  const selectedState = useMemo(() => (selectedId ? [selectedId] : []), [selectedId]);

  const handleExpandedChange = useCallback((state: TreeExpandedState) => {
    setCollapsed(
      new Set(
        Object.entries(state)
          .filter(([, expanded]) => !expanded)
          .map(([id]) => id)
      )
    );
  }, []);

  const tree = useTree({
    expandedState,
    onExpandedStateChange: handleExpandedChange,
    selectedState,
  });

  const select = (id: string | undefined) => {
    const task = findTask(tasks, id);
    if (task) {
      onSelect?.(task);
    }
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {
    const target = event.target as HTMLElement;
    if (event.key === 'Enter' && target.getAttribute('role') === 'treeitem') {
      event.preventDefault();
      select(target.dataset.value);
    }
  };

  const renderNode = ({
    node,
    expanded,
    hasChildren,
    selected,
    elementProps,
  }: RenderTreeNodePayload) => {
    const task = findTask(tasks, node.value);
    if (!task) {
      return null;
    }
    return (
      <Group
        {...elementProps}
        gap={6}
        wrap="nowrap"
        align="flex-start"
        className={cx(elementProps.className, classes.node)}
        data-selected={selected || undefined}
        onClick={(event: React.MouseEvent) => {
          elementProps.onClick(event);
          onSelect?.(task);
        }}
      >
        <ActionIcon
          variant="subtle"
          color="gray"
          size="sm"
          tabIndex={-1}
          aria-label={expanded ? labels.collapse : labels.expand}
          className={classes.toggle}
          data-hidden={!hasChildren || undefined}
          data-expanded={expanded || undefined}
          onClick={(event: React.MouseEvent) => {
            event.stopPropagation();
            tree.toggleExpanded(node.value);
          }}
        >
          <IconChevronRight size={14} />
        </ActionIcon>
        <TaskKindIcon kind={task.kind} size="sm" />
        <Stack gap={2} flex={1} miw={0}>
          <Group gap="xs" wrap="nowrap" justify="space-between">
            <Text size="sm" fw={500} truncate="end" miw={0}>
              {task.title}
            </Text>
            <Text component="span" size="xs" c="dimmed" className={classes.elapsed}>
              <TaskElapsed task={task} />
            </Text>
          </Group>
          <Group gap={6} wrap="nowrap" miw={0}>
            <StatusBadge
              variant="dot"
              size="xs"
              status={getTaskUiStatus(task.status)}
              label={getTaskStatusLabel(task.status, labels)}
            />
            {task.lastActivity && (
              <Text size="xs" c="dimmed" truncate="end" miw={0}>
                · {task.lastActivity}
              </Text>
            )}
          </Group>
        </Stack>
      </Group>
    );
  };

  return (
    <Tree
      data={data}
      tree={tree}
      expandOnClick={false}
      levelOffset="md"
      renderNode={renderNode}
      onKeyDown={handleKeyDown}
      aria-label={labels.subtasks}
      className={className}
      style={style}
    />
  );
});

AgentTree.displayName = 'AgentTree';
Depends on:
API reference
Prop
Type
Required
tasks
BackgroundTask[]
Yes
Root tasks with nested `children`
selectedId
string | null
No
Id of the selected task
onSelect
(task: BackgroundTask) => void
No
Called when a node is clicked or chosen with Enter
defaultCollapsedIds
string[]
No
Ids of collapsed nodes when uncontrolled, every node is expanded by default
labels
Partial<BackgroundTaskLabels>
No
Overrides of the default English labels
className
string
No
Class name added to the root element
style
React.CSSProperties
No
Inline styles added to the root element