diff --git a/components/AppShell.tsx b/components/AppShell.tsx index e02bdd8240..69d090a3b2 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -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"; @@ -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} diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 788e10f4b3..151b8938a2 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -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"; @@ -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. */ @@ -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"; @@ -1340,6 +1342,8 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi )} + + {bashRunning && !pendingBash && (
{t("chat.runningCommand")} diff --git a/components/RunningSubagentsStatus.test.mjs b/components/RunningSubagentsStatus.test.mjs new file mode 100644 index 0000000000..5164a0c62b --- /dev/null +++ b/components/RunningSubagentsStatus.test.mjs @@ -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(/