From f6f9a5b157d0179f38d96d8cdbd05629dc3f9be8 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Tue, 3 Mar 2026 15:55:37 -0800 Subject: [PATCH] refactor(web): eliminate SubagentPanel, unify into ChatPanel subagent mode Delete subagent-panel.tsx (~670 lines) and its test. The SubagentPanel bypassed the AI SDK's useChat pipeline with a manual createStreamParser, causing tool events to never render. Instead, add a lightweight subagent mode to ChatPanel via sessionKey/subagentTask/onBack props that reuses the same DefaultChatTransport + useChat pipeline, fixing tool event rendering and persisted message loading for completed subagent sessions. --- apps/web/app/components/chat-panel.tsx | 183 ++++- .../subagent-panel.messages.test.ts | 37 - apps/web/app/components/subagent-panel.tsx | 636 ------------------ apps/web/app/workspace/page.tsx | 29 +- 4 files changed, 171 insertions(+), 714 deletions(-) delete mode 100644 apps/web/app/components/subagent-panel.messages.test.ts delete mode 100644 apps/web/app/components/subagent-panel.tsx diff --git a/apps/web/app/components/chat-panel.tsx b/apps/web/app/components/chat-panel.tsx index d9e051f1365..14a2741149b 100644 --- a/apps/web/app/components/chat-panel.tsx +++ b/apps/web/app/components/chat-panel.tsx @@ -673,6 +673,14 @@ type ChatPanelProps = { onDeleteSession?: (sessionId: string) => void; /** Called when user renames the current session. */ onRenameSession?: (sessionId: string, newTitle: string) => void; + /** Subagent mode: when set, connects to an existing subagent session via its gateway session key. */ + sessionKey?: string; + /** The subagent task description (shown as the first user message in subagent mode). */ + subagentTask?: string; + /** Display label for the subagent header. */ + subagentLabel?: string; + /** Back button handler (subagent mode only). */ + onBack?: () => void; }; export const ChatPanel = forwardRef( @@ -690,9 +698,14 @@ export const ChatPanel = forwardRef( onFilePathClick, onDeleteSession, onRenameSession: _onRenameSession, + sessionKey: subagentSessionKey, + subagentTask, + subagentLabel, + onBack, }, ref, ) { + const isSubagentMode = !!subagentSessionKey; const editorRef = useRef(null); const [editorEmpty, setEditorEmpty] = useState(true); const [currentSessionId, setCurrentSessionId] = useState< @@ -736,12 +749,19 @@ export const ChatPanel = forwardRef( sessionIdRef.current = currentSessionId; }, [currentSessionId]); + const subagentSessionKeyRef = useRef(subagentSessionKey); + useEffect(() => { + subagentSessionKeyRef.current = subagentSessionKey; + }, [subagentSessionKey]); + // ── Transport (per-instance) ── const transport = useMemo( () => new DefaultChatTransport({ api: "/api/chat", body: () => { + const sk = subagentSessionKeyRef.current; + if (sk) {return { sessionKey: sk };} const sid = sessionIdRef.current; return sid ? { sessionId: sid } : {}; }, @@ -815,6 +835,7 @@ export const ChatPanel = forwardRef( // ── Stream reconnection ── // Attempts to reconnect to an active agent run for the given session. // Replays buffered SSE events and streams live updates. + // Accepts either a web sessionId or a gateway sessionKey (subagent mode). const attemptReconnect = useCallback( async ( sessionId: string, @@ -823,13 +844,17 @@ export const ChatPanel = forwardRef( role: "user" | "assistant" | "system"; parts: UIMessage["parts"]; }>, + options?: { sessionKey?: string }, ): Promise => { const abort = new AbortController(); reconnectAbortRef.current = abort; try { + const streamParam = options?.sessionKey + ? `sessionKey=${encodeURIComponent(options.sessionKey)}` + : `sessionId=${encodeURIComponent(sessionId)}`; const res = await fetch( - `/api/chat/stream?sessionId=${encodeURIComponent(sessionId)}`, + `/api/chat/stream?${streamParam}`, { signal: abort.signal }, ); if (!res.ok || !res.body) { @@ -962,7 +987,7 @@ export const ChatPanel = forwardRef( }; useEffect(() => { - if (!filePath) { + if (!filePath || isSubagentMode) { return; } let cancelled = false; @@ -1063,7 +1088,7 @@ export const ChatPanel = forwardRef( // and reconnect to any active stream. const initialSessionHandled = useRef(false); useEffect(() => { - if (filePath || !initialSessionId || initialSessionHandled.current) { + if (filePath || isSubagentMode || !initialSessionId || initialSessionHandled.current) { return; } initialSessionHandled.current = true; @@ -1071,6 +1096,80 @@ export const ChatPanel = forwardRef( // eslint-disable-next-line react-hooks/exhaustive-deps -- run once on mount }, []); + // ── Subagent mode: load persisted messages + reconnect to active stream ── + useEffect(() => { + if (!subagentSessionKey || !subagentTask) {return;} + let cancelled = false; + + reconnectAbortRef.current?.abort(); + void stop(); + savedMessageIdsRef.current.clear(); + setQueuedMessages([]); + + const taskMsg = { + id: `task-${subagentSessionKey}`, + role: "user" as const, + parts: [{ type: "text" as const, text: subagentTask }] as UIMessage["parts"], + }; + setMessages([taskMsg]); + + void (async () => { + if (cancelled) {return;} + + // Load persisted messages from the subagent session JSONL + let baseMessages: Array<{ id: string; role: "user" | "assistant"; parts: UIMessage["parts"] }> = [taskMsg]; + try { + const msgRes = await fetch(`/api/web-sessions/${encodeURIComponent(subagentSessionKey)}`); + if (cancelled) {return;} + if (msgRes.ok) { + const msgData = await msgRes.json(); + const sessionMessages: Array<{ + id: string; + role: "user" | "assistant"; + content: string; + parts?: Array>; + _streaming?: boolean; + }> = msgData.messages || []; + + const completedMessages = sessionMessages.some((m) => m._streaming) + ? sessionMessages.filter((m) => !m._streaming) + : sessionMessages; + + if (completedMessages.length > 0) { + const uiMessages = completedMessages.map((msg) => { + savedMessageIdsRef.current.add(msg.id); + return { + id: msg.id, + role: msg.role, + parts: (msg.parts ?? [{ type: "text" as const, text: msg.content }]) as UIMessage["parts"], + }; + }); + baseMessages = [taskMsg, ...uiMessages]; + if (!cancelled) { + setMessages(baseMessages); + } + } + + } else { + // No persisted session file — use task message only + } + } catch { + // ignore — fall through to reconnect with task message only + } + + // Try to reconnect to an active stream (may be still running) + if (!cancelled) { + await attemptReconnect(subagentSessionKey, baseMessages, { sessionKey: subagentSessionKey }); + } + })(); + + return () => { + cancelled = true; + reconnectAbortRef.current?.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable setters + }, [subagentSessionKey, subagentTask, attemptReconnect]); + // ── Poll for subagent spawns during active streaming ── const [hasRunningSubagents, setHasRunningSubagents] = useState(false); @@ -1229,7 +1328,7 @@ export const ChatPanel = forwardRef( } let sessionId = currentSessionId; - if (!sessionId) { + if (!sessionId && !isSubagentMode) { const titleSource = userText || "File attachment"; const title = @@ -1453,23 +1552,26 @@ export const ChatPanel = forwardRef( // Stop the server-side agent run and wait for confirmation so the // session is no longer in "running" state before we stop the // client-side stream (which may trigger queued message flush). - if (currentSessionId) { + const stopKey = subagentSessionKey || currentSessionId; + if (stopKey) { try { await fetch("/api/chat/stop", { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ - sessionId: currentSessionId, - }), + body: JSON.stringify( + subagentSessionKey + ? { sessionKey: subagentSessionKey } + : { sessionId: currentSessionId }, + ), }); } catch { /* ignore */ } } // Stop the useChat transport stream (transitions status → "ready"). void stop(); - }, [currentSessionId, stop]); + }, [currentSessionId, subagentSessionKey, stop]); // ── Queue handlers ── @@ -1621,11 +1723,36 @@ export const ChatPanel = forwardRef( > {/* Header — sticky glass bar */}
+ {isSubagentMode ? ( + <> + +
+

+ {subagentLabel || (subagentTask && subagentTask.length > 60 ? subagentTask.slice(0, 60) + "..." : subagentTask)} +

+

+ {isStreaming ? : "Completed"} +

+
+ + ) : ( + <>
{compact && fileContext ? (

( )}

+ + )}
- {/* File-scoped session tabs (compact mode) */} - {compact && fileContext && fileSessions.length > 0 && ( + {/* File-scoped session tabs (compact mode, not in subagent mode) */} + {!isSubagentMode && compact && fileContext && fileSessions.length > 0 && (
(
)} - {/* Attachment preview strip */} + {/* Attachment preview strip (hidden in subagent mode) */} + {!isSubagentMode && ( ( clearAllAttachments } /> + )} ( onChange={(isEmpty) => setEditorEmpty(isEmpty) } - onNativeFileDrop={uploadAndAttachNativeFiles} + onNativeFileDrop={isSubagentMode ? undefined : uploadAndAttachNativeFiles} placeholder={ - compact && fileContext - ? `Ask about ${fileContext.isDirectory ? "this folder" : fileContext.filename}...` - : isStreaming - ? "Type to queue a message..." - : attachedFiles.length > - 0 - ? "Add a message or send files..." - : "Type @ to mention files..." + isSubagentMode + ? (isStreaming ? "Type to queue a message..." : "Type @ to mention files...") + : compact && fileContext + ? `Ask about ${fileContext.isDirectory ? "this folder" : fileContext.filename}...` + : isStreaming + ? "Type to queue a message..." + : attachedFiles.length > + 0 + ? "Add a message or send files..." + : "Type @ to mention files..." } disabled={loadingSession} compact={compact} @@ -2017,6 +2150,7 @@ export const ChatPanel = forwardRef( className={`flex items-center justify-between ${compact ? "px-2 pb-1.5" : "px-3 pb-2.5"}`} >
+ {!isSubagentMode && ( + )}
{/* Send / Stop / Queue buttons */}
@@ -2147,7 +2282,8 @@ export const ChatPanel = forwardRef(
- {/* File picker modal */} + {/* File picker modal (not in subagent mode) */} + {!isSubagentMode && ( @@ -2155,6 +2291,7 @@ export const ChatPanel = forwardRef( } onSelect={handleFilesSelected} /> + )} ); diff --git a/apps/web/app/components/subagent-panel.messages.test.ts b/apps/web/app/components/subagent-panel.messages.test.ts deleted file mode 100644 index 40966fd6c05..00000000000 --- a/apps/web/app/components/subagent-panel.messages.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildMessagesFromParsed } from "./subagent-panel"; - -describe("buildMessagesFromParsed", () => { - it("splits assistant output at user-message boundaries (prevents turn merging)", () => { - const messages = buildMessagesFromParsed("sub-1", "Initial task", [ - { type: "text", text: "Working on it." }, - { type: "reasoning", text: "Checking files", state: "streaming" }, - { type: "user-message", id: "u-1", text: "Please include a summary" }, - { type: "text", text: "Added a summary section." }, - ]); - - expect(messages).toHaveLength(4); - expect(messages[0]?.role).toBe("user"); - expect(messages[1]?.role).toBe("assistant"); - expect(messages[2]).toMatchObject({ - id: "u-1", - role: "user", - parts: [{ type: "text", text: "Please include a summary" }], - }); - expect(messages[3]).toMatchObject({ - role: "assistant", - parts: [{ type: "text", text: "Added a summary section." }], - }); - }); - - it("creates stable fallback user IDs when stream omits explicit user-message id", () => { - const messages = buildMessagesFromParsed("sub-2", "Task", [ - { type: "user-message", text: "Follow-up without id" }, - { type: "text", text: "Handled follow-up." }, - ]); - - expect(messages[1]?.id).toBe("user-sub-2-0"); - expect(messages[1]?.role).toBe("user"); - expect(messages[2]?.role).toBe("assistant"); - }); -}); diff --git a/apps/web/app/components/subagent-panel.tsx b/apps/web/app/components/subagent-panel.tsx deleted file mode 100644 index 2d43216bbb0..00000000000 --- a/apps/web/app/components/subagent-panel.tsx +++ /dev/null @@ -1,636 +0,0 @@ -"use client"; - -import type { UIMessage } from "ai"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ChatMessage } from "./chat-message"; -import { createStreamParser } from "./chat-panel"; -import { UnicodeSpinner } from "./unicode-spinner"; -import { ChatEditor, type ChatEditorHandle } from "./tiptap/chat-editor"; - -type SubagentPanelProps = { - sessionKey: string; - task: string; - label?: string; - onBack: () => void; - onSubagentClick?: (task: string) => void; - onFilePathClick?: (path: string) => Promise | boolean | void; -}; - -type QueuedMessage = { - id: string; - text: string; - mentionedFiles: Array<{ name: string; path: string }>; -}; - -function QueueItem({ - msg, - idx, - onEdit, - onSendNow, - onRemove, -}: { - msg: QueuedMessage; - idx: number; - onEdit: (id: string, text: string) => void; - onSendNow: (id: string) => void; - onRemove: (id: string) => void; -}) { - const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(msg.text); - const inputRef = useRef(null); - - const autoResize = () => { - const el = inputRef.current; - if (!el) { - return; - } - el.style.height = "auto"; - el.style.height = `${el.scrollHeight}px`; - }; - - useEffect(() => { - if (!editing) { - return; - } - inputRef.current?.focus(); - const len = inputRef.current?.value.length ?? 0; - inputRef.current?.setSelectionRange(len, len); - autoResize(); - }, [editing]); - - const commitEdit = () => { - const trimmed = draft.trim(); - if (trimmed && trimmed !== msg.text) { - onEdit(msg.id, trimmed); - } else { - setDraft(msg.text); - } - setEditing(false); - }; - - return ( -
0 ? "border-t" : ""}`} - style={idx > 0 ? { borderColor: "var(--color-border)" } : undefined} - > - - {idx + 1} - - {editing ? ( -