finishWorkPanelExit(workPanelExitGeneration.current)
}
- subagentPanel={subagentPanelOpen ? subagentPanel : null}
- onCloseSubagentPanel={closeSubagentPanel}
containerWidth={shellWidth}
sidebarWidth={sidebarWidth}
sidebarCollapsed={sidebarCollapsed}
diff --git a/apps/desktop/src/features/app/useAppShellRuntime.tsx b/apps/desktop/src/features/app/useAppShellRuntime.tsx
index 54ccb5f3d4..08d0f08695 100644
--- a/apps/desktop/src/features/app/useAppShellRuntime.tsx
+++ b/apps/desktop/src/features/app/useAppShellRuntime.tsx
@@ -54,20 +54,13 @@ export function useAppShellRuntime() {
const handlePlansChanged = useAppStore((s) => s.handlePlansChanged);
const abort = useAppStore((s) => s.abort);
const settings = useAppStore((s) => s.settings);
- const subagentPanel = useAppStore((s) => s.subagentPanel);
- const closeSubagentPanel = useAppStore((s) => s.closeSubagentPanel);
const workPanelOpen = useAppStore((s) => s.workPanelOpen);
const workPanelWidth = useAppStore((s) => s.workPanelWidth);
- const subagentPanelOpen = Boolean(
- page === "chat" &&
- subagentPanel &&
- subagentPanel.sessionId === activeSessionId,
- );
const pluginThemes = useAppStore((s) => s.pluginThemes);
const refreshPluginThemes = useAppStore((s) => s.refreshPluginThemes);
const plugins = useAppStore((s) => s.plugins);
const projectPath = useAppStore((s) => s.workspace?.path ?? null);
- const workPanelVisible = workPanelOpen || subagentPanelOpen;
+ const workPanelVisible = workPanelOpen;
const [searchOpen, setSearchOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
@@ -213,15 +206,6 @@ export function useAppShellRuntime() {
presentedWorkPanelRef.current = presentedWorkPanelOpen;
}, [presentedWorkPanelOpen]);
- useEffect(() => {
- if (
- subagentPanel &&
- (page !== "chat" || subagentPanel.sessionId !== activeSessionId)
- ) {
- closeSubagentPanel();
- }
- }, [activeSessionId, closeSubagentPanel, page, subagentPanel]);
-
// Destination pages own the center pane. Leaving Chat while previewing must
// restore that pane before the destination is presented; otherwise the
// sidebar can change `page` successfully while the route stays unmounted.
@@ -248,11 +232,6 @@ export function useAppShellRuntime() {
store.openWorkPanel();
return;
}
- // Close a visible subagent dock through the same path as Cmd/Ctrl+J.
- if (store.subagentPanel) {
- store.toggleWorkPanel();
- return;
- }
// Prefer the visible presentation over a briefly stale session projection:
// a second click on the same button must always collapse a panel the user
// can currently see instead of routing through openWorkPanel again.
@@ -300,7 +279,7 @@ export function useAppShellRuntime() {
const pageHidesWorkPanel =
page === "settings" || page === "plugins" || page === "scheduled";
const shouldPresent =
- ready && !pageHidesWorkPanel && (workPanelOpen || subagentPanelOpen);
+ ready && !pageHidesWorkPanel && workPanelOpen;
const request = ++workPanelReservationRequest.current;
if (shouldPresent) {
@@ -336,7 +315,7 @@ export function useAppShellRuntime() {
isCurrent: () => request === workPanelReservationRequest.current,
commit: () => setPresentedWorkPanelOpen(shouldPresent),
});
- }, [page, ready, subagentPanelOpen, workPanelOpen]);
+ }, [page, ready, workPanelOpen]);
// Fallback if animationend is skipped (display:none mid-flight, etc.).
useEffect(() => {
@@ -919,9 +898,6 @@ export function useAppShellRuntime() {
ready,
page,
activeSessionId,
- subagentPanel,
- subagentPanelOpen,
- closeSubagentPanel,
workPanelOpen,
searchOpen,
setSearchOpen,
diff --git a/apps/desktop/src/features/chat/transcript/SubagentDetail.tsx b/apps/desktop/src/features/chat/transcript/SubagentDetail.tsx
index 70f298b82d..f003518f15 100644
--- a/apps/desktop/src/features/chat/transcript/SubagentDetail.tsx
+++ b/apps/desktop/src/features/chat/transcript/SubagentDetail.tsx
@@ -1,328 +1,13 @@
-import {
- useEffect,
- useId,
- useLayoutEffect,
- useRef,
- useState,
-} from "react";
+import { useId } from "react";
import { useTranslation } from "react-i18next";
-import type { UiMessage } from "@pi-desktop/shared";
-import { formatToolDuration } from "../../../lib/tool-display";
-import { toolResultPayload } from "../../../lib/tool-presentation";
+import { IconTarget } from "../../../components/icons";
import {
- delegationIsCreating,
- subagentOutcome,
summarizeSubagentActivity,
type DelegationActivityItem,
- type DelegationFailure,
type SubagentOutcome,
type SubagentTiming,
} from "../../../lib/subagent-topology";
-import type { SubagentRun } from "../../../lib/assistant-turns";
-import { useAppStore } from "../../../stores/app-store";
-import {
- IconBot,
- IconCheck,
- IconChevronDown,
- IconChevronRight,
- IconCircleAlert,
- IconStop,
- IconTarget,
-} from "../../../components/icons";
-import { useDisclosureAnchorNotifier } from "../../../lib/disclosure-anchor-context";
-import { CopyButton } from "./shared";
-import {
- delegateAgentName,
- delegateModelId,
- delegateThinkingLevel,
-} from "./model";
-import {
- SubagentRunRows,
- ToolRow,
-} from "./ToolRow";
-
-function delegateTaskDescription(message: UiMessage): string {
- const args = message.toolArgs;
- if (!args || typeof args !== "object" || Array.isArray(args)) return "";
- const task = (args as { task?: unknown }).task;
- return typeof task === "string" ? task.trim() : "";
-}
-
-/**
- * Why a settled delegate failed, at the foot of its detail panel (issue #161).
- *
- * The step stream ends on `Failed` / `Timed out` / `Aborted` without saying
- * why: a delegate that dies before emitting a message row has no other carrier
- * for its reason, and the badge plus a duration is all a reader gets. The
- * runtime already reports `error: { code, message }` on the delegation roster
- * entry, so it is rendered here with the same visual language as the parent
- * reply's error card instead of being reachable only by reading the raw tool
- * result.
- */
-function SubagentFailureCard({
- outcome,
- failure,
-}: {
- outcome: SubagentOutcome;
- failure: DelegationFailure;
-}) {
- const { t } = useTranslation();
- const [open, setOpen] = useState(true);
- const detailsId = useId();
- const headingId = useId();
- // A known runtime code already has a localized sentence; otherwise the
- // outcome's own label is the summary, which stays truthful and localized.
- const localizedKey = `errors.${failure.code}`;
- const localized = failure.code ? t(localizedKey) : localizedKey;
- const summary =
- failure.code && localized !== localizedKey
- ? localized
- : t(`chat.subagentStatus.${outcome}`);
- // A code-only error carries no detail to disclose, so it stays a one-line
- // card rather than opening onto an empty box.
- const hasMessage = failure.message.length > 0;
-
- return (
-
-
-
-
-
-
- {summary}
- {failure.code ? {failure.code} : null}
-
-
- {/* The toggle stays outside the collapsed region, otherwise hiding
- * the details would take away the control that brings them back. */}
- {hasMessage ? (
- setOpen((value) => !value)}
- >
-
- {open ? t("chat.hideErrorDetails") : t("chat.showErrorDetails")}
-
- ) : null}
-
-
- {hasMessage ? (
-
- ) : null}
-
- );
-}
-
-/**
- * The side-sheet view for a selected delegate. It shows a sticky identity
- * header, the task as an inset grouped card, and the live process timeline.
- * Reports and counters remain omitted from this compact surface.
- */
-export function SubagentDetail({
- message,
- delegate,
- delegationStatuses,
- delegationFailures,
- delegationTimings,
-}: {
- message: UiMessage;
- delegate?: SubagentRun;
- delegationStatuses?: ReadonlyMap
;
- delegationFailures?: ReadonlyMap;
- delegationTimings?: ReadonlyMap;
-}) {
- const { t } = useTranslation();
- const agentName = delegateAgentName(message, delegate);
- const modelId = delegateModelId(message);
- const thinkingLevel = delegateThinkingLevel(message);
- const thinkingLabel = thinkingLevel ?? "";
- const modelLabel = [modelId, thinkingLabel].filter(Boolean).join(" ");
- const outcome = subagentOutcome(message, delegationStatuses);
- // A bare `running` Task row (no delegation handle yet) is still being
- // created: the delegate runtime is spawning. Name that phase explicitly
- // instead of a generic running state, and keep the badge class distinct.
- const creating = outcome === "running" && delegationIsCreating(message);
- const statusKey = creating
- ? "chat.subagentCreating"
- : `chat.subagentStatus.${outcome}`;
- const payload = toolResultPayload(message);
- const payloadRecord =
- payload && typeof payload === "object" && !Array.isArray(payload)
- ? (payload as { delegationId?: unknown; startedAt?: unknown; completedAt?: unknown })
- : undefined;
- const delegationId =
- typeof payloadRecord?.delegationId === "string"
- ? payloadRecord.delegationId
- : message.toolCallId || message.id;
- const timing = delegationTimings?.get(delegationId);
- const failure = delegationFailures?.get(delegationId);
- const startedAt =
- timing?.startedAt ??
- (typeof payloadRecord?.startedAt === "number"
- ? payloadRecord.startedAt
- : creating && message.createdAt
- ? Date.parse(message.createdAt) || undefined
- : undefined);
- const completedAt =
- timing?.completedAt ??
- (typeof payloadRecord?.completedAt === "number" ? payloadRecord.completedAt : undefined);
- const [now, setNow] = useState(Date.now);
- const durationMs =
- startedAt !== undefined
- ? Math.max(0, (completedAt ?? (outcome === "running" ? now : startedAt)) - startedAt)
- : message.toolDurationMs;
- const duration =
- typeof durationMs === "number" && durationMs > 0
- ? formatToolDuration(durationMs / 1000)
- : "";
- const statusClass = creating
- ? "outcome-creating"
- : outcome.replaceAll("_", "-");
- const taskDescription = delegateTaskDescription(message);
- const taskBodyId = useId();
- const taskLabelId = useId();
- const taskBodyRef = useRef(null);
- const [taskExpanded, setTaskExpanded] = useState(false);
- const taskToggleRef = useRef(null);
- const notifyDisclosureAnchor = useDisclosureAnchorNotifier();
- const [taskOverflow, setTaskOverflow] = useState(false);
-
- useLayoutEffect(() => {
- setTaskExpanded(false);
- }, [taskDescription]);
-
- useLayoutEffect(() => {
- const element = taskBodyRef.current;
- if (!element) return;
- const measure = () => {
- const overflowing = element.scrollHeight > element.clientHeight + 1;
- setTaskOverflow((current) => (taskExpanded ? current : overflowing));
- };
- measure();
- const observer = new ResizeObserver(measure);
- observer.observe(element);
- return () => observer.disconnect();
- }, [taskDescription, taskExpanded]);
-
- useEffect(() => {
- if (outcome !== "running") return;
- setNow(Date.now());
- const id = window.setInterval(() => setNow(Date.now()), 1000);
- return () => window.clearInterval(id);
- }, [outcome]);
-
- return (
-
-
-
-
- {t("panel.subagentTask")}
-
-
-
- {taskDescription || t("panel.subagentTaskEmpty")}
-
- {taskOverflow ? (
-
{
- // Expanding the brief changes the height of whichever scroller
- // this card sits in (#324), so it holds the same reading
- // position the tool and activity titles do.
- notifyDisclosureAnchor?.(taskToggleRef.current);
- setTaskExpanded((expanded) => !expanded);
- }}
- >
-
- {taskExpanded
- ? t("chat.subagentTaskCollapse")
- : t("chat.subagentTaskExpand")}
-
-
-
- ) : null}
-
-
- {delegate ? (
-
- ) : null}
- {/* A completed delegate has nothing to explain, so the card is tied to a
- * non-success terminal outcome rather than to the error field alone. */}
- {failure && outcome !== "completed" && outcome !== "running" ? (
-
- ) : null}
-
- );
-}
+import { ToolRow } from "./ToolRow";
/**
* A truthful one-level graph of one parent fan-out (ADR 0062).
@@ -379,10 +64,3 @@ export function SubagentTopology({
);
}
-
-/**
- * One item on the activity timeline between two answers: either a thinking
- * segment (an assistant message's reasoning) or a tool call. Grouping both
- * into a single disclosure keeps long agent loops from stacking alternating
- * "Thinking" / "Processed" rows down the transcript.
- */
diff --git a/apps/desktop/src/features/chat/transcript/ToolRow.tsx b/apps/desktop/src/features/chat/transcript/ToolRow.tsx
index 94350b875a..b234973ae7 100644
--- a/apps/desktop/src/features/chat/transcript/ToolRow.tsx
+++ b/apps/desktop/src/features/chat/transcript/ToolRow.tsx
@@ -154,8 +154,8 @@ export const ToolRow = memo(function ToolRow({
const detailsId = useId();
const root = useAppStore((s) => s.workspace?.path);
const openTarget = useOpenPreviewTarget();
- const toggleSubagentPanel = useAppStore((s) => s.toggleSubagentPanel);
- const subagentPanel = useAppStore((s) => s.subagentPanel);
+ const openSubagentTab = useAppStore((s) => s.openSubagentTab);
+ const activeWorkPanelTabId = useAppStore((s) => s.activeWorkPanelTabId);
const status = message.toolStatus;
const action = getToolAction(message.toolName);
// A run row states what the command did, not what the call around it did: an
@@ -289,7 +289,7 @@ export const ToolRow = memo(function ToolRow({
: message.toolCallId || message.id;
const panelOpen =
variant === "topology" &&
- subagentPanel?.delegationId === panelSelectionId;
+ activeWorkPanelTabId === `subagent:${panelSelectionId}`;
const renderedOpen = variant === "topology" ? panelOpen : open;
const inlineOpen = variant !== "topology" && open;
const delegationTiming =
@@ -370,15 +370,14 @@ export const ToolRow = memo(function ToolRow({
{variant === "topology" ? (
{
if (!hasDetails) return;
onUserInteraction?.();
- toggleSubagentPanel(panelSelectionId);
+ openSubagentTab(panelSelectionId, agentName || undefined);
}}
>
diff --git a/apps/desktop/src/lib/assistant-turns.ts b/apps/desktop/src/lib/assistant-turns.ts
index 60cd553740..848566be32 100644
--- a/apps/desktop/src/lib/assistant-turns.ts
+++ b/apps/desktop/src/lib/assistant-turns.ts
@@ -114,13 +114,18 @@ function collectSubagentRuns(
return runs;
}
-// Map each Task call to the last call of its chain (ADR 0279): a resumed
-// delegation is one delegate session continued by a later Task call, so the
-// chain's rows all belong on the latest card, where they read as one
-// continuing conversation rather than a card per call.
-function chainLatestCalls(
- messages: readonly UiMessage[],
-): (toolCallId: string) => string {
+/**
+ * The delegation-chain structure of one transcript (ADR 0279).
+ *
+ * `callByDelegationId` maps each Task result's `delegationId` to the call that
+ * returned it; `childOf` links a resumed call to the call it resumed. Shared
+ * by the delegation card grouping and the subagent transcript tab so both read
+ * the same chain semantics from one place.
+ */
+export function delegationChainMaps(messages: readonly UiMessage[]): {
+ callByDelegationId: Map;
+ childOf: Map;
+} {
const callByDelegationId = new Map();
const childOf = new Map();
for (const message of messages) {
@@ -152,6 +157,17 @@ function chainLatestCalls(
: undefined;
if (prior) childOf.set(prior, toolCallId);
}
+ return { callByDelegationId, childOf };
+}
+
+// Map each Task call to the last call of its chain (ADR 0279): a resumed
+// delegation is one delegate session continued by a later Task call, so the
+// chain's rows all belong on the latest card, where they read as one
+// continuing conversation rather than a card per call.
+function chainLatestCalls(
+ messages: readonly UiMessage[],
+): (toolCallId: string) => string {
+ const { childOf } = delegationChainMaps(messages);
const latest = new Map();
return (toolCallId: string): string => {
const cached = latest.get(toolCallId);
diff --git a/apps/desktop/src/lib/subagent-panel.ts b/apps/desktop/src/lib/subagent-panel.ts
index bf35b1e9b2..0d7cd01f08 100644
--- a/apps/desktop/src/lib/subagent-panel.ts
+++ b/apps/desktop/src/lib/subagent-panel.ts
@@ -1,15 +1,11 @@
import type { UiMessage } from "@pi-desktop/shared";
import { toolResultPayload } from "./tool-presentation";
-/** The renderer-local subagent detail currently shown in the work-panel dock. */
-export type SubagentPanelSelection = {
- sessionId: string;
- /** Stable delegation id from the Task result, used to re-find live rows. */
- delegationId: string;
- /** Connect an explicit search to the shared transcript reading view. */
- searchRequestId?: number;
-};
-
+/**
+ * The stable delegation id a transcript row is keyed by: the Task result's
+ * `delegationId` when the runtime recorded one, else the call's own identity.
+ * Shared by the delegation topology and the subagent transcript tab.
+ */
export function delegationIdForMessage(message: UiMessage): string {
const payload = toolResultPayload(message);
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
diff --git a/apps/desktop/src/lib/subagent-transcript.ts b/apps/desktop/src/lib/subagent-transcript.ts
new file mode 100644
index 0000000000..ca08778486
--- /dev/null
+++ b/apps/desktop/src/lib/subagent-transcript.ts
@@ -0,0 +1,170 @@
+import type { UiMessage } from "@pi-desktop/shared";
+import {
+ delegationChainMaps,
+ messageThinking,
+ type SubagentRunItem,
+} from "./assistant-turns";
+import { isDelegationStartTool } from "./tool-display";
+import { delegationIdForMessage } from "./subagent-panel";
+
+/** One user/assistant exchange of a delegate session. */
+export type SubagentTranscriptTurn = { task: string; rows: SubagentRunItem[] };
+
+/**
+ * A delegate's conversation as alternating turns: each Task call of the
+ * delegation chain opens a turn with its `task` argument, followed by the
+ * rows the delegate produced under that call.
+ */
+export type SubagentTranscript = {
+ agentName?: string;
+ turns: SubagentTranscriptTurn[];
+};
+
+function delegateTaskDescription(call: UiMessage): string {
+ const args = call.toolArgs;
+ if (!args || typeof args !== "object" || Array.isArray(args)) return "";
+ const task = (args as { task?: unknown }).task;
+ return typeof task === "string" ? task.trim() : "";
+}
+
+function requestedAgentName(call: UiMessage): string {
+ const args = call.toolArgs;
+ if (!args || typeof args !== "object" || Array.isArray(args)) return "";
+ const agent = (args as { agent?: unknown }).agent;
+ return typeof agent === "string" ? agent : "";
+}
+
+/** Task calls whose `resume` argument resolves to one of `priorCallIds`. */
+function resumedCalls(
+ messages: readonly UiMessage[],
+ priorCallIds: ReadonlySet,
+ callByDelegationId: ReadonlyMap,
+): Set {
+ const next = new Set();
+ for (const message of messages) {
+ if (message.role !== "tool" || !isDelegationStartTool(message.toolName)) {
+ continue;
+ }
+ const args = message.toolArgs;
+ const resume =
+ args && typeof args === "object" && !Array.isArray(args)
+ ? (args as { resume?: unknown }).resume
+ : undefined;
+ if (typeof resume !== "string" || !resume.trim()) continue;
+ // The runtime records `resume` as a prior delegation id; a bare call id
+ // is accepted too, so the chain stays legible before any result lands.
+ const target = callByDelegationId.get(resume.trim()) ?? resume.trim();
+ if (priorCallIds.has(target) && message.toolCallId) next.add(message.toolCallId);
+ }
+ return next;
+}
+
+/**
+ * Build the delegate's transcript from the session messages.
+ *
+ * A Task call belongs to the delegation of `delegationId` when the call
+ * identifies itself by that id — via its result payload, or via its own call
+ * id when the runtime recorded none — or when it resumed another call of that
+ * chain. Turns appear in session order; each turn's rows are the messages
+ * whose `parentToolCallId` names that call, mapped exactly like the
+ * delegation card's run grouping. Returns null when no Task call matches, so
+ * the tab can show its unavailable state instead of an empty conversation.
+ */
+export function buildSubagentTranscript(
+ messages: readonly UiMessage[],
+ delegationId: string,
+): SubagentTranscript | null {
+ const target = delegationId.trim();
+ const { childOf, callByDelegationId } = delegationChainMaps(messages);
+ // A resumed call may cite the prior delegation id (the runtime's shape) or
+ // a bare prior call id. `childOf` covers the delegation-id form; the bare
+ // form is resolved here so the chain still closes before results land.
+ const resumeLink = new Map(childOf);
+ for (const message of messages) {
+ if (message.role !== "tool" || !isDelegationStartTool(message.toolName)) {
+ continue;
+ }
+ const args = message.toolArgs;
+ const resume =
+ args && typeof args === "object" && !Array.isArray(args)
+ ? (args as { resume?: unknown }).resume
+ : undefined;
+ if (typeof resume !== "string" || !resume.trim()) continue;
+ const priorId = callByDelegationId.get(resume.trim()) ?? resume.trim();
+ if (message.toolCallId && !resumeLink.has(priorId)) {
+ resumeLink.set(priorId, message.toolCallId);
+ }
+ }
+ const calls = messages.filter(
+ (message) =>
+ message.role === "tool" &&
+ isDelegationStartTool(message.toolName) &&
+ Boolean(message.toolCallId),
+ );
+ // The chain is the connected component of resumed calls around the call
+ // this delegation id names; forward and backward walks share that set.
+ let chain = new Set(
+ calls
+ .filter((call) => delegationIdForMessage(call) === target)
+ .map((call) => call.toolCallId as string),
+ );
+ if (chain.size === 0) return null;
+ for (;;) {
+ const forward = resumedCalls(messages, chain, callByDelegationId);
+ const backward = new Set(
+ [...chain].flatMap((callId) => {
+ const prior = [...resumeLink.entries()]
+ .filter(([, next]) => next === callId)
+ .map(([priorId]) => priorId);
+ return prior;
+ }),
+ );
+ const grown = new Set([
+ ...chain,
+ ...[...forward, ...backward].filter((id) => !chain.has(id)),
+ ]);
+ if (grown.size === chain.size) break;
+ chain = grown;
+ }
+
+ // Attribution prefers what the delegate's own rows carried; a chain whose
+ // delegate has not produced a row yet falls back to the requested agent.
+ const runAgentName = messages.find(
+ (message) =>
+ message.parentToolCallId &&
+ chain.has(message.parentToolCallId) &&
+ message.agentName,
+ )?.agentName;
+ const requestedName = calls
+ .filter((call) => chain.has(call.toolCallId as string))
+ .map(requestedAgentName)
+ .find(Boolean);
+ const agentName = runAgentName || requestedName;
+
+ const turns: SubagentTranscriptTurn[] = [];
+ for (const call of calls) {
+ const callId = call.toolCallId as string;
+ if (!chain.has(callId)) continue;
+ const rows: SubagentRunItem[] = [];
+ for (const message of messages) {
+ if (message.parentToolCallId !== callId) continue;
+ if (message.role === "tool") {
+ rows.push({ kind: "tool", message });
+ continue;
+ }
+ // A delegate turn can carry reasoning and text at once, and both are
+ // worth showing: the text is the only place its narration and report
+ // exist.
+ if (messageThinking(message)) rows.push({ kind: "thinking", message });
+ if ((message.content || "").trim() || message.error) {
+ rows.push({ kind: "answer", message });
+ }
+ }
+ turns.push({ task: delegateTaskDescription(call), rows });
+ }
+
+ return {
+ ...(agentName ? { agentName } : {}),
+ turns,
+ };
+}
diff --git a/apps/desktop/src/lib/work-panel-tabs.ts b/apps/desktop/src/lib/work-panel-tabs.ts
index 5ae9c299db..b9a2f6c142 100644
--- a/apps/desktop/src/lib/work-panel-tabs.ts
+++ b/apps/desktop/src/lib/work-panel-tabs.ts
@@ -2,10 +2,13 @@ export type WorkPanelTabKind =
| "new"
| "review"
| "file"
- | "plugin";
+ | "plugin"
+ | "subagent";
export type WorkPanelTab = {
id: string;
+ /** Display name captured at open time, used by labels that have no resource. */
+ label?: string;
kind: WorkPanelTabKind;
resource?: string;
/** Guest URL or workspace path for the Browser plugin view (D333). */
@@ -61,7 +64,7 @@ export function switchWorkPanelContextState(
}
export function toolWorkPanelTab(
- kind: Exclude,
+ kind: Exclude,
): WorkPanelTab {
return { id: kind, kind };
}
@@ -92,6 +95,26 @@ export function pluginWorkPanelTab(pluginId: string, viewId: string): WorkPanelT
return { id: `plugin:${resource}`, kind: "plugin", resource };
}
+/**
+ * A subagent transcript tab (ADR 0062 delegations).
+ *
+ * Keyed by the delegation id, so re-opening the same delegate reuses its tab
+ * and parallel delegates coexist as independent tabs. The agent name is
+ * captured at open time so the strip can label the tab even before the
+ * delegate produced any row.
+ */
+export function subagentWorkPanelTab(
+ delegationId: string,
+ agentName?: string,
+): WorkPanelTab {
+ return {
+ id: `subagent:${delegationId}`,
+ kind: "subagent",
+ resource: delegationId,
+ ...(agentName ? { label: agentName } : {}),
+ };
+}
+
export const BROWSER_PLUGIN_TAB = {
pluginId: "pi.browser",
viewId: "browser",
@@ -181,7 +204,8 @@ export function isKnownWorkPanelTab(tab: WorkPanelTab): boolean {
return (
Boolean(tab) &&
(tab.kind === "new" || tab.kind === "review" ||
- tab.kind === "file" || tab.kind === "plugin")
+ tab.kind === "file" || tab.kind === "plugin" ||
+ tab.kind === "subagent")
);
}
@@ -335,3 +359,27 @@ export function closeWorkPanelTabState(
activeTabId: tabs[Math.min(index, tabs.length - 1)]?.id ?? null,
};
}
+
+/**
+ * Display labels for the subagent tabs in strip order.
+ *
+ * Delegates with the same agent name would otherwise render identical tab
+ * labels, so a repeated base label gains a 1-based `#n` suffix within its
+ * label group; a label that occurs once stays unnumbered. Other tab kinds are
+ * unique by construction and are not passed here.
+ */
+export function subagentTabDisplayLabels(
+ baseLabels: readonly string[],
+): string[] {
+ const counts = new Map();
+ for (const label of baseLabels) {
+ counts.set(label, (counts.get(label) ?? 0) + 1);
+ }
+ const seen = new Map();
+ return baseLabels.map((label) => {
+ if ((counts.get(label) ?? 0) <= 1) return label;
+ const index = (seen.get(label) ?? 0) + 1;
+ seen.set(label, index);
+ return `${label}#${index}`;
+ });
+}
diff --git a/apps/desktop/src/stores/app-state.ts b/apps/desktop/src/stores/app-state.ts
index aeecff1570..33807d7a90 100644
--- a/apps/desktop/src/stores/app-state.ts
+++ b/apps/desktop/src/stores/app-state.ts
@@ -40,7 +40,6 @@ import type {
QueuedPromptDirection,
QueuedPrompts,
} from "../lib/queued-prompts";
-import type { SubagentPanelSelection } from "../lib/subagent-panel";
import type {
ComposerDraftSnapshot,
ComposerPrefill,
@@ -350,8 +349,6 @@ export type AppState = {
dismissToast: (id: number) => void;
composerPrefill: ComposerPrefill | null;
clearComposerPrefill: () => void;
- /** Renderer-only subagent details selected from the transcript. */
- subagentPanel: SubagentPanelSelection | null;
workPanelOpen: boolean;
workPanelTabs: WorkPanelTab[];
activeWorkPanelTabId: string | null;
@@ -360,9 +357,8 @@ export type AppState = {
workPanelWidth: number;
/** Chat-initiated "preview this file" request consumed by the files viewer. */
workPanelFileRequest: { path: string; seq: number; mimeType?: string } | null;
- /** Toggle the selected subagent detail. */
- toggleSubagentPanel: (delegationId: string) => void;
- closeSubagentPanel: () => void;
+ /** Open (or activate) the transcript tab of one delegated subagent. */
+ openSubagentTab: (delegationId: string, agentName?: string) => void;
/** Abort one session's running turn, visible or not. */
abortSession: (sessionId: string) => Promise;
openWorkPanel: () => void;
diff --git a/apps/desktop/src/stores/app-store.ts b/apps/desktop/src/stores/app-store.ts
index ee8efa7f6e..d27cbe8cfd 100644
--- a/apps/desktop/src/stores/app-store.ts
+++ b/apps/desktop/src/stores/app-store.ts
@@ -145,7 +145,6 @@ import {
} from "../lib/queued-prompts";
import type { AgentQueueChangedEvent, QueuedTurnSummary } from "@pi-desktop/shared";
import { settleBootstrapRequests } from "../lib/bootstrap-result";
-import type { SubagentPanelSelection } from "../lib/subagent-panel";
import {
createSessionRuntime,
type SessionRuntime,
diff --git a/apps/desktop/src/stores/runtime/transcript-reading-runtime.ts b/apps/desktop/src/stores/runtime/transcript-reading-runtime.ts
index d3452a15ca..ad8d6d80de 100644
--- a/apps/desktop/src/stores/runtime/transcript-reading-runtime.ts
+++ b/apps/desktop/src/stores/runtime/transcript-reading-runtime.ts
@@ -1,6 +1,5 @@
import type { SessionDetail } from "@pi-desktop/shared";
import type { SessionHistoryReadOptions } from "../../lib/api";
-import { delegationIdForMessage } from "../../lib/subagent-panel";
import {
EMPTY_TRANSCRIPT,
extendTranscriptView,
@@ -53,12 +52,7 @@ export function createTranscriptReadingRuntime({ get, set }: StoreAccess, read:
set((state) => {
if (!state.transcriptViews[sessionId]) return {};
const { [sessionId]: _released, ...transcriptViews } = state.transcriptViews;
- return {
- transcriptViews,
- ...(state.subagentPanel?.sessionId === sessionId && state.subagentPanel.searchRequestId
- ? { subagentPanel: null }
- : {}),
- };
+ return { transcriptViews };
});
}
@@ -93,23 +87,11 @@ export function createTranscriptReadingRuntime({ get, set }: StoreAccess, read:
if (!session || !message) throw new Error("Message no longer exists in this conversation.");
if (message.parentToolCallId && !session.navigationParent)
throw new Error("The task for this message no longer exists in this conversation.");
- const parent = session.navigationParent;
set((state) => ({
transcriptViews: {
...state.transcriptViews,
[sessionId]: transcriptViewFromSession(session, focus),
},
- ...(parent
- ? {
- subagentPanel: {
- sessionId,
- delegationId: delegationIdForMessage(parent),
- searchRequestId: focus.requestId,
- },
- }
- : state.subagentPanel?.searchRequestId
- ? { subagentPanel: null }
- : {}),
}));
} catch (error) {
if (get().transcriptViews[sessionId] !== pending) return;
diff --git a/apps/desktop/src/stores/slices/initial-state.ts b/apps/desktop/src/stores/slices/initial-state.ts
index 742bb6b2f0..95518c86fc 100644
--- a/apps/desktop/src/stores/slices/initial-state.ts
+++ b/apps/desktop/src/stores/slices/initial-state.ts
@@ -41,7 +41,6 @@ export function createInitialState(): AppStateData {
.filter(([, meta]) => meta.collapsed === true)
.map(([path]) => [path, true]),
),
- subagentPanel: null,
workPanelOpen: false,
workPanelTabs: [],
activeWorkPanelTabId: null,
diff --git a/apps/desktop/src/stores/slices/work-panel-slice.ts b/apps/desktop/src/stores/slices/work-panel-slice.ts
index 2b3b607b92..1e2df303a5 100644
--- a/apps/desktop/src/stores/slices/work-panel-slice.ts
+++ b/apps/desktop/src/stores/slices/work-panel-slice.ts
@@ -10,6 +10,7 @@ import {
reorderWorkPanelTabsState,
replaceWorkPanelTabState,
sanitizeWorkPanelTabsState,
+ subagentWorkPanelTab,
switchWorkPanelContextState,
type WorkPanelContext,
type WorkPanelTab,
@@ -97,8 +98,7 @@ export function createWorkPanelSlice({
isSessionSelectionPending,
}: WorkPanelSliceDependencies): Pick<
AppState,
- "toggleSubagentPanel"
- | "closeSubagentPanel"
+ "openSubagentTab"
| "openWorkPanel"
| "toggleWorkPanel"
| "openWorkPanelTab"
@@ -117,21 +117,11 @@ export function createWorkPanelSlice({
let workPanelFileRequestSeq = 0;
return {
- toggleSubagentPanel: (delegationId) => {
- const state = get();
- const sessionId = state.activeSessionId;
+ openSubagentTab: (delegationId, agentName) => {
const id = delegationId.trim();
- if (!sessionId || !id) return;
- if (
- state.subagentPanel?.sessionId === sessionId &&
- state.subagentPanel.delegationId === id
- ) {
- set({ subagentPanel: null });
- return;
- }
- set({ subagentPanel: { sessionId, delegationId: id } });
+ if (!id) return;
+ get().openWorkPanelTab(subagentWorkPanelTab(id, agentName || undefined));
},
- closeSubagentPanel: () => set({ subagentPanel: null }),
openWorkPanel: () => {
const state = get();
@@ -149,11 +139,6 @@ export function createWorkPanelSlice({
toggleWorkPanel: () => {
const state = get();
- if (state.subagentPanel) {
- state.closeSubagentPanel();
- if (get().workPanelOpen) get().collapseWorkPanel();
- return;
- }
if (state.workPanelOpen) {
state.collapseWorkPanel();
return;
diff --git a/apps/desktop/src/styles/work-panel.css b/apps/desktop/src/styles/work-panel.css
index 085268cd9a..5e145e17e5 100644
--- a/apps/desktop/src/styles/work-panel.css
+++ b/apps/desktop/src/styles/work-panel.css
@@ -318,8 +318,8 @@
/* One button group with the viewport-fixed collapse toggle: the same control
gap the header reserves for it, and no divider, inset, or margin of its own.
The rule owns spacing only — `+` and maximize take the shared chrome-control
- geometry and transparent seat from chrome.css, beside the subagent back
- control's own matching rule, so the group reads as quiet icon controls. */
+ geometry and transparent seat from chrome.css, so the group reads as quiet
+ icon controls. */
.work-panel-actions {
display: flex;
flex: 0 0 auto;
@@ -327,24 +327,6 @@
gap: var(--ds-work-panel-control-gap);
}
-.work-panel-subagent-back {
- display: inline-flex;
- width: 28px;
- height: 28px;
- flex: 0 0 auto;
- align-items: center;
- justify-content: center;
- border-radius: var(--radius-md);
- color: var(--ds-text-secondary);
- transition:
- background var(--motion-duration-fast) var(--motion-ease-out),
- color var(--motion-duration-fast) var(--motion-ease-out);
-}
-
-.work-panel-subagent-back:hover {
- background: var(--ds-bg-hover);
- color: var(--ds-text-primary);
-}
/* Preview mode: the panel takes MainChat's width as well, so the divider has no
second column to trade width with and the dock fills its flex row. */
@@ -358,23 +340,6 @@
cursor: default;
}
-.work-panel-subagent-heading {
- display: inline-flex;
- min-width: 0;
- align-items: center;
- gap: 7px;
- padding: 0 8px;
- color: var(--ds-text-secondary);
-}
-
-.work-panel-subagent-heading span {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-size: var(--text-md);
- font-weight: var(--font-weight-medium);
-}
.work-panel-body {
position: relative;
@@ -395,34 +360,44 @@
display: none;
}
-/* ---------- Subagent detail dock --------------------------------------- */
+/* ---------- Subagent transcript tab ------------------------------------- */
-.subagent-panel {
- position: relative;
+/*
+ * A delegation renders as a conversation: the tab reuses the main transcript's
+ * message-list classes for its rows, so the only tab-specific layout is the
+ * column (scroll area above a read-only composer) and the composer itself.
+ * The scroll area owns the only scrollbar; the composer never scrolls.
+ */
+.subagent-transcript-tab {
display: flex;
min-width: 0;
min-height: 0;
flex: 1;
flex-direction: column;
- background: transparent;
}
-.subagent-panel-scroll {
+.subagent-transcript-scroll {
min-width: 0;
min-height: 0;
flex: 1;
overflow-x: hidden;
overflow-y: auto;
padding: 0 0 32px;
- background: transparent;
}
-.subagent-panel-jump {
- bottom: 12px;
- z-index: 6;
+.subagent-transcript-list {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
}
-.subagent-panel-empty {
+/* A tab pane is narrower than the chat column, so the user plate's percentage
+ cap needs a definite floor of its own; the bubble still shrink-wraps. */
+.subagent-transcript-list .message-row.user .message-col {
+ max-width: min(100%, 520px);
+}
+
+.subagent-transcript-empty {
display: grid;
min-height: 160px;
place-items: center;
@@ -432,372 +407,23 @@
text-align: center;
}
-.subagent-panel-switcher:hover {
- background: transparent;
-}
-
-.subagent-detail {
- display: flex;
- min-width: 0;
- flex-direction: column;
- gap: 16px;
-}
-
-/* Sticky identity so the sheet keeps the agent in view while the process scrolls. */
-.subagent-detail-hero {
- position: sticky;
- top: 0;
- z-index: 2;
- display: flex;
- flex-direction: row;
- align-items: center;
- gap: 10px;
- padding: 16px 16px 12px;
- background: var(--ds-bg-dock);
-}
-
-.subagent-detail-heading {
- display: flex;
- min-width: 0;
- flex: 1;
- align-items: center;
- gap: 12px;
-}
-
-.subagent-detail-avatar {
- position: relative;
- display: inline-flex;
- width: 36px;
- height: 36px;
- flex: 0 0 auto;
- align-items: center;
- justify-content: center;
- border-radius: var(--radius-round);
- background: var(--ds-tile-deep);
- color: var(--ds-text-secondary);
-}
-
-.subagent-detail-status {
- position: absolute;
- right: -1px;
- bottom: -1px;
- width: 10px;
- height: 10px;
- border: 2px solid var(--ds-bg-dock);
- border-radius: var(--radius-round);
- background: var(--ds-text-muted);
-}
-
-.subagent-detail-status.outcome-completed {
- background: var(--ds-success);
-}
-
-.subagent-detail-status.outcome-running {
- background: var(--ds-accent);
- animation: subagent-status-pulse 1.6s var(--motion-ease-standard) infinite;
-}
-
-.subagent-detail-status.outcome-failed,
-.subagent-detail-status.outcome-denied {
- background: var(--ds-error);
-}
-
-.subagent-detail-status.outcome-truncated,
-.subagent-detail-status.outcome-timed-out {
- background: var(--ds-warning);
-}
-
-@keyframes subagent-status-pulse {
- 50% {
- opacity: 0.45;
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .subagent-detail-status.outcome-running,
- .subagent-detail-status.outcome-creating {
- animation: none;
- }
-}
-
-.subagent-detail-heading-copy {
- display: flex;
- min-width: 0;
- flex: 1;
- flex-direction: column;
- justify-content: center;
- gap: 2px;
-}
-
-.subagent-detail-name {
- min-width: 0;
- overflow: hidden;
- color: var(--ds-text-primary);
- font-size: var(--text-base);
- font-weight: var(--font-weight-semibold);
- line-height: var(--leading-tight);
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.subagent-detail-model {
- min-width: 0;
- overflow: hidden;
- color: var(--ds-text-muted);
- font-family: var(--font-mono);
- font-size: var(--text-xs);
- line-height: var(--leading-tight);
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.subagent-detail-summary {
- display: flex;
- flex: 0 0 auto;
- flex-wrap: nowrap;
- align-items: center;
- gap: 6px;
- color: var(--ds-text-muted);
-}
-
-.subagent-detail-badge {
- display: inline-flex;
- flex: none;
- align-items: center;
- height: 20px;
- padding: 0 8px;
- border-radius: var(--radius-full);
- background: var(--ds-tile-deep);
- color: var(--ds-text-secondary);
- font-size: var(--text-xs);
- font-weight: var(--font-weight-medium);
- line-height: var(--leading-none);
- white-space: nowrap;
-}
-
-.subagent-detail-badge.outcome-completed {
- background: color-mix(in oklab, var(--ds-success) 16%, transparent);
- color: var(--ds-success);
-}
-
-.subagent-detail-badge.outcome-running {
- background: var(--ds-tile-deep);
- color: var(--ds-text-primary);
-}
-
-.subagent-detail-badge.outcome-creating {
- background: color-mix(in oklab, var(--ds-purple) 16%, transparent);
- color: var(--ds-purple);
-}
-
-.subagent-detail-status.outcome-creating {
- background: var(--ds-purple);
- animation: subagent-status-pulse 1.6s var(--motion-ease-standard) infinite;
-}
-
-.subagent-detail-badge.outcome-failed,
-.subagent-detail-badge.outcome-denied {
- background: color-mix(in oklab, var(--ds-error) 16%, transparent);
- color: var(--ds-error);
-}
-
-.subagent-detail-badge.outcome-truncated,
-.subagent-detail-badge.outcome-timed-out,
-.subagent-detail-badge.outcome-aborted,
-.subagent-detail-badge.outcome-stopped {
- background: color-mix(in oklab, var(--ds-warning) 16%, transparent);
- color: var(--ds-warning);
-}
-
-.subagent-detail-meta {
- flex: none;
- color: var(--ds-text-muted);
- font-size: var(--text-xs);
- font-variant-numeric: tabular-nums;
- line-height: var(--leading-row);
- white-space: nowrap;
-}
-
-.subagent-detail-task,
-.subagent-detail > .subagent-run {
- padding: 0 16px;
-}
-
-/* Issue #161: the failure card closes out a settled delegate, so it lines up
- with the task card and the run rows instead of the panel's own edges. The
- card carries its own padding, so it is inset with margins. */
-.subagent-detail > .subagent-failure {
- width: auto;
- margin: 0 16px;
-}
-
-.subagent-detail-section-label,
-.subagent-detail > .subagent-run .subagent-run-heading.is-dock {
- min-height: 20px;
- margin: 0;
- color: var(--ds-text-secondary);
- font-size: var(--text-sm);
- font-weight: var(--font-weight-semibold);
- letter-spacing: var(--tracking-wide);
- line-height: var(--leading-row);
- text-transform: uppercase;
-}
-
-:lang(zh-CN) .subagent-detail-section-label,
-:lang(zh-CN) .subagent-detail > .subagent-run .subagent-run-heading.is-dock,
-:lang(zh-TW) .subagent-detail-section-label,
-:lang(zh-TW) .subagent-detail > .subagent-run .subagent-run-heading.is-dock,
-:lang(ko) .subagent-detail-section-label,
-:lang(ko) .subagent-detail > .subagent-run .subagent-run-heading.is-dock {
- letter-spacing: var(--tracking-normal);
- text-transform: none;
-}
-
-.subagent-detail-task {
- display: flex;
- min-width: 0;
- flex-direction: column;
- gap: 8px;
-}
-
-.subagent-detail-task-card {
- min-width: 0;
- border-radius: var(--radius-md);
- background: var(--ds-tile);
- padding: 12px 14px;
-}
-
-.subagent-task-message-body {
- color: var(--ds-text-primary);
- font-size: var(--text-md);
- line-height: var(--leading-body);
- white-space: pre-wrap;
- overflow-wrap: anywhere;
-}
-
-.subagent-task-message-body.is-collapsed {
- display: -webkit-box;
- overflow: hidden;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 4;
-}
-
-.subagent-task-toggle {
- display: inline-flex;
- align-items: center;
- gap: 2px;
- margin-top: 8px;
- padding: 0;
- color: var(--ds-text-muted);
- font-size: var(--text-sm);
- line-height: var(--leading-row);
+.subagent-transcript-jump {
+ bottom: 76px;
+ z-index: 6;
}
-.subagent-task-toggle svg {
+.subagent-transcript-composer {
flex: none;
- transition: transform var(--motion-duration-fast) var(--motion-ease-out);
-}
-
-.subagent-task-toggle[aria-expanded="true"] svg {
- transform: rotate(180deg);
-}
-
-.subagent-task-toggle:hover {
- color: var(--ds-text-primary);
-}
-
-.subagent-detail > .subagent-run {
- /* This is a flex child of the detail column. Without an explicit zero
- minimum, a long command/path can win the min-content calculation and push
- the whole side sheet past the work-panel edge. */
- min-width: 0;
- margin: 0;
- border-radius: 0;
- background: transparent;
- padding: 0 16px;
-}
-
-.subagent-detail > .subagent-run .subagent-run-heading.is-dock {
- gap: 8px;
-}
-
-.subagent-detail > .subagent-run .subagent-run-heading.is-dock .subagent-run-count {
- color: var(--ds-text-muted);
- font-family: var(--font-sans);
- font-size: var(--text-xs);
- font-weight: var(--font-weight-medium);
- letter-spacing: var(--tracking-normal);
- text-transform: none;
-}
-
-.subagent-detail > .subagent-run .subagent-run-rows {
- position: relative;
- margin-top: 6px;
- padding: 2px 0 8px 16px;
- border-left: 1px solid var(--ds-border-subtle);
- border-radius: 0;
- background: transparent;
-}
-
-.subagent-detail > .subagent-run .subagent-run-rows.is-panel-flow > div {
- display: grid;
- gap: 4px;
- min-width: 0;
-}
-
-.subagent-detail > .subagent-run .subagent-run-rows.is-panel-flow > div > .tool-row,
-.subagent-detail > .subagent-run .subagent-run-rows.is-panel-flow > div > .subagent-answer {
- position: relative;
- min-width: 0;
-}
-
-/* Dock rows share the main transcript's content model: labels may stay
- compact, but the actual path, command, and summary must use the committed
- panel width instead of becoming a one-line ellipsis. */
-.subagent-detail > .subagent-run .subagent-run-rows.is-panel-flow .tool-row-header {
- align-items: flex-start;
- min-height: 28px;
- padding-block: 4px;
-}
-
-.subagent-detail > .subagent-run .subagent-run-rows.is-panel-flow .tool-row-summary {
- overflow: visible;
- overflow-wrap: anywhere;
- text-overflow: clip;
- white-space: normal;
- word-break: break-word;
+ border-top: 1px solid var(--ds-border-subtle);
+ padding: 10px 14px;
}
-.subagent-detail > .subagent-run .subagent-run-rows.is-panel-flow .tool-row-agent {
- max-width: 100%;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.subagent-detail > .subagent-run .subagent-run-rows.is-panel-flow > div > .subagent-answer {
+.subagent-transcript-composer textarea {
+ display: block;
width: 100%;
- overflow-wrap: anywhere;
- word-break: break-word;
+ min-height: calc(var(--leading-chat) * 2em);
+ resize: none;
}
-
-.subagent-detail > .subagent-run .subagent-run-rows.is-panel-flow > div > .tool-row::before,
-.subagent-detail > .subagent-run .subagent-run-rows.is-panel-flow > div > .subagent-answer::before {
- position: absolute;
- top: 12px;
- left: -20px;
- width: 7px;
- height: 7px;
- border: 2px solid var(--ds-bg-dock);
- border-radius: var(--radius-round);
- background: var(--ds-text-muted);
- content: "";
-}
-
-.subagent-detail > .subagent-run .subagent-run-rows.is-panel-flow > div > .tool-row:last-child::before,
-.subagent-detail > .subagent-run .subagent-run-rows.is-panel-flow > div > .subagent-answer:last-child::before {
- background: var(--ds-accent);
-}
-
/* Shared per-tool empty state used by Files and plugin fallback surfaces. */
.work-tab-empty {
display: flex;
diff --git a/apps/desktop/test/interaction-polish.test.mjs b/apps/desktop/test/interaction-polish.test.mjs
index 038276c082..4377e0d875 100644
--- a/apps/desktop/test/interaction-polish.test.mjs
+++ b/apps/desktop/test/interaction-polish.test.mjs
@@ -13,7 +13,7 @@ test("high-traffic chrome uses shared motion tokens on hover fills", () => {
".search-item",
".footer-action",
".notification-item",
- ".work-panel-subagent-back",
+ ".work-panel-tab-close",
]) {
// Match the selector anywhere in a rule's selector list, and require the
// transition inside that rule's own body — a shared list is as valid as a
diff --git a/apps/desktop/test/subagent-panel.test.mjs b/apps/desktop/test/subagent-panel.test.mjs
index 61f6a6170c..b04164f6b5 100644
--- a/apps/desktop/test/subagent-panel.test.mjs
+++ b/apps/desktop/test/subagent-panel.test.mjs
@@ -3,201 +3,123 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
-const panelSource = await readFile(
- new URL("../src/components/workpanel/SubagentPanel.tsx", import.meta.url),
+const tabSource = await readFile(
+ new URL("../src/components/workpanel/SubagentTranscriptTab.tsx", import.meta.url),
"utf8",
);
const workPanelSource = await readFile(
new URL("../src/components/workpanel/WorkPanel.tsx", import.meta.url),
"utf8",
);
+const tabsSource = await readFile(
+ new URL("../src/lib/work-panel-tabs.ts", import.meta.url),
+ "utf8",
+);
const appSource = await readAppSource();
const transcriptSource = await readTranscriptSource();
-const detailSource = transcriptSource.slice(
- transcriptSource.indexOf("export function SubagentDetail"),
- transcriptSource.indexOf("/**\n * A truthful one-level graph", transcriptSource.indexOf("export function SubagentDetail")),
-);
-const failureCardSource = transcriptSource.slice(
- transcriptSource.indexOf("function SubagentFailureCard("),
- transcriptSource.indexOf("export function SubagentDetail"),
-);
const storeSource = await readStoreSource();
const workPanelCss = await readFile(
new URL("../src/styles/work-panel.css", import.meta.url),
"utf8",
);
-const messagesCss = await readFile(
- new URL("../src/styles/messages.css", import.meta.url),
+const englishCatalogSource = await readFile(
+ new URL("../../../packages/i18n/src/locales/en/index.ts", import.meta.url),
+ "utf8",
+);
+const chineseCatalogSource = await readFile(
+ new URL("../../../packages/i18n/src/locales/zh-CN/index.ts", import.meta.url),
"utf8",
);
-test("a topology node toggles a session-scoped side-panel selection", () => {
- assert.match(transcriptSource, /const toggleSubagentPanel = useAppStore\(\(s\) => s\.toggleSubagentPanel\)/);
+test("a topology node opens a real subagent tab in the work panel", () => {
+ assert.match(transcriptSource, /const openSubagentTab = useAppStore\(\(s\) => s\.openSubagentTab\)/);
assert.match(transcriptSource, /const panelSelectionId =/);
- assert.match(transcriptSource, /toggleSubagentPanel\(panelSelectionId\)/);
- assert.match(transcriptSource, /aria-controls=\{panelOpen \? "subagent-panel" : undefined\}/);
- assert.match(transcriptSource, /variant !== "topology" && open/);
- assert.match(transcriptSource, /variant !== "topology" && open && hasDetails/);
- assert.match(storeSource, /subagentPanel: SubagentPanelSelection \| null/);
- assert.match(storeSource, /toggleSubagentPanel:\s*\(delegationId\) => \{/);
+ assert.match(transcriptSource, /openSubagentTab\(panelSelectionId, agentName \|\| undefined\)/);
assert.match(
- storeSource,
- /state\.subagentPanel\?\.sessionId === sessionId[\s\S]*?state\.subagentPanel\.delegationId === id[\s\S]*?set\(\{ subagentPanel: null \}\)/,
+ transcriptSource,
+ /activeWorkPanelTabId === `subagent:\$\{panelSelectionId\}`/,
);
- assert.match(storeSource, /set\(\{ subagentPanel: \{ sessionId, delegationId: id \} \}\)/);
- assert.match(storeSource, /closeSubagentPanel: \(\) => set\(\{ subagentPanel: null \}\)/);
- assert.match(storeSource, /if \(state\.subagentPanel\) \{/);
- assert.match(storeSource, /state\.closeSubagentPanel\(\)/);
- assert.match(storeSource, /if \(get\(\)\.workPanelOpen\) get\(\)\.collapseWorkPanel\(\)/);
-});
-
-test("the side panel renders the live conversation process", () => {
- assert.match(panelSource, / {
- assert.match(panelSource, /buildTranscriptEntries\(messages\)/);
- assert.match(panelSource, /selection\.delegationId/);
- assert.match(panelSource, /useTranscriptView\(selection\.sessionId\)/);
- assert.match(panelSource, /collectDelegationStatuses\(selected\.turnActivityItems/);
- assert.match(panelSource, /collectDelegationTimings\(selected\.turnActivityItems\)/);
- assert.match(panelSource, / \{/);
+ assert.match(
+ storeSource,
+ /openWorkPanelTab\(subagentWorkPanelTab\(id, agentName \|\| undefined\)\)/,
+ );
+ // The overlay state and its actions are gone: closing happens per tab.
+ assert.doesNotMatch(storeSource, /subagentPanel/);
+ assert.doesNotMatch(storeSource, /toggleSubagentPanel|closeSubagentPanel/);
+ assert.match(tabsSource, /\| "plugin"\s*\|\s*"subagent";/);
+ assert.match(
+ tabsSource,
+ /export function subagentWorkPanelTab\(\s*delegationId: string,\s*agentName\?: string,\s*\): WorkPanelTab \{/,
+ );
+ assert.match(tabsSource, /id: `subagent:\$\{delegationId\}`/);
+ assert.match(tabsSource, /tab\.kind === "subagent"\)/);
+ // Retained runtime state must survive the session-switch sanitizer.
+ assert.match(tabsSource, /export function subagentTabDisplayLabels\(/);
+ assert.match(tabsSource, /tab\.kind === "subagent"\)/);
+ // Retained runtime state must survive the session-switch sanitizer.
+ assert.match(tabsSource, /export function subagentTabDisplayLabels\(/);
});
-test("the work-panel dock hosts subagent details without creating a resource tab", () => {
- assert.match(workPanelSource, /subagentPanel\?: SubagentPanelSelection \| null/);
- assert.match(workPanelSource, /onCloseSubagentPanel\?: \(\) => void/);
- assert.match(workPanelSource, /\{subagentPanel \? \(/);
- assert.match(workPanelSource, / /);
- assert.match(workPanelSource, /!subagentPanel && activeTab\?\.kind === "review"/);
- assert.match(workPanelSource, /subagentPanel && onCloseSubagentPanel/);
- assert.match(appSource, /const subagentPanelOpen = Boolean\(/);
- assert.match(appSource, /page === "chat"/);
- assert.match(appSource, /page !== "chat" \|\| subagentPanel\.sessionId !== activeSessionId/);
- assert.match(appSource, /workPanelOpen \|\| subagentPanelOpen/);
- assert.match(appSource, /subagentPanel=\{subagentPanelOpen \? subagentPanel : null\}/);
- assert.doesNotMatch(workPanelSource, /setContextOpen/);
+test("the work panel renders the delegation tab like every other resource", () => {
+ assert.match(workPanelSource, /subagent: IconBot/);
+ assert.match(workPanelSource, /if \(tab\.kind === "subagent"\) return tab\.label \?\? t\("panel\.tabs\.subagent"\);/);
+ assert.match(workPanelSource, /const subagentTabsInOrder = tabs\.filter\(\(tab\) => tab\.kind === "subagent"\)/);
+ assert.match(workPanelSource, /subagentTabDisplayLabels\(/);
+ assert.match(workPanelSource, /\{activeTab\?\.kind === "subagent" && \(/);
+ assert.match(
+ workPanelSource,
+ / /,
+ );
+ // The tab strip is never replaced by a dock heading or a back control.
+ assert.doesNotMatch(workPanelSource, /work-panel-subagent-heading|work-panel-subagent-back/);
+ assert.doesNotMatch(workPanelSource, /subagentPanel\?: SubagentPanelSelection/);
+ assert.doesNotMatch(workPanelSource, /!subagentPanel &&/);
+ // The shell no longer overrides visibility for a dock-only surface.
+ assert.match(appSource, /const workPanelVisible = workPanelOpen;/);
+ assert.doesNotMatch(appSource, /subagentPanelOpen/);
});
-test("the task dock keeps one body scroll owner while the process streams", () => {
- assert.match(workPanelCss, /\.subagent-panel \{[^}]*flex: 1/);
- assert.match(workPanelCss, /\.subagent-panel-scroll \{[^}]*overflow-y: auto/);
- assert.match(messagesCss, /\.subagent-run-rows\.is-panel-flow \{[\s\S]*?overflow: visible/);
- assert.match(panelSource, /useFollowScroll\(\)/);
- assert.match(panelSource, /ref=\{scrollRef\}/);
- assert.match(panelSource, /onScroll=\{handleScroll\}/);
- assert.match(panelSource, /onClick=\{jumpToLatest\}/);
- assert.match(panelSource, /role="log"/);
- assert.match(panelSource, /aria-live="polite"/);
- assert.match(panelSource, /tabIndex=\{0\}/);
- assert.match(panelSource, /\[jumpToLatest, selection\.delegationId, searchTarget\]/);
+test("the delegation tab renders a message list with a read-only composer", () => {
+ assert.match(tabSource, /data-testid="subagent-transcript-tab"/);
+ assert.match(tabSource, /buildSubagentTranscript\(messages, delegationId\)/);
+ assert.match(tabSource, /className="message-row user"/);
+ assert.match(tabSource, /className="message-user-text selectable"/);
+ assert.match(tabSource, /className="message-row assistant"/);
+ assert.match(tabSource, /data-message-id=\{row\.message\.id\}/);
+ assert.match(tabSource, /