diff --git a/packages/gui-plugins/src/agents/i18n/en.ts b/packages/gui-plugins/src/agents/i18n/en.ts new file mode 100644 index 000000000000..48df7e601150 --- /dev/null +++ b/packages/gui-plugins/src/agents/i18n/en.ts @@ -0,0 +1,33 @@ +export default { + "tab.title": "Subagents", + "mobile.title": "Subagents", + "view": "View subagents", + "count": "Agents", + "empty": "No subagents running", + "emptyRunning": "No running subagents", + "emptyHistory": "No subagent history", + "emptySearch": "No matching subagents", + "total": "Total cost", + "session": "Session", + "subagents": "Subagents", + "tokens": "tokens", + "cost": "Cost", + "search.placeholder": "Search subagents...", + "sort.recent": "Recent", + "sort.cost": "Cost", + "sort.tokens": "Tokens", + "sort.name": "Name", + "sort.direction": "Toggle sort direction", + "tab.all": "All", + "tab.running": "Running", + "tab.history": "History", + "latestActivity": "Latest activity", + "lastResponse": "Last response", + "elapsed": "Elapsed {{value}}", + "footerNote": "Tokens are cumulative · Costs as reported", + "status.idle": "Idle", + "status.busy": "Running", + "status.cancelled": "Cancelled", + "status.completed": "Completed", + "status.error": "Failed", +} diff --git a/packages/gui-plugins/src/agents/index.ts b/packages/gui-plugins/src/agents/index.ts new file mode 100644 index 000000000000..c0e47b493c1f --- /dev/null +++ b/packages/gui-plugins/src/agents/index.ts @@ -0,0 +1,6 @@ +import { Plugin } from "@opencode/plugin/gui" +import en from "./i18n/en" + +export default Plugin.define({ + i18n: { en }, +}) diff --git a/packages/gui-plugins/src/agents/indicator.tsx b/packages/gui-plugins/src/agents/indicator.tsx new file mode 100644 index 000000000000..83862b2e4838 --- /dev/null +++ b/packages/gui-plugins/src/agents/indicator.tsx @@ -0,0 +1,104 @@ +import { Show, createMemo, type ComponentProps, type JSX } from "solid-js" +import { Icon } from "@opencode/ui/icon" +import { IconButton } from "@opencode/ui/icon-button" +import { Tooltip } from "@opencode/ui/tooltip" +import { useI18n } from "@opencode/ui/context/i18n" +import { usePlugin, type MountedSession } from "@opencode/plugin/gui" +import { childStatus, childrenCost, childrenOf, subagentTasks } from "./status" + +function AgentsTooltipRow(props: { name: JSX.Element; value: JSX.Element }) { + return ( +
+ {props.name} + {props.value} +
+ ) +} + +export function AgentsTabIcon(props: { session: MountedSession }) { + const busy = createMemo(() => { + if (!props.session.id) return false + + const data = props.session.server.data + const tasks = subagentTasks(data.session.message.list(props.session.id)) + + if (tasks.blocking.length > 0) return true + + return childrenOf(data.session.list(), props.session.id).some( + (info) => + childStatus({ + part: tasks.parts.get(info.id), + running: data.session.status(info.id) === "running", + outcome: info.outcome, + }) === "busy", + ) + }) + + return ( +
+ + + + +
+ ) +} + +export function SessionAgentsIndicator(props: { + session: MountedSession + variant?: "button" | "indicator" + placement?: ComponentProps["placement"] +}) { + const ctx = usePlugin() + const layout = ctx.layout + const i18n = useI18n() + + const variant = createMemo(() => props.variant ?? "button") + + const children = createMemo(() => { + if (!props.session.id) return [] + + return props.session.server.data.session.list().filter((info) => info.parentID === props.session.id) + }) + + const subCost = createMemo(() => childrenCost(children())) + + const toggleAgents = () => { + if (!props.session.id) return + + layout.toggle(`${ctx.id}:main`, props.session) + } + + const tooltipValue = () => ( +
+
{ctx.t("view")}
+ + +
+ ) + + return ( + + + } + onClick={toggleAgents} + aria-label={ctx.t("view")} + /> + } + > + + + + + ) +} diff --git a/packages/gui-plugins/src/agents/panel.tsx b/packages/gui-plugins/src/agents/panel.tsx new file mode 100644 index 000000000000..6a125f857670 --- /dev/null +++ b/packages/gui-plugins/src/agents/panel.tsx @@ -0,0 +1,502 @@ +import { For, Show, createMemo, createSignal, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import { Icon } from "@opencode/ui/icon" +import { ScrollView } from "@opencode/ui/scroll-view" +import { Select } from "@opencode/ui/select" +import { useI18n } from "@opencode/ui/context/i18n" +import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2" +import type { SessionInfo } from "@opencode/client/promise" +import type { IconName } from "@opencode/ui/icons/catalog" +import { createKeyed, usePlugin, type MountedSession } from "@opencode/plugin/gui" +import type definition from "./index" +import { + childHref, + childrenCost, + childrenOf, + childStatus, + filterAgents, + formatCost, + formatElapsed, + formatRelative, + formatTokens, + latestActivity, + lastResponse, + sortAgents, + stripSubagentSuffix, + subagentDescriptions, + subagentTasks, + tokenTotal, + treeCost, + type AgentSort, + type DerivedStatus, +} from "./status" + +type SectionTab = "all" | "running" | "history" + +const sortOptions: AgentSort[] = ["recent", "cost", "tokens", "name"] + +/** MountedSession keys join server key and session id with a newline. */ +function sessionServerKey(session: MountedSession): string { + return session.key.split("\n")[0] ?? "" +} + +function statusLabel(status: DerivedStatus, t: (key: string) => string): string { + if (status === "busy") return t("status.busy") + + if (status === "cancelled") return t("status.cancelled") + + if (status === "completed") return t("status.completed") + + if (status === "error") return t("status.error") + + return t("status.idle") +} + +function sortLabel(sort: AgentSort, t: (key: string) => string): string { + if (sort === "cost") return t("sort.cost") + + if (sort === "tokens") return t("sort.tokens") + + if (sort === "name") return t("sort.name") + + return t("sort.recent") +} + +function AgentStatusIcon(props: { status: DerivedStatus }) { + return ( + }> + + + ) +} + +function RunningPill(props: { label: string }) { + return ( + + + {props.label} + + ) +} + +function toolIcon(tool: string): IconName { + if (tool === "edit" || tool === "write") return "edit" + + if (tool === "read") return "filetree" + + if (tool === "grep" || tool === "websearch") return "magnifying-glass" + + if (tool === "shell" || tool === "task" || tool === "subagent") return "console" + + if (tool === "webfetch") return "globe" + + return "filetree" +} + +export default function SessionAgentsPanel(props: { session: MountedSession }) { + const ctx = usePlugin() + const i18n = useI18n() + const data = () => props.session.server.data + const serverKey = sessionServerKey(props.session) + + // One-shot family plus transcript load per routed session; the live event stream owns freshness after that, + // so the tab needs no polling and no per-child requests. + createKeyed( + () => (props.session.server.connected ? props.session.id : undefined), + (id) => { + const store = props.session.server.data + void store.session.sync(id, { children: true }).catch(() => undefined) + void store.session.message.sync(id).catch(() => undefined) + }, + ) + + const [query, setQuery] = createSignal("") + const [sort, setSort] = createSignal("recent") + const [ascending, setAscending] = createSignal(false) + const [tab, setTab] = createSignal("all") + const [expanded, setExpanded] = createStore>({}) + const [loaded, setLoaded] = createStore>({}) + const [now, setNow] = createSignal(Date.now()) + const timer = setInterval(() => setNow(Date.now()), 1000) + onCleanup(() => clearInterval(timer)) + + const children = createMemo(() => childrenOf(data().session.list(), props.session.id)) + const info = createMemo(() => data().session.get(props.session.id)) + const tasks = createMemo(() => subagentTasks(data().session.message.list(props.session.id))) + const descriptions = createMemo(() => subagentDescriptions(data().session.message.list(props.session.id))) + + const titleOf = (child: SessionInfo): string => { + const description = descriptions().get(child.id) + + if (description) return description + + if (child.title) return stripSubagentSuffix(child.title) + + return child.id + } + + const titles = createMemo(() => new Map(children().map((child) => [child.id, titleOf(child)] as const))) + const nameOf = (id: string): string => titles().get(id) ?? id + + const statusOf = (id: string): DerivedStatus => { + const store = data() + + return childStatus({ + part: tasks().parts.get(id), + running: store.session.status(id) === "running", + outcome: store.session.get(id)?.outcome, + }) + } + + // Foreground calls with no child session yet: still running, nothing to drill into. + const unclaimed = createMemo(() => { + const known = new Set(children().map((child) => child.id)) + + return tasks().blocking.filter((item) => !item.childID || !known.has(item.childID)) + }) + + const totalCost = createMemo(() => treeCost(info()?.cost, children())) + const subCost = createMemo(() => childrenCost(children())) + const ownCost = createMemo(() => info()?.cost ?? 0) + + const filtered = createMemo(() => filterAgents(children(), query(), nameOf)) + const sorted = createMemo(() => sortAgents(filtered(), { sort: sort(), ascending: ascending(), name: nameOf })) + + const busyIDs = createMemo( + () => + new Set( + sorted() + .filter((child) => statusOf(child.id) === "busy") + .map((child) => child.id), + ), + ) + + const running = createMemo(() => sorted().filter((child) => busyIDs().has(child.id))) + const history = createMemo(() => sorted().filter((child) => !busyIDs().has(child.id))) + + const runningCount = createMemo(() => running().length + unclaimed().length) + + // Message detail for the few running children; history rows sync on expand instead. + // Already-synced children stay out: membership churn must not refetch the survivors. + const [synced, setSynced] = createSignal>(new Set()) + + const runningKey = createMemo(() => { + const ids = children() + .filter((child) => statusOf(child.id) === "busy") + .map((child) => child.id) + .sort() + + return ids.length > 0 ? ids.join(" ") : undefined + }) + + createKeyed(runningKey, (key) => { + const store = props.session.server.data + const fresh = key.split(" ").filter((id) => !synced().has(id)) + + if (fresh.length === 0) return + + setSynced((prev) => new Set([...prev, ...fresh])) + void Promise.all(fresh.map((id) => store.session.message.sync(id))).catch(() => undefined) + }) + + const toggle = (child: SessionInfo, defaultOpen: boolean) => { + const open = expanded[child.id] ?? defaultOpen + setExpanded(child.id, !open) + + if (!open && !loaded[child.id]) { + void data() + .session.message.sync(child.id) + .then(() => setLoaded(child.id, true)) + .catch(() => undefined) + } + } + + const searching = createMemo(() => query().trim().length > 0) + const visibleRunning = createMemo(() => (tab() === "history" ? [] : running())) + const visibleUnclaimed = createMemo(() => (tab() === "history" ? [] : unclaimed())) + const visibleHistory = createMemo(() => (tab() === "running" ? [] : history())) + + const emptyText = () => { + if (searching() && visibleRunning().length + visibleUnclaimed().length + visibleHistory().length === 0) + return ctx.t("emptySearch") + + if (tab() === "running") return ctx.t("emptyRunning") + + if (tab() === "history") return ctx.t("emptyHistory") + + return ctx.t("empty") + } + + const showSections = () => + visibleRunning().length + visibleUnclaimed().length + visibleHistory().length > 0 || tab() !== "all" || searching() + + return ( + +
+
+
+ {ctx.t("tab.title")} + + {children().length.toLocaleString(i18n.locale())} + +
+
+ + {runningCount().toLocaleString(i18n.locale())} {ctx.t("status.busy")} + + + {ctx.t("total")} {formatCost(totalCost(), i18n.locale())} + + + {ctx.t("subagents")} {formatCost(subCost(), i18n.locale())} · {ctx.t("session")}{" "} + {formatCost(ownCost(), i18n.locale())} + +
+
+ +
+
+ + + + setQuery(event.currentTarget.value)} + placeholder={ctx.t("search.placeholder")} + aria-label={ctx.t("search.placeholder")} + class="w-full rounded-md border border-[var(--v2-border-border-base)] bg-transparent py-1.5 pl-8 pr-2 text-12-regular text-text-strong placeholder:text-text-faint focus:outline-none" + /> +
+