From 3c5fcdd69e660ad2cf1ac191bec6b64eb571ec86 Mon Sep 17 00:00:00 2001 From: lihong Date: Sun, 20 Sep 2026 00:30:16 +0800 Subject: [PATCH 1/3] feat(aux-panel): right-edge icon rail, session-details sections, native child tabs - add the right-edge icon rail (s1) and retire the old right-edge chrome overlay; rework the Windows/Linux caption-overhang reserve to sit beside the new rail column (s2) - session-details tab: move the sub-agent rows INTO the details content via a beforeTokens slot (identifiers -> sub-agents -> tokens -> timestamps -> tasks); shared PlanEntryRow / DelegationRow / NativeSubAgentRow components - unified child-session open: clicking any agent's native sub-agent row idempotently upserts a hidden delegate conversation (open_native_subagent_session, also exposed over the web API) and opens a pinned child tab beside the parent, reusing the full chat window; the composer only appears once the connection lands on the child's own handle - tab store: register native child tabs with parent-cascade close - i18n: new copy across all 10 locales --- docs/specs/right-edge-rail/README.md | 56 ++ .../right-edge-rail/openchamber-reference.png | Bin 0 -> 87844 bytes .../right-edge-rail/s1-right-edge-rail.md | 73 +++ .../right-edge-rail/s2-reserve-cleanup.md | 65 ++ .../s3-session-details-sections.md | 79 +++ .../right-edge-rail/s4-i18n-acceptance.md | 56 ++ src-tauri/src/commands/conversations.rs | 380 ++++++++++- src-tauri/src/lib.rs | 1 + src-tauri/src/models/message.rs | 2 +- src-tauri/src/web/handlers/conversations.rs | 24 + src-tauri/src/web/router.rs | 4 + src/app/workspace/layout.tsx | 261 ++++---- src/components/chat/agent-plan-overlay.tsx | 72 +-- src/components/chat/delegation-row.tsx | 125 ++++ src/components/chat/native-subagent-row.tsx | 243 +++++++ src/components/chat/plan-entry-row.tsx | 63 ++ src/components/chat/sub-agent-overlay.tsx | 113 +--- .../conversation-detail-panel-layout.test.ts | 10 +- .../conversation-detail-panel.tsx | 40 +- .../conversations/session-details-content.tsx | 12 + .../aux-panel-session-details-tab.test.tsx | 579 ++++++++++++++++- .../layout/aux-panel-session-details-tab.tsx | 376 ++++++++++- src/components/layout/aux-panel.test.tsx | 25 +- src/components/layout/aux-panel.tsx | 16 +- src/components/layout/folder-title-bar.tsx | 6 +- src/components/layout/right-edge-chrome.tsx | 114 ---- .../layout/right-edge-rail.test.tsx | 138 ++++ src/components/layout/right-edge-rail.tsx | 202 ++++++ src/components/message/agent-tool-call.tsx | 153 +---- .../message/message-list-view.test.tsx | 2 +- src/components/message/message-list-view.tsx | 85 +-- src/components/message/plan-card.tsx | 4 +- src/components/tasks/tasks-chrome-actions.tsx | 3 +- .../workbench/workbench-content.tsx | 2 +- src/contexts/aux-panel-context.tsx | 21 +- src/contexts/delegation-context.tsx | 27 +- src/i18n/messages/ar.json | 7 +- src/i18n/messages/de.json | 7 +- src/i18n/messages/en.json | 7 +- src/i18n/messages/es.json | 7 +- src/i18n/messages/fr.json | 7 +- src/i18n/messages/ja.json | 7 +- src/i18n/messages/ko.json | 7 +- src/i18n/messages/pt.json | 7 +- src/i18n/messages/zh-CN.json | 7 +- src/i18n/messages/zh-TW.json | 7 +- src/lib/api.ts | 29 + src/lib/collab-tool.ts | 10 + src/lib/delegation-sources.test.ts | 453 +++++++++++++ src/lib/delegation-sources.ts | 599 ++++++++++++++++++ src/lib/native-subagent-fields.test.ts | 154 +++++ src/lib/native-subagent-fields.ts | 168 +++++ src/lib/tool-call-normalization.test.ts | 30 + src/lib/tool-call-normalization.ts | 19 + src/lib/window-chrome.test.ts | 49 +- src/lib/window-chrome.ts | 41 +- .../tab-store-native-child-cascade.test.ts | 185 ++++++ src/stores/tab-store.ts | 58 ++ 58 files changed, 4523 insertions(+), 774 deletions(-) create mode 100644 docs/specs/right-edge-rail/README.md create mode 100644 docs/specs/right-edge-rail/openchamber-reference.png create mode 100644 docs/specs/right-edge-rail/s1-right-edge-rail.md create mode 100644 docs/specs/right-edge-rail/s2-reserve-cleanup.md create mode 100644 docs/specs/right-edge-rail/s3-session-details-sections.md create mode 100644 docs/specs/right-edge-rail/s4-i18n-acceptance.md create mode 100644 src/components/chat/delegation-row.tsx create mode 100644 src/components/chat/native-subagent-row.tsx create mode 100644 src/components/chat/plan-entry-row.tsx delete mode 100644 src/components/layout/right-edge-chrome.tsx create mode 100644 src/components/layout/right-edge-rail.test.tsx create mode 100644 src/components/layout/right-edge-rail.tsx create mode 100644 src/lib/delegation-sources.test.ts create mode 100644 src/lib/delegation-sources.ts create mode 100644 src/lib/native-subagent-fields.test.ts create mode 100644 src/lib/native-subagent-fields.ts create mode 100644 src/stores/tab-store-native-child-cascade.test.ts diff --git a/docs/specs/right-edge-rail/README.md b/docs/specs/right-edge-rail/README.md new file mode 100644 index 0000000000..8b643e27a1 --- /dev/null +++ b/docs/specs/right-edge-rail/README.md @@ -0,0 +1,56 @@ +# 右侧图标栏 + 右栏分区改造 — 总览 + +参考 openchamber 的右侧竖排图标栏形态,对 codeg 工作区做两项改造: + +> 参考效果图(openchamber 右栏 + 最右图标栏):[openchamber-reference.png](./openchamber-reference.png) +> — 右栏自上而下为:会话(上下文 %)、项目(agents)、轮次统计、**子代理**(可折叠、 +> 右侧计数、每行"名称 + 状态")、**任务**("已完成/总数"进度 + 每行任务); +> 最右缘为全高竖排图标栏。 + +1. **最右侧竖排图标栏**:取代现在钉在窗口右上角的横排按钮(终端 / 右栏开关 / 设置), + 变成贴窗口右缘、全高、占位的独立竖条;右栏各 tab 直达。 +2. **右栏"会话详情"tab 内新增分区**:把消息区左上角浮动的"智能体计划"任务列表与 + 子代理列表,以分节形式并入右栏会话详情(浮层本身保留)。 + +## 分期 + +| 期 | 内容 | spec | +|---|---|---| +| s1 | 最右侧竖排图标栏(新组件 + 挂载 + 退役旧右上角簇) | [s1-right-edge-rail.md](./s1-right-edge-rail.md) | +| s2 | 清理右缘避让机制(window-chrome 常量、aux tab 条塌陷逻辑) | [s2-reserve-cleanup.md](./s2-reserve-cleanup.md) | +| s3 | 会话详情 tab 新增"任务"与"子代理"分区 | [s3-session-details-sections.md](./s3-session-details-sections.md) | +| s4 | i18n 十语言文案 + 测试与验收 | [s4-i18n-acceptance.md](./s4-i18n-acceptance.md) | + +## 已确认的决策(用户拍板) + +- 任务列表以**会话详情 tab 内分区**呈现(不另开 tab、不合并成新 tab)。 +- 消息区左上角的浮动计划卡 / 子代理浮层**保留**,与右栏分区数据同源。 +- 图标栏内容:**右栏各 tab + 终端 + 设置**;左栏切换仍留在左上角 `LeftEdgeChrome`。 +- 图标栏实现:**占位的独立竖条**(布局中真实的右缘列,非 fixed 悬浮), + Windows/Linux 原生标题按钮保持 fixed 于右上角,图标栏顶部预留 `h-10` 拖拽区让位。 + +## 关键现状(探索结论) + +- 右上角横排按钮:`src/components/layout/right-edge-chrome.tsx`, + 由 `src/app/workspace/layout.tsx:1217-1231` 以 fixed overlay 钉住。 +- 避让体系:`src/lib/window-chrome.ts` 的 `RIGHT_CHROME_CLUSTER=116` / + `rightChromeReserve` / `rightChromeClusterWidth`,消费方: + `workspace/layout.tsx:328`(中列 reserve)、`conversation-detail-panel.tsx:2331`、 + `aux-panel.tsx:192`(tab 条塌陷 `shouldCollapseAuxTabs`)。 +- 右栏:`src/components/layout/aux-panel.tsx`,4 tab;状态在 + `src/contexts/aux-panel-context.tsx`(`AuxPanelTab`、localStorage + `workspace:right-sidebar`)。 +- 浮动任务卡:`src/components/chat/agent-plan-overlay.tsx`;子代理浮层: + `src/components/chat/sub-agent-overlay.tsx`;共栈容器在 + `src/components/message/message-list-view.tsx:1612-1644`。 +- 子代理数据:`extractDelegationSources`(`message-list-view.tsx:409-467`)、 + `useDelegationCardModel`、`src/contexts/delegation-context.tsx`。 +- 会话详情 tab:`src/components/layout/aux-panel-session-details-tab.tsx` → + `SessionDetailsContent`(`src/components/conversations/session-details-content.tsx`)。 + +## 不动的东西 + +- 后端 / Rust 层零改动;数据全部来自既有 store/context。 +- 移动端(`MobileFolderWorkspaceShell`、`folder-title-bar.tsx`、抽屉)不动。 +- 快捷键语义(`toggle_aux_panel`、`toggle_terminal`、`open_settings`)不变。 +- 左栏 `LeftEdgeChrome`、`WindowControls`、`StatusBar` 位置不变。 diff --git a/docs/specs/right-edge-rail/openchamber-reference.png b/docs/specs/right-edge-rail/openchamber-reference.png new file mode 100644 index 0000000000000000000000000000000000000000..aa681083efa933e9c571c0516b26e08bfef43ca7 GIT binary patch literal 87844 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%0DEzu& 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 literal 0 HcmV?d00001 diff --git a/docs/specs/right-edge-rail/s1-right-edge-rail.md b/docs/specs/right-edge-rail/s1-right-edge-rail.md new file mode 100644 index 0000000000..3267cf0594 --- /dev/null +++ b/docs/specs/right-edge-rail/s1-right-edge-rail.md @@ -0,0 +1,73 @@ +# s1 — 最右侧竖排图标栏 + +## 目标 + +新建贴窗口右缘、全高、占位的竖排图标栏,取代 `RightEdgeChrome` 的右上角横排按钮。 +形态对齐 openchamber:一列图标,点哪个面板开哪个,当前激活项高亮。 + +## 新组件 + +`src/components/layout/right-edge-rail.tsx` — `RightEdgeRail()` + +- 结构:` + ) +} diff --git a/src/components/message/agent-tool-call.tsx b/src/components/message/agent-tool-call.tsx index 16e04ac98c..b41eb04e29 100644 --- a/src/components/message/agent-tool-call.tsx +++ b/src/components/message/agent-tool-call.tsx @@ -1,10 +1,13 @@ import { memo, useMemo, useState, type ReactNode } from "react" import type { AdaptedContentPart } from "@/lib/adapters/ai-elements-adapter" -import type { AgentToolCall, AgentType } from "@/lib/types" -import { tryParseJson, extractJsonField } from "./content-parts-renderer" +import type { AgentToolCall } from "@/lib/types" import { SubagentSessionDialog } from "./subagent-session-dialog" import { useSessionViewerHost } from "./session-viewer-host" import { shortAgentId } from "@/lib/collab-tool" +import { + childSessionOfLaunch, + parseSubAgentLaunchFields, +} from "@/lib/native-subagent-fields" import { MessageResponse } from "@/components/ai-elements/message" import { Shimmer } from "@/components/ai-elements/shimmer" import { @@ -54,15 +57,6 @@ function adaptToolCalls( ) } -// A parsed JSON field is only usable here if it's a non-empty STRING. Some -// hosts (e.g. CodeBuddy) hand us inputs where `subagent_type` / `description` -// arrive as objects (or empty `{}`); the old `as string` casts let those leak -// straight into the rendered `title`, crashing React with "Objects are not -// valid as a React child". Coerce so a non-string field is treated as absent. -function asText(v: unknown): string | null { - return typeof v === "string" && v.length > 0 ? v : null -} - interface TaskOutcomeEnvelope { durationMs: number | null isBackground: boolean @@ -163,49 +157,6 @@ function parseGrokSubagentProgress( : null } -/** - * The child's own session, when the sub-agent ran as a standalone session on - * disk. TWO agents do this, and for the same reason: the child is a full - * session that streams its transcript to disk while none of it is forwarded - * over ACP, so opening that session is the ONLY way to see the child's work. - * - * Grok, live, arrives as `meta.grokSubagentSession.childSessionId` - * (`connection.rs::grok_subagent_meta`, re-sent on every progress tick because - * meta is replaced wholesale); in history it comes off the parsed - * `agent_stats.child_session_id` (`parsers/grok.rs::subagent_stats`). - * - * Codex needs neither, because its child's thread id IS its rollout's id and - * the card already carries it as `agent_id` — the badge and the session key are - * the same string. Both of its paths already write it - * (`connection.rs::classify_codex_subagent_activity` live, - * `parsers/codex.rs::inject_agent_id_into_input` on reload), alongside the - * launch marker that identifies the producer. - * - * The agent type is pinned per branch rather than read from the conversation: - * the card has no conversation-level agent type of its own, and a parent's - * child is a session of the parent's own kind in both cases. A third producer - * of `child_session_id` is the line to revisit. - */ -function parseChildSessionId( - meta: Record | null | undefined, - statsChildSessionId: string | null | undefined, - codexSubagentId: string | null -): { sessionId: string; agentType: AgentType } | null { - const raw = meta?.grokSubagentSession - if (raw && typeof raw === "object" && !Array.isArray(raw)) { - const live = (raw as Record).childSessionId - if (typeof live === "string" && live.length > 0) { - return { sessionId: live, agentType: "grok" } - } - } - if (statsChildSessionId && statsChildSessionId.length > 0) { - return { sessionId: statsChildSessionId, agentType: "grok" } - } - return codexSubagentId - ? { sessionId: codexSubagentId, agentType: "codex" } - : null -} - /** * How the codex sub-agent itself ended, as a pill chip. * @@ -263,10 +214,25 @@ export const AgentToolCallPart = memo(function AgentToolCallPart({ part.state === "input-available" || part.state === "input-streaming" const isError = part.state === "output-error" - const parsed = useMemo( - () => (part.input ? tryParseJson(part.input) : null), + // Shared with the aux panel's native-sub-agent rows: one parser for the + // launch payload, so the two surfaces never drift on field spellings. + const launchFields = useMemo( + () => parseSubAgentLaunchFields(part.input ?? null), [part.input] ) + const { + subagentType, + description, + prompt, + model, + agentId, + isCursorTask, + // The launch capsule's own status describes the spawn, so the codex + // child's outcome (codexSubagentState) needs its own chip — see + // `SubAgentLaunchFields`. + isCodexSubagentLaunch: isCodexSubagent, + codexSubagentState, + } = launchFields // Background sub-agent lifecycle. Historical/refetched turns carry the // parser's structured marker (settled state + summary + result folded from @@ -282,8 +248,8 @@ export const AgentToolCallPart = memo(function AgentToolCallPart({ // suffix / error box / background label) instead of dumping raw JSON into // the body. Gated on the live input's `_toolName:"task"` identity stamp. const taskOutcome = useMemo( - () => parseTaskOutcomeEnvelope(part.output, parsed?._toolName === "task"), - [part.output, parsed] + () => parseTaskOutcomeEnvelope(part.output, isCursorTask), + [part.output, isCursorTask] ) const outcomeError = taskOutcome?.error ?? null const outcomeBackground = taskOutcome?.isBackground === true @@ -302,68 +268,6 @@ export const AgentToolCallPart = memo(function AgentToolCallPart({ [part.agentTranscript] ) - const subagentType = useMemo( - () => - asText(parsed?.subagent_type) ?? - // Codex's live `spawn_agent` payload labels the agent with `agent_type` - // instead of `subagent_type` (the historical parser already maps it - // across). Read both so the prefix shows during streaming too. - asText(parsed?.agent_type) ?? - // Cursor's live task payload carries `subagentType` as a protobuf-es - // oneof object ({case: "generalPurpose", …}); its history parser emits - // a plain snake_case string, so read the live case here for parity. - asText(parsed?.subagentType) ?? - asText((parsed?.subagentType as { case?: unknown } | undefined)?.case) ?? - (part.input ? extractJsonField(part.input, "subagent_type") : null) ?? - (part.input ? extractJsonField(part.input, "agent_type") : null), - [parsed, part.input] - ) - - const description = useMemo( - () => - asText(parsed?.description) ?? - (part.input ? extractJsonField(part.input, "description") : null), - [parsed, part.input] - ) - - const prompt = useMemo( - () => - asText(parsed?.prompt) ?? - (part.input ? extractJsonField(part.input, "prompt") : null), - [parsed, part.input] - ) - - const model = useMemo( - () => - asText(parsed?.model) ?? - (part.input ? extractJsonField(part.input, "model") : null), - [parsed, part.input] - ) - - // codex 0.147's native team-of-agents marks its capsules as LAUNCH-only - // (`CODEX_SUBAGENT_LAUNCH_KEY`, written by both the live path and the rollout - // parser). The card settles when codex acknowledges the spawn, which is not - // when the child finishes — an asynchronous child can still be working long - // after. Say so, rather than let a green "completed" claim the sub-agent is - // done. - const isCodexSubagent = parsed?.__codegCodexSubagentLaunch === true - - // …and codex DOES eventually say how the child ended - // (`SubAgentActivity{kind}`), which both paths stamp here. Present only once - // that has been heard; while it is absent the child's fate is genuinely - // unknown, which is what the launch note describes. - const codexSubagentState = asText(parsed?.__codegCodexSubagentState) - - // codex spawn capsules carry the sub-agent's UUID (`agent_id`); show it in the - // pill so the execution capsule reads uniformly with the live/wait collab - // capsules. Other agents (e.g. Claude Task) have no `agent_id` → no badge. - const agentId = useMemo( - () => - asText(parsed?.agent_id) ?? - (part.input ? extractJsonField(part.input, "agent_id") : null), - [parsed, part.input] - ) - const title = useMemo(() => { if (subagentType) { return description ? `${subagentType}: ${description}` : subagentType @@ -421,13 +325,8 @@ export const AgentToolCallPart = memo(function AgentToolCallPart({ // running child can be watched while it works instead of only after it // reports back. const childSession = useMemo( - () => - parseChildSessionId( - part.meta, - agentStats?.child_session_id, - isCodexSubagent ? agentId : null - ), - [part.meta, agentStats?.child_session_id, isCodexSubagent, agentId] + () => childSessionOfLaunch(launchFields, part.meta, agentStats), + [launchFields, part.meta, agentStats] ) const viewerHost = useSessionViewerHost() const [sessionOpen, setSessionOpen] = useState(false) diff --git a/src/components/message/message-list-view.test.tsx b/src/components/message/message-list-view.test.tsx index 36afd24f69..eaa234675f 100644 --- a/src/components/message/message-list-view.test.tsx +++ b/src/components/message/message-list-view.test.tsx @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest" import { advanceReplyFold, dedupeCompactionItems, - extractDelegationSources, isForkPointUnnamed, markThreadTail, mergeConsecutiveAssistantTurns, @@ -13,6 +12,7 @@ import { type ResolvedMessageGroup, type ThreadRenderItem, } from "./message-list-view" +import { extractDelegationSources } from "@/lib/delegation-sources" import type { AdaptedContentPart } from "@/lib/adapters/ai-elements-adapter" import type { MessageTurn } from "@/lib/types" diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index 3ea21ded51..73cabb2fa6 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -36,12 +36,7 @@ import { UserImageAttachments } from "./user-image-attachments" import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay" import { SubAgentOverlay } from "@/components/chat/sub-agent-overlay" import { SessionViewerHost } from "@/components/message/session-viewer-host" -import { normalizeToolName } from "@/lib/tool-call-normalization" -import { parseResumeTaskId } from "@/lib/codeg-mcp-tool" -import { - isDelegateToAgentToolName, - isRefusedResume, -} from "@/lib/delegation-card" +import { extractDelegationSources } from "@/lib/delegation-sources" import type { DelegationCardSource } from "@/hooks/use-delegation-card-model" import { MessageThread, @@ -388,84 +383,6 @@ export function singletonSourceTurns(turn: MessageTurn): MessageTurn[] { return cached } -// Collect the sub-agent delegations within a turn's adapted parts, recursing -// through tool-groups and goal-runs (both kinds are normally standalone parts — -// `isAgentLikeToolName` keeps them out of tool-groups — but we scan nested -// containers defensively so a delegation is never missed). -// -// Two kinds qualify: -// - `delegate_to_agent`, which STARTED a sub-agent, keyed by its own -// tool_use_id; -// - `resume_delegation`, which brought an interrupted one BACK. Its own -// tool_call_id is not a binding key (the broker re-binds the child to the -// original delegate call, usually in an earlier turn), so it is keyed by -// the task id in its arguments — `taskIdHint`, exactly as -// `ResumedDelegationCard` does. Without this arm a resumed sub-agent would -// be missing from the overlay while it runs, because the reply that -// resumed it contains no `delegate_to_agent` call at all. -// -// `seenTaskIds` de-dupes repeated resumes of one task inside a single reply -// (the second is refused, but the overlay renders a row per source regardless). -function collectDelegationSources( - parts: AdaptedContentPart[], - out: DelegationCardSource[], - seenTaskIds: Set -): void { - for (const part of parts) { - if (part.type === "tool-call") { - if (!part.toolCallId) continue - const name = normalizeToolName(part.toolName) - if (isDelegateToAgentToolName(name)) { - out.push({ - parentToolUseId: part.toolCallId, - input: part.input ?? null, - output: part.output ?? null, - errorText: part.errorText ?? null, - state: part.state, - meta: part.meta ?? null, - }) - } else if (name === "resume_delegation") { - // A refusal names the task's agent and child but revived nothing — - // listing it would put a sub-agent in the overlay that is not running - // on this turn's behalf. Same judgement as `ResumedDelegationCard`, - // which falls back to the plain tool card here. - if (isRefusedResume(part.output ?? null, part.errorText ?? null)) { - continue - } - const taskId = parseResumeTaskId(part.input ?? null) - // No task id ⇒ nothing to resolve the sub-agent by; a duplicate ⇒ - // already listed. - if (!taskId || seenTaskIds.has(taskId)) continue - seenTaskIds.add(taskId) - out.push({ - parentToolUseId: part.toolCallId, - taskIdHint: taskId, - // Deliberately not the resume's `{task_id, reason}` arguments — - // `parseInput` looks for `task`/`agent_type`/`working_dir` and would - // only warn about an unrecognized shape. See `ResumedDelegationCard`. - input: null, - output: part.output ?? null, - errorText: part.errorText ?? null, - state: part.state, - meta: part.meta ?? null, - }) - } - } else if (part.type === "tool-group") { - collectDelegationSources(part.items, out, seenTaskIds) - } else if (part.type === "goal-run") { - collectDelegationSources(part.items, out, seenTaskIds) - } - } -} - -export function extractDelegationSources( - parts: AdaptedContentPart[] -): DelegationCardSource[] { - const out: DelegationCardSource[] = [] - collectDelegationSources(parts, out, new Set()) - return out -} - function extractTextFromParts(parts: AdaptedContentPart[]): string { return parts .flatMap((p): string[] => { diff --git a/src/components/message/plan-card.tsx b/src/components/message/plan-card.tsx index 5f4f5b1a28..d48c330176 100644 --- a/src/components/message/plan-card.tsx +++ b/src/components/message/plan-card.tsx @@ -70,7 +70,9 @@ export function getPriorityClassName(priority: string): string { } } -function StatusIcon({ +// Exported: `PlanEntryRow` (overlay + aux-panel rows) renders the SAME icon +// set, so a status can never look different across plan surfaces. +export function StatusIcon({ status, isStreaming, }: { diff --git a/src/components/tasks/tasks-chrome-actions.tsx b/src/components/tasks/tasks-chrome-actions.tsx index 421ea77deb..74b1fa67f2 100644 --- a/src/components/tasks/tasks-chrome-actions.tsx +++ b/src/components/tasks/tasks-chrome-actions.tsx @@ -19,8 +19,7 @@ export const OPEN_TASK_SETTINGS_EVENT = "codeg:open-task-settings" * * They cost nothing in width: a full-page route hides the terminal and aux * toggles (they act on the workspace this route covers), so these two take - * their place and the cluster keeps its three-button reservation - * (RIGHT_CHROME_CLUSTER). + * their place in the top-right caption cluster. */ export function TasksChromeActions({ buttonClassName, diff --git a/src/components/workbench/workbench-content.tsx b/src/components/workbench/workbench-content.tsx index e481d04b25..3b6e21f091 100644 --- a/src/components/workbench/workbench-content.tsx +++ b/src/components/workbench/workbench-content.tsx @@ -83,7 +83,7 @@ export function WorkbenchRouteStrip() { } /** The active route's chrome-cluster buttons, or nothing. Rendered by both - * chrome hosts (RightEdgeChrome on desktop, FolderTitleBar on mobile). */ + * chrome hosts (RightEdgeRail on desktop, FolderTitleBar on mobile). */ export function WorkbenchRouteChromeActions( props: WorkbenchChromeActionsProps ) { diff --git a/src/contexts/aux-panel-context.tsx b/src/contexts/aux-panel-context.tsx index c7dfe89403..b2874d21ab 100644 --- a/src/contexts/aux-panel-context.tsx +++ b/src/contexts/aux-panel-context.tsx @@ -14,8 +14,6 @@ import { savePersistedPanelState, } from "@/lib/panel-state-storage" import { useActiveFolder } from "@/contexts/active-folder-context" -import { detectPlatform } from "@/hooks/use-platform" -import { isDesktop } from "@/lib/platform" export type AuxPanelTab = | "session_details" @@ -30,19 +28,6 @@ const MIN_WIDTH = 200 const MAX_WIDTH = 900 const DEFAULT_IS_OPEN = false -// The tabs now sit on their own row below the fixed top-right window-chrome -// overlay (terminal/aux/settings), so they no longer need extra width to clear -// it. The minimum only has to keep that overlay — and, on Windows/Linux, the -// native caption strip beside it (~116 + 138) — from spilling past the panel's -// left edge over the middle column. Elsewhere the base 200 is plenty. -function resolveAuxMinWidth(): number { - const platform = detectPlatform() - if (isDesktop() && (platform === "windows" || platform === "linux")) { - return 260 - } - return MIN_WIDTH -} - interface AuxPanelContextValue { isOpen: boolean restored: boolean @@ -89,8 +74,10 @@ export function AuxPanelProvider({ children }: AuxPanelProviderProps) { const [pendingRevealPath, setPendingRevealPath] = useState( null ) - // Platform-derived minimum (see resolveAuxMinWidth); stable for the session. - const minWidth = useMemo(() => resolveAuxMinWidth(), []) + // Uniform minimum: the right-edge rail is a beside-column now and the + // Windows/Linux caption overhang past it (98px) only affects the strip's + // collapse-to-dropdown switch, not the panel's usable floor. + const minWidth = MIN_WIDTH const toggle = useCallback(() => setIsOpen((prev) => !prev), []) diff --git a/src/contexts/delegation-context.tsx b/src/contexts/delegation-context.tsx index e370e87fe4..4c2cf7d455 100644 --- a/src/contexts/delegation-context.tsx +++ b/src/contexts/delegation-context.tsx @@ -69,6 +69,14 @@ interface DelegationContextValue { * tool_call_id matches no binding — the task id is the only handle it holds. */ findByTaskId(taskId: string): DelegationBinding | undefined + /** + * Every live binding, in insertion order. The aux panel's session-details + * sub-agents section needs a whole-collection view the keyed lookups can't + * give it — the provider is mounted once above every conversation, so the + * caller scopes the result itself (by `parentConnectionId`). Read-only: + * mutating the array does not touch provider state. + */ + listAllBindings(): DelegationBinding[] } const DelegationContext = createContext(null) @@ -251,9 +259,24 @@ export function DelegationProvider({ children }: { children: ReactNode }) { [byToolUseId] ) + const listAllBindings = useCallback( + (): DelegationBinding[] => Array.from(byToolUseId.values()), + [byToolUseId] + ) + const value = useMemo( - () => ({ findByParentToolUseId, findByChildConversationId, findByTaskId }), - [findByParentToolUseId, findByChildConversationId, findByTaskId] + () => ({ + findByParentToolUseId, + findByChildConversationId, + findByTaskId, + listAllBindings, + }), + [ + findByParentToolUseId, + findByChildConversationId, + findByTaskId, + listAllBindings, + ] ) return ( diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 08361d289d..f65acc2390 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1982,7 +1982,8 @@ "toolResult": "النتيجة", "toolError": "خطأ" }, - "moreActions": "إجراءات إضافية" + "moreActions": "إجراءات إضافية", + "childSessionReadOnly": "لا يمكن متابعة جلسة الوكيل الفرعي هذه — السجل للقراءة فقط." }, "sessionDetails": { "menuLabel": "تفاصيل الجلسة", @@ -2017,7 +2018,9 @@ "updatedAt": "تاريخ التحديث", "none": "—", "copyField": "نسخ {field}", - "copiedField": "تم نسخ {field}" + "copiedField": "تم نسخ {field}", + "tasksHeading": "المهام", + "subAgentsHeading": "الوكلاء الفرعيون" }, "conversationCard": { "untitledConversation": "محادثة بدون عنوان", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index a4fcf64caa..4118f8c5ef 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1982,7 +1982,8 @@ "toolResult": "Ergebnis", "toolError": "Fehler" }, - "moreActions": "Weitere Aktionen" + "moreActions": "Weitere Aktionen", + "childSessionReadOnly": "Diese Unteragent-Sitzung kann nicht fortgesetzt werden — das Protokoll ist schreibgeschützt." }, "sessionDetails": { "menuLabel": "Sitzungsdetails", @@ -2017,7 +2018,9 @@ "updatedAt": "Aktualisiert", "none": "—", "copyField": "{field} kopieren", - "copiedField": "{field} kopiert" + "copiedField": "{field} kopiert", + "tasksHeading": "Aufgaben", + "subAgentsHeading": "Unter-Agenten" }, "conversationCard": { "untitledConversation": "Unbenannte Konversation", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 7c84cab256..c5a1ec9b3c 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1982,7 +1982,8 @@ "toolResult": "Result", "toolError": "Error" }, - "moreActions": "More actions" + "moreActions": "More actions", + "childSessionReadOnly": "This sub-agent session can't be continued — its transcript is read-only." }, "sessionDetails": { "menuLabel": "Session Details", @@ -2017,7 +2018,9 @@ "updatedAt": "Updated", "none": "—", "copyField": "Copy {field}", - "copiedField": "Copied {field}" + "copiedField": "Copied {field}", + "tasksHeading": "Tasks", + "subAgentsHeading": "Sub-agents" }, "conversationCard": { "untitledConversation": "Untitled conversation", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 7d7af796b7..b35fbd43e5 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1982,7 +1982,8 @@ "toolResult": "Resultado", "toolError": "Error" }, - "moreActions": "Más acciones" + "moreActions": "Más acciones", + "childSessionReadOnly": "Esta sesión de subagente no se puede continuar: el registro es de solo lectura." }, "sessionDetails": { "menuLabel": "Detalles de la sesión", @@ -2017,7 +2018,9 @@ "updatedAt": "Actualizado", "none": "—", "copyField": "Copiar {field}", - "copiedField": "{field} copiado" + "copiedField": "{field} copiado", + "tasksHeading": "Tareas", + "subAgentsHeading": "Subagentes" }, "conversationCard": { "untitledConversation": "Conversación sin título", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 12da2f2c78..092b2300d2 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1982,7 +1982,8 @@ "toolResult": "Résultat", "toolError": "Erreur" }, - "moreActions": "Plus d’actions" + "moreActions": "Plus d’actions", + "childSessionReadOnly": "Cette session de sous-agent ne peut pas être reprise : le journal est en lecture seule." }, "sessionDetails": { "menuLabel": "Détails de la session", @@ -2017,7 +2018,9 @@ "updatedAt": "Mis à jour", "none": "—", "copyField": "Copier {field}", - "copiedField": "{field} copié" + "copiedField": "{field} copié", + "tasksHeading": "Tâches", + "subAgentsHeading": "Sous-agents" }, "conversationCard": { "untitledConversation": "Conversation sans titre", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index be0fef3a5b..e3a3c7f4fd 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1982,7 +1982,8 @@ "toolResult": "結果", "toolError": "エラー" }, - "moreActions": "その他の操作" + "moreActions": "その他の操作", + "childSessionReadOnly": "このサブエージェントのセッションは続行できません。記録は閲覧専用です。" }, "sessionDetails": { "menuLabel": "セッション詳細", @@ -2017,7 +2018,9 @@ "updatedAt": "更新日時", "none": "—", "copyField": "{field}をコピー", - "copiedField": "{field}をコピーしました" + "copiedField": "{field}をコピーしました", + "tasksHeading": "タスク", + "subAgentsHeading": "サブエージェント" }, "conversationCard": { "untitledConversation": "無題の会話", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 43e1b7255d..13f9cc3d05 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1982,7 +1982,8 @@ "toolResult": "결과", "toolError": "오류" }, - "moreActions": "추가 작업" + "moreActions": "추가 작업", + "childSessionReadOnly": "이 하위 에이전트 세션은 계속할 수 없습니다. 기록은 읽기 전용입니다." }, "sessionDetails": { "menuLabel": "세션 세부 정보", @@ -2017,7 +2018,9 @@ "updatedAt": "업데이트됨", "none": "—", "copyField": "{field} 복사", - "copiedField": "{field} 복사됨" + "copiedField": "{field} 복사됨", + "tasksHeading": "작업", + "subAgentsHeading": "서브 에이전트" }, "conversationCard": { "untitledConversation": "제목 없는 대화", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 095a75b598..e9adeb5799 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1982,7 +1982,8 @@ "toolResult": "Resultado", "toolError": "Erro" }, - "moreActions": "Mais ações" + "moreActions": "Mais ações", + "childSessionReadOnly": "Essa sessão de subagente não pode ser continuada — o registro é somente leitura." }, "sessionDetails": { "menuLabel": "Detalhes da sessão", @@ -2017,7 +2018,9 @@ "updatedAt": "Atualizado", "none": "—", "copyField": "Copiar {field}", - "copiedField": "{field} copiado" + "copiedField": "{field} copiado", + "tasksHeading": "Tarefas", + "subAgentsHeading": "Subagentes" }, "conversationCard": { "untitledConversation": "Conversa sem título", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index dc4e9eeeed..38cb00c13e 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1982,7 +1982,8 @@ "toolResult": "结果", "toolError": "错误" }, - "moreActions": "更多操作" + "moreActions": "更多操作", + "childSessionReadOnly": "该子代理会话无法继续,记录仅供查看。" }, "sessionDetails": { "menuLabel": "会话详情", @@ -2017,7 +2018,9 @@ "updatedAt": "更新时间", "none": "—", "copyField": "复制{field}", - "copiedField": "已复制{field}" + "copiedField": "已复制{field}", + "tasksHeading": "任务", + "subAgentsHeading": "子代理" }, "conversationCard": { "untitledConversation": "未命名会话", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index d5500e9e49..05db001166 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1982,7 +1982,8 @@ "toolResult": "結果", "toolError": "錯誤" }, - "moreActions": "更多操作" + "moreActions": "更多操作", + "childSessionReadOnly": "該子代理會話無法繼續,記錄僅供檢視。" }, "sessionDetails": { "menuLabel": "對話詳情", @@ -2017,7 +2018,9 @@ "updatedAt": "更新時間", "none": "—", "copyField": "複製{field}", - "copiedField": "已複製{field}" + "copiedField": "已複製{field}", + "tasksHeading": "任務", + "subAgentsHeading": "子代理" }, "conversationCard": { "untitledConversation": "未命名會話", diff --git a/src/lib/api.ts b/src/lib/api.ts index e8e62b3fc8..6d0ad2a56b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -3324,6 +3324,35 @@ export async function createConversation( }) } +export interface OpenNativeSubagentSessionResult { + conversationId: number + agentType: AgentType + folderId: number +} + +/** + * Register an agent's OWN spawned child session (the handle from a native + * sub-agent launch — grok/codex/opencode/cursor/claude/…) as a regular + * conversation row so it can open as an ordinary chat tab: `external_id` + * carries the child's own session id and `parent_id` links it back to the + * launching conversation, which keeps it out of the sidebar. + * + * Idempotent on (agentType, childSessionId): repeated clicks return the same + * row. The returned `agentType`/`folderId` are read from the PARENT row and + * are the authoritative pair for `openTab` / ACP connect. + */ +export async function openNativeSubagentSession( + parentConversationId: number, + childSessionId: string, + title?: string +): Promise { + return getTransport().call("open_native_subagent_session", { + parentConversationId, + childSessionId, + title: title ?? null, + }) +} + /** * Create a folderless "chat mode" conversation. The backend lazily creates a * dated per-conversation scratch dir and a dedicated hidden chat folder diff --git a/src/lib/collab-tool.ts b/src/lib/collab-tool.ts index e553fcf06f..178bd2f782 100644 --- a/src/lib/collab-tool.ts +++ b/src/lib/collab-tool.ts @@ -263,6 +263,16 @@ export function mergeCollabOp( return JSON.stringify({ ...parsed, [COLLAB_OP_KEY]: cleanOp }) } +/** Read the merged op back out of a collab `rawInput` (the reader half of + * `mergeCollabOp`; null for any non-collab or un-merged input). */ +export function parseCollabOp( + rawInput: string | null | undefined +): string | null { + const parsed = tryParseObject(rawInput) + if (!parsed) return null + return asText(parsed[COLLAB_OP_KEY]) +} + /** Parse the displayable fields out of a collab tool call's `rawInput`. */ export function parseCollabToolInput( rawInput: string | null | undefined diff --git a/src/lib/delegation-sources.test.ts b/src/lib/delegation-sources.test.ts new file mode 100644 index 0000000000..dab84801b1 --- /dev/null +++ b/src/lib/delegation-sources.test.ts @@ -0,0 +1,453 @@ +import { describe, expect, it } from "vitest" + +import type { DelegationBinding } from "@/contexts/delegation-context" +import type { DelegationCardSource } from "@/hooks/use-delegation-card-model" +import type { AdaptedContentPart } from "@/lib/adapters/ai-elements-adapter" +import type { LiveContentBlock } from "@/contexts/acp-connections-context" +import { + buildSubAgentSectionItems, + buildSubAgentSectionRows, + extractDelegationSources, + extractLiveDelegationSources, + extractLiveNativeSubAgentSources, + extractNativeSubAgentSources, +} from "@/lib/delegation-sources" + +function toolCall( + overrides: Partial & { toolCallId: string } +): AdaptedContentPart { + return { + type: "tool-call", + toolName: "mcp__codeg__delegate_to_agent", + input: null, + state: "output-available", + ...overrides, + } as AdaptedContentPart +} + +function liveToolCall(overrides: { + tool_call_id: string + title: string + raw_input: string | null + status?: string + raw_output_chunks?: string[] +}): LiveContentBlock { + return { + type: "tool_call", + info: { + tool_call_id: overrides.tool_call_id, + title: overrides.title, + kind: "other", + status: overrides.status ?? "in_progress", + content: null, + raw_input: overrides.raw_input, + raw_output_chunks: overrides.raw_output_chunks ?? [], + raw_output_total_bytes: 0, + locations: null, + meta: null, + images: [], + }, + } +} + +function binding(overrides: Partial): DelegationBinding { + return { + parentConnectionId: "conn-parent", + parentToolUseId: "tool-delegate-1", + childConnectionId: "child-conn", + childConversationId: 99, + agentType: "codex", + status: "running", + task: null, + taskId: "task-1", + ...overrides, + } +} + +describe("extractDelegationSources", () => { + it("picks delegate calls and skips unrelated tools", () => { + const sources = extractDelegationSources([ + toolCall({ toolCallId: "a" }), + toolCall({ toolCallId: "b", toolName: "bash" }), + ]) + expect(sources.map((s) => s.parentToolUseId)).toEqual(["a"]) + }) + + it("recurses through tool groups and goal runs", () => { + const group = { + type: "tool-group", + items: [toolCall({ toolCallId: "in-group" })], + } as unknown as AdaptedContentPart + const goalRun = { + type: "goal-run", + items: [toolCall({ toolCallId: "in-goal" })], + } as unknown as AdaptedContentPart + expect(extractDelegationSources([group, goalRun])).toHaveLength(2) + }) + + it("drops a refused resume and keys an accepted one by task id", () => { + const refused = toolCall({ + toolCallId: "r1", + toolName: "mcp__codeg__resume_delegation", + input: JSON.stringify({ task_id: "task-1", reason: "go" }), + output: "Not resumed: Unknown task id.", + }) + expect(extractDelegationSources([refused])).toEqual([]) + + const accepted = toolCall({ + toolCallId: "r2", + toolName: "mcp__codeg__resume_delegation", + input: JSON.stringify({ task_id: "task-2", reason: "go" }), + output: JSON.stringify({ status: "running" }), + }) + const sources = extractDelegationSources([accepted]) + expect(sources).toHaveLength(1) + expect(sources[0].taskIdHint).toBe("task-2") + }) +}) + +describe("extractLiveDelegationSources", () => { + it("maps an in-flight delegate call onto a source with raw input", () => { + const sources = extractLiveDelegationSources([ + liveToolCall({ + tool_call_id: "live-1", + title: "delegate_to_agent", + raw_input: JSON.stringify({ agent_type: "codex", task: "do it" }), + }), + ]) + expect(sources).toHaveLength(1) + expect(sources[0]).toMatchObject({ + parentToolUseId: "live-1", + input: JSON.stringify({ agent_type: "codex", task: "do it" }), + state: "input-available", + }) + }) + + it("flips state on a completed call and skips non-tool blocks", () => { + const sources = extractLiveDelegationSources([ + { type: "text", text: "hi" }, + liveToolCall({ + tool_call_id: "live-2", + title: "delegate_to_agent", + raw_input: "{}", + status: "completed", + }), + ]) + expect(sources).toHaveLength(1) + expect(sources[0].state).toBe("output-available") + }) +}) + +describe("buildSubAgentSectionItems", () => { + const delegate: DelegationCardSource = { + parentToolUseId: "tool-delegate-1", + input: JSON.stringify({ agent_type: "codex", task: "work" }), + output: JSON.stringify({ kind: "ack", task_id: "task-1" }), + } + + it("de-dupes a turn source and the live source by task id", () => { + const items = buildSubAgentSectionItems( + [delegate], + [ + { + parentToolUseId: "tool-resume-1", + taskIdHint: "task-1", + }, + ], + [], + "conn-parent" + ) + expect(items).toHaveLength(1) + // The resume's output folds into the original row rather than a new row. + expect(items[0].source.taskIdHint).toBe("task-1") + }) + + it("merges the live binding into the matching row and drops the orphan", () => { + const b = binding({}) + const items = buildSubAgentSectionItems([delegate], [], [b], "conn-parent") + expect(items).toHaveLength(1) + expect(items[0].binding).toBe(b) + }) + + it("keeps a binding that has no turn-side row", () => { + const b = binding({ parentToolUseId: "tool-only", taskId: "task-9" }) + const items = buildSubAgentSectionItems([delegate], [], [b], "conn-parent") + expect(items).toHaveLength(2) + expect(items[1].binding).toBe(b) + expect(items[1].source.parentToolUseId).toBe("tool-only") + }) + + it("scopes bindings to the parent connection", () => { + const other = binding({ parentConnectionId: "conn-other" }) + const items = buildSubAgentSectionItems( + [delegate], + [], + [other], + "conn-parent" + ) + // The foreign binding neither merges nor appends. + expect(items).toHaveLength(1) + expect(items[0].binding).toBeUndefined() + }) + + it("drops every binding when the parent connection is unknown", () => { + const items = buildSubAgentSectionItems([delegate], [], [binding({})], null) + expect(items).toHaveLength(1) + expect(items[0].binding).toBeUndefined() + }) + + it("keeps two same-task delegations distinct when task id is absent", () => { + // No output ⇒ no task id ⇒ tool-call-id identity: two calls stay separate. + const items = buildSubAgentSectionItems( + [ + { parentToolUseId: "x", input: "{}" }, + { parentToolUseId: "y", input: "{}" }, + ], + [], + [], + "conn-parent" + ) + expect(items.map((i) => i.source.parentToolUseId)).toEqual(["x", "y"]) + }) +}) + +// ── Native sub-agents: every agent's OWN spawned children ─────────────────── +// +// The message-area dispatch (content-parts-renderer) is the contract: a launch +// renders under the normalized names "agent" / "collab_agent", and the aux +// list must show exactly the same calls. One case per producer spelling — +// Claude/Grok/Cursor/OpenCode (`agent`), codex live collab (`collab_agent`, +// spawn-only), codex native team-of-agents (`multi_agent_v1__*`), and Hermes's +// batched `delegate_task` (one call, one row per task). + +describe("extractNativeSubAgentSources", () => { + it("collects native launches under the agent names and skips the rest", () => { + const sources = extractNativeSubAgentSources([ + toolCall({ toolCallId: "t1", toolName: "Agent" }), + toolCall({ toolCallId: "t2", toolName: "call_omo_agent" }), + toolCall({ toolCallId: "t3", toolName: "bash" }), + toolCall({ toolCallId: "t4", toolName: "mcp__codeg__delegate_to_agent" }), + ]) + expect(sources.map((s) => s.toolCallId)).toEqual(["t1", "t2"]) + }) + + it("reads a codex team-of-agents spawn despite the namespace prefix", () => { + // `multi_agent_v1__spawn_agent` must collapse to "agent" (normalizeToolName + // op-suffix rule) or the row never appears — neither the message area nor + // the aux list knew these calls existed before. + const sources = extractNativeSubAgentSources([ + toolCall({ + toolCallId: "s1", + toolName: "multi_agent_v1__spawn_agent", + input: '{"message":"用法语说你好"}', + }), + toolCall({ toolCallId: "w1", toolName: "multi_agent_v1__wait_agent" }), + toolCall({ toolCallId: "c1", toolName: "multi_agent_v1__close_agent" }), + ]) + expect(sources.map((s) => s.toolCallId)).toEqual(["s1"]) + expect(sources[0].input).toContain("message") + }) + + it("keeps only the collab spawn among the collab ops", () => { + const collab = (op: string) => + JSON.stringify({ + senderThreadId: "t", + receiverThreadIds: [], + agentsStates: {}, + __codegCollabOp: op, + }) + const sources = extractNativeSubAgentSources([ + toolCall({ + toolCallId: "sp", + toolName: "collab_agent", + input: collab("spawnAgent"), + }), + toolCall({ + toolCallId: "wa", + toolName: "collab_agent", + input: collab("wait"), + }), + toolCall({ + toolCallId: "cl", + toolName: "collab_agent", + input: collab("closeAgent"), + }), + toolCall({ + toolCallId: "ls", + toolName: "collab_agent", + input: collab("listAgents"), + }), + ]) + expect(sources.map((s) => s.toolCallId)).toEqual(["sp"]) + }) + + it("expands a Hermes batched delegate_task into one row per task", () => { + const sources = extractNativeSubAgentSources([ + toolCall({ + toolCallId: "batch1", + toolName: "delegate_task", + input: JSON.stringify({ + tasks: [ + { goal: "用日语问候", context: "演示" }, + { goal: "算 97 是否质数" }, + { context: "没有 goal 的条目" }, + ], + }), + }), + ]) + expect(sources.map((s) => s.toolCallId)).toEqual([ + "batch1#task-0", + "batch1#task-1", + ]) + // The synthesized launch payload carries the goal as description/prompt so + // the shared field parser titles the row; the real call output is kept. + expect(JSON.parse(sources[0].input ?? "{}").description).toBe("用日语问候") + expect(sources[0].output).toBeNull() + }) + + it("lifts a parser-injected per-task child handle into row-scoped stats", () => { + // The hermes parser resolves each task to its own child session and + // injects `__codegChildSessionId` into the task entry. The row MUST carry + // that handle on ITS OWN agent_stats — sharing the call-level stats would + // hand every row the same child session. + const sources = extractNativeSubAgentSources([ + toolCall({ + toolCallId: "batch2", + toolName: "delegate_task", + input: JSON.stringify({ + tasks: [ + { goal: "map the repo", __codegChildSessionId: "child-a" }, + { goal: "write tests" }, + ], + }), + }), + ]) + expect(sources.map((s) => s.toolCallId)).toEqual([ + "batch2#task-0", + "batch2#task-1", + ]) + expect(sources[0].agentStats?.child_session_id).toBe("child-a") + expect(sources[1].agentStats?.child_session_id ?? null).toBeNull() + }) + + it("recurses through tool groups", () => { + const sources = extractNativeSubAgentSources([ + { + type: "tool-group", + items: [toolCall({ toolCallId: "g1", toolName: "Agent" })], + } as AdaptedContentPart, + ]) + expect(sources.map((s) => s.toolCallId)).toEqual(["g1"]) + }) +}) + +describe("extractLiveNativeSubAgentSources", () => { + it("routes a live codex collab spawn and drops the sibling wait", () => { + const collab = (op: string) => + JSON.stringify({ + senderThreadId: "t", + receiverThreadIds: [], + agentsStates: {}, + __codegCollabOp: op, + }) + const sources = extractLiveNativeSubAgentSources([ + liveToolCall({ + tool_call_id: "sp", + title: "spawnAgent", + raw_input: collab("spawnAgent"), + status: "completed", + }), + liveToolCall({ + tool_call_id: "wa", + title: "wait", + raw_input: collab("wait"), + }), + ]) + expect(sources.map((s) => s.toolCallId)).toEqual(["sp"]) + expect(sources[0].state).toBe("output-available") + }) + + it("falls back to the live ACP title when the collab op is unmerged", () => { + // Live wire: the input shaper has not folded COLLAB_OP_KEY in yet — the + // title IS the op. + const raw = JSON.stringify({ + senderThreadId: "t", + receiverThreadIds: [], + agentsStates: {}, + }) + const sources = extractLiveNativeSubAgentSources([ + liveToolCall({ tool_call_id: "sp", title: "spawnAgent", raw_input: raw }), + liveToolCall({ tool_call_id: "wa", title: "wait", raw_input: raw }), + ]) + expect(sources.map((s) => s.toolCallId)).toEqual(["sp"]) + }) + + it("expands a live Hermes batched launch by raw title before inference", () => { + const sources = extractLiveNativeSubAgentSources([ + liveToolCall({ + tool_call_id: "b", + title: "delegate_task", + raw_input: '{"tasks":[{"goal":"任务甲"},{"goal":"任务乙"}]}', + }), + ]) + expect(sources.map((s) => s.toolCallId)).toEqual(["b#task-0", "b#task-1"]) + expect(sources[1].input).toContain("任务乙") + }) +}) + +describe("buildSubAgentSectionRows natives", () => { + it("de-dupes by tool-call id and lets the live state win", () => { + const rows = buildSubAgentSectionRows( + [], + [], + [], + null, + [ + { + toolCallId: "n1", + input: "{}", + output: null, + errorText: null, + state: "input-available", + meta: null, + }, + ], + [ + { + toolCallId: "n1", + input: "{}", + output: "done", + errorText: null, + state: "output-available", + meta: null, + }, + ] + ) + const natives = rows.filter((r) => r.kind === "native") + expect(natives).toHaveLength(1) + if (natives[0].kind === "native") { + expect(natives[0].source.state).toBe("output-available") + expect(natives[0].source.output).toBe("done") + } + }) + + it("lists delegations before natives", () => { + const row = (toolCallId: string) => ({ + toolCallId, + input: '{"description":"问候"}', + output: null, + errorText: null, + state: "output-available" as const, + meta: null, + }) + const rows = buildSubAgentSectionRows( + [{ parentToolUseId: "d1", input: "{}" }], + [], + [], + null, + [row("n1"), row("n2")], + [] + ) + expect(rows.map((r) => r.kind)).toEqual(["delegation", "native", "native"]) + }) +}) diff --git a/src/lib/delegation-sources.ts b/src/lib/delegation-sources.ts new file mode 100644 index 0000000000..5d704aa013 --- /dev/null +++ b/src/lib/delegation-sources.ts @@ -0,0 +1,599 @@ +import type { AdaptedContentPart } from "@/lib/adapters/ai-elements-adapter" +import { + inferLiveToolName, + normalizeToolName, +} from "@/lib/tool-call-normalization" +import { + classifyCollabOp, + COLLAB_AGENT_TOOL_NAME, + parseCollabOp, +} from "@/lib/collab-tool" +import { parseResumeTaskId } from "@/lib/codeg-mcp-tool" +import { + isDelegateToAgentToolName, + isRefusedResume, + parseDelegateTaskId, + parseDelegationMeta, +} from "@/lib/delegation-card" +import type { DelegationCardSource } from "@/hooks/use-delegation-card-model" +import type { DelegationBinding } from "@/contexts/delegation-context" +import type { + LiveContentBlock, + ToolCallMeta, +} from "@/contexts/acp-connections-context" +import type { AgentExecutionStats } from "@/lib/types" + +// Shared by `MessageListView`'s sub-agent overlay and the aux panel's session +// details "sub-agents" section: both list the session's delegations, and the +// two surfaces must agree on WHICH tool calls count as one. +// +// Two kinds qualify: +// - `delegate_to_agent`, which STARTED a sub-agent, keyed by its own +// tool_use_id; +// - `resume_delegation`, which brought an interrupted one BACK. Its own +// tool_call_id is not a binding key (the broker re-binds the child to the +// original delegate call, usually in an earlier turn), so it is keyed by +// the task id in its arguments — `taskIdHint`, exactly as +// `ResumedDelegationCard` does. Without this arm a resumed sub-agent would +// be missing from the overlay while it runs, because the reply that +// resumed it contains no `delegate_to_agent` call at all. +// +// `seenTaskIds` de-dupes repeated resumes of one task inside a single scan +// (the second is refused, but the list renders a row per source regardless). +function collectDelegationSources( + parts: AdaptedContentPart[], + out: DelegationCardSource[], + seenTaskIds: Set +): void { + for (const part of parts) { + if (part.type === "tool-call") { + if (!part.toolCallId) continue + const name = normalizeToolName(part.toolName) + if (isDelegateToAgentToolName(name)) { + out.push({ + parentToolUseId: part.toolCallId, + input: part.input ?? null, + output: part.output ?? null, + errorText: part.errorText ?? null, + state: part.state, + meta: part.meta ?? null, + }) + } else if (name === "resume_delegation") { + // A refusal names the task's agent and child but revived nothing — + // listing it would put a sub-agent in the list that is not running on + // this turn's behalf. Same judgement as `ResumedDelegationCard`, which + // falls back to the plain tool card here. + if (isRefusedResume(part.output ?? null, part.errorText ?? null)) { + continue + } + const taskId = parseResumeTaskId(part.input ?? null) + // No task id ⇒ nothing to resolve the sub-agent by; a duplicate ⇒ + // already listed. + if (!taskId || seenTaskIds.has(taskId)) continue + seenTaskIds.add(taskId) + out.push({ + parentToolUseId: part.toolCallId, + taskIdHint: taskId, + // Deliberately not the resume's `{task_id, reason}` arguments — + // `parseInput` looks for `task`/`agent_type`/`working_dir` and would + // only warn about an unrecognized shape. See `ResumedDelegationCard`. + input: null, + output: part.output ?? null, + errorText: part.errorText ?? null, + state: part.state, + meta: part.meta ?? null, + }) + } + } else if (part.type === "tool-group") { + collectDelegationSources(part.items, out, seenTaskIds) + } else if (part.type === "goal-run") { + collectDelegationSources(part.items, out, seenTaskIds) + } + } +} + +export function extractDelegationSources( + parts: AdaptedContentPart[] +): DelegationCardSource[] { + const out: DelegationCardSource[] = [] + collectDelegationSources(parts, out, new Set()) + return out +} + +// ── Live (in-flight reply) extraction ─────────────────────────────────────── +// +// The aux panel's sub-agents section watches the runtime store's timeline, +// which ends in a streaming-phase turn built by +// `buildStreamingTurnsFromLiveMessage` — so scanning `liveMessage.content` +// directly mirrors that builder's `tool_use` mapping and catches a delegation +// the moment its `delegate_to_agent` call arrives, before any promoted turn +// carries it. Same qualifying rules as the adapted path above. + +const LIVE_TOOL_CALL_STATE: Record = { + completed: "output-available", + failed: "output-error", +} + +export function extractLiveDelegationSources( + content: LiveContentBlock[] +): DelegationCardSource[] { + const out: DelegationCardSource[] = [] + const seenTaskIds = new Set() + for (const block of content) { + if (block.type !== "tool_call") continue + const info = block.info + if (!info.tool_call_id) continue + const name = normalizeToolName(info.title || info.kind) + if (isDelegateToAgentToolName(name)) { + out.push({ + parentToolUseId: info.tool_call_id, + input: info.raw_input, + output: + info.raw_output_chunks.length > 0 + ? info.raw_output_chunks.join("") + : null, + state: LIVE_TOOL_CALL_STATE[info.status] ?? "input-available", + meta: info.meta, + }) + } else if (name === "resume_delegation") { + const joinedOutput = + info.raw_output_chunks.length > 0 + ? info.raw_output_chunks.join("") + : null + if (isRefusedResume(joinedOutput, null)) continue + const taskId = parseResumeTaskId(info.raw_input) + if (!taskId || seenTaskIds.has(taskId)) continue + seenTaskIds.add(taskId) + out.push({ + parentToolUseId: info.tool_call_id, + taskIdHint: taskId, + input: null, + output: joinedOutput, + state: LIVE_TOOL_CALL_STATE[info.status] ?? "input-available", + meta: info.meta, + }) + } + } + return out +} + +// ── The aux panel's per-session delegation list ───────────────────────────── +// +// The session-details "sub-agents" section merges three feeds into one list: +// - every turn of the loaded timeline (all replies, not just the last one — +// the floating overlay only shows the LAST reply, this list shows the +// session); +// - the in-flight reply's raw `liveMessage` blocks (a delegation created +// mid-stream isn't in any promoted turn yet); +// - the live `DelegationContext` bindings, which for a running child carry +// fresher status/task text than the tool call's output does. +// +// Identity is by the broker task id when known (it survives resume — a +// resume's card re-binds to the ORIGINAL delegate call), otherwise by +// tool-call id. The binding for the same delegation merges into the earliest +// turn-sourced row (turn order = chronology) and is dropped from the +// binding-only pass; bindings with no turn-side row appear last. +// +// `parentConnectionId` scopes the bindings to THIS conversation: the provider +// is mounted once above every conversation, so without the filter one +// session's list would show another session's delegations. + +export interface SubAgentSectionItem { + source: DelegationCardSource + /** The live binding merged into this row, if one matched. */ + binding?: DelegationBinding +} + +export function buildSubAgentSectionItems( + turnSources: DelegationCardSource[], + liveSources: DelegationCardSource[], + bindings: readonly DelegationBinding[], + parentConnectionId: string | null +): SubAgentSectionItem[] { + const items: SubAgentSectionItem[] = [] + // key → index into `items`, for cross-source de-dup. + const indexByKey = new Map() + + const keyOfSource = (s: DelegationCardSource): string | null => { + const meta = parseDelegationMeta(s.meta ?? null) + const taskId = + s.taskIdHint ?? + parseDelegateTaskId(s.output ?? null, s.errorText ?? null) ?? + meta?.taskId ?? + null + // taskIdHint alone is not identity: two DIFFERENT resumes could name the + // same task only if it were resumed twice, and the second is refused + // (already filtered) — so it is safe here. Fall back to tool-call id for + // never-completed delegate calls (no task id parsed yet). + return taskId ? `task:${taskId}` : `tool:${s.parentToolUseId}` + } + + const seenTask = new Set() + const upsertSource = (s: DelegationCardSource): void => { + const key = keyOfSource(s) + if (key) { + const existing = indexByKey.get(key) + if (existing != null) { + // Enrich the earlier row only, never overwrite: the earliest is the + // originating `delegate_to_agent` (a resume's source is a + // representation of the same task, not new evidence). But a resume + // DOES carry output/meta the origin lacks — fold in what's missing. + const prev = items[existing].source + items[existing] = { + ...items[existing], + source: { + ...prev, + output: prev.output ?? s.output, + meta: prev.meta ?? s.meta, + errorText: prev.errorText ?? s.errorText, + state: s.state ?? prev.state, + taskIdHint: prev.taskIdHint ?? s.taskIdHint, + }, + } + return + } + indexByKey.set(key, items.length) + seenTask.add(key) + } + items.push({ source: s }) + } + + for (const s of turnSources) upsertSource(s) + for (const s of liveSources) upsertSource(s) + + const scoped = parentConnectionId + ? bindings.filter((b) => b.parentConnectionId === parentConnectionId) + : [] + + const matchedBindings = new Set() + for (const item of items) { + const binding = scoped.find( + (b) => + b.parentToolUseId === item.source.parentToolUseId || + (item.source.taskIdHint != null && b.taskId === item.source.taskIdHint) + ) + if (binding) { + item.binding = binding + matchedBindings.add( + binding.taskId + ? `task:${binding.taskId}` + : `tool:${binding.parentToolUseId}` + ) + } + } + for (const b of scoped) { + const key = b.taskId ? `task:${b.taskId}` : `tool:${b.parentToolUseId}` + if (matchedBindings.has(key) || seenTask.has(key)) continue + matchedBindings.add(key) + items.push({ + source: { + parentToolUseId: b.parentToolUseId, + input: null, + output: null, + errorText: null, + state: "input-available", + meta: null, + }, + binding: b, + }) + } + return items +} + +// ── Native sub-agents (every agent's OWN spawned children) ────────────────── +// +// A `delegate_to_agent` delegation is only ONE way a session grows children. +// Each host agent also spawns sub-agents natively — Claude Code's `Task`, +// Codex's `spawn_agent`, Grok's `spawn_subagent`, Cursor's `task`, OpenCode's +// `call_omo_agent` — and the message area already renders each as an Agent +// capsule. The aux panel's sub-agents section must show the SAME set: whosever +// agent the parent is, its native children belong in the list next to the +// codeg delegations. +// +// Identity predicate — the EXACT dispatch the message area uses +// (`content-parts-renderer`), which renders a native sub-agent under TWO +// normalized names: +// - `"agent"` — Claude Code's `Task`, Grok's `spawn_subagent`, Cursor's +// `task`, OpenCode's `call_omo_agent`, and every settled codex `spawn_agent` +// (the alias table folds them all in; the rollout parser rewrites history +// to "agent"); +// - `COLLAB_AGENT_TOOL_NAME` ("collab_agent") — codex's LIVE `spawn_agent`, +// which `inferLiveToolName` routes to the collab card by input shape +// (`isCodexCollabInput`) BEFORE the alias would map it to "agent". +// Missing the second name would make a running codex child invisible until +// reload — the opposite of what the live list is for. The live side reuses the +// runtime store's own `inferLiveToolName`, so a streaming call lands here under +// the same predicate its streaming card uses. codeg's delegation tools +// normalize elsewhere ("delegate_to_agent") and can never collide. + +export interface NativeSubAgentSource { + /** The launching tool call's id — the row's identity. */ + toolCallId: string + /** Raw JSON input of the launching call (subagent_type/description/…). */ + input: string | null + /** Raw output (result text, background marker, async-launch ack, …). */ + output: string | null + errorText: string | null + state: DelegationCardSource["state"] + meta: ToolCallMeta + /** Only present in history: the parser folded `agent_stats` off the + * matching tool_result (duration, child_session_id). */ + agentStats?: AgentExecutionStats | null +} + +const NATIVE_AGENT_TOOL_NAMES: ReadonlySet = new Set([ + "agent", + COLLAB_AGENT_TOOL_NAME, +]) + +// Hermes's native sub-agent launcher batches a whole team into ONE tool call: +// `delegate_task` with input {tasks: [{goal, context}, …]} — one child per +// entry. Normalizing its name would collide (the canonical `"task"` is shared +// by every generic task tool), so the launcher is identified by its RAW name +// here and expanded at extraction: one row per task, the tool-call id gaining a +// per-task suffix. The wire carries no child-session handle, so rows are +// status-only UNLESS the history parser (`parsers/hermes.rs`) resolved a task +// to its own child session — it then injects `__codegChildSessionId` into that +// task entry, and the row below lifts it into a ROW-scoped +// `agent_stats.child_session_id`, making just that row clickable. +const HERMES_BATCH_LAUNCH_RAW_NAME = "delegate_task" + +function isHermesBatchLaunch(rawName: string | null | undefined): boolean { + if (!rawName) return false + const canonical = rawName + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + return canonical === HERMES_BATCH_LAUNCH_RAW_NAME +} + +/** Goal lines of a batched `delegate_task` launch; [] unless the input + * parses to a non-empty `tasks` array. (Truncated live inputs simply yield + * no rows until the parse succeeds.) Each entry also carries the parser- + * injected per-task child handle (`__codegChildSessionId`, + * `parsers/hermes.rs::attach_delegate_task_child_handles`) when the task's + * goal matched its own child session. */ +function parseBatchDelegationGoals( + input: string | null +): { goal: string; childSessionId: string | null }[] { + if (!input) return [] + try { + const parsed = JSON.parse(input) as { tasks?: unknown } | null + if (!parsed || !Array.isArray(parsed.tasks)) return [] + const goals: { goal: string; childSessionId: string | null }[] = [] + for (const task of parsed.tasks) { + if (!task || typeof task !== "object") continue + const entry = task as Record + const goal = + typeof entry.goal === "string" && entry.goal.trim() + ? entry.goal.trim() + : typeof entry.description === "string" && entry.description.trim() + ? entry.description.trim() + : null + if (!goal) continue + const childSessionId = + typeof entry.__codegChildSessionId === "string" && + entry.__codegChildSessionId.length > 0 + ? entry.__codegChildSessionId + : null + goals.push({ goal, childSessionId }) + } + return goals + } catch { + return [] + } +} + +/** + * Whether a call under one of the native-agent names actually LAUNCHES a + * child. Under plain `"agent"` every match does (the name only ever comes + * from a Task/spawn-class tool). Under `collab_agent` the name is shared by + * codex's whole team-of-agents op family — `spawn_agent` (launch), + * `wait_agent` (poll), `close_agent`, `list_agents` (roster) — and only + * spawn starts a child. Listing a `wait` would duplicate the row its spawn + * already created under a different tool-call id, so the op decides. + */ +function isNativeAgentLaunch( + name: string, + input: string | null, + /** The live wire carries the collab op as the tool_call's ACP title; the + * input shaper only folds it under `COLLAB_OP_KEY` once a turn promotes. */ + liveTitle?: string | null +): boolean { + if (name !== COLLAB_AGENT_TOOL_NAME) return true + return classifyCollabOp(parseCollabOp(input) ?? liveTitle ?? null) === "spawn" +} + +function collectNativeSubAgentSources( + parts: AdaptedContentPart[], + out: NativeSubAgentSource[] +): void { + for (const part of parts) { + if (part.type === "tool-call") { + if (!part.toolCallId) continue + if (isHermesBatchLaunch(part.toolName)) { + pushBatchLaunchRows( + { + toolCallId: part.toolCallId, + input: part.input ?? null, + output: part.output ?? null, + errorText: part.errorText ?? null, + state: part.state, + meta: part.meta ?? null, + agentStats: part.agentStats ?? null, + }, + out + ) + continue + } + const name = normalizeToolName(part.toolName).toLowerCase() + if (!NATIVE_AGENT_TOOL_NAMES.has(name)) continue + if (!isNativeAgentLaunch(name, part.input ?? null)) continue + out.push({ + toolCallId: part.toolCallId, + input: part.input ?? null, + output: part.output ?? null, + errorText: part.errorText ?? null, + state: part.state, + meta: part.meta ?? null, + agentStats: part.agentStats ?? null, + }) + } else if (part.type === "tool-group") { + collectNativeSubAgentSources(part.items, out) + } else if (part.type === "goal-run") { + collectNativeSubAgentSources(part.items, out) + } + } +} + +/** One batched launch → one row per task. The row's `input` is a synthesized + * launch payload (`{description, prompt}` = the task goal) so the shared + * `parseSubAgentLaunchFields` gives the row a title without a bespoke parser; + * `output` stays the REAL call output (the batch result text) for the status + * reading. Tool-call id gains a per-task suffix, keeping rows distinct and + * stable across the live → promoted handoff. A resolved task carries its + * OWN child handle as row-scoped `agent_stats` — the shared call stats would + * otherwise hand EVERY row the same session. */ +function pushBatchLaunchRows( + base: NativeSubAgentSource, + out: NativeSubAgentSource[] +): void { + const tasks = parseBatchDelegationGoals(base.input) + tasks.forEach((task, i) => { + out.push({ + ...base, + toolCallId: `${base.toolCallId}#task-${i}`, + input: JSON.stringify({ description: task.goal, prompt: task.goal }), + agentStats: task.childSessionId + ? { ...(base.agentStats ?? {}), child_session_id: task.childSessionId } + : base.agentStats, + }) + }) +} + +export function extractNativeSubAgentSources( + parts: AdaptedContentPart[] +): NativeSubAgentSource[] { + const out: NativeSubAgentSource[] = [] + collectNativeSubAgentSources(parts, out) + return out +} + +export function extractLiveNativeSubAgentSources( + content: LiveContentBlock[] +): NativeSubAgentSource[] { + const out: NativeSubAgentSource[] = [] + for (const block of content) { + if (block.type !== "tool_call") continue + const info = block.info + if (!info.tool_call_id) continue + if (isHermesBatchLaunch(info.title || info.kind)) { + pushBatchLaunchRows( + { + toolCallId: info.tool_call_id, + input: info.raw_input, + output: + info.raw_output_chunks.length > 0 + ? info.raw_output_chunks.join("") + : null, + errorText: null, + state: LIVE_TOOL_CALL_STATE[info.status] ?? "input-available", + meta: info.meta, + }, + out + ) + continue + } + const name = inferLiveToolName({ + title: info.title, + kind: info.kind, + rawInput: info.raw_input, + meta: info.meta, + }).toLowerCase() + if (!NATIVE_AGENT_TOOL_NAMES.has(name)) continue + // Live wire: the codex collab op is the tool_call's ACP title (the + // input shaper folds it under COLLAB_OP_KEY only for promoted turns). + if (!isNativeAgentLaunch(name, info.raw_input, info.title)) continue + out.push({ + toolCallId: info.tool_call_id, + input: info.raw_input, + output: + info.raw_output_chunks.length > 0 + ? info.raw_output_chunks.join("") + : null, + errorText: null, + state: LIVE_TOOL_CALL_STATE[info.status] ?? "input-available", + meta: info.meta, + }) + } + return out +} + +// The section list merges BOTH families into one list: codeg delegations, +// exactly as `buildSubAgentSectionItems` above resolves them, then every +// agent's native children (each family keeps its own turn order — the two +// families grouping, not interleaving, is acceptable in a collapsed-count +// section and keeps each family's de-dup logic untouched). Identity differs +// by family — delegations key on broker task id / tool id, natives on the +// launching tool-call id — and the two key spaces never collide. The message +// area already renders both, so a row per tool call here agrees with what the +// transcript shows. + +export type SubAgentSectionRow = + | { kind: "delegation"; item: SubAgentSectionItem } + | { kind: "native"; source: NativeSubAgentSource } + +export function buildSubAgentSectionRows( + turnSources: DelegationCardSource[], + liveSources: DelegationCardSource[], + bindings: readonly DelegationBinding[], + parentConnectionId: string | null, + turnNatives: readonly NativeSubAgentSource[], + liveNatives: readonly NativeSubAgentSource[] +): SubAgentSectionRow[] { + const delegations = buildSubAgentSectionItems( + turnSources, + liveSources, + bindings, + parentConnectionId + ) + const rows: SubAgentSectionRow[] = delegations.map((item) => ({ + kind: "delegation" as const, + item, + })) + // Live natives enrich the turn-side row with the freshest state while the + // same call streams; a never-seen id appends. (Turn-side natives from the + // SAME adapter cache are already settled; the live block is the only + // richer source mid-stream.) + const indexByToolCallId = new Map() + for (let i = 0; i < turnNatives.length; i += 1) { + const s = turnNatives[i] + if (indexByToolCallId.has(s.toolCallId)) continue + indexByToolCallId.set(s.toolCallId, rows.length) + rows.push({ kind: "native", source: s }) + } + for (const s of liveNatives) { + const existing = indexByToolCallId.get(s.toolCallId) + if (existing != null) { + const prev = ( + rows[existing] as Extract + ).source + rows[existing] = { + kind: "native", + source: { + ...prev, + output: prev.output ?? s.output, + meta: prev.meta ?? s.meta, + // The live state tracks the call as it streams; take the newer. + state: s.state, + agentStats: prev.agentStats ?? s.agentStats, + }, + } + continue + } + indexByToolCallId.set(s.toolCallId, rows.length) + rows.push({ kind: "native", source: s }) + } + return rows +} diff --git a/src/lib/native-subagent-fields.test.ts b/src/lib/native-subagent-fields.test.ts new file mode 100644 index 0000000000..90f4a8d7ec --- /dev/null +++ b/src/lib/native-subagent-fields.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest" + +import { + childSessionOfLaunch, + parseChildSessionId, + parseSubAgentLaunchFields, +} from "@/lib/native-subagent-fields" + +describe("parseSubAgentLaunchFields", () => { + it("reads every Claude Task spelling", () => { + const f = parseSubAgentLaunchFields( + JSON.stringify({ + subagent_type: "general-purpose", + description: "法语问候", + prompt: "用法语问好", + model: "sonnet", + }) + ) + expect(f.subagentType).toBe("general-purpose") + expect(f.description).toBe("法语问候") + expect(f.prompt).toBe("用法语问好") + expect(f.model).toBe("sonnet") + expect(f.isCursorTask).toBe(false) + expect(f.isCodexSubagentLaunch).toBe(false) + }) + + it("falls back to message for the codex team spawn payload", () => { + // codex's native team-of-agents spawn carries only {message}: the title + // has to come from the task text, not the generic fallback. + const f = parseSubAgentLaunchFields('{"message":"先用法语说你好"}') + expect(f.subagentType).toBeNull() + expect(f.description).toBe("先用法语说你好") + }) + + it("treats non-string fields as absent, not as renderable objects", () => { + // CodeBuddy hands over {subagent_type: {}} — an object leaking into a + // React child position crashes the render. + const f = parseSubAgentLaunchFields('{"subagent_type":{},"description":{}}') + expect(f.subagentType).toBeNull() + expect(f.description).toBeNull() + }) + + it("recognizes the Cursor task identity stamp", () => { + const f = parseSubAgentLaunchFields( + '{"prompt":"go","_toolName":"task","subagentType":{"case":"custom"}}' + ) + expect(f.isCursorTask).toBe(true) + expect(f.subagentType).toBe("custom") + }) + + it("reads codex launch marker and child state", () => { + const f = parseSubAgentLaunchFields( + '{"agent_id":"019f07aa-f57b-4000-8000-000000000000","__codegCodexSubagentLaunch":true,"__codegCodexSubagentState":"completed"}' + ) + expect(f.isCodexSubagentLaunch).toBe(true) + expect(f.agentId).toBe("019f07aa-f57b-4000-8000-000000000000") + expect(f.codexSubagentState).toBe("completed") + }) + + it("rescues fields from a truncated live input", () => { + // The full JSON.parse fails mid-stream; extractJsonField still reads the + // field as long as its value itself is closed. + const f = parseSubAgentLaunchFields( + '{"subagent_type":"Explore","description":"扫代码","prompt' + ) + expect(f.subagentType).toBe("Explore") + expect(f.description).toBe("扫代码") + }) + + it("survives null and garbage", () => { + expect(parseSubAgentLaunchFields(null).subagentType).toBeNull() + expect(parseSubAgentLaunchFields("not json").subagentType).toBeNull() + }) +}) + +describe("parseChildSessionId", () => { + it("prefers the grok live meta marker", () => { + expect( + parseChildSessionId( + { grokSubagentSession: { childSessionId: "grok-child" } }, + null, + null + ) + ).toEqual({ sessionId: "grok-child", agentType: "grok" }) + }) + + it("reads the history stats field", () => { + expect(parseChildSessionId(null, "hist-child", null)).toEqual({ + sessionId: "hist-child", + agentType: "grok", + }) + }) + + it("uses the codex agent id as the session key", () => { + expect(parseChildSessionId(null, null, "019f-uuid")).toEqual({ + sessionId: "019f-uuid", + agentType: "codex", + }) + }) + + it("returns null with nothing to key on", () => { + expect(parseChildSessionId(null, null, null)).toBeNull() + }) + + it("follows the parent's agent type when the caller knows it", () => { + // The aux-panel row passes the conversation's own agent: a parent's child + // is a session of the parent's kind, so the parent decides the type for + // every handle branch (and the per-branch pins below only serve callers + // without one, like the message-area capsule). + expect( + parseChildSessionId( + { grokSubagentSession: { childSessionId: "c" } }, + null, + null, + "codex" + ) + ).toEqual({ sessionId: "c", agentType: "codex" }) + expect(parseChildSessionId(null, "hist-child", null, "cursor")).toEqual({ + sessionId: "hist-child", + agentType: "cursor", + }) + expect(parseChildSessionId(null, null, "019f-uuid", "codex")).toEqual({ + sessionId: "019f-uuid", + agentType: "codex", + }) + }) +}) + +describe("childSessionOfLaunch", () => { + it("only folds the codex agent id in for a marked launch", () => { + const fields = { + agentId: "019f-uuid", + isCodexSubagentLaunch: false, + } + expect(childSessionOfLaunch(fields, null, null)).toBeNull() + expect( + childSessionOfLaunch( + { ...fields, isCodexSubagentLaunch: true }, + null, + null + ) + ).toEqual({ sessionId: "019f-uuid", agentType: "codex" }) + }) + + it("takes the stats child session id from agentStats", () => { + expect( + childSessionOfLaunch( + { agentId: null, isCodexSubagentLaunch: false }, + null, + { child_session_id: "grok-hist" } as never + ) + ).toEqual({ sessionId: "grok-hist", agentType: "grok" }) + }) +}) diff --git a/src/lib/native-subagent-fields.ts b/src/lib/native-subagent-fields.ts new file mode 100644 index 0000000000..fd68bb8bed --- /dev/null +++ b/src/lib/native-subagent-fields.ts @@ -0,0 +1,168 @@ +/** + * Shared field parsing for a NATIVE sub-agent launch tool call (Claude + * `Task`, codex `spawn_agent`, grok `spawn_subagent`, Cursor `task`, Open + * Code's `call_omo_agent`). + * + * Both consumers read the SAME wire input and must agree: the message-area + * Agent capsule (`agent-tool-call.tsx`) and the aux panel's sub-agents rows + * (`native-subagent-row.tsx`). If the two parsed the launch payload + * separately they would drift on which field names count — exactly the + * failure this module exists to prevent. + */ + +import { + extractJsonField, + tryParseJson, +} from "@/components/message/content-parts-renderer" +import type { AgentExecutionStats, AgentType } from "@/lib/types" + +// A parsed JSON field is only usable when it's a non-empty STRING. Some +// hosts (e.g. CodeBuddy) hand us inputs where `subagent_type` / `description` +// arrive as objects (or empty `{}`); `as string` casts let those leak +// straight into rendered text, crashing React with "Objects are not valid as +// a React child". Coerce so a non-string field is treated as absent. +function asText(v: unknown): string | null { + return typeof v === "string" && v.length > 0 ? v : null +} + +/** Every displayable field the launch capsule derives from `rawInput`, + * parsed once. The `extractJsonField` fallbacks rescue truncated live + * inputs the full parse can't handle. */ +export interface SubAgentLaunchFields { + subagentType: string | null + description: string | null + prompt: string | null + model: string | null + agentId: string | null + /** Cursor's live task payload carries `_toolName:"task"` as its identity + * stamp; the completion envelope folds in only for a call so marked. */ + isCursorTask: boolean + /** + * codex 0.147's native team-of-agents marks its capsules as LAUNCH-only + * (`CODEX_SUBAGENT_LAUNCH_KEY`, written by both the live path and the + * rollout parser). The card settles when codex acknowledges the spawn, + * which is not when the child finishes — an asynchronous child can still + * be working long after. + */ + isCodexSubagentLaunch: boolean + /** + * How the codex child itself ended (`SubAgentActivity{kind}`, both paths + * stamp `__codegCodexSubagentState`). Present only once that has been + * heard; while it is absent the child's fate is genuinely unknown. + */ + codexSubagentState: string | null +} + +function field( + parsed: Record | null, + input: string | null, + ...keys: string[] +): string | null { + for (const key of keys) { + const direct = asText(parsed?.[key]) + if (direct) return direct + } + if (!input) return null + for (const key of keys) { + const rescued = extractJsonField(input, key) + if (rescued) return rescued + } + return null +} + +export function parseSubAgentLaunchFields( + input: string | null +): SubAgentLaunchFields { + const parsed = input ? tryParseJson(input) : null + return { + subagentType: + // Codex's live `spawn_agent` labels the agent `agent_type`; Cursor's + // live task carries `subagentType` as a protobuf-es oneof object + // ({case: …}) its history parser emits as a plain string. Read all + // spellings so the prefix shows during streaming too. + asText(parsed?.subagent_type) ?? + asText(parsed?.agent_type) ?? + asText(parsed?.subagentType) ?? + asText((parsed?.subagentType as { case?: unknown } | undefined)?.case) ?? + field(parsed, input, "subagent_type", "agent_type"), + // codex's native team-of-agents spawn carries the child's assignment in + // `message` (no description field at all); fall back to it so the row/card + // titles itself by the task text instead of the generic fallback. + description: + field(parsed, input, "description") ?? field(parsed, input, "message"), + prompt: field(parsed, input, "prompt"), + model: field(parsed, input, "model"), + // codex spawn capsules carry the sub-agent's UUID (`agent_id`); the pill + // and the codex child-session key are the same string. + agentId: field(parsed, input, "agent_id"), + isCursorTask: parsed?._toolName === "task", + isCodexSubagentLaunch: parsed?.__codegCodexSubagentLaunch === true, + codexSubagentState: asText(parsed?.__codegCodexSubagentState), + } +} + +/** + * The child's own session, when the sub-agent ran as a standalone session on + * disk. TWO agents do this, and for the same reason: the child is a full + * session that streams its transcript to disk while none of it is forwarded + * over ACP, so opening that session is the ONLY way to see the child's work. + * + * Grok, live, arrives as `meta.grokSubagentSession.childSessionId` + * (`connection.rs::grok_subagent_meta`, re-sent on every progress tick because + * meta is replaced wholesale); in history it comes off the parsed + * `agent_stats.child_session_id` (`parsers/grok.rs::subagent_stats`). + * + * Codex needs neither, because its child's thread id IS its rollout's id and + * the card already carries it as `agent_id` — the badge and the session key are + * the same string. Both of its paths already write it + * (`connection.rs::classify_codex_subagent_activity` live, + * `parsers/codex.rs::inject_agent_id_into_input` on reload), alongside the + * launch marker that identifies the producer. + * + * The agent type follows the PARENT when the caller knows it (the aux-panel + * row passes the conversation's own type — a parent's child is a session of + * the parent's kind). Without it the branch pins its own producer: grok for + * the meta/stats handles, codex for the agent-id handle — the message-area + * capsule has no conversation-level agent type of its own and relies on these. + * New per-agent handles land on the same three branches. + */ +export function parseChildSessionId( + meta: Record | null | undefined, + statsChildSessionId: string | null | undefined, + codexSubagentId: string | null, + parentAgentType?: AgentType | null +): { sessionId: string; agentType: AgentType } | null { + const raw = meta?.grokSubagentSession + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + const live = (raw as Record).childSessionId + if (typeof live === "string" && live.length > 0) { + return { sessionId: live, agentType: parentAgentType ?? "grok" } + } + } + if (statsChildSessionId && statsChildSessionId.length > 0) { + return { + sessionId: statsChildSessionId, + agentType: parentAgentType ?? "grok", + } + } + return codexSubagentId + ? { sessionId: codexSubagentId, agentType: parentAgentType ?? "codex" } + : null +} + +/** Convenience over the two primitives: the child session for a launch whose + * parsed fields you already hold. `parentAgentType` — the owning + * conversation's agent — makes the handle's kind authoritative when known. */ +export function childSessionOfLaunch( + fields: Pick, + meta: Record | null | undefined, + agentStats: AgentExecutionStats | null | undefined, + parentAgentType?: AgentType | null +): { sessionId: string; agentType: AgentType } | null { + return parseChildSessionId( + meta, + agentStats?.child_session_id, + fields.isCodexSubagentLaunch ? fields.agentId : null, + parentAgentType + ) +} diff --git a/src/lib/tool-call-normalization.test.ts b/src/lib/tool-call-normalization.test.ts index 3e9111f5ff..3778b148a6 100644 --- a/src/lib/tool-call-normalization.test.ts +++ b/src/lib/tool-call-normalization.test.ts @@ -601,6 +601,23 @@ describe("normalizeToolName collapses Codex goal tools across wrappers", () => { }) }) +describe("normalizeToolName collapses codex multi-agent family", () => { + it.each([ + ["multi_agent_v1__spawn_agent", "agent"], + ["multi_agent_v2__spawn_agent", "agent"], + ["multi_agent_v1__wait_agent", "task"], + ["multi_agent_v1__close_agent", "task"], + ])("%s -> %s", (input, expected) => { + expect(normalizeToolName(input)).toBe(expected) + }) + + it("does not collapse unrelated names containing agent", () => { + // The rule keys on the op suffix only; a bare word "agent" inside any + // other spelling must not hit it. + expect(normalizeToolName("agents_status")).not.toBe("agent") + }) +}) + describe("inferLiveToolName codex collab detection", () => { const collabRaw = JSON.stringify({ prompt: "run pnpm build", @@ -1282,3 +1299,16 @@ describe("normalizeToolName collapses the codeg-mcp workbench companions", () => expect(normalizeToolName("task_update")).toBe("taskupdate") }) }) + +describe("Claude Code PowerShell tool joins the Terminal card", () => { + it("classifies PowerShell / pwsh as bash", () => { + // claude-agent-acp only lifts Bash's `description` into + // `_meta.claudeCode.title`, so the PowerShell card reaching the + // command-card branch is what lets it read `{command, description}` off + // its own input — otherwise its cards head with the raw command and + // never show the model's description. + expect(normalizeToolName("PowerShell")).toBe("bash") + expect(normalizeToolName("powershell")).toBe("bash") + expect(normalizeToolName("pwsh")).toBe("bash") + }) +}) diff --git a/src/lib/tool-call-normalization.ts b/src/lib/tool-call-normalization.ts index 18cdecec2e..7b3a56af87 100644 --- a/src/lib/tool-call-normalization.ts +++ b/src/lib/tool-call-normalization.ts @@ -147,6 +147,12 @@ const EXACT_TOOL_NAME_ALIASES: Record = { lsp_goto_definition: "lsp", lsp_servers: "lsp", execute: "bash", + // Claude Code's PowerShell tool: same `{command, description, …}` envelope + // as Bash, so it belongs on the Terminal card — and only the command-card + // branch reads `description` (claude-agent-acp lifts `_meta.claudeCode.title` + // for Bash alone, so the input field is the sole source here). + powershell: "bash", + pwsh: "bash", search: "grep", fetch: "webfetch", think: "task", @@ -562,6 +568,19 @@ export function normalizeToolName(toolName: string): string { // freeform matcher below intentionally does NOT catch the underscore form. if (/[^a-z0-9]ask_user_question$/.test(canonical)) return "question" + // Multi-agent companion tools exposed under an implementation namespace. + // codex 0.147's native team-of-agents reaches the transcript as + // `multi_agent_v1__spawn_agent` / `…__wait_agent` / `…__close_agent` — the + // bare `spawn_agent` aliases above never see it, and the freeform `\bagent\b` + // matcher CANNOT (no word boundary inside the underscore form), so the spawn + // calls used to fall through to the generic tool shell: no Agent capsule in + // the message area, no sub-agent rows in the aux panel. Collapse the family + // on its op suffix, the same way the delegation tools above collapse on + // theirs. A codex thread id never spells like this, so no false positive. + if (/[^a-z0-9]spawn_agent$/.test(canonical)) return "agent" + if (/[^a-z0-9]wait_agent$/.test(canonical)) return "task" + if (/[^a-z0-9]close_agent$/.test(canonical)) return "task" + // codeg-mcp live-feedback poll. Same host-prefix story as the delegation tools // (`mcp____check_user_feedback`, `/check_user_feedback`, …) — // collapse every separator to the canonical name the renderer dispatches on. diff --git a/src/lib/window-chrome.test.ts b/src/lib/window-chrome.test.ts index cd1537557c..51afb2708a 100644 --- a/src/lib/window-chrome.test.ts +++ b/src/lib/window-chrome.test.ts @@ -3,48 +3,49 @@ import { describe, expect, it } from "vitest" import { LEFT_CHROME_CLUSTER, MAC_TRAFFIC_LIGHT_INSET, - RIGHT_CHROME_CLUSTER, + RIGHT_EDGE_RAIL_WIDTH, WINDOW_CAPTION_WIDTH, + captionOverhangPastRail, leftChromeReserve, - rightChromeClusterWidth, - rightChromeReserve, } from "./window-chrome" -// The app "zoom" scales the root font-size (rem), so the rem-sized chrome buttons -// grow with zoom. Their fixed-px containers must grow by the same factor or the -// buttons overflow/clip at high zoom (the 150% bug). These guard that only the -// DOM button CLUSTER scales, while the native insets (macOS traffic-light -// clearance, Windows/Linux caption strip) stay fixed. +// The app "zoom" scales the root font-size (rem), so the rem-sized chrome +// buttons grow with zoom. Their fixed-px containers must grow by the same +// factor or the buttons overflow/clip at high zoom (the 150% bug). These guard +// that only the DOM button CLUSTER / rail scale, while the native insets +// (macOS traffic-light clearance, Windows/Linux caption strip) stay fixed. describe("window-chrome zoom scaling", () => { it("defaults to 100% (no scaling) and matches the pre-zoom baseline", () => { - // Baseline the aux-panel collapse test also hard-codes: 116 (mac/web) and - // 116 + 138 = 254 (win/linux caption reserved). - expect(rightChromeReserve(false)).toBe(RIGHT_CHROME_CLUSTER) - expect(rightChromeReserve(true)).toBe( - RIGHT_CHROME_CLUSTER + WINDOW_CAPTION_WIDTH + expect(captionOverhangPastRail(false)).toBe(0) + // Caption strip (138) minus the rail's own width (40) = 98 still overhangs + // the right-edge column on Windows/Linux. + expect(captionOverhangPastRail(true)).toBe( + WINDOW_CAPTION_WIDTH - RIGHT_EDGE_RAIL_WIDTH ) - expect(rightChromeClusterWidth()).toBe(RIGHT_CHROME_CLUSTER) expect(leftChromeReserve(false)).toBe(LEFT_CHROME_CLUSTER) expect(leftChromeReserve(true)).toBe( MAC_TRAFFIC_LIGHT_INSET + LEFT_CHROME_CLUSTER ) }) - it("scales only the button cluster at 150%, leaving native insets fixed", () => { - // 116 → 174, 80 → 120. - expect(rightChromeClusterWidth(150)).toBe(174) - expect(rightChromeReserve(false, 150)).toBe(174) - // Native caption strip stays 138. - expect(rightChromeReserve(true, 150)).toBe(174 + WINDOW_CAPTION_WIDTH) - // Native traffic-light inset stays 76; only the 80 cluster scales to 120. + it("scales only the rem-sized parts at 150%, leaving native insets fixed", () => { + // 80 → 120. expect(leftChromeReserve(false, 150)).toBe(120) + // Native traffic-light inset stays 76; only the 80 cluster scales to 120. expect(leftChromeReserve(true, 150)).toBe(MAC_TRAFFIC_LIGHT_INSET + 120) + // The rail scales to 60 but the caption strip stays a fixed 138, so the + // overhang SHRINKS with zoom (138 − 60 = 78). + expect(captionOverhangPastRail(true, 150)).toBe( + WINDOW_CAPTION_WIDTH - Math.round((RIGHT_EDGE_RAIL_WIDTH * 150) / 100) + ) + expect(captionOverhangPastRail(true, 150)).toBe(78) }) - it("scales the cluster down below 100% too and rounds to whole pixels", () => { - // 116 * 0.9 = 104.4 → 104 (rounded). - expect(rightChromeClusterWidth(90)).toBe(104) + it("scales down below 100% too, rounds to whole pixels, and clamps at 0", () => { // 80 * 0.5 = 40, plus the fixed 76 inset. expect(leftChromeReserve(true, 50)).toBe(MAC_TRAFFIC_LIGHT_INSET + 40) + // High zoom: once the (scaled) rail covers the whole caption strip there + // is no overhang left to reserve — clamps at 0, never negative. + expect(captionOverhangPastRail(true, 400)).toBe(0) }) }) diff --git a/src/lib/window-chrome.ts b/src/lib/window-chrome.ts index e254263c19..cb06521d4d 100644 --- a/src/lib/window-chrome.ts +++ b/src/lib/window-chrome.ts @@ -28,11 +28,14 @@ export const WINDOW_CAPTION_WIDTH = 138 export const LEFT_CHROME_CLUSTER = 80 /** - * Right cluster: terminal + aux + settings (three icon buttons + padding). - * A full-page workbench route swaps the first two for its own controls (see - * `WorkbenchRouteChromeActions`), so the count — and this reservation — holds. + * The desktop right-edge icon rail's own width (`RightEdgeRail`, w-10 = 2.5rem). + * It is a layout column (a flex sibling of the shell group), not an overlay, so + * columns no longer reserve anything for it — the only thing still floating + * over the window's right edge is the NATIVE caption strip, which the rail's + * leading h-10 filler yields to. The rail is rem-sized (grows with zoom) so + * its pixel footprint scales like the old chrome cluster did. */ -export const RIGHT_CHROME_CLUSTER = 116 +export const RIGHT_EDGE_RAIL_WIDTH = 40 /** * Scale a DOM button-cluster width by the app's rem-based zoom. @@ -64,26 +67,22 @@ export function leftChromeReserve(macInset: boolean, zoom = 100): number { } /** - * Width the window's right-edge column reserves for the right overlay. - * `winLinuxCaption` adds the native caption-button strip (desktop Win/Linux); - * `zoom` (a percent, default 100) scales the rem-sized button cluster, while the - * fixed native caption strip stays constant. + * How far the native caption strip (Windows/Linux desktop) overhangs PAST the + * right-edge rail into whichever column owns the window's right edge at a given + * moment (the aux panel when open, else the middle column's top strip). + * + * The caption buttons stay a fixed top-right overlay 138px wide; the rail + * absorbs the rightmost (zoom-scaled) 40px of it with its leading drag filler, + * so only the remainder can collide with strip content and needs reserving. + * macOS/web have no caption strip → 0. */ -export function rightChromeReserve( +export function captionOverhangPastRail( winLinuxCaption: boolean, zoom = 100 ): number { - return ( - scaleCluster(RIGHT_CHROME_CLUSTER, zoom) + - (winLinuxCaption ? WINDOW_CAPTION_WIDTH : 0) + if (!winLinuxCaption) return 0 + return Math.max( + 0, + WINDOW_CAPTION_WIDTH - scaleCluster(RIGHT_EDGE_RAIL_WIDTH, zoom) ) } - -/** - * The right-edge overlay's OWN width — just the (zoom-scaled) button cluster. - * The native caption strip isn't part of this box; it's cleared by the overlay's - * `right` offset (see `FolderLayoutShell`), so only the cluster is measured here. - */ -export function rightChromeClusterWidth(zoom = 100): number { - return scaleCluster(RIGHT_CHROME_CLUSTER, zoom) -} diff --git a/src/stores/tab-store-native-child-cascade.test.ts b/src/stores/tab-store-native-child-cascade.test.ts new file mode 100644 index 0000000000..2f88821b95 --- /dev/null +++ b/src/stores/tab-store-native-child-cascade.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { + peekClosedTab, + popClosedTab, + resetClosedTabStackForTests, +} from "@/lib/closed-tab-stack" +import { + resetAppWorkspaceStore, + useAppWorkspaceStore, +} from "./app-workspace-store" +import { resetTabStore, useTabStore } from "./tab-store" +import type { DbConversationSummary, FolderDetail } from "@/lib/types" + +vi.mock("@/lib/api", () => ({ + listOpenedTabs: vi.fn(), + saveOpenedTabs: vi.fn(), + getFolderConversation: vi.fn(), +})) + +vi.mock("@/lib/platform", () => ({ + subscribe: vi.fn(), + onTransportReconnect: vi.fn(), +})) + +const folder = { + id: 1, + name: "repo", + path: "/repo", +} as unknown as FolderDetail + +function conversationTab(id: string, conversationId: number, isPinned = true) { + return { + id, + kind: "conversation" as const, + folderId: 1, + conversationId, + agentType: "claude_code" as const, + title: `conv-${conversationId}`, + isPinned, + } +} + +function summaryOf( + id: number, + over: Partial = {} +): DbConversationSummary { + return { + id, + folder_id: 1, + title: `conv-${id}`, + title_locked: false, + agent_type: "claude_code", + status: "in_progress", + kind: "delegate", + model: null, + git_branch: null, + external_id: `child-${id}`, + message_count: 0, + child_count: 0, + created_at: "2026-06-10T10:00:00.000Z", + updated_at: "2026-06-10T10:00:00.000Z", + pinned_at: null, + ...over, + } +} + +function seed(parentConvId = 7) { + useAppWorkspaceStore.setState({ folders: [folder], allFolders: [folder] }) + useTabStore.setState({ + rawTabs: [ + conversationTab("tab-parent", parentConvId), + conversationTab("tab-child", 42), + conversationTab("tab-other", 9), + ], + activeTabId: "tab-other", + }) +} + +beforeEach(() => { + resetTabStore() + resetAppWorkspaceStore() + resetClosedTabStackForTests() +}) + +describe("native child tabs close with their parent", () => { + it("registerNativeChildTab links the pair; closing the parent closes the child", () => { + seed() + const store = useTabStore.getState() + store.registerNativeChildTab(42, 7) + expect(useTabStore.getState().childTabParents.get(42)).toBe(7) + + store.closeTab("tab-parent") + + const remaining = useTabStore + .getState() + .rawTabs.filter((t) => t.conversationId != null) + expect(remaining.map((t) => t.conversationId)).toEqual([9]) + // The child's link is pruned along with its tab. + expect(useTabStore.getState().childTabParents.size).toBe(0) + }) + + it("the cascade does not offer the child to reopen, only the parent", () => { + seed() + const store = useTabStore.getState() + store.registerNativeChildTab(42, 7) + store.closeTab("tab-parent") + + // The user closed the parent — that one is reopenable. The cascade-closed + // child was never asked for, so it must not shadow it on the stack. + expect(popClosedTab()).toMatchObject({ conversationId: 7 }) + expect(popClosedTab()).toBeNull() + expect(peekClosedTab()).toBeNull() + }) + + it("a restored session (empty link map) cascades from the seeded child summary", () => { + seed() + // After a restart `childTabParents` is empty — the child tab was rehydrated + // and its summary seeded from the DB (parent_id = 7). Closing the parent + // must still take it down. + useTabStore.setState({ + childSummaries: new Map([[42, summaryOf(42, { parent_id: 7 })]]), + }) + useTabStore.getState().closeTab("tab-parent") + + const remaining = useTabStore + .getState() + .rawTabs.filter((t) => t.conversationId != null) + expect(remaining.map((t) => t.conversationId)).toEqual([9]) + }) + + it("closing the child leaves the parent alone and prunes the link", () => { + seed() + const store = useTabStore.getState() + store.registerNativeChildTab(42, 7) + store.closeTab("tab-child") + + const remaining = useTabStore + .getState() + .rawTabs.filter((t) => t.conversationId != null) + expect(remaining.map((t) => t.conversationId)).toEqual([7, 9]) + expect(useTabStore.getState().childTabParents.size).toBe(0) + }) + + it("the cascade recurses through grandchild rows", () => { + seed() + useTabStore.setState({ + rawTabs: [ + ...useTabStore.getState().rawTabs, + conversationTab("tab-grandchild", 43), + ], + }) + const store = useTabStore.getState() + store.registerNativeChildTab(42, 7) + store.registerNativeChildTab(43, 42) + store.closeTab("tab-parent") + + const remaining = useTabStore + .getState() + .rawTabs.filter((t) => t.conversationId != null) + expect(remaining.map((t) => t.conversationId)).toEqual([9]) + }) + + it("an unregistered child tab survives its parent's close", () => { + seed() + // A tab opened from the sidebar (or a codeg delegation child opened some + // other way) is not linked — the parent's close must not evict it. + useTabStore.getState().closeTab("tab-parent") + const remaining = useTabStore + .getState() + .rawTabs.filter((t) => t.conversationId != null) + expect(remaining.map((t) => t.conversationId)).toEqual([42, 9]) + }) + + it("registerNativeChildTab is idempotent and ignores self-links", () => { + seed() + const store = useTabStore.getState() + store.registerNativeChildTab(42, 7) + const after = useTabStore.getState().childTabParents + store.registerNativeChildTab(42, 7) + expect(useTabStore.getState().childTabParents).toBe(after) + store.registerNativeChildTab(7, 7) + expect(useTabStore.getState().childTabParents.has(7)).toBe(false) + }) +}) diff --git a/src/stores/tab-store.ts b/src/stores/tab-store.ts index 0d76967df9..8d017e5ffc 100644 --- a/src/stores/tab-store.ts +++ b/src/stores/tab-store.ts @@ -181,6 +181,14 @@ export interface TabStoreState { overGroupId: string | null } | null childSummaries: Map + /** + * Native sub-session tabs opened from the session-details row, keyed by + * child conversation id → parent conversation id. Lets `closeTab` cascade + * a child tab away when its parent's tab is closed. This map is in-memory + * only (never persisted); after a restart the seeded `childSummaries` + * entries — which carry `parent_id` — cover the same set. + */ + childTabParents: Map /** * Derived from `rawTabs` × `conversations` × `childSummaries`: tab titles and * status decorated from the live conversation list, with cross-derive @@ -217,6 +225,16 @@ export interface TabStoreState { * an `opened_tabs` row) pointing at a deleted conversation. */ closeTab: (tabId: string, options?: { recordForReopen?: boolean }) => void + /** + * Record that a conversation tab was opened as a native sub-session of + * `parentConversationId`, so closing the parent's tab cascades this tab + * away too. Called right before `openTab` from the session-details row — + * idempotent; re-registering the same pair is a no-op. + */ + registerNativeChildTab: ( + childConversationId: number, + parentConversationId: number + ) => void closeConversationTab: ( folderId: number, conversationId: number, @@ -1102,6 +1120,7 @@ function initialTabState() { ...readPersistedGroupState(), tabDrag: null as TabStoreState["tabDrag"], childSummaries: new Map(), + childTabParents: new Map(), tabs: [] as TabItemInternal[], reseedTick: 0, saveReconcileTick: 0, @@ -1211,6 +1230,15 @@ export const useTabStore = create()((set, get) => ({ runtime.activateConversationPane() }, + registerNativeChildTab: (childConversationId, parentConversationId) => { + if (childConversationId === parentConversationId) return + const prev = get().childTabParents + if (prev.get(childConversationId) === parentConversationId) return + const next = new Map(prev) + next.set(childConversationId, parentConversationId) + set({ childTabParents: next }) + }, + closeTab: (tabId, options) => { const shouldActivateConversation = tabId === get().activeTabId @@ -1285,6 +1313,36 @@ export const useTabStore = create()((set, get) => ({ clearMessageInputDraftV2(closingDraftKey) } recomputeTabs() + + // Cascade: native sub-session tabs opened from this conversation go + // away with it (the row lives in the parent's session details — an + // orphaned child tab is a dead end). Sources: the in-memory link map + // (this session) plus seeded child summaries' `parent_id` (covers tabs + // restored after a restart). Never offered to reopen: the user closed + // the parent, not the child. Recursion prunes each child's own link. + if (closingTab.conversationId != null) { + const parentConvId = closingTab.conversationId + if (get().childTabParents.has(parentConvId)) { + const prunedLinks = new Map(get().childTabParents) + prunedLinks.delete(parentConvId) + set({ childTabParents: prunedLinks }) + } + const childConvIds = new Set() + for (const [childId, parentId] of get().childTabParents) { + if (parentId === parentConvId) childConvIds.add(childId) + } + for (const [id, summary] of get().childSummaries) { + if (summary.parent_id === parentConvId) childConvIds.add(id) + } + for (const childConvId of childConvIds) { + const childTab = get().rawTabs.find( + (t) => t.conversationId === childConvId + ) + if (childTab) { + get().closeTab(childTab.id, { recordForReopen: false }) + } + } + } } if (shouldActivateConversation) { From e6908250168f04c03e7c4889d0d15853df9b30c7 Mon Sep 17 00:00:00 2001 From: lihong Date: Sun, 20 Sep 2026 00:30:16 +0800 Subject: [PATCH 2/3] feat(session-details): pack identifier and token fields two per row The aux-panel tab was one field per row; half-width columns fit ids, model names, branches and token counts. Genuinely wide values (external id, the used / max (pct) context-window form) keep the full row, and timestamps stay one-up since date+time strings would wrap. Drops the container-query variants that made the narrow tab single-column. --- .../conversations/session-details-content.tsx | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/components/conversations/session-details-content.tsx b/src/components/conversations/session-details-content.tsx index c3aac3a929..ecec538d60 100644 --- a/src/components/conversations/session-details-content.tsx +++ b/src/components/conversations/session-details-content.tsx @@ -376,9 +376,12 @@ export function SessionDetailsContent({ const numeric = "font-mono tabular-nums" return ( - // `@container` drives the identifier / token / timestamp grids: one column - // in the narrow aux-panel tab, two in the wider details dialog. -
+ // The identifier / token grids are always two-up: the aux panel is narrow + // but its values there (ids, numbers, durations) fit a half-width column, + // and the genuinely wide values (external id, context window) span the + // full row. Timestamps stay one-up — their date+time strings are the one + // field whose half-width column would force wrapping. +
{/* Identity: the conversation title with its agent and status. */}

