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 0000000000..aa681083ef Binary files /dev/null and b/docs/specs/right-edge-rail/openchamber-reference.png differ 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 93592fc834..09a8b9debf 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -37,12 +37,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, @@ -390,84 +385,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 4a1aa40f67..3946ca8103 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2032,7 +2032,8 @@ "toolResult": "النتيجة", "toolError": "خطأ" }, - "moreActions": "إجراءات إضافية" + "moreActions": "إجراءات إضافية", + "childSessionReadOnly": "لا يمكن متابعة جلسة الوكيل الفرعي هذه — السجل للقراءة فقط." }, "sessionDetails": { "menuLabel": "تفاصيل الجلسة", @@ -2067,7 +2068,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 e86c65284b..305ad369cf 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2032,7 +2032,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", @@ -2067,7 +2068,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 e443fcae3b..607da10859 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2032,7 +2032,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", @@ -2067,7 +2068,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 14d1b1cbae..9724a311b1 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2032,7 +2032,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", @@ -2067,7 +2068,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 d202efaec5..9c02649fb3 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2032,7 +2032,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", @@ -2067,7 +2068,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 ae969c2388..1f02277d09 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2032,7 +2032,8 @@ "toolResult": "結果", "toolError": "エラー" }, - "moreActions": "その他の操作" + "moreActions": "その他の操作", + "childSessionReadOnly": "このサブエージェントのセッションは続行できません。記録は閲覧専用です。" }, "sessionDetails": { "menuLabel": "セッション詳細", @@ -2067,7 +2068,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 bd275ba99c..80c6f69a01 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2032,7 +2032,8 @@ "toolResult": "결과", "toolError": "오류" }, - "moreActions": "추가 작업" + "moreActions": "추가 작업", + "childSessionReadOnly": "이 하위 에이전트 세션은 계속할 수 없습니다. 기록은 읽기 전용입니다." }, "sessionDetails": { "menuLabel": "세션 세부 정보", @@ -2067,7 +2068,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 daae1a55fb..b4d65186a6 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2032,7 +2032,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", @@ -2067,7 +2068,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 25dedd6e4b..ac7f09222e 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2032,7 +2032,8 @@ "toolResult": "结果", "toolError": "错误" }, - "moreActions": "更多操作" + "moreActions": "更多操作", + "childSessionReadOnly": "该子代理会话无法继续,记录仅供查看。" }, "sessionDetails": { "menuLabel": "会话详情", @@ -2067,7 +2068,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 16d8afb045..ad0fcdeff6 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2032,7 +2032,8 @@ "toolResult": "結果", "toolError": "錯誤" }, - "moreActions": "更多操作" + "moreActions": "更多操作", + "childSessionReadOnly": "該子代理會話無法繼續,記錄僅供檢視。" }, "sessionDetails": { "menuLabel": "對話詳情", @@ -2067,7 +2068,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 5ee81a8c06..2ef44c304c 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 6a32f2c79b..d2528ed348 100644 --- a/src/lib/tool-call-normalization.test.ts +++ b/src/lib/tool-call-normalization.test.ts @@ -625,6 +625,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", @@ -1353,6 +1370,19 @@ describe("normalizeToolName collapses the codeg-mcp workbench companions", () => }) }) +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") + }) +}) + describe("inferLiveToolName meta.opencode.toolName override", () => { // Every row below is a frame captured from opencode 1.18.30 driven over real // ACP: the arg-less opening `tool_call`, the `in_progress` update that fills diff --git a/src/lib/tool-call-normalization.ts b/src/lib/tool-call-normalization.ts index af292d75b5..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", @@ -597,6 +599,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) {