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.
This commit is contained in:
parent
477daad4ff
commit
f6f9a5b157
@ -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<ChatPanelHandle, ChatPanelProps>(
|
||||
@ -690,9 +698,14 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
onFilePathClick,
|
||||
onDeleteSession,
|
||||
onRenameSession: _onRenameSession,
|
||||
sessionKey: subagentSessionKey,
|
||||
subagentTask,
|
||||
subagentLabel,
|
||||
onBack,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const isSubagentMode = !!subagentSessionKey;
|
||||
const editorRef = useRef<ChatEditorHandle>(null);
|
||||
const [editorEmpty, setEditorEmpty] = useState(true);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<
|
||||
@ -736,12 +749,19 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
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<ChatPanelHandle, ChatPanelProps>(
|
||||
// ── 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<ChatPanelHandle, ChatPanelProps>(
|
||||
role: "user" | "assistant" | "system";
|
||||
parts: UIMessage["parts"];
|
||||
}>,
|
||||
options?: { sessionKey?: string },
|
||||
): Promise<boolean> => {
|
||||
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<ChatPanelHandle, ChatPanelProps>(
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!filePath) {
|
||||
if (!filePath || isSubagentMode) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@ -1063,7 +1088,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
// 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<ChatPanelHandle, ChatPanelProps>(
|
||||
// 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<Record<string, unknown>>;
|
||||
_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<ChatPanelHandle, ChatPanelProps>(
|
||||
}
|
||||
|
||||
let sessionId = currentSessionId;
|
||||
if (!sessionId) {
|
||||
if (!sessionId && !isSubagentMode) {
|
||||
const titleSource =
|
||||
userText || "File attachment";
|
||||
const title =
|
||||
@ -1453,23 +1552,26 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
// 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<ChatPanelHandle, ChatPanelProps>(
|
||||
>
|
||||
{/* Header — sticky glass bar */}
|
||||
<header
|
||||
className={`${compact ? "px-3 py-2" : "px-3 py-2 md:px-6 md:py-3"} flex items-center justify-between z-20`}
|
||||
className={`${compact ? "px-3 py-2" : "px-3 py-2 md:px-6 md:py-3"} flex items-center ${isSubagentMode ? "gap-3" : "justify-between"} z-20`}
|
||||
style={{
|
||||
background: "var(--color-bg-glass)",
|
||||
}}
|
||||
>
|
||||
{isSubagentMode ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="p-1.5 rounded-lg flex-shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Back to parent chat"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m12 19-7-7 7-7" />
|
||||
<path d="M19 12H5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-semibold truncate" style={{ color: "var(--color-text)" }}>
|
||||
{subagentLabel || (subagentTask && subagentTask.length > 60 ? subagentTask.slice(0, 60) + "..." : subagentTask)}
|
||||
</h2>
|
||||
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{isStreaming ? <UnicodeSpinner name="braille" /> : "Completed"}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="min-w-0 flex-1">
|
||||
{compact && fileContext ? (
|
||||
<h2
|
||||
@ -1710,10 +1837,12 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* 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 && (
|
||||
<div
|
||||
className="px-2 py-1.5 border-b flex gap-1 overflow-x-auto z-20"
|
||||
style={{
|
||||
@ -1981,7 +2110,8 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attachment preview strip */}
|
||||
{/* Attachment preview strip (hidden in subagent mode) */}
|
||||
{!isSubagentMode && (
|
||||
<AttachmentStrip
|
||||
files={attachedFiles}
|
||||
compact={compact}
|
||||
@ -1990,6 +2120,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
clearAllAttachments
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChatEditor
|
||||
ref={editorRef}
|
||||
@ -1997,16 +2128,18 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
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<ChatPanelHandle, ChatPanelProps>(
|
||||
className={`flex items-center justify-between ${compact ? "px-2 pb-1.5" : "px-3 pb-2.5"}`}
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{!isSubagentMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@ -2047,6 +2181,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Send / Stop / Queue buttons */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
@ -2147,7 +2282,8 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File picker modal */}
|
||||
{/* File picker modal (not in subagent mode) */}
|
||||
{!isSubagentMode && (
|
||||
<FilePickerModal
|
||||
open={showFilePicker}
|
||||
onClose={() =>
|
||||
@ -2155,6 +2291,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
}
|
||||
onSelect={handleFilesSelected}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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> | 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<HTMLTextAreaElement>(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 (
|
||||
<div
|
||||
className={`flex items-start gap-2.5 group py-2 ${idx > 0 ? "border-t" : ""}`}
|
||||
style={idx > 0 ? { borderColor: "var(--color-border)" } : undefined}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 mt-px text-[11px] font-medium tabular-nums w-4"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{idx + 1}
|
||||
</span>
|
||||
{editing ? (
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="flex-1 text-[13px] leading-[1.45] min-w-0 resize-none rounded-md px-2 py-1 outline-none"
|
||||
style={{
|
||||
color: "var(--color-text-secondary)",
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
rows={1}
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
autoResize();
|
||||
}}
|
||||
onBlur={commitEdit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
commitEdit();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setDraft(msg.text);
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<p
|
||||
className="text-[13px] leading-[1.45] line-clamp-1 min-w-0"
|
||||
style={{ color: "var(--color-text-secondary)" }}
|
||||
>
|
||||
{msg.text}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!editing && (
|
||||
<div className="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md p-1 transition-colors hover:bg-stone-100 dark:hover:bg-stone-800"
|
||||
title="Edit message"
|
||||
onClick={() => {
|
||||
setDraft(msg.text);
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-stone-400">
|
||||
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md p-1 transition-colors hover:bg-stone-100 dark:hover:bg-stone-800"
|
||||
title="Send now"
|
||||
onClick={() => onSendNow(msg.id)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-stone-400">
|
||||
<path d="M12 19V5" />
|
||||
<path d="m5 12 7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md p-1 transition-colors hover:bg-stone-100 dark:hover:bg-stone-800"
|
||||
title="Remove from queue"
|
||||
onClick={() => onRemove(msg.id)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-stone-400">
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function taskMessage(sessionKey: string, task: string): UIMessage {
|
||||
return {
|
||||
id: `task-${sessionKey}`,
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: task }],
|
||||
} as UIMessage;
|
||||
}
|
||||
|
||||
export function buildMessagesFromParsed(
|
||||
sessionKey: string,
|
||||
task: string,
|
||||
parts: Array<Record<string, unknown>>,
|
||||
): UIMessage[] {
|
||||
const messages: UIMessage[] = [taskMessage(sessionKey, task)];
|
||||
let assistantParts: UIMessage["parts"] = [];
|
||||
let assistantCount = 0;
|
||||
let userCount = 0;
|
||||
|
||||
const pushAssistant = () => {
|
||||
if (assistantParts.length === 0) {return;}
|
||||
messages.push({
|
||||
id: `assistant-${sessionKey}-${assistantCount++}`,
|
||||
role: "assistant",
|
||||
parts: assistantParts,
|
||||
} as UIMessage);
|
||||
assistantParts = [];
|
||||
};
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === "user-message") {
|
||||
pushAssistant();
|
||||
messages.push({
|
||||
id: (part.id as string | undefined) ?? `user-${sessionKey}-${userCount++}`,
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: (part.text as string) ?? "" }],
|
||||
} as UIMessage);
|
||||
continue;
|
||||
}
|
||||
assistantParts.push(part as UIMessage["parts"][number]);
|
||||
}
|
||||
pushAssistant();
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function SubagentPanel({
|
||||
sessionKey,
|
||||
task,
|
||||
label,
|
||||
onBack,
|
||||
onSubagentClick,
|
||||
onFilePathClick,
|
||||
}: SubagentPanelProps) {
|
||||
const editorRef = useRef<ChatEditorHandle>(null);
|
||||
const [editorEmpty, setEditorEmpty] = useState(true);
|
||||
const [messages, setMessages] = useState<UIMessage[]>(() => [taskMessage(sessionKey, task)]);
|
||||
const [queuedMessages, setQueuedMessages] = useState<QueuedMessage[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const userScrolledAwayRef = useRef(false);
|
||||
const streamAbortRef = useRef<AbortController | null>(null);
|
||||
const scrollRafRef = useRef(0);
|
||||
|
||||
const displayLabel = label || (task.length > 60 ? task.slice(0, 60) + "..." : task);
|
||||
|
||||
const streamFromResponse = useCallback(
|
||||
async (
|
||||
res: Response,
|
||||
onUpdate: (parts: Array<Record<string, unknown>>) => void,
|
||||
signal: AbortSignal,
|
||||
) => {
|
||||
if (!res.body) {return;}
|
||||
const parser = createStreamParser();
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let frameRequested = false;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {break;}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let idx;
|
||||
while ((idx = buffer.indexOf("\n\n")) !== -1) {
|
||||
const chunk = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 2);
|
||||
if (chunk.startsWith("data: ")) {
|
||||
try {
|
||||
const event = JSON.parse(chunk.slice(6)) as Record<string, unknown>;
|
||||
parser.processEvent(event);
|
||||
} catch {
|
||||
// ignore malformed event
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!frameRequested) {
|
||||
frameRequested = true;
|
||||
requestAnimationFrame(() => {
|
||||
frameRequested = false;
|
||||
if (!signal.aborted) {
|
||||
onUpdate(parser.getParts() as Array<Record<string, unknown>>);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!signal.aborted) {
|
||||
onUpdate(parser.getParts() as Array<Record<string, unknown>>);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const reconnect = useCallback(async () => {
|
||||
streamAbortRef.current?.abort();
|
||||
const abort = new AbortController();
|
||||
streamAbortRef.current = abort;
|
||||
setIsReconnecting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/chat/stream?sessionKey=${encodeURIComponent(sessionKey)}`, {
|
||||
signal: abort.signal,
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
setConnected(false);
|
||||
setIsStreaming(false);
|
||||
return;
|
||||
}
|
||||
setConnected(true);
|
||||
setIsStreaming(res.headers.get("X-Run-Active") !== "false");
|
||||
await streamFromResponse(
|
||||
res,
|
||||
(parts) => setMessages(buildMessagesFromParsed(sessionKey, task, parts)),
|
||||
abort.signal,
|
||||
);
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== "AbortError") {
|
||||
console.error("Subagent reconnect error:", err);
|
||||
}
|
||||
} finally {
|
||||
setIsReconnecting(false);
|
||||
if (!abort.signal.aborted) {
|
||||
setIsStreaming(false);
|
||||
streamAbortRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [sessionKey, task, streamFromResponse]);
|
||||
|
||||
const sendSubagentMessage = useCallback(
|
||||
async (text: string, mentionedFiles: Array<{ name: string; path: string }>) => {
|
||||
const trimmed = text.trim();
|
||||
const hasMentions = mentionedFiles.length > 0;
|
||||
if (!trimmed && !hasMentions) {return;}
|
||||
|
||||
const allFilePaths = mentionedFiles.map((f) => f.path);
|
||||
const payloadText = allFilePaths.length > 0
|
||||
? `[Attached files: ${allFilePaths.join(", ")}]\n\n${trimmed}`
|
||||
: trimmed;
|
||||
|
||||
const optimisticUser: UIMessage = {
|
||||
id: `user-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: payloadText }],
|
||||
} as UIMessage;
|
||||
const baseMessages = [...messages, optimisticUser];
|
||||
setMessages(baseMessages);
|
||||
|
||||
streamAbortRef.current?.abort();
|
||||
const abort = new AbortController();
|
||||
streamAbortRef.current = abort;
|
||||
setIsStreaming(true);
|
||||
setConnected(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: abort.signal,
|
||||
body: JSON.stringify({
|
||||
sessionKey,
|
||||
messages: [optimisticUser],
|
||||
}),
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
setIsStreaming(false);
|
||||
return;
|
||||
}
|
||||
await streamFromResponse(
|
||||
res,
|
||||
(parts) => {
|
||||
const assistantMsg: UIMessage = {
|
||||
id: `assistant-${sessionKey}-${Date.now()}`,
|
||||
role: "assistant",
|
||||
parts: parts as UIMessage["parts"],
|
||||
} as UIMessage;
|
||||
setMessages([...baseMessages, assistantMsg]);
|
||||
},
|
||||
abort.signal,
|
||||
);
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== "AbortError") {
|
||||
console.error("Subagent send error:", err);
|
||||
}
|
||||
} finally {
|
||||
if (!abort.signal.aborted) {
|
||||
setIsStreaming(false);
|
||||
streamAbortRef.current = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
[messages, sessionKey, streamFromResponse],
|
||||
);
|
||||
|
||||
const removeQueuedMessage = useCallback((id: string) => {
|
||||
setQueuedMessages((prev) => prev.filter((m) => m.id !== id));
|
||||
}, []);
|
||||
|
||||
const updateQueuedMessageText = useCallback((id: string, text: string) => {
|
||||
setQueuedMessages((prev) =>
|
||||
prev.map((m) => (m.id === id ? { ...m, text } : m)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleEditorSubmit = useCallback(
|
||||
async (text: string, mentionedFiles: Array<{ name: string; path: string }>) => {
|
||||
if (isStreaming || isReconnecting) {
|
||||
setQueuedMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
text,
|
||||
mentionedFiles,
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
await sendSubagentMessage(text, mentionedFiles);
|
||||
},
|
||||
[isStreaming, isReconnecting, sendSubagentMessage],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
streamAbortRef.current?.abort();
|
||||
setIsReconnecting(false);
|
||||
try {
|
||||
await fetch("/api/chat/stop", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionKey }),
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setIsStreaming(false);
|
||||
streamAbortRef.current = null;
|
||||
}, [sessionKey]);
|
||||
|
||||
const forceSendQueuedMessage = useCallback(
|
||||
async (id: string) => {
|
||||
const msg = queuedMessages.find((m) => m.id === id);
|
||||
if (!msg) {
|
||||
return;
|
||||
}
|
||||
setQueuedMessages((prev) => prev.filter((m) => m.id !== id));
|
||||
await handleStop();
|
||||
setTimeout(() => {
|
||||
void sendSubagentMessage(msg.text, msg.mentionedFiles);
|
||||
}, 100);
|
||||
},
|
||||
[queuedMessages, handleStop, sendSubagentMessage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void reconnect();
|
||||
return () => {
|
||||
streamAbortRef.current?.abort();
|
||||
};
|
||||
}, [reconnect]);
|
||||
|
||||
useEffect(() => {
|
||||
const wasBusy = isStreaming || isReconnecting;
|
||||
if (wasBusy || queuedMessages.length === 0) {return;}
|
||||
const [next, ...rest] = queuedMessages;
|
||||
setQueuedMessages(rest);
|
||||
queueMicrotask(() => {
|
||||
void sendSubagentMessage(next.text, next.mentionedFiles);
|
||||
});
|
||||
}, [isStreaming, isReconnecting, queuedMessages, sendSubagentMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el) {return;}
|
||||
const onScroll = () => {
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
userScrolledAwayRef.current = distanceFromBottom > 80;
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (userScrolledAwayRef.current) {return;}
|
||||
if (scrollRafRef.current) {return;}
|
||||
scrollRafRef.current = requestAnimationFrame(() => {
|
||||
scrollRafRef.current = 0;
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
const statusLabel = useMemo(() => {
|
||||
if (!connected && (isStreaming || isReconnecting)) {return <UnicodeSpinner name="braille">Connecting</UnicodeSpinner>;}
|
||||
if (isReconnecting) {return <UnicodeSpinner name="braille">Resuming</UnicodeSpinner>;}
|
||||
if (isStreaming) {return <UnicodeSpinner name="braille" />;}
|
||||
return "Completed";
|
||||
}, [connected, isStreaming, isReconnecting]);
|
||||
|
||||
return (
|
||||
<div ref={scrollContainerRef} className="h-full overflow-y-auto">
|
||||
<div className="flex flex-col min-h-full">
|
||||
<header
|
||||
className="px-3 py-2 md:px-6 md:py-3 flex items-center gap-3 sticky top-0 z-20 backdrop-blur-md"
|
||||
style={{ background: "var(--color-bg-glass)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="p-1.5 rounded-lg flex-shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title="Back to parent chat"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m12 19-7-7 7-7" />
|
||||
<path d="M19 12H5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-semibold truncate" style={{ color: "var(--color-text)" }}>
|
||||
{displayLabel}
|
||||
</h2>
|
||||
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{statusLabel}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 px-6">
|
||||
<div className="max-w-2xl mx-auto py-3">
|
||||
{messages.map((message, i) => (
|
||||
<ChatMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
isStreaming={(isStreaming || isReconnecting) && i === messages.length - 1}
|
||||
onSubagentClick={onSubagentClick}
|
||||
onFilePathClick={onFilePathClick}
|
||||
/>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="px-3 pb-3 pt-0 md:px-6 md:pb-5 sticky bottom-0 z-20 backdrop-blur-md"
|
||||
style={{ background: "var(--color-bg-glass)" }}
|
||||
>
|
||||
<div className="max-w-[720px] mx-auto rounded-3xl overflow-hidden" style={{ background: "var(--color-chat-input-bg)", border: "1px solid var(--color-border)" }}>
|
||||
{queuedMessages.length > 0 && (
|
||||
<div className="px-3 pt-3">
|
||||
<div
|
||||
className="rounded-xl border overflow-hidden"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderColor: "var(--color-border)",
|
||||
boxShadow: "var(--shadow-sm)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider"
|
||||
style={{
|
||||
color: "var(--color-text-muted)",
|
||||
background: "var(--color-surface-hover)",
|
||||
}}
|
||||
>
|
||||
Queued ({queuedMessages.length})
|
||||
</div>
|
||||
<div className="flex flex-col p-2">
|
||||
{queuedMessages.map((msg, idx) => (
|
||||
<QueueItem
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
idx={idx}
|
||||
onEdit={updateQueuedMessageText}
|
||||
onSendNow={forceSendQueuedMessage}
|
||||
onRemove={removeQueuedMessage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ChatEditor
|
||||
ref={editorRef}
|
||||
onSubmit={handleEditorSubmit}
|
||||
onChange={(isEmpty) => setEditorEmpty(isEmpty)}
|
||||
placeholder={isStreaming || isReconnecting ? "Type to queue a message..." : "Type @ to mention files..."}
|
||||
/>
|
||||
<div className="flex items-center justify-end px-3 pb-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(isStreaming || isReconnecting) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleStop()}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center"
|
||||
style={{ background: "var(--color-text)", color: "var(--color-bg)" }}
|
||||
title="Stop generating"
|
||||
>
|
||||
<svg width="8" height="8" viewBox="0 0 10 10" fill="currentColor">
|
||||
<rect width="10" height="10" rx="1.5" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{isStreaming || isReconnecting ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editorRef.current?.submit();
|
||||
}}
|
||||
disabled={editorEmpty}
|
||||
className="h-7 px-3 rounded-full flex items-center gap-1.5 text-[12px] font-medium disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: !editorEmpty
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-surface-hover)",
|
||||
color: !editorEmpty
|
||||
? "white"
|
||||
: "var(--color-text-muted)",
|
||||
}}
|
||||
title="Add to queue"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 10 4 15 9 20" />
|
||||
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
|
||||
</svg>
|
||||
Queue
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editorRef.current?.submit();
|
||||
}}
|
||||
disabled={editorEmpty}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: !editorEmpty
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-border-strong)",
|
||||
color: "white",
|
||||
}}
|
||||
title="Send message"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 19V5" />
|
||||
<path d="m5 12 7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -18,7 +18,6 @@ import { ChatSessionsSidebar } from "../components/workspace/chat-sessions-sideb
|
||||
import { EmptyState } from "../components/workspace/empty-state";
|
||||
import { ReportViewer } from "../components/charts/report-viewer";
|
||||
import { ChatPanel, type ChatPanelHandle, type SubagentSpawnInfo } from "../components/chat-panel";
|
||||
import { SubagentPanel } from "../components/subagent-panel";
|
||||
import { EntryDetailModal } from "../components/workspace/entry-detail-modal";
|
||||
import { useSearchIndex } from "@/lib/search-index";
|
||||
import { parseWorkspaceLink, isWorkspaceLink } from "@/lib/workspace-links";
|
||||
@ -1512,33 +1511,27 @@ function WorkspacePageInner() {
|
||||
/* Main chat view (default when no file is selected) */
|
||||
<>
|
||||
<div className="flex-1 flex flex-col min-w-0" style={{ background: "var(--color-main-bg)" }}>
|
||||
{activeSubagent ? (
|
||||
<SubagentPanel
|
||||
sessionKey={activeSubagent.childSessionKey}
|
||||
task={activeSubagent.task}
|
||||
label={activeSubagent.label}
|
||||
onBack={handleBackFromSubagent}
|
||||
onSubagentClick={handleSubagentClickFromChat}
|
||||
onFilePathClick={handleFilePathClickFromChat}
|
||||
/>
|
||||
) : (
|
||||
<ChatPanel
|
||||
ref={chatRef}
|
||||
key={activeSubagent?.childSessionKey ?? "main"}
|
||||
ref={activeSubagent ? undefined : chatRef}
|
||||
sessionTitle={activeSessionTitle}
|
||||
initialSessionId={activeSessionId ?? undefined}
|
||||
onActiveSessionChange={(id) => {
|
||||
onActiveSessionChange={activeSubagent ? undefined : (id) => {
|
||||
setActiveSessionId(id);
|
||||
setActiveSubagentKey(null);
|
||||
}}
|
||||
onSessionsChange={refreshSessions}
|
||||
onSubagentSpawned={handleSubagentSpawned}
|
||||
onSessionsChange={activeSubagent ? undefined : refreshSessions}
|
||||
onSubagentSpawned={activeSubagent ? undefined : handleSubagentSpawned}
|
||||
onSubagentClick={handleSubagentClickFromChat}
|
||||
onFilePathClick={handleFilePathClickFromChat}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onRenameSession={handleRenameSession}
|
||||
onDeleteSession={activeSubagent ? undefined : handleDeleteSession}
|
||||
onRenameSession={activeSubagent ? undefined : handleRenameSession}
|
||||
compact={isMobile}
|
||||
sessionKey={activeSubagent?.childSessionKey}
|
||||
subagentTask={activeSubagent?.task}
|
||||
subagentLabel={activeSubagent?.label}
|
||||
onBack={activeSubagent ? handleBackFromSubagent : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Chat sessions sidebar — static on desktop, drawer overlay on mobile */}
|
||||
{isMobile ? (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user