@@ -391,7 +394,7 @@ export function SessionDetailsContent({

{/* Identifiers, packed two-up to keep the view short. */} -
+
)} - + {summary.external_id ? ( {t("loadFailed")}
) : hasTokenInfo ? ( -
+
{totalTokens != null && ( {formatTokenCount(totalTokens)} @@ -470,7 +473,13 @@ export function SessionDetailsContent({ )} {contextWindowValue != null && ( - + {contextWindowValue} )} @@ -489,7 +498,7 @@ export function SessionDetailsContent({

{t("timestampsHeading")}

-
+
{formatDate(summary.created_at)} From 3d381d9a36901b7d44ea86a326586b391331eaf1 Mon Sep 17 00:00:00 2001 From: lihong Date: Sun, 20 Sep 2026 02:34:52 +0800 Subject: [PATCH 3/3] fix(tools): merge left both PowerShell alias blocks in one table main added `powershell` to EXACT_TOOL_NAME_ALIASES; the branch's block added the same key plus `pwsh`, so the merged literal carried duplicates (TS1117). Keep main's entry and fold `pwsh` in beside it. --- src/lib/tool-call-normalization.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/lib/tool-call-normalization.ts b/src/lib/tool-call-normalization.ts index 7c7f86dabb..992f9c4f54 100644 --- a/src/lib/tool-call-normalization.ts +++ b/src/lib/tool-call-normalization.ts @@ -46,6 +46,8 @@ const EXACT_TOOL_NAME_ALIASES: Record = { // tool name back out of the transcript — so the same call rendered one way // while it ran and another way on reload. powershell: "bash", + // `pwsh` reaches clients as the same tool's other spelling. + pwsh: "bash", exec_command: "exec_command", "functions.exec_command": "exec_command", "functions.read": "read", @@ -165,12 +167,6 @@ const EXACT_TOOL_NAME_ALIASES: Record = { lsp_goto_definition: "lsp", lsp_servers: "lsp", execute: "bash", - // Claude Code's PowerShell tool: same `{command, description, …}` envelope - // as Bash, so it belongs on the Terminal card — and only the command-card - // branch reads `description` (claude-agent-acp lifts `_meta.claudeCode.title` - // for Bash alone, so the input field is the sole source here). - powershell: "bash", - pwsh: "bash", search: "grep", fetch: "webfetch", think: "task",