From 1e41d499d0375ac99623ca798219e5df9743560e Mon Sep 17 00:00:00 2001 From: TossPig Date: Sun, 25 Oct 2026 18:43:48 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(chat):=20=E6=98=BE=E7=A4=BA=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E4=B8=AD=E5=AD=90=E4=BB=A3=E7=90=86=E6=95=B0=E9=87=8F?= =?UTF-8?q?=E5=8F=8A=E4=BB=BB=E5=8A=A1=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/AppShell.tsx | 3 +- components/ChatActivityStatus.test.mjs | 102 +++++++++++++++++++++++++ components/ChatActivityStatus.tsx | 42 ++++++++++ components/ChatWindow.tsx | 20 ++--- docs/agents/subagents.md | 4 + e2e/README.md | 1 + e2e/run.mjs | 2 + e2e/subagent-status.mjs | 70 +++++++++++++++++ lib/i18n/messages/en.ts | 2 + lib/i18n/messages/zh-CN.ts | 2 + lib/i18n/messages/zh-TW.ts | 2 + lib/session-family.test.mjs | 17 ++++- lib/session-family.ts | 9 ++- 13 files changed, 261 insertions(+), 15 deletions(-) create mode 100644 components/ChatActivityStatus.test.mjs create mode 100644 components/ChatActivityStatus.tsx create mode 100644 e2e/subagent-status.mjs 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/ChatActivityStatus.test.mjs b/components/ChatActivityStatus.test.mjs new file mode 100644 index 0000000000..42d194dc11 --- /dev/null +++ b/components/ChatActivityStatus.test.mjs @@ -0,0 +1,102 @@ +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 { ChatActivityStatus } = await jiti.import("./ChatActivityStatus.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(props = {}) { + return renderToStaticMarkup(React.createElement(I18nProvider, null, + React.createElement(ChatActivityStatus, { label: null, commandRunning: false, ...props }))); +} + +test("hides the whole status when no agent or command is active", () => { + assert.equal(renderStatus(), ""); + assert.equal(renderStatus({ runningSubagents: [] }), ""); +}); + +test("shows count and descriptions below the main phase without an Agents click", () => { + const html = renderStatus({ + label: "Waiting for model...", + runningSubagents: [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.ok(html.indexOf("Waiting for model...") < html.indexOf("3 subagents running")); + assert.match(html, /role="status"/); + assert.match(html, /break-words/); +}); + +test("keeps background status when the main agent is idle or streaming", () => { + const html = renderStatus({ runningSubagents: [child("a", "Review")] }); + assert.match(html, /1 subagent running \(Review\)/); + assert.doesNotMatch(html, /Waiting for model|animate-/); +}); + +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({ runningSubagents: 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({ runningSubagents: getRunningSubagents(subagents, new Set()) }), ""); +}); + +test("preserves phase and shell command labels without subagents", () => { + const html = renderStatus({ label: "Compacting context...", commandRunning: true }); + assert.match(html, /Compacting context\.\.\./); + assert.match(html, /Running command\.\.\./); + assert.doesNotMatch(html, /subagent|role="status"/); +}); + +test("keeps full long descriptions, escapes markup and falls back for a blank description", () => { + const description = `Check ${"very-long-task-".repeat(80)}`; + const html = renderStatus({ runningSubagents: [child("a", description), child("b", " ")] }); + assert.ok(html.includes(description.replaceAll("<", "<").replaceAll(">", ">"))); + assert.match(html, /, explore\)/); + assert.doesNotMatch(html, / { + 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("wires the family running list into the chat, independent of the main phase", () => { + const shell = readFileSync(new URL("./AppShell.tsx", import.meta.url), "utf8"); + const chat = readFileSync(new URL("./ChatWindow.tsx", import.meta.url), "utf8"); + assert.match(shell, /runningSubagents=\{activeSessionFamily \? getRunningSubagents\(activeSessionFamily\.subagents, runningSessionIds\) : \[\]\}/); + assert.match(chat, / ( + session.relation?.kind === "subagent" + ? session.relation.description.trim() || session.relation.profile + : session.id + )).join(locale === "en" ? ", " : "、"); + + return ( + <> + {label && ( +
+ {label} +
+ )} + {runningSubagents.length > 0 && ( +
+ {t(runningSubagents.length === 1 ? "chat.runningSubagent" : "chat.runningSubagents", { + count: runningSubagents.length, descriptions, + })} +
+ )} + {commandRunning && ( +
+ {t("chat.runningCommand")} +
+ )} + + ); +} diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 788e10f4b3..f3c7a3ce3a 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 { ChatActivityStatus } from "./ChatActivityStatus"; 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"; @@ -1334,17 +1336,11 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi )} - {agentRunning && !hasStreamingContent && (agentPhase || isCompacting) && ( -
- {phaseLabel(agentPhase, t, isCompacting)} -
- )} - - {bashRunning && !pendingBash && ( -
- {t("chat.runningCommand")} -
- )} + {pendingBash && ( `), 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. diff --git a/e2e/README.md b/e2e/README.md index 60e63fa74c..936ac02870 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -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 diff --git a/e2e/run.mjs b/e2e/run.mjs index a6445d84dd..629f6318fe 100644 --- a/e2e/run.mjs +++ b/e2e/run.mjs @@ -16,6 +16,7 @@ import { checkCodeBackground } from "./code-background.mjs"; import { checkModelDiscovery } from "./model-discovery.mjs"; import { checkModelContext } from "./model-context.mjs"; import { checkModelSelector } from "./model-selector.mjs"; +import { checkSubagentStatus } from "./subagent-status.mjs"; import { checkFilesPlacement, showSidebarSessions } from "./sidebar.mjs"; import { checkSessionNavigation } from "./session-navigation.mjs"; import { checkProjectNavigation, createProjectNavigationFixtures } from "./project-navigation.mjs"; @@ -514,6 +515,7 @@ try { await page.locator(".markdown-code-block pre").waitFor(); await checkCodeBackground(page); await checkModelSelector(page, { base, project, artifacts }); + await checkSubagentStatus(page, { base, sessionId: RICH, artifacts, width: viewport.width }); assert.deepEqual(errors, [], `Browser errors at width ${viewport.width}`); console.log(`PASS: ${viewport.width}px browser pagination, branch, markdown, code, tool call, and compaction navigation`); await context.tracing.stop(); diff --git a/e2e/subagent-status.mjs b/e2e/subagent-status.mjs new file mode 100644 index 0000000000..0c26a587c6 --- /dev/null +++ b/e2e/subagent-status.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { join } from "node:path"; + +/** Exercise real chat rendering/polling with synthetic run ids, never dispatch a model. */ +export async function checkSubagentStatus(page, { base, sessionId, artifacts, width }) { + const listRoute = (url) => url.pathname === "/api/sessions"; + const runningRoute = (url) => url.pathname === "/api/agent/running"; + const descriptions = ["Check configuration", "Read logs", `Verify ${"long-task-".repeat(50)}`]; + const ids = ["e2e-child-config", "e2e-child-logs", "e2e-child-nested"]; + let running = [...ids]; + await page.route(listRoute, async (route) => { + const response = await route.fetch(); + const data = await response.json(); + const main = data.sessions.find((session) => session.id === sessionId); + assert.ok(main); + data.sessions.push(...ids.map((id, index) => ({ + ...main, id, name: undefined, path: `${main.cwd}/${id}.jsonl`, firstMessage: descriptions[index], + parentSessionId: index === 2 ? ids[0] : sessionId, + relation: { + kind: "subagent", parentSessionId: index === 2 ? ids[0] : sessionId, + profile: "explore", description: descriptions[index], status: "running", + }, + }))); + data.runningSessionIds = running; + await route.fulfill({ response, json: data }); + }); + await page.route(runningRoute, async (route) => { + const response = await route.fetch(); + await route.fulfill({ response, json: { ...await response.json(), runningSessionIds: running } }); + }); + const status = page.getByRole("status").filter({ hasText: "subagent" }); + try { + await page.goto(`${base}/?session=${sessionId}`, { waitUntil: "domcontentloaded" }); + await status.filter({ hasText: "3 subagents running" }).waitFor(); + assert.equal(await status.textContent(), `3 subagents running (${descriptions.join(", ")})`); + // The parent is idle and Agents was never opened; a nested child still counts. + assert.equal(await page.getByText("Waiting for model...", { exact: true }).count(), 0); + await status.scrollIntoViewIfNeeded(); + const bounds = await status.boundingBox(); + assert.ok(bounds && bounds.x >= 0 && bounds.x + bounds.width <= width, + "Long descriptions wrap within the chat on desktop and phone"); + assert.ok(await status.evaluate((element) => element.scrollWidth <= element.clientWidth + 1)); + await page.screenshot({ path: join(artifacts, `subagent-status-${width}.png`) }); + + running = ids.slice(1); + await status.filter({ hasText: "2 subagents running" }).waitFor(); + assert.equal(await status.textContent(), `2 subagents running (${descriptions.slice(1).join(", ")})`); + running = [ids[2]]; + await status.filter({ hasText: "1 subagent running" }).waitFor(); + assert.equal(await status.textContent(), `1 subagent running (${descriptions[2]})`); + running = []; + await status.waitFor({ state: "detached" }); + + running = [ids[0], ids[1]]; + for (const [locale, text] of [ + ["zh-CN", "有 2 个子代理正在运行(Check configuration、Read logs)"], + ["zh-TW", "有 2 個子代理正在執行(Check configuration、Read logs)"], + ]) { + await page.evaluate((value) => localStorage.setItem("pi-locale", value), locale); + await page.reload({ waitUntil: "domcontentloaded" }); + await page.getByRole("status").filter({ hasText: text }).waitFor(); + } + console.log(`PASS: ${width}px running subagent count/descriptions, nested family, idle parent, poll removal, wrapping and Chinese locales`); + } finally { + await page.unroute(listRoute); + await page.unroute(runningRoute); + await page.evaluate(() => localStorage.setItem("pi-locale", "en")); + await page.goto(`${base}/?session=${sessionId}`, { waitUntil: "domcontentloaded" }); + } +} diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index cd3d53ef39..912717f20d 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -492,6 +492,8 @@ export const enLocale: LocalePlugin = { "chat.runningTools": "Running {names}...", "chat.runningToolsMore": "Running {names} (+{count})...", "chat.waitingModel": "Waiting for model...", + "chat.runningSubagent": "{count} subagent running ({descriptions})", + "chat.runningSubagents": "{count} subagents running ({descriptions})", "chat.runningCommand": "Running command...", "chat.thinking": "Thinking...", "chat.processDetails": "Process details", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index bb1c64868e..9f67d3e678 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -492,6 +492,8 @@ export const zhCNLocale: LocalePlugin = { "chat.runningTools": "正在运行 {names}...", "chat.runningToolsMore": "正在运行 {names}(另有 {count} 个)...", "chat.waitingModel": "正在等待模型...", + "chat.runningSubagent": "有 {count} 个子代理正在运行({descriptions})", + "chat.runningSubagents": "有 {count} 个子代理正在运行({descriptions})", "chat.runningCommand": "正在运行命令...", "chat.thinking": "正在思考...", "chat.processDetails": "处理详情", diff --git a/lib/i18n/messages/zh-TW.ts b/lib/i18n/messages/zh-TW.ts index fe29c7f3bc..fbb9d994b3 100644 --- a/lib/i18n/messages/zh-TW.ts +++ b/lib/i18n/messages/zh-TW.ts @@ -492,6 +492,8 @@ export const zhTWLocale: LocalePlugin = { "chat.runningTools": "正在執行 {names}...", "chat.runningToolsMore": "正在執行 {names}(另有 {count} 個)...", "chat.waitingModel": "正在等待模型...", + "chat.runningSubagent": "有 {count} 個子代理正在執行({descriptions})", + "chat.runningSubagents": "有 {count} 個子代理正在執行({descriptions})", "chat.runningCommand": "正在執行命令...", "chat.thinking": "正在思考...", "chat.processDetails": "處理詳細資料", diff --git a/lib/session-family.test.mjs b/lib/session-family.test.mjs index dbfacb6a7a..332a81aa97 100644 --- a/lib/session-family.test.mjs +++ b/lib/session-family.test.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { createJiti } from "jiti"; -const { getAgentsBadge, getSessionFamily, listSessionFamilies } =await createJiti(import.meta.url).import("./session-family.ts"); +const { getAgentsBadge, getRunningSubagents, getSessionFamily, listSessionFamilies } =await createJiti(import.meta.url).import("./session-family.ts"); function session(id, modified, relation) { return { @@ -62,6 +62,21 @@ test("a fork is a family of its own, and its subagents fold into it, not into it assert.equal(getSessionFamily([fork, origin, forkAgent], "fork-agent")?.root.id, "fork"); }); +test("the running list includes nested descendants but excludes the main agent and other families", () => { + const main = session("main", "2026-01-01T00:00:00.000Z"); + const child = session("child", main.modified, { + kind: "subagent", parentSessionId: "main", profile: "explore", description: "Explore", status: "running", + }); + const nested = session("nested", main.modified, { + kind: "subagent", parentSessionId: "child", profile: "review", description: "Review", status: "running", + }); + const family = getSessionFamily([main, child, nested], "main"); + const running = new Set(["main", "nested", "elsewhere"]); + assert.deepEqual(getRunningSubagents(family.subagents, running), [nested]); + assert.equal(getAgentsBadge(family.subagents, running).count, getRunningSubagents(family.subagents, running).length); + assert.deepEqual(getRunningSubagents(family.subagents, new Set()), []); +}); + test("the Agents badge counts running subagents while any runs, else all of them", () => { const subagents = ["a", "b", "c"].map((id) => session(id, "2026-01-01T00:00:00.000Z", { kind: "subagent", parentSessionId: "main", profile: "explore", description: id, status: "completed", diff --git a/lib/session-family.ts b/lib/session-family.ts index 87b57cb332..04562bcfb1 100644 --- a/lib/session-family.ts +++ b/lib/session-family.ts @@ -80,11 +80,18 @@ export function getSessionFamily( } /** Subagents of a family that the sidebar's running poll lists. */ +export function getRunningSubagents( + subagents: readonly SessionInfo[], + runningSessionIds: ReadonlySet, +): SessionInfo[] { + return subagents.filter((session) => runningSessionIds.has(session.id)); +} + export function countRunningSubagents( subagents: readonly SessionInfo[], runningSessionIds: ReadonlySet, ): number { - return subagents.filter((session) => runningSessionIds.has(session.id)).length; + return getRunningSubagents(subagents, runningSessionIds).length; } /** The top bar's Agents badge: how many run while any does, else how many there are. */ From 96f3633cb8c9e68273597898a4646ea7562c783d Mon Sep 17 00:00:00 2001 From: TossPig Date: Sun, 25 Oct 2026 19:28:27 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(chat):=20=E7=8B=AC=E7=AB=8B=E5=AD=90?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E7=8A=B6=E6=80=81=E7=BB=84=E4=BB=B6=E5=B9=B6?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=9B=9B=E6=96=B9=E5=9D=97=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/ChatActivityStatus.tsx | 42 ------------ components/ChatWindow.tsx | 20 ++++-- ...st.mjs => RunningSubagentsStatus.test.mjs} | 67 +++++++++++-------- components/RunningSubagentsStatus.tsx | 40 +++++++++++ docs/agents/subagents.md | 2 +- e2e/subagent-status.mjs | 20 +++++- lib/session-family.ts | 2 +- 7 files changed, 115 insertions(+), 78 deletions(-) delete mode 100644 components/ChatActivityStatus.tsx rename components/{ChatActivityStatus.test.mjs => RunningSubagentsStatus.test.mjs} (55%) create mode 100644 components/RunningSubagentsStatus.tsx diff --git a/components/ChatActivityStatus.tsx b/components/ChatActivityStatus.tsx deleted file mode 100644 index 165d9b2f79..0000000000 --- a/components/ChatActivityStatus.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import { useI18n } from "@/hooks/useI18n"; -import type { SessionInfo } from "@/lib/types"; - -interface Props { - label: string | null; - commandRunning: boolean; - runningSubagents?: readonly SessionInfo[]; -} - -/** Chat-tail activity; background subagents stay visible after the main turn settles. */ -export function ChatActivityStatus({ label, commandRunning, runningSubagents = [] }: Props) { - const { t, locale } = useI18n(); - const descriptions = runningSubagents.map((session) => ( - session.relation?.kind === "subagent" - ? session.relation.description.trim() || session.relation.profile - : session.id - )).join(locale === "en" ? ", " : "、"); - - return ( - <> - {label && ( -
- {label} -
- )} - {runningSubagents.length > 0 && ( -
- {t(runningSubagents.length === 1 ? "chat.runningSubagent" : "chat.runningSubagents", { - count: runningSubagents.length, descriptions, - })} -
- )} - {commandRunning && ( -
- {t("chat.runningCommand")} -
- )} - - ); -} diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index f3c7a3ce3a..151b8938a2 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -17,7 +17,7 @@ import { dropMentionText, splitDroppedItems, uploadFiles, type DroppedItem } fro import { MessageView } from "./MessageView"; import { MarkdownBody } from "./MarkdownBody"; import { ChatInput, type ChatInputHandle } from "./ChatInput"; -import { ChatActivityStatus } from "./ChatActivityStatus"; +import { RunningSubagentsStatus } from "./RunningSubagentsStatus"; import { ChatMinimap, useMessageRefs } from "./ChatMinimap"; import { ExtensionStatusBar } from "./ExtensionStatusBar"; import { AnsiText } from "./AnsiText"; @@ -1336,11 +1336,19 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi )} - + {agentRunning && !hasStreamingContent && (agentPhase || isCompacting) && ( +
+ {phaseLabel(agentPhase, t, isCompacting)} +
+ )} + + + + {bashRunning && !pendingBash && ( +
+ {t("chat.runningCommand")} +
+ )} {pendingBash && ( { +test("hides the entire block and icon when no subagents run", () => { assert.equal(renderStatus(), ""); - assert.equal(renderStatus({ runningSubagents: [] }), ""); + assert.equal(renderStatus([]), ""); }); -test("shows count and descriptions below the main phase without an Agents click", () => { - const html = renderStatus({ - label: "Waiting for model...", - runningSubagents: [child("a", "Check config"), child("b", "Read logs"), child("c", "Verify fix")], - }); +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.ok(html.indexOf("Waiting for model...") < html.indexOf("3 subagents running")); assert.match(html, /role="status"/); assert.match(html, /break-words/); + assert.doesNotMatch(html, /Waiting for model|Running command|pulse_1/); }); -test("keeps background status when the main agent is idle or streaming", () => { - const html = renderStatus({ runningSubagents: [child("a", "Review")] }); - assert.match(html, /1 subagent running \(Review\)/); - assert.doesNotMatch(html, /Waiting for model|animate-/); +test("shows a decorative four-square spinner before the text", () => { + const html = renderStatus([child("a", "Review")]); + assert.equal((html.match(/