Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ import type { SessionStatsInfo } from "@/lib/pi-types";
import { formatActiveDuration, formatCompactCount, formatCostShort } from "@/lib/session-stats-format";
import type { FileViewerState } from "@/lib/file-viewer-state";
import { PRESET_READ_ONLY, type ToolEntry } from "@/lib/tool-presets";
import { getAgentsBadge, getSessionFamily } from "@/lib/session-family";
import { getAgentsBadge, getRunningSubagents, getSessionFamily } from "@/lib/session-family";
import { getLastSettingsSection, settingsSectionRequiresProject, type SettingsSection } from "@/lib/settings-navigation";

type SessionCopyField = "file" | "id" | "projectDir" | "gitBranch" | "gitWorktree";
Expand Down Expand Up @@ -2574,6 +2574,7 @@ export function AppShell() {
initialScrollPosition={selectedSession ? sessionScrollPositionsRef.current.get(selectedSession.id) ?? null : null}
onScrollPositionChange={handleSessionScrollPositionChange}
sessionRunning={Boolean(selectedSession && runningSessionIds.has(selectedSession.id))}
runningSubagents={activeSessionFamily ? getRunningSubagents(activeSessionFamily.subagents, runningSessionIds) : []}
newSessionCwd={effectiveNewSessionCwd}
newSessionDraftKey={newSessionDraftKey}
newSessionContextBar={newSessionContextBar}
Expand Down
6 changes: 5 additions & 1 deletion components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { dropMentionText, splitDroppedItems, uploadFiles, type DroppedItem } fro
import { MessageView } from "./MessageView";
import { MarkdownBody } from "./MarkdownBody";
import { ChatInput, type ChatInputHandle } from "./ChatInput";
import { RunningSubagentsStatus } from "./RunningSubagentsStatus";
import { ChatMinimap, useMessageRefs } from "./ChatMinimap";
import { ExtensionStatusBar } from "./ExtensionStatusBar";
import { AnsiText } from "./AnsiText";
Expand Down Expand Up @@ -52,6 +53,7 @@ interface Props {
initialScrollPosition?: ChatScrollPosition | null;
onScrollPositionChange?: (sessionId: string, position: ChatScrollPosition) => void;
sessionRunning?: boolean;
runningSubagents?: readonly SessionInfo[];
newSessionCwd: string | null;
newSessionDraftKey: string | null;
/** Shown above the composer while a fresh composer is still empty: where its session starts. */
Expand Down Expand Up @@ -247,7 +249,7 @@ function ProcessDetailsGroup({ messageCount, toolCallCount, durationMs, cacheMis
);
}

export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initialScrollPosition, onScrollPositionChange, sessionRunning, newSessionCwd, newSessionDraftKey, newSessionContextBar, initialNewSessionChoices, onNewSessionChoicesChange, onAgentEnd, onToolEnd, onAttentionNeeded, onSessionCreated, onSessionForked, modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSystemToolsChange, onSystemInfoLoaderChange, onSessionStatsChange, onSessionStatsPanelOpen, onOpenSettings, onContextUsageChange, onOpenFile, onFilesUploaded, onOpenSubagent, onAskInNewChat, quoteSelectionEnabled = false, initialPrompt, onInitialPromptConsumed, soundEnabled = true, onSoundToggle, playDoneSound = () => {}, unlockAudio, holdAudio }: Props) {
export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initialScrollPosition, onScrollPositionChange, sessionRunning, runningSubagents, newSessionCwd, newSessionDraftKey, newSessionContextBar, initialNewSessionChoices, onNewSessionChoicesChange, onAgentEnd, onToolEnd, onAttentionNeeded, onSessionCreated, onSessionForked, modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSystemToolsChange, onSystemInfoLoaderChange, onSessionStatsChange, onSessionStatsPanelOpen, onOpenSettings, onContextUsageChange, onOpenFile, onFilesUploaded, onOpenSubagent, onAskInNewChat, quoteSelectionEnabled = false, initialPrompt, onInitialPromptConsumed, soundEnabled = true, onSoundToggle, playDoneSound = () => {}, unlockAudio, holdAudio }: Props) {
const { t } = useI18n();
const isMobile = useIsMobile();
const completionNotificationsEnabled = session?.relation?.kind !== "subagent";
Expand Down Expand Up @@ -1340,6 +1342,8 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi
</div>
)}

<RunningSubagentsStatus runningSubagents={runningSubagents} />

{bashRunning && !pendingBash && (
<div className="py-2 text-[13px] text-text-muted">
<span className="animate-[pulse_1.5s_infinite]">{t("chat.runningCommand")}</span>
Expand Down
146 changes: 146 additions & 0 deletions components/RunningSubagentsStatus.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createJiti } from "jiti";

const jiti = createJiti(import.meta.url, { jsx: { runtime: "automatic" }, tsconfigPaths: true });
const React = await jiti.import("react");
const { renderToStaticMarkup } = await jiti.import("react-dom/server");
const { RunningSubagentsStatus } = await jiti.import("./RunningSubagentsStatus.tsx");
const { I18nProvider } = await jiti.import("@/hooks/useI18n");
const { getRunningSubagents } = await jiti.import("@/lib/session-family");
const { getLocalePlugin } = await jiti.import("@/lib/i18n/registry");
const { translateMessage } = await jiti.import("@/lib/i18n/format");

function child(id, description, status = "running") {
return {
id,
relation: { kind: "subagent", parentSessionId: "main", profile: "explore", description, status },
};
}

function renderStatus(runningSubagents) {
return renderToStaticMarkup(React.createElement(I18nProvider, null,
React.createElement(RunningSubagentsStatus, { runningSubagents })));
}

test("hides the entire block and icon when no subagents run", () => {
assert.equal(renderStatus(), "");
assert.equal(renderStatus([]), "");
});

test("shows count and descriptions without needing the main phase or an Agents click", () => {
const html = renderStatus([child("a", "Check config"), child("b", "Read logs"), child("c", "Verify fix")]);
assert.match(html, /3 subagents running \(Check config, Read logs, Verify fix\)/);
assert.match(html, /role="status"/);
assert.match(html, /break-words/);
assert.doesNotMatch(html, /Waiting for model|Running command|pulse_1/);
});

test("shows a small decorative native SVG before the text", () => {
const html = renderStatus([child("a", "Review")]);
assert.equal((html.match(/<img\b/g) ?? []).length, 1);
assert.match(html, /src="\/icons\/pi-blocks-running\.svg"/);
assert.match(html, /width="16" height="16" alt="" aria-hidden="true"/);
assert.match(html, /h-4 w-4 shrink-0/, "The icon uses a 1rem box that follows the root font size");
assert.doesNotMatch(html, /animate-spin|<svg|<rect/);
assert.ok(html.indexOf("<img") < html.indexOf("1 subagent running"));
});

test("preserves the three original paths and solid colours in a transparent SVG", () => {
for (const name of ["pi-blocks-running.svg", "pi-blocks-still.svg"]) {
const svg = readFileSync(new URL(`../public/icons/${name}`, import.meta.url), "utf8");
const paths = [...svg.matchAll(/<path\b[^>]*fill="([^"]+)"[^>]*d="([^"]+)"/g)];
assert.deepEqual(paths.map(([, fill, d]) => [fill, d]), [
["#F19284", "M13 13H42V32H32V23H13Z"],
["#4C9BBE", "M13 23H23V32H32V42H23V51H13Z"],
["#F0BE59", "M42 32H51V51H42Z"],
]);
assert.match(svg, /width="16" height="16" viewBox="12 12 40 40"/);
assert.doesNotMatch(svg, /<image\b|<rect\b|<filter\b|<script\b|gradient|data:image|transform/i);
}
});

test("selects an actually static SVG for reduced motion without JavaScript", () => {
const html = renderStatus([child("a", "Review")]);
assert.match(html, /<picture\b/);
assert.match(html, /<source media="\(prefers-reduced-motion: reduce\)" srcSet="\/icons\/pi-blocks-still\.svg"/);
const still = readFileSync(new URL("../public/icons/pi-blocks-still.svg", import.meta.url), "utf8");
assert.doesNotMatch(still, /animation|<animate\b/);
});

test("smoothly crossfades the blocks clockwise every 450ms and respects reduced motion", () => {
const svg = readFileSync(new URL("../public/icons/pi-blocks-running.svg", import.meta.url), "utf8");
assert.match(svg, /animation: pi-block-highlight \.45s ease-in-out infinite/);
assert.match(svg, /#pi-yellow\s*\{ animation-delay: -\.3s;/);
assert.match(svg, /#pi-blue\s*\{ animation-delay: -\.15s;/);
assert.match(svg, /0%, 100%\s*\{ opacity: 1;/);
assert.match(svg, /33\.333333%, 66\.666667%\s*\{ opacity: \.3;/);
assert.doesNotMatch(svg, /steps\(|setInterval|requestAnimationFrame/);
assert.match(svg, /@media \(prefers-reduced-motion: reduce\)\s*\{\s*path\s*\{ animation: none; opacity: 1;/);
});

test("removes settled runs and their descriptions using the live running list", () => {
const subagents = [
child("active", "Still running", "completed"), // Persisted status may lag a resume.
child("done", "Done", "completed"),
child("stopped", "Stopped", "aborted"),
child("error", "Error", "failed"),
child("interrupted", "Interrupted", "interrupted"),
child("queued", "Queued", "queued"),
];
const html = renderStatus(getRunningSubagents(subagents, new Set(["active", "elsewhere"])));
assert.match(html, /1 subagent running \(Still running\)/);
assert.doesNotMatch(html, /Done|Stopped|Error|Interrupted|Queued/);
assert.equal(renderStatus(getRunningSubagents(subagents, new Set())), "");
});

test("keeps full long descriptions, escapes markup and falls back for a blank description", () => {
const description = `Check ${"very-long-task-".repeat(80)}<script>alert(1)</script>`;
const html = renderStatus([child("a", description), child("b", " ")]);
assert.ok(html.includes(description.replaceAll("<", "&lt;").replaceAll(">", "&gt;")));
assert.match(html, /, explore\)/);
assert.doesNotMatch(html, /<script|truncate|line-clamp/);
});

test("provides count and description translations in every supported locale", () => {
const expected = {
en: ["1 subagent running (Check)", "3 subagents running (Check, Logs, Verify)"],
"zh-CN": ["有 1 个子代理正在运行(检查)", "有 3 个子代理正在运行(检查、日志、验证)"],
"zh-TW": ["有 1 個子代理正在執行(檢查)", "有 3 個子代理正在執行(檢查、日誌、驗證)"],
};
for (const [locale, values] of Object.entries(expected)) {
const messages = { [locale]: getLocalePlugin(locale).messages };
const english = locale === "en";
assert.equal(translateMessage(locale, "chat.runningSubagent", messages, {
count: 1, descriptions: english ? "Check" : locale === "zh-CN" ? "检查" : "檢查",
}), values[0]);
assert.equal(translateMessage(locale, "chat.runningSubagents", messages, {
count: 3, descriptions: english ? "Check, Logs, Verify" : locale === "zh-CN" ? "检查、日志、验证" : "檢查、日誌、驗證",
}), values[1]);
}
});

test("inserts the independent block while preserving both original status blocks in ChatWindow", () => {
const shell = readFileSync(new URL("./AppShell.tsx", import.meta.url), "utf8");
const chat = readFileSync(new URL("./ChatWindow.tsx", import.meta.url), "utf8");
const component = readFileSync(new URL("./RunningSubagentsStatus.tsx", import.meta.url), "utf8");
const mainPhase = `{agentRunning && !hasStreamingContent && (agentPhase || isCompacting) && (
<div className="break-words py-2 text-[13px] text-text-muted">
<span className="animate-[pulse_1.5s_infinite]">{phaseLabel(agentPhase, t, isCompacting)}</span>
</div>
)}`;
const command = `{bashRunning && !pendingBash && (
<div className="py-2 text-[13px] text-text-muted">
<span className="animate-[pulse_1.5s_infinite]">{t("chat.runningCommand")}</span>
</div>
)}`;
const subagentBlock = "<RunningSubagentsStatus runningSubagents={runningSubagents} />";
assert.match(shell, /runningSubagents=\{activeSessionFamily \? getRunningSubagents\(activeSessionFamily\.subagents, runningSessionIds\) : \[\]\}/);
assert.ok(chat.includes(mainPhase), "The main phase condition, markup and pulse stay unchanged in ChatWindow");
assert.ok(chat.includes(command), "The command condition, markup and pulse stay unchanged in ChatWindow");
assert.ok(chat.indexOf(mainPhase) < chat.indexOf(subagentBlock));
assert.ok(chat.indexOf(subagentBlock) < chat.indexOf(command));
assert.doesNotMatch(component, /phaseLabel|commandRunning|agentRunning|hasStreamingContent|pulse_1/);
assert.doesNotMatch(chat, /ChatActivityStatus/);
});
39 changes: 39 additions & 0 deletions components/RunningSubagentsStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"use client";

import { useI18n } from "@/hooks/useI18n";
import type { SessionInfo } from "@/lib/types";

interface Props {
runningSubagents?: readonly SessionInfo[];
}

/** An independent chat-tail block; never owns the main agent's phase or command status. */
export function RunningSubagentsStatus({ runningSubagents = [] }: Props) {
const { t, locale } = useI18n();
if (runningSubagents.length === 0) return null;

const descriptions = runningSubagents.map((session) => (
session.relation?.kind === "subagent"
? session.relation.description.trim() || session.relation.profile
: session.id
)).join(locale === "en" ? ", " : "、");

return (
<div role="status" className="flex items-start gap-2 py-2 text-[13px] text-text-muted">
{/* Native SVG: its three blocks light up in sequence without rotating the silhouette. */}
<picture className="mt-0.5 block h-4 w-4 shrink-0">
<source media="(prefers-reduced-motion: reduce)" srcSet="/icons/pi-blocks-still.svg" />
<img
className="block"
src="/icons/pi-blocks-running.svg"
width="16" height="16" alt="" aria-hidden="true"
/>
</picture>
<span className="min-w-0 break-words">
{t(runningSubagents.length === 1 ? "chat.runningSubagent" : "chat.runningSubagents", {
count: runningSubagents.length, descriptions,
})}
</span>
</div>
);
}
4 changes: 4 additions & 0 deletions docs/agents/subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
- Each row of the agent switcher (main agent and every subagent) shows its active time, total tokens and cost, with the stats popover's breakdown in its tooltip; the header shows their sum. `POST /api/sessions/stats` reads each id as `GET /api/sessions/[id]` does (a live wrapper's entries, else the cached file) and counts like the popover (`computeSessionStats()`, `computeSessionTotalActiveMs()`), never touching an `/api/agent/` route. A summary is kept per SessionManager until its entry count or last entry changes, since the switcher polls every 3 s on a visible page while any agent in the family runs, and reads once more when a run starts or ends. The toolbar's numbers are the main session's alone; the switcher's sum is the only place subagent spend adds up.
- The top bar's Agents badge counts the family's subagents, and while any of them runs it counts only those, accent-tinted and ringed by a spinner (`getAgentsBadge()`, `lib/session-family.ts`); the button's title and label say how many run. Running means listed by the sidebar's running poll, as in the switcher's header, so the badge adds no request of its own.

## Chat-tail running status
- `RunningSubagentsStatus` only shows the current family’s running subagent count and dispatch descriptions in parentheses in its own block below the main phase label. The original main phase and shell-command JSX remains unchanged in `ChatWindow`, including its conditions, styles and pulse; the new component takes no main-agent or command status. A decorative 1rem native SVG (`public/icons/pi-blocks-running.svg`) sits before only the subagent line. Its original three solid-colour blocks stay fixed while opacity crossfades clockwise (coral → yellow → blue), using a 450 ms `ease-in-out` cycle with 150 ms phase offsets. Each block fades from full opacity to 0.3 over the first third, stays dim for the middle third, then fades back to full opacity, matching the selected soft-fade preview without a jump at the cycle boundary; no JavaScript timer, raster conversion or whole-icon rotation. A tight viewBox fills the 1rem box, which follows the root font size (14 px at the default). A `<picture>` source selects `public/icons/pi-blocks-still.svg` under `prefers-reduced-motion`, showing all three blocks at full opacity; Chromium did not apply the embedded SVG's media query when loaded as an image, so the image source must actually be static. The running SVG also keeps its media query for direct document viewing. The line stays visible while the main agent streams or is idle, and disappears with its icon when no subagents run. Long descriptions wrap beside the non-shrinking icon rather than requiring a hover or opening Agents.
- `AppShell` passes `getRunningSubagents()` from the existing family and running-id poll; the count and descriptions use the same list as the Agents badge, including nested descendants, with no new request. Persisted status can lag, so running ids, not `relation.status`, decide membership. Completed, failed, aborted and interrupted runs drop out on the next poll; queued/MCP preparation is not a running wrapper yet. Blank descriptions fall back to the profile.

## Viewer tab
- The `Agent` card's button opens the child as a read-only tab in the right panel (`components/SubagentViewer.tsx`, tab id `agent:<sessionId>`), beside file and terminal tabs. The agent switcher above the chat opens a child as the main chat from its row; the row's side-panel button toggles this tab (accent-coloured and filled while the open panel has it, active or not; a click then closes the tab, and the panel with its last tab) and leaves the switcher open, except on phones, where the panel covers the chat. The tab reads only `GET /api/sessions/[id]` and `GET /api/sessions/[id]/context` (a running wrapper's in-memory entries, else the file), never an `/api/agent/` route, so viewing cannot start, reopen or keep alive an `AgentSession`. While the sidebar's running poll lists the child, it polls the newest page every 2 s on a visible page, one request at a time; only the active tab is mounted, so a hidden or closed tab polls nothing. It shows finished entries only, not the streaming message.
- A poll is folded into the pages already loaded (`mergeTailPage()`, `components/subagent-viewer-state.ts`): if the newest page overlaps them, the older pages stay and shown messages keep their objects; otherwise it replaces them. "Load more" asks `before` the oldest entry and prepends only while that is still the oldest (`prependOlderPage()`), so a page is never prepended twice or over a gap. Session info is refetched when the run starts or ends, so the persisted status replaces the live one. Subagent tabs are not persisted and are dropped on every project switch (`changeFileWorkspace()`), while the file tabs are parked; an active subagent tab hands the panel to the restored project's file tabs.
1 change: 1 addition & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Coverage:
session, directory, draft, files and group expansion; + still opens in the
same tab, and no session is created, copied, prompted or stopped. An
unavailable directory shows the validation error instead of another session.
- Running subagents appear at the chat tail without opening Agents, even with an idle parent. Synthetic running-id polls check nested descendants, matching counts and descriptions, removal through zero, long-description wrapping at desktop/phone widths, and both Chinese locales without dispatching a model.
- The model picker, with a synthetic catalog: on a phone, a simulated keyboard
(visual viewport shrink, pan, a late height with no event) keeps the same
filter focused and the list in the visible viewport. Native phone keyboards
Expand Down
Loading
Loading