Reconnect to the correct chat session when initialSessionId is provided instead of always picking the latest, and pass through the subagent sessionKey for clickable subagent cards.
2299 lines
66 KiB
TypeScript
2299 lines
66 KiB
TypeScript
"use client";
|
|
|
|
import { useChat } from "@ai-sdk/react";
|
|
import { DefaultChatTransport, type UIMessage } from "ai";
|
|
import {
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { ChatMessage } from "./chat-message";
|
|
import {
|
|
FilePickerModal,
|
|
type SelectedFile,
|
|
} from "./file-picker-modal";
|
|
import { ChatEditor, type ChatEditorHandle } from "./tiptap/chat-editor";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "./ui/dropdown-menu";
|
|
import { UnicodeSpinner } from "./unicode-spinner";
|
|
|
|
// ── Attachment types & helpers ──
|
|
|
|
type AttachedFile = {
|
|
id: string;
|
|
name: string;
|
|
path: string;
|
|
/** True while the file is still uploading to the server. */
|
|
uploading?: boolean;
|
|
/** Local blob URL for instant preview before upload completes. */
|
|
localUrl?: string;
|
|
};
|
|
|
|
function getFileCategory(
|
|
name: string,
|
|
): "image" | "video" | "audio" | "pdf" | "code" | "document" | "other" {
|
|
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
|
if (
|
|
[
|
|
"jpg", "jpeg", "png", "gif", "webp", "svg", "bmp",
|
|
"ico", "tiff", "heic",
|
|
].includes(ext)
|
|
)
|
|
{return "image";}
|
|
if (["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(ext))
|
|
{return "video";}
|
|
if (["mp3", "wav", "ogg", "aac", "flac", "m4a"].includes(ext))
|
|
{return "audio";}
|
|
if (ext === "pdf") {return "pdf";}
|
|
if (
|
|
[
|
|
"js", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "java",
|
|
"cpp", "c", "h", "css", "html", "json", "yaml", "yml",
|
|
"toml", "md", "sh", "bash", "sql", "swift", "kt",
|
|
].includes(ext)
|
|
)
|
|
{return "code";}
|
|
if (
|
|
[
|
|
"doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt",
|
|
"rtf", "csv", "pages", "numbers", "key",
|
|
].includes(ext)
|
|
)
|
|
{return "document";}
|
|
return "other";
|
|
}
|
|
|
|
function shortenPath(path: string): string {
|
|
return path
|
|
.replace(/^\/Users\/[^/]+/, "~")
|
|
.replace(/^\/home\/[^/]+/, "~")
|
|
.replace(/^[A-Z]:\\Users\\[^\\]+/, "~");
|
|
}
|
|
|
|
const categoryMeta: Record<string, { bg: string; fg: string }> = {
|
|
image: { bg: "rgba(16, 185, 129, 0.12)", fg: "#10b981" },
|
|
video: { bg: "rgba(139, 92, 246, 0.12)", fg: "#8b5cf6" },
|
|
audio: { bg: "rgba(245, 158, 11, 0.12)", fg: "#f59e0b" },
|
|
pdf: { bg: "rgba(239, 68, 68, 0.12)", fg: "#ef4444" },
|
|
code: { bg: "rgba(59, 130, 246, 0.12)", fg: "#3b82f6" },
|
|
document: { bg: "rgba(107, 114, 128, 0.12)", fg: "#6b7280" },
|
|
other: { bg: "rgba(107, 114, 128, 0.08)", fg: "#9ca3af" },
|
|
};
|
|
|
|
function FileTypeIcon({ category }: { category: string }) {
|
|
const props = {
|
|
width: 16,
|
|
height: 16,
|
|
viewBox: "0 0 24 24",
|
|
fill: "none",
|
|
stroke: "currentColor",
|
|
strokeWidth: 2,
|
|
strokeLinecap: "round" as const,
|
|
strokeLinejoin: "round" as const,
|
|
};
|
|
switch (category) {
|
|
case "image":
|
|
return (
|
|
<svg {...props}>
|
|
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
|
<circle cx="9" cy="9" r="2" />
|
|
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
|
</svg>
|
|
);
|
|
case "video":
|
|
return (
|
|
<svg {...props}>
|
|
<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" />
|
|
<rect x="2" y="6" width="14" height="12" rx="2" />
|
|
</svg>
|
|
);
|
|
case "audio":
|
|
return (
|
|
<svg {...props}>
|
|
<path d="M9 18V5l12-2v13" />
|
|
<circle cx="6" cy="18" r="3" />
|
|
<circle cx="18" cy="16" r="3" />
|
|
</svg>
|
|
);
|
|
case "pdf":
|
|
return (
|
|
<svg {...props}>
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
<path d="M14 2v6h6" />
|
|
<path d="M10 13h4" />
|
|
<path d="M10 17h4" />
|
|
</svg>
|
|
);
|
|
case "code":
|
|
return (
|
|
<svg {...props}>
|
|
<polyline points="16 18 22 12 16 6" />
|
|
<polyline points="8 6 2 12 8 18" />
|
|
</svg>
|
|
);
|
|
case "document":
|
|
return (
|
|
<svg {...props}>
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
<path d="M14 2v6h6" />
|
|
<path d="M16 13H8" />
|
|
<path d="M16 17H8" />
|
|
<path d="M10 9H8" />
|
|
</svg>
|
|
);
|
|
default:
|
|
return (
|
|
<svg {...props}>
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
<path d="M14 2v6h6" />
|
|
</svg>
|
|
);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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">
|
|
{msg.attachedFiles.length > 0 && (
|
|
<div className="flex gap-1 shrink-0">
|
|
{msg.attachedFiles.map((af) => {
|
|
const cat = getFileCategory(af.name);
|
|
const src = cat === "image"
|
|
? (af.localUrl || `/api/workspace/raw-file?path=${encodeURIComponent(af.path)}`)
|
|
: af.path ? `/api/workspace/thumbnail?path=${encodeURIComponent(af.path)}&size=100` : undefined;
|
|
return (
|
|
<img
|
|
key={af.id}
|
|
src={src}
|
|
alt={af.name}
|
|
className="rounded object-cover"
|
|
style={{ height: 28, width: 28, background: "var(--color-surface-hover)" }}
|
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
<p
|
|
className="text-[13px] leading-[1.45] line-clamp-1 min-w-0"
|
|
style={{ color: "var(--color-text-secondary)" }}
|
|
>
|
|
{msg.text || `${msg.attachedFiles.length} ${msg.attachedFiles.length === 1 ? "file" : "files"}`}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{!editing && (
|
|
<div className="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
{/* Edit */}
|
|
<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>
|
|
{/* Send now */}
|
|
<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>
|
|
{/* Delete */}
|
|
<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>
|
|
);
|
|
}
|
|
|
|
function AttachmentStrip({
|
|
files,
|
|
compact,
|
|
onRemove,
|
|
onClearAll: _onClearAll,
|
|
}: {
|
|
files: AttachedFile[];
|
|
compact?: boolean;
|
|
onRemove: (id: string) => void;
|
|
onClearAll: () => void;
|
|
}) {
|
|
if (files.length === 0) {return null;}
|
|
|
|
return (
|
|
<div className={`${compact ? "px-2" : "px-3"} pt-2`}>
|
|
<div
|
|
className="flex gap-2 overflow-x-auto pb-1"
|
|
style={{ scrollbarWidth: "thin" }}
|
|
>
|
|
{files.map((af) => {
|
|
const category = getFileCategory(
|
|
af.name,
|
|
);
|
|
const meta =
|
|
categoryMeta[category] ??
|
|
categoryMeta.other;
|
|
const short = shortenPath(af.path);
|
|
|
|
return (
|
|
<div
|
|
key={af.id}
|
|
className="relative group flex-shrink-0 rounded-xl overflow-hidden"
|
|
style={{
|
|
background:
|
|
"var(--color-surface-hover)",
|
|
border: "1px solid var(--color-border)",
|
|
}}
|
|
>
|
|
{/* Remove button */}
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
onRemove(af.id)
|
|
}
|
|
className="absolute top-1 right-1 z-10 w-[18px] h-[18px] rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
|
style={{
|
|
background:
|
|
"rgba(0,0,0,0.55)",
|
|
color: "white",
|
|
backdropFilter:
|
|
"blur(4px)",
|
|
}}
|
|
>
|
|
<svg
|
|
width="8"
|
|
height="8"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="3"
|
|
strokeLinecap="round"
|
|
>
|
|
<path d="M18 6 6 18" />
|
|
<path d="m6 6 12 12" />
|
|
</svg>
|
|
</button>
|
|
|
|
{category === "image" ? (
|
|
/* Image thumbnail — no filename */
|
|
<img
|
|
src={af.localUrl || `/api/workspace/raw-file?path=${encodeURIComponent(af.path)}`}
|
|
alt={af.name}
|
|
className="block rounded-xl object-cover"
|
|
style={{
|
|
height: 80,
|
|
width: "auto",
|
|
minWidth: 60,
|
|
maxWidth: 140,
|
|
opacity: af.uploading ? 0.6 : 1,
|
|
background: "var(--color-bg-secondary)",
|
|
}}
|
|
onError={(e) => {
|
|
(e.currentTarget as HTMLImageElement).style.display = "none";
|
|
}}
|
|
/>
|
|
) : category === "pdf" && af.path ? (
|
|
/* PDF thumbnail via Quick Look */
|
|
<img
|
|
src={`/api/workspace/thumbnail?path=${encodeURIComponent(af.path)}&size=200`}
|
|
alt={af.name}
|
|
className="block rounded-xl object-cover"
|
|
style={{
|
|
height: 80,
|
|
width: "auto",
|
|
minWidth: 60,
|
|
maxWidth: 140,
|
|
opacity: af.uploading ? 0.6 : 1,
|
|
background: "var(--color-bg-secondary)",
|
|
}}
|
|
onError={(e) => {
|
|
(e.currentTarget as HTMLImageElement).style.display = "none";
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="flex items-center gap-2.5 px-3 py-2.5" style={{ opacity: af.uploading ? 0.6 : 1 }}>
|
|
<div
|
|
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
|
|
style={{
|
|
background: meta.bg,
|
|
color: meta.fg,
|
|
}}
|
|
>
|
|
<FileTypeIcon category={category} />
|
|
</div>
|
|
<div className="min-w-0 max-w-[140px]">
|
|
<p
|
|
className="text-[11px] font-medium truncate"
|
|
style={{ color: "var(--color-text)" }}
|
|
title={af.path || af.name}
|
|
>
|
|
{af.name}
|
|
</p>
|
|
<p
|
|
className="text-[9px] truncate"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
title={af.path || af.name}
|
|
>
|
|
{af.uploading ? "Uploading..." : short}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── SSE stream parser for reconnection ──
|
|
// Converts raw SSE events (AI SDK v6 wire format) into UIMessage parts.
|
|
|
|
type ParsedPart =
|
|
| { type: "text"; text: string }
|
|
| { type: "user-message"; id?: string; text: string }
|
|
| { type: "reasoning"; text: string; state?: string }
|
|
| {
|
|
type: "dynamic-tool";
|
|
toolName: string;
|
|
toolCallId: string;
|
|
state: string;
|
|
input?: Record<string, unknown>;
|
|
output?: Record<string, unknown>;
|
|
};
|
|
|
|
export function createStreamParser() {
|
|
const parts: ParsedPart[] = [];
|
|
let currentTextIdx = -1;
|
|
let currentReasoningIdx = -1;
|
|
|
|
function processEvent(event: Record<string, unknown>) {
|
|
const t = event.type as string;
|
|
|
|
switch (t) {
|
|
case "user-message":
|
|
currentTextIdx = -1;
|
|
currentReasoningIdx = -1;
|
|
parts.push({
|
|
type: "user-message",
|
|
id: event.id as string | undefined,
|
|
text: (event.text as string) ?? "",
|
|
});
|
|
break;
|
|
case "reasoning-start":
|
|
parts.push({
|
|
type: "reasoning",
|
|
text: "",
|
|
state: "streaming",
|
|
});
|
|
currentReasoningIdx = parts.length - 1;
|
|
break;
|
|
case "reasoning-delta": {
|
|
if (currentReasoningIdx >= 0) {
|
|
const p = parts[currentReasoningIdx] as {
|
|
type: "reasoning";
|
|
text: string;
|
|
};
|
|
p.text += event.delta as string;
|
|
}
|
|
break;
|
|
}
|
|
case "reasoning-end":
|
|
if (currentReasoningIdx >= 0) {
|
|
const p = parts[currentReasoningIdx] as {
|
|
type: "reasoning";
|
|
state?: string;
|
|
};
|
|
delete p.state;
|
|
}
|
|
currentReasoningIdx = -1;
|
|
break;
|
|
case "text-start":
|
|
parts.push({ type: "text", text: "" });
|
|
currentTextIdx = parts.length - 1;
|
|
break;
|
|
case "text-delta": {
|
|
if (currentTextIdx >= 0) {
|
|
const p = parts[currentTextIdx] as {
|
|
type: "text";
|
|
text: string;
|
|
};
|
|
p.text += event.delta as string;
|
|
}
|
|
break;
|
|
}
|
|
case "text-end":
|
|
currentTextIdx = -1;
|
|
break;
|
|
case "tool-input-start":
|
|
parts.push({
|
|
type: "dynamic-tool",
|
|
toolCallId: event.toolCallId as string,
|
|
toolName: event.toolName as string,
|
|
state: "input-available",
|
|
input: {},
|
|
});
|
|
break;
|
|
case "tool-input-available":
|
|
for (let i = parts.length - 1; i >= 0; i--) {
|
|
const p = parts[i];
|
|
if (
|
|
p.type === "dynamic-tool" &&
|
|
p.toolCallId === event.toolCallId
|
|
) {
|
|
p.input =
|
|
(event.input as Record<string, unknown>) ??
|
|
{};
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
case "tool-output-partial":
|
|
for (let i = parts.length - 1; i >= 0; i--) {
|
|
const p = parts[i];
|
|
if (
|
|
p.type === "dynamic-tool" &&
|
|
p.toolCallId === event.toolCallId
|
|
) {
|
|
p.output =
|
|
(event.output as Record<
|
|
string,
|
|
unknown
|
|
>) ?? {};
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
case "tool-output-available":
|
|
for (let i = parts.length - 1; i >= 0; i--) {
|
|
const p = parts[i];
|
|
if (
|
|
p.type === "dynamic-tool" &&
|
|
p.toolCallId === event.toolCallId
|
|
) {
|
|
p.state = "output-available";
|
|
p.output =
|
|
(event.output as Record<
|
|
string,
|
|
unknown
|
|
>) ?? {};
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
case "tool-output-error":
|
|
for (let i = parts.length - 1; i >= 0; i--) {
|
|
const p = parts[i];
|
|
if (
|
|
p.type === "dynamic-tool" &&
|
|
p.toolCallId === event.toolCallId
|
|
) {
|
|
p.state = "error";
|
|
p.output = {
|
|
error: event.errorText as string,
|
|
};
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return {
|
|
processEvent,
|
|
getParts: (): ParsedPart[] => parts.map((p) => ({ ...p })),
|
|
};
|
|
}
|
|
|
|
/** Imperative handle for parent-driven session control (main page). */
|
|
export type ChatPanelHandle = {
|
|
loadSession: (sessionId: string) => Promise<void>;
|
|
newSession: () => Promise<void>;
|
|
/** Create a new session and immediately send a message. */
|
|
sendNewMessage: (text: string) => Promise<void>;
|
|
/** Insert a file mention into the chat editor (e.g. from sidebar drag). */
|
|
insertFileMention?: (name: string, path: string) => void;
|
|
};
|
|
|
|
export type FileContext = {
|
|
path: string;
|
|
filename: string;
|
|
/** When true the path refers to a directory rather than a file. */
|
|
isDirectory?: boolean;
|
|
};
|
|
|
|
type FileScopedSession = {
|
|
id: string;
|
|
title: string;
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
messageCount: number;
|
|
};
|
|
|
|
/** A message waiting to be sent after the current agent run finishes. */
|
|
type QueuedMessage = {
|
|
id: string;
|
|
text: string;
|
|
mentionedFiles: Array<{ name: string; path: string }>;
|
|
attachedFiles: AttachedFile[];
|
|
createdAt: number;
|
|
};
|
|
|
|
export type SubagentSpawnInfo = {
|
|
childSessionKey: string;
|
|
runId: string;
|
|
task: string;
|
|
label?: string;
|
|
parentSessionId: string;
|
|
status?: "running" | "completed" | "error";
|
|
};
|
|
|
|
type ChatPanelProps = {
|
|
/** When set, scopes sessions to this file and prepends content as context. */
|
|
fileContext?: FileContext;
|
|
/** Compact mode for workspace sidebar (smaller UI, built-in session tabs). */
|
|
compact?: boolean;
|
|
/** Override the header title when a session is active (e.g. show the session's actual title). */
|
|
sessionTitle?: string;
|
|
/** Session ID to auto-load on mount (for non-file panels that remount after navigation). */
|
|
initialSessionId?: string;
|
|
/** Called when file content may have changed after agent edits. */
|
|
onFileChanged?: (newContent: string) => void;
|
|
/** Called when active session changes (for external sidebar highlighting). */
|
|
onActiveSessionChange?: (sessionId: string | null) => void;
|
|
/** Called when session list needs refresh (for external sidebar). */
|
|
onSessionsChange?: () => void;
|
|
/** Called when the agent spawns a subagent. */
|
|
onSubagentSpawned?: (info: SubagentSpawnInfo) => void;
|
|
/** Called when user clicks a subagent card in the chat to view its output. */
|
|
onSubagentClick?: (task: string) => void;
|
|
/** Called when user clicks an inline file path in chat output. */
|
|
onFilePathClick?: (path: string) => Promise<boolean | void> | boolean | void;
|
|
/** Called when user deletes the current session (e.g. from header menu). */
|
|
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>(
|
|
function ChatPanelInner(
|
|
{
|
|
fileContext,
|
|
compact,
|
|
sessionTitle,
|
|
initialSessionId,
|
|
onFileChanged,
|
|
onActiveSessionChange,
|
|
onSessionsChange,
|
|
onSubagentSpawned,
|
|
onSubagentClick,
|
|
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<
|
|
string | null
|
|
>(null);
|
|
const [loadingSession, setLoadingSession] = useState(false);
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
// ── Attachment state ──
|
|
const [attachedFiles, setAttachedFiles] = useState<
|
|
AttachedFile[]
|
|
>([]);
|
|
const [showFilePicker, setShowFilePicker] =
|
|
useState(false);
|
|
|
|
// ── Reconnection state ──
|
|
const [isReconnecting, setIsReconnecting] = useState(false);
|
|
const reconnectAbortRef = useRef<AbortController | null>(null);
|
|
|
|
// Track persisted messages to avoid double-saves
|
|
const savedMessageIdsRef = useRef<Set<string>>(new Set());
|
|
// Set when /new or + triggers a new session
|
|
const newSessionPendingRef = useRef(false);
|
|
// Whether the next message should include file context
|
|
const isFirstFileMessageRef = useRef(true);
|
|
|
|
// File-scoped session list (compact mode only)
|
|
const [fileSessions, setFileSessions] = useState<
|
|
FileScopedSession[]
|
|
>([]);
|
|
|
|
// ── Message queue (messages to send after current run completes) ──
|
|
const [queuedMessages, setQueuedMessages] = useState<QueuedMessage[]>([]);
|
|
const [rawView, _setRawView] = useState(false);
|
|
|
|
const filePath = fileContext?.path ?? null;
|
|
|
|
// ── Ref-based session ID for transport ──
|
|
const sessionIdRef = useRef<string | null>(null);
|
|
useEffect(() => {
|
|
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 } : {};
|
|
},
|
|
}),
|
|
[],
|
|
);
|
|
|
|
const { messages, sendMessage, status, stop, error, setMessages } =
|
|
useChat({ transport });
|
|
|
|
const isStreaming =
|
|
status === "streaming" ||
|
|
status === "submitted" ||
|
|
isReconnecting;
|
|
|
|
// Auto-scroll to bottom on new messages, but only when the user
|
|
// is already near the bottom. If the user scrolls up during
|
|
// streaming, we stop auto-scrolling until they return to the
|
|
// bottom (or a new user message is sent).
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
const userScrolledAwayRef = useRef(false);
|
|
const scrollRafRef = useRef(0);
|
|
|
|
// Detect when the user scrolls away from the bottom.
|
|
useEffect(() => {
|
|
const el = scrollContainerRef.current;
|
|
if (!el) {return;}
|
|
|
|
const onScroll = () => {
|
|
const distanceFromBottom =
|
|
el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
// Threshold: if within 80px of the bottom, consider "at bottom"
|
|
userScrolledAwayRef.current = distanceFromBottom > 80;
|
|
};
|
|
|
|
el.addEventListener("scroll", onScroll, { passive: true });
|
|
return () => el.removeEventListener("scroll", onScroll);
|
|
}, []);
|
|
|
|
// Auto-scroll effect — skips when user has scrolled away.
|
|
useEffect(() => {
|
|
if (userScrolledAwayRef.current) {return;}
|
|
if (scrollRafRef.current) {return;}
|
|
scrollRafRef.current = requestAnimationFrame(() => {
|
|
scrollRafRef.current = 0;
|
|
messagesEndRef.current?.scrollIntoView({
|
|
behavior: "smooth",
|
|
});
|
|
});
|
|
}, [messages]);
|
|
|
|
// ── Session persistence helpers ──
|
|
|
|
const createSession = useCallback(
|
|
async (title: string): Promise<string> => {
|
|
const body: Record<string, string> = { title };
|
|
if (filePath) {
|
|
body.filePath = filePath;
|
|
}
|
|
const res = await fetch("/api/web-sessions", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const data = await res.json();
|
|
return data.session.id;
|
|
},
|
|
[filePath],
|
|
);
|
|
|
|
// ── 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,
|
|
baseMessages: Array<{
|
|
id: string;
|
|
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?${streamParam}`,
|
|
{ signal: abort.signal },
|
|
);
|
|
if (!res.ok || !res.body) {
|
|
return false; // No active run
|
|
}
|
|
|
|
// If the run already completed (still in the grace
|
|
// period), skip the expensive SSE replay -- the
|
|
// persisted messages we already loaded are final.
|
|
if (res.headers.get("X-Run-Active") === "false") {
|
|
void res.body.cancel();
|
|
return false;
|
|
}
|
|
|
|
setIsReconnecting(true);
|
|
|
|
const parser = createStreamParser();
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
const reconnectMsgId = `reconnect-${sessionId}`;
|
|
let buffer = "";
|
|
let frameRequested = false;
|
|
|
|
const updateUI = () => {
|
|
// Guard: if the session was switched while a
|
|
// rAF was pending, don't overwrite the new
|
|
// session's messages with stale data.
|
|
if (abort.signal.aborted) {return;}
|
|
const assistantMsg = {
|
|
id: reconnectMsgId,
|
|
role: "assistant" as const,
|
|
parts: parser.getParts() as UIMessage["parts"],
|
|
};
|
|
setMessages([
|
|
...baseMessages,
|
|
assistantMsg,
|
|
]);
|
|
};
|
|
|
|
// Read the SSE stream
|
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- loop reads until done
|
|
while (true) {
|
|
const { done, value } =
|
|
await reader.read();
|
|
if (done) {break;}
|
|
|
|
buffer += decoder.decode(value, {
|
|
stream: true,
|
|
});
|
|
|
|
// Parse SSE events (data: <json>\n\n)
|
|
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),
|
|
);
|
|
parser.processEvent(event);
|
|
} catch {
|
|
/* skip malformed events */
|
|
}
|
|
}
|
|
}
|
|
|
|
// Batch UI updates to animation frames
|
|
if (!frameRequested) {
|
|
frameRequested = true;
|
|
requestAnimationFrame(() => {
|
|
frameRequested = false;
|
|
updateUI();
|
|
});
|
|
}
|
|
}
|
|
|
|
// Final update after stream ends
|
|
updateUI();
|
|
|
|
// Mark all messages as saved (server persisted them)
|
|
if (!abort.signal.aborted) {
|
|
for (const m of baseMessages) {
|
|
savedMessageIdsRef.current.add(m.id);
|
|
}
|
|
savedMessageIdsRef.current.add(reconnectMsgId);
|
|
}
|
|
|
|
setIsReconnecting(false);
|
|
reconnectAbortRef.current = null;
|
|
return true;
|
|
} catch (err) {
|
|
if (
|
|
(err as Error).name !== "AbortError"
|
|
) {
|
|
console.error(
|
|
"Reconnection error:",
|
|
err,
|
|
);
|
|
}
|
|
setIsReconnecting(false);
|
|
reconnectAbortRef.current = null;
|
|
return false;
|
|
}
|
|
},
|
|
[setMessages],
|
|
);
|
|
|
|
// ── File-scoped session initialization ──
|
|
const fetchFileSessionsRef = useRef<
|
|
(() => Promise<FileScopedSession[]>) | null
|
|
>(null);
|
|
|
|
fetchFileSessionsRef.current = async () => {
|
|
if (!filePath) {
|
|
return [];
|
|
}
|
|
try {
|
|
const res = await fetch(
|
|
`/api/web-sessions?filePath=${encodeURIComponent(filePath)}`,
|
|
);
|
|
const data = await res.json();
|
|
return (data.sessions || []) as FileScopedSession[];
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!filePath || isSubagentMode) {
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
|
|
sessionIdRef.current = null;
|
|
setCurrentSessionId(null);
|
|
onActiveSessionChange?.(null);
|
|
setMessages([]);
|
|
savedMessageIdsRef.current.clear();
|
|
isFirstFileMessageRef.current = true;
|
|
|
|
void (async () => {
|
|
const sessions =
|
|
(await fetchFileSessionsRef.current?.()) ?? [];
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
setFileSessions(sessions);
|
|
|
|
if (sessions.length > 0) {
|
|
const target = (initialSessionId
|
|
? sessions.find((s) => s.id === initialSessionId)
|
|
: undefined) ?? sessions[0];
|
|
setCurrentSessionId(target.id);
|
|
sessionIdRef.current = target.id;
|
|
onActiveSessionChange?.(target.id);
|
|
isFirstFileMessageRef.current = false;
|
|
|
|
try {
|
|
const msgRes = await fetch(
|
|
`/api/web-sessions/${target.id}`,
|
|
);
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
const msgData = await msgRes.json();
|
|
const sessionMessages: Array<{
|
|
id: string;
|
|
role: "user" | "assistant";
|
|
content: string;
|
|
parts?: Array<Record<string, unknown>>;
|
|
_streaming?: boolean;
|
|
}> = msgData.messages || [];
|
|
|
|
// Filter out in-progress streaming messages
|
|
// (will be rebuilt from the live SSE stream)
|
|
const hasStreaming = sessionMessages.some(
|
|
(m) => m._streaming,
|
|
);
|
|
const completedMessages = hasStreaming
|
|
? sessionMessages.filter(
|
|
(m) => !m._streaming,
|
|
)
|
|
: sessionMessages;
|
|
|
|
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"],
|
|
};
|
|
},
|
|
);
|
|
if (!cancelled) {
|
|
setMessages(uiMessages);
|
|
}
|
|
|
|
if (!cancelled) {
|
|
await attemptReconnect(
|
|
target.id,
|
|
uiMessages,
|
|
);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- stable setters
|
|
}, [filePath, attemptReconnect]);
|
|
|
|
// ── Non-file panel: auto-restore session on mount ──
|
|
// When the main ChatPanel remounts after navigation (e.g. user viewed
|
|
// a file then returned to chat), re-load the previously active session
|
|
// and reconnect to any active stream.
|
|
const initialSessionHandled = useRef(false);
|
|
useEffect(() => {
|
|
if (filePath || isSubagentMode || !initialSessionId || initialSessionHandled.current) {
|
|
return;
|
|
}
|
|
initialSessionHandled.current = true;
|
|
void handleSessionSelect(initialSessionId);
|
|
// 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);
|
|
|
|
useEffect(() => {
|
|
if (!currentSessionId || !onSubagentSpawned) {return;}
|
|
let cancelled = false;
|
|
|
|
const poll = async () => {
|
|
try {
|
|
const res = await fetch(
|
|
`/api/chat/subagents?sessionId=${encodeURIComponent(currentSessionId)}`,
|
|
);
|
|
if (cancelled || !res.ok) {return;}
|
|
const data = await res.json();
|
|
const subagents: Array<{
|
|
sessionKey: string;
|
|
runId: string;
|
|
task: string;
|
|
label?: string;
|
|
status: "running" | "completed" | "error";
|
|
}> = data.subagents ?? [];
|
|
let anyRunning = false;
|
|
for (const sa of subagents) {
|
|
if (sa.status === "running") {anyRunning = true;}
|
|
onSubagentSpawned({
|
|
childSessionKey: sa.sessionKey,
|
|
runId: sa.runId,
|
|
task: sa.task,
|
|
label: sa.label,
|
|
parentSessionId: currentSessionId,
|
|
status: sa.status,
|
|
});
|
|
}
|
|
if (!cancelled) {setHasRunningSubagents(anyRunning);}
|
|
} catch { /* ignore */ }
|
|
};
|
|
|
|
void poll();
|
|
const id = setInterval(poll, 3_000);
|
|
return () => { cancelled = true; clearInterval(id); };
|
|
}, [currentSessionId, onSubagentSpawned]);
|
|
|
|
// ── Post-stream side-effects (file-reload, session refresh) ──
|
|
// Message persistence is handled server-side by ActiveRunManager,
|
|
// so we only refresh the file sessions list and notify the parent
|
|
// when the file content may have changed.
|
|
const prevStatusRef = useRef(status);
|
|
useEffect(() => {
|
|
const wasStreaming =
|
|
prevStatusRef.current === "streaming" ||
|
|
prevStatusRef.current === "submitted";
|
|
const isNowReady = status === "ready";
|
|
|
|
if (wasStreaming && isNowReady && currentSessionId) {
|
|
// Mark all current messages as saved — the server
|
|
// already persisted them via ActiveRunManager.
|
|
for (const m of messages) {
|
|
savedMessageIdsRef.current.add(m.id);
|
|
}
|
|
|
|
if (filePath) {
|
|
void fetchFileSessionsRef.current?.().then(
|
|
(sessions) => {
|
|
setFileSessions(sessions);
|
|
},
|
|
);
|
|
}
|
|
|
|
if (filePath && onFileChanged) {
|
|
fetch(
|
|
`/api/workspace/file?path=${encodeURIComponent(filePath)}`,
|
|
)
|
|
.then((r) => r.json())
|
|
.then((data) => {
|
|
if (data.content) {
|
|
onFileChanged(data.content);
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
onSessionsChange?.();
|
|
}
|
|
prevStatusRef.current = status;
|
|
}, [
|
|
status,
|
|
messages,
|
|
currentSessionId,
|
|
filePath,
|
|
onFileChanged,
|
|
onSessionsChange,
|
|
]);
|
|
|
|
// ── Actions ──
|
|
|
|
// Ref for handleNewSession so handleEditorSubmit doesn't depend on the hook order
|
|
const handleNewSessionRef = useRef<() => void>(() => {});
|
|
|
|
/** Submit from the Tiptap editor (called on Enter or send button).
|
|
* `overrideAttachments` is used by the queue system to pass saved attachments directly. */
|
|
const handleEditorSubmit = useCallback(
|
|
async (
|
|
text: string,
|
|
mentionedFiles: Array<{ name: string; path: string }>,
|
|
overrideAttachments?: AttachedFile[],
|
|
) => {
|
|
const hasText = text.trim().length > 0;
|
|
const hasMentions = mentionedFiles.length > 0;
|
|
// Use override attachments (from queue) or current state
|
|
const readyFiles = overrideAttachments
|
|
? overrideAttachments.filter((f) => !f.uploading && f.path)
|
|
: attachedFiles.filter((f) => !f.uploading && f.path);
|
|
const hasFiles = readyFiles.length > 0;
|
|
if (!hasText && !hasMentions && !hasFiles) {
|
|
return;
|
|
}
|
|
|
|
const userText = text.trim();
|
|
const currentAttachments = [...readyFiles];
|
|
|
|
if (userText.toLowerCase() === "/new") {
|
|
// Revoke blob URLs before clearing
|
|
for (const f of attachedFiles) {
|
|
if (f.localUrl) {URL.revokeObjectURL(f.localUrl);}
|
|
}
|
|
setAttachedFiles([]);
|
|
handleNewSessionRef.current();
|
|
return;
|
|
}
|
|
|
|
// Queue the message if the agent is still running.
|
|
if (isStreaming) {
|
|
// Clear attachment strip but keep blob URLs alive for queue thumbnails
|
|
if (!overrideAttachments) {
|
|
setAttachedFiles([]);
|
|
}
|
|
setQueuedMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: crypto.randomUUID(),
|
|
text: userText,
|
|
mentionedFiles,
|
|
attachedFiles: currentAttachments,
|
|
createdAt: Date.now(),
|
|
},
|
|
]);
|
|
return;
|
|
}
|
|
|
|
// Clear attachments (revoke blob URLs to free memory)
|
|
if (!overrideAttachments && currentAttachments.length > 0) {
|
|
for (const f of attachedFiles) {
|
|
if (f.localUrl) {URL.revokeObjectURL(f.localUrl);}
|
|
}
|
|
setAttachedFiles([]);
|
|
}
|
|
|
|
let sessionId = currentSessionId;
|
|
if (!sessionId && !isSubagentMode) {
|
|
const titleSource =
|
|
userText || "File attachment";
|
|
const title =
|
|
titleSource.length > 60
|
|
? titleSource.slice(0, 60) + "..."
|
|
: titleSource;
|
|
sessionId = await createSession(title);
|
|
setCurrentSessionId(sessionId);
|
|
sessionIdRef.current = sessionId;
|
|
onActiveSessionChange?.(sessionId);
|
|
onSessionsChange?.();
|
|
|
|
if (filePath) {
|
|
void fetchFileSessionsRef.current?.().then(
|
|
(sessions) => {
|
|
setFileSessions(sessions);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
// Build message with optional attachment prefix
|
|
let messageText = userText;
|
|
|
|
// Merge mention paths and attachment paths
|
|
const allFilePaths = [
|
|
...mentionedFiles.map((f) => f.path),
|
|
...currentAttachments.map((f) => f.path),
|
|
];
|
|
if (allFilePaths.length > 0) {
|
|
const prefix = `[Attached files: ${allFilePaths.join(", ")}]`;
|
|
messageText = messageText
|
|
? `${prefix}\n\n${messageText}`
|
|
: prefix;
|
|
}
|
|
|
|
if (fileContext && isFirstFileMessageRef.current) {
|
|
const label = fileContext.isDirectory ? "directory" : "file";
|
|
messageText = `[Context: workspace ${label} '${fileContext.path}']\n\n${messageText}`;
|
|
isFirstFileMessageRef.current = false;
|
|
}
|
|
|
|
// Reset scroll lock so we auto-scroll to the new user message
|
|
userScrolledAwayRef.current = false;
|
|
void sendMessage({ text: messageText });
|
|
},
|
|
[
|
|
attachedFiles,
|
|
isStreaming,
|
|
currentSessionId,
|
|
createSession,
|
|
onActiveSessionChange,
|
|
onSessionsChange,
|
|
filePath,
|
|
fileContext,
|
|
sendMessage,
|
|
],
|
|
);
|
|
|
|
// ── Queue flush: send the next queued message once the stream finishes ──
|
|
const prevFlushStatusRef = useRef(status);
|
|
useEffect(() => {
|
|
const wasStreaming =
|
|
prevFlushStatusRef.current === "streaming" ||
|
|
prevFlushStatusRef.current === "submitted";
|
|
const isNowReady = status === "ready";
|
|
prevFlushStatusRef.current = status;
|
|
|
|
if (wasStreaming && isNowReady && queuedMessages.length > 0) {
|
|
const [next, ...rest] = queuedMessages;
|
|
setQueuedMessages(rest);
|
|
// Revoke blob URLs from queued attachments (no longer needed for thumbnails)
|
|
for (const f of next.attachedFiles) {
|
|
if (f.localUrl) {URL.revokeObjectURL(f.localUrl);}
|
|
}
|
|
// Use a microtask so React can settle the status update first.
|
|
queueMicrotask(() => {
|
|
void handleEditorSubmit(next.text, next.mentionedFiles, next.attachedFiles);
|
|
});
|
|
}
|
|
}, [status, queuedMessages, handleEditorSubmit]);
|
|
|
|
const handleSessionSelect = useCallback(
|
|
async (sessionId: string) => {
|
|
if (sessionId === currentSessionId) {
|
|
return;
|
|
}
|
|
|
|
// Stop any active stream/reconnection for the old session.
|
|
reconnectAbortRef.current?.abort();
|
|
void stop();
|
|
|
|
setLoadingSession(true);
|
|
setCurrentSessionId(sessionId);
|
|
sessionIdRef.current = sessionId;
|
|
onActiveSessionChange?.(sessionId);
|
|
savedMessageIdsRef.current.clear();
|
|
isFirstFileMessageRef.current = false;
|
|
setQueuedMessages([]);
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`/api/web-sessions/${sessionId}`,
|
|
);
|
|
if (!response.ok) {
|
|
throw new Error("Failed to load session");
|
|
}
|
|
|
|
const data = await response.json();
|
|
const sessionMessages: Array<{
|
|
id: string;
|
|
role: "user" | "assistant";
|
|
content: string;
|
|
parts?: Array<Record<string, unknown>>;
|
|
_streaming?: boolean;
|
|
}> = data.messages || [];
|
|
|
|
const hasStreaming = sessionMessages.some(
|
|
(m) => m._streaming,
|
|
);
|
|
const completedMessages = hasStreaming
|
|
? sessionMessages.filter(
|
|
(m) => !m._streaming,
|
|
)
|
|
: sessionMessages;
|
|
|
|
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"],
|
|
};
|
|
},
|
|
);
|
|
|
|
setMessages(uiMessages);
|
|
|
|
// Clear loading state *before* reconnecting — the
|
|
// persisted messages are now visible. attemptReconnect
|
|
// manages its own `isReconnecting` state which shows
|
|
// "Resuming stream..." instead of "Loading session...".
|
|
setLoadingSession(false);
|
|
|
|
// Always try to reconnect -- the stream endpoint
|
|
// returns 404 gracefully if no active run exists,
|
|
// and this avoids missing runs whose _streaming
|
|
// flag hasn't been persisted yet.
|
|
await attemptReconnect(sessionId, uiMessages);
|
|
} catch (err) {
|
|
console.error("Error loading session:", err);
|
|
setLoadingSession(false);
|
|
}
|
|
},
|
|
[
|
|
currentSessionId,
|
|
setMessages,
|
|
onActiveSessionChange,
|
|
stop,
|
|
attemptReconnect,
|
|
],
|
|
);
|
|
|
|
const handleNewSession = useCallback(() => {
|
|
reconnectAbortRef.current?.abort();
|
|
void stop();
|
|
setIsReconnecting(false);
|
|
setCurrentSessionId(null);
|
|
sessionIdRef.current = null;
|
|
onActiveSessionChange?.(null);
|
|
setMessages([]);
|
|
savedMessageIdsRef.current.clear();
|
|
isFirstFileMessageRef.current = true;
|
|
newSessionPendingRef.current = false;
|
|
setQueuedMessages([]);
|
|
// Focus the chat input after state updates so "New Chat" is ready to type.
|
|
requestAnimationFrame(() => {
|
|
editorRef.current?.focus();
|
|
});
|
|
}, [setMessages, onActiveSessionChange, stop]);
|
|
|
|
// Keep the ref in sync so handleEditorSubmit can call it
|
|
handleNewSessionRef.current = handleNewSession;
|
|
|
|
useImperativeHandle(
|
|
ref,
|
|
() => ({
|
|
loadSession: handleSessionSelect,
|
|
newSession: async () => { handleNewSession(); },
|
|
sendNewMessage: async (text: string) => {
|
|
handleNewSession();
|
|
const title =
|
|
text.length > 60 ? text.slice(0, 60) + "..." : text;
|
|
const sessionId = await createSession(title);
|
|
setCurrentSessionId(sessionId);
|
|
sessionIdRef.current = sessionId;
|
|
onActiveSessionChange?.(sessionId);
|
|
onSessionsChange?.();
|
|
userScrolledAwayRef.current = false;
|
|
void sendMessage({ text });
|
|
},
|
|
insertFileMention: (name: string, path: string) => {
|
|
editorRef.current?.insertFileMention(name, path);
|
|
},
|
|
}),
|
|
[handleSessionSelect, handleNewSession, createSession, onActiveSessionChange, onSessionsChange, sendMessage],
|
|
);
|
|
|
|
// ── Stop handler (aborts server-side run + client-side stream) ──
|
|
const handleStop = useCallback(async () => {
|
|
// Abort reconnection stream if active (immediate visual feedback).
|
|
reconnectAbortRef.current?.abort();
|
|
setIsReconnecting(false);
|
|
|
|
// 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).
|
|
const stopKey = subagentSessionKey || currentSessionId;
|
|
if (stopKey) {
|
|
try {
|
|
await fetch("/api/chat/stop", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(
|
|
subagentSessionKey
|
|
? { sessionKey: subagentSessionKey }
|
|
: { sessionId: currentSessionId },
|
|
),
|
|
});
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
// Stop the useChat transport stream (transitions status → "ready").
|
|
void stop();
|
|
}, [currentSessionId, subagentSessionKey, stop]);
|
|
|
|
// ── Queue handlers ──
|
|
|
|
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));
|
|
}, []);
|
|
|
|
/** Force-send: stop the agent, then immediately submit this queued message. */
|
|
const forceSendQueuedMessage = useCallback(
|
|
async (id: string) => {
|
|
const msg = queuedMessages.find((m) => m.id === id);
|
|
if (!msg) {return;}
|
|
// Remove it from the queue first.
|
|
setQueuedMessages((prev) => prev.filter((m) => m.id !== id));
|
|
// Stop the current agent run.
|
|
await handleStop();
|
|
// Submit the message after a short delay to let status settle.
|
|
setTimeout(() => {
|
|
void handleEditorSubmit(msg.text, msg.mentionedFiles, msg.attachedFiles);
|
|
}, 100);
|
|
},
|
|
[queuedMessages, handleStop, handleEditorSubmit],
|
|
);
|
|
|
|
// ── Attachment handlers ──
|
|
|
|
const handleFilesSelected = useCallback(
|
|
(files: SelectedFile[]) => {
|
|
const newFiles: AttachedFile[] = files.map(
|
|
(f) => ({
|
|
id: `${f.path}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
name: f.name,
|
|
path: f.path,
|
|
}),
|
|
);
|
|
setAttachedFiles((prev) => [
|
|
...prev,
|
|
...newFiles,
|
|
]);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const removeAttachment = useCallback((id: string) => {
|
|
setAttachedFiles((prev) => {
|
|
const removed = prev.find((f) => f.id === id);
|
|
if (removed?.localUrl) {URL.revokeObjectURL(removed.localUrl);}
|
|
return prev.filter((f) => f.id !== id);
|
|
});
|
|
}, []);
|
|
|
|
const clearAllAttachments = useCallback(() => {
|
|
setAttachedFiles((prev) => {
|
|
for (const f of prev) {
|
|
if (f.localUrl) {URL.revokeObjectURL(f.localUrl);}
|
|
}
|
|
return [];
|
|
});
|
|
}, []);
|
|
|
|
/** Upload native files (e.g. dropped from Finder/Desktop) and attach them.
|
|
* Shows files instantly with a local preview, then uploads in the background. */
|
|
const uploadAndAttachNativeFiles = useCallback(
|
|
(files: FileList) => {
|
|
const fileArray = Array.from(files);
|
|
|
|
// Immediately add placeholder entries with local blob URLs
|
|
const placeholders: AttachedFile[] = fileArray.map((file) => ({
|
|
id: `pending-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
name: file.name,
|
|
path: "",
|
|
uploading: true,
|
|
localUrl: URL.createObjectURL(file),
|
|
}));
|
|
setAttachedFiles((prev) => [...prev, ...placeholders]);
|
|
|
|
// Upload each file in the background and update the entry
|
|
for (let i = 0; i < fileArray.length; i++) {
|
|
const file = fileArray[i];
|
|
const placeholderId = placeholders[i].id;
|
|
const localUrl = placeholders[i].localUrl;
|
|
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
fetch("/api/workspace/upload", {
|
|
method: "POST",
|
|
body: form,
|
|
})
|
|
.then((res) => res.ok ? res.json() : null)
|
|
.then((json: { ok?: boolean; path?: string } | null) => {
|
|
if (json?.ok && json.path) {
|
|
// Replace placeholder with the real uploaded file
|
|
setAttachedFiles((prev) =>
|
|
prev.map((f) =>
|
|
f.id === placeholderId
|
|
? { ...f, path: json.path!, uploading: false }
|
|
: f,
|
|
),
|
|
);
|
|
} else {
|
|
// Upload failed — remove the placeholder
|
|
setAttachedFiles((prev) => prev.filter((f) => f.id !== placeholderId));
|
|
if (localUrl) {URL.revokeObjectURL(localUrl);}
|
|
}
|
|
})
|
|
.catch(() => {
|
|
setAttachedFiles((prev) => prev.filter((f) => f.id !== placeholderId));
|
|
if (localUrl) {URL.revokeObjectURL(localUrl);}
|
|
});
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
// ── Status label ──
|
|
|
|
const _statusLabel = loadingSession
|
|
? "Loading session..."
|
|
: isReconnecting
|
|
? "Resuming stream..."
|
|
: status === "ready"
|
|
? "Ready"
|
|
: status === "submitted"
|
|
? "Thinking..."
|
|
: status === "streaming"
|
|
? (hasRunningSubagents ? "Waiting for subagents..." : "Streaming...")
|
|
: status === "error"
|
|
? "Error"
|
|
: status;
|
|
|
|
// Show an inline Unicode spinner in the message flow when the AI
|
|
// is thinking/streaming but hasn't produced visible text yet.
|
|
const lastMsg = messages.length > 0 ? messages[messages.length - 1] : null;
|
|
const lastAssistantHasText =
|
|
lastMsg?.role === "assistant" &&
|
|
lastMsg.parts.some((p) => p.type === "text" && (p as { text: string }).text.length > 0);
|
|
const showInlineSpinner = isStreaming && !lastAssistantHasText;
|
|
|
|
// ── Render ──
|
|
|
|
return (
|
|
<div
|
|
className="h-full flex flex-col"
|
|
style={{ background: "var(--color-main-bg)" }}
|
|
>
|
|
{/* Header — sticky glass bar */}
|
|
<header
|
|
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
|
|
className="text-xs font-semibold truncate"
|
|
style={{
|
|
color: "var(--color-text)",
|
|
}}
|
|
>
|
|
Chat: {fileContext.filename}
|
|
</h2>
|
|
) : (
|
|
<h2
|
|
className="text-sm font-semibold"
|
|
style={{
|
|
color: "var(--color-text)",
|
|
}}
|
|
>
|
|
{currentSessionId
|
|
? (sessionTitle || "Chat Session")
|
|
: "New Chat"}
|
|
</h2>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{currentSessionId && onDeleteSession && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
className="p-1.5 rounded-lg"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
title="More options"
|
|
aria-label="More options"
|
|
>
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<circle cx="12" cy="12" r="1" />
|
|
<circle cx="5" cy="12" r="1" />
|
|
<circle cx="19" cy="12" r="1" />
|
|
</svg>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" side="bottom">
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
onSelect={() => onDeleteSession(currentSessionId)}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><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>
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
{compact && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleNewSession()}
|
|
className="p-1.5 rounded-lg"
|
|
style={{
|
|
color: "var(--color-text-muted)",
|
|
}}
|
|
title="New chat"
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M12 5v14" />
|
|
<path d="M5 12h14" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</header>
|
|
|
|
{/* 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={{
|
|
borderColor: "var(--color-border)",
|
|
background: "var(--color-bg-glass)",
|
|
}}
|
|
>
|
|
{fileSessions.slice(0, 10).map((s) => (
|
|
<button
|
|
key={s.id}
|
|
type="button"
|
|
onClick={() =>
|
|
handleSessionSelect(s.id)
|
|
}
|
|
className="px-2.5 py-1 text-[10px] rounded-full whitespace-nowrap shrink-0 font-medium"
|
|
style={{
|
|
background:
|
|
s.id === currentSessionId
|
|
? "var(--color-accent)"
|
|
: "var(--color-surface-hover)",
|
|
color:
|
|
s.id === currentSessionId
|
|
? "white"
|
|
: "var(--color-text-muted)",
|
|
border:
|
|
s.id === currentSessionId
|
|
? "none"
|
|
: "1px solid var(--color-border)",
|
|
}}
|
|
>
|
|
{s.title.length > 25
|
|
? s.title.slice(0, 25) + "..."
|
|
: s.title}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
ref={scrollContainerRef}
|
|
className="flex-1 overflow-y-auto min-h-0"
|
|
>
|
|
{/* Messages */}
|
|
<div
|
|
className={compact ? "px-3" : "px-6"}
|
|
>
|
|
{loadingSession ? (
|
|
<div className="flex items-center justify-center h-full min-h-[60vh]">
|
|
<div className="text-center">
|
|
<UnicodeSpinner
|
|
name="braille"
|
|
className="block text-2xl mx-auto mb-3"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
/>
|
|
<p
|
|
className="text-xs"
|
|
style={{
|
|
color: "var(--color-text-muted)",
|
|
}}
|
|
>
|
|
Loading session...
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : messages.length === 0 ? (
|
|
<div className="flex items-center justify-center h-full min-h-[60vh]">
|
|
<div className="text-center max-w-md px-4">
|
|
{compact ? (
|
|
<p
|
|
className="text-sm"
|
|
style={{
|
|
color: "var(--color-text-muted)",
|
|
}}
|
|
>
|
|
Ask about this file
|
|
</p>
|
|
) : (
|
|
<>
|
|
<h3
|
|
className="font-instrument text-3xl tracking-tight mb-2"
|
|
style={{
|
|
color: "var(--color-text)",
|
|
}}
|
|
>
|
|
What can I help with?
|
|
</h3>
|
|
<p
|
|
className="text-sm leading-relaxed"
|
|
style={{
|
|
color: "var(--color-text-muted)",
|
|
}}
|
|
>
|
|
Send a message to start a
|
|
conversation with your
|
|
agent.
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={`${compact ? "" : "max-w-2xl mx-auto"} py-3`}
|
|
>
|
|
{rawView ? (
|
|
<pre
|
|
className="text-xs whitespace-pre-wrap break-all font-mono p-4 rounded-xl"
|
|
style={{ color: "var(--color-text)", background: "var(--color-surface-hover)" }}
|
|
>
|
|
{JSON.stringify(messages, null, 2)}
|
|
</pre>
|
|
) : messages.map((message, i) => (
|
|
<ChatMessage
|
|
key={message.id}
|
|
message={message}
|
|
isStreaming={isStreaming && i === messages.length - 1}
|
|
onSubagentClick={onSubagentClick}
|
|
onFilePathClick={onFilePathClick}
|
|
sessionId={currentSessionId}
|
|
/>
|
|
))}
|
|
{showInlineSpinner && (
|
|
<div className="py-3 min-w-0">
|
|
<UnicodeSpinner
|
|
name="pulse"
|
|
className="text-base"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Transport-level error display */}
|
|
{error && (
|
|
<div
|
|
className="px-3 py-2 flex items-center gap-2 sticky bottom-[72px] z-10"
|
|
style={{
|
|
background: `color-mix(in srgb, var(--color-error) 6%, var(--color-surface))`,
|
|
borderColor: `color-mix(in srgb, var(--color-error) 18%, transparent)`,
|
|
color: "var(--color-error)",
|
|
}}
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className="shrink-0"
|
|
>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<line
|
|
x1="12"
|
|
y1="8"
|
|
x2="12"
|
|
y2="12"
|
|
/>
|
|
<line
|
|
x1="12"
|
|
y1="16"
|
|
x2="12.01"
|
|
y2="16"
|
|
/>
|
|
</svg>
|
|
<p className="text-xs">{error.message}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Input bar at bottom */}
|
|
<div
|
|
className={`${compact ? "px-3 py-2" : "px-3 pb-3 pt-0 md:px-6 md:pb-5"} z-20`}
|
|
style={{ background: "var(--color-bg-glass)" }}
|
|
>
|
|
<div
|
|
className={compact ? "" : "max-w-[720px] mx-auto"}
|
|
>
|
|
<div
|
|
data-chat-drop-target=""
|
|
className="rounded-3xl overflow-hidden border shadow-[0_0_32px_rgba(0,0,0,0.07)] transition-[outline,box-shadow] duration-150 ease-out data-drag-hover:outline-2 data-drag-hover:outline-dashed data-drag-hover:outline-(--color-accent) data-drag-hover:-outline-offset-2 data-drag-hover:shadow-[0_0_0_4px_color-mix(in_srgb,var(--color-accent)_15%,transparent),0_0_32px_rgba(0,0,0,0.07)]!"
|
|
style={{
|
|
background: "var(--color-surface)",
|
|
borderColor: "var(--color-border)",
|
|
}}
|
|
onDragOver={(e) => {
|
|
if (
|
|
e.dataTransfer?.types.includes("application/x-file-mention") ||
|
|
e.dataTransfer?.types.includes("Files")
|
|
) {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "copy";
|
|
// visual feedback
|
|
(e.currentTarget as HTMLElement).setAttribute("data-drag-hover", "");
|
|
}
|
|
}}
|
|
onDragLeave={(e) => {
|
|
// Only remove when leaving the container itself (not entering a child)
|
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
|
(e.currentTarget as HTMLElement).removeAttribute("data-drag-hover");
|
|
}
|
|
}}
|
|
onDrop={(e) => {
|
|
(e.currentTarget as HTMLElement).removeAttribute("data-drag-hover");
|
|
|
|
// Sidebar file mention drop
|
|
const data = e.dataTransfer?.getData("application/x-file-mention");
|
|
if (data) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
try {
|
|
const { name, path } = JSON.parse(data) as { name: string; path: string };
|
|
if (name && path) {
|
|
editorRef.current?.insertFileMention(name, path);
|
|
}
|
|
} catch {
|
|
// ignore malformed data
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Native file drop (from OS file manager / Desktop)
|
|
const files = e.dataTransfer?.files;
|
|
if (files && files.length > 0) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
uploadAndAttachNativeFiles(files);
|
|
}
|
|
}}
|
|
>
|
|
{/* Queued messages indicator */}
|
|
{queuedMessages.length > 0 && (
|
|
<div className={compact ? "px-2 pt-2" : "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)" }}
|
|
>
|
|
Queue ({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>
|
|
)}
|
|
|
|
{/* Attachment preview strip (hidden in subagent mode) */}
|
|
{!isSubagentMode && (
|
|
<AttachmentStrip
|
|
files={attachedFiles}
|
|
compact={compact}
|
|
onRemove={removeAttachment}
|
|
onClearAll={
|
|
clearAllAttachments
|
|
}
|
|
/>
|
|
)}
|
|
|
|
<ChatEditor
|
|
ref={editorRef}
|
|
onSubmit={handleEditorSubmit}
|
|
onChange={(isEmpty) =>
|
|
setEditorEmpty(isEmpty)
|
|
}
|
|
onNativeFileDrop={isSubagentMode ? undefined : uploadAndAttachNativeFiles}
|
|
placeholder={
|
|
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}
|
|
/>
|
|
|
|
{/* Toolbar row */}
|
|
<div
|
|
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={() =>
|
|
setShowFilePicker(
|
|
true,
|
|
)
|
|
}
|
|
className="p-1.5 rounded-lg hover:opacity-80 transition-opacity"
|
|
style={{
|
|
color:
|
|
attachedFiles.length >
|
|
0
|
|
? "var(--color-accent)"
|
|
: "var(--color-text-muted)",
|
|
}}
|
|
title="Attach files"
|
|
>
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<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">
|
|
{isStreaming && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleStop()}
|
|
className={`${compact ? "w-6 h-6" : "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 ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
editorRef.current?.submit();
|
|
}}
|
|
disabled={
|
|
(editorEmpty &&
|
|
attachedFiles.length === 0) ||
|
|
loadingSession
|
|
}
|
|
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 || attachedFiles.length > 0
|
|
? "var(--color-accent)"
|
|
: "var(--color-surface-hover)",
|
|
color:
|
|
!editorEmpty || attachedFiles.length > 0
|
|
? "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 &&
|
|
attachedFiles.length === 0) ||
|
|
loadingSession
|
|
}
|
|
className={`${compact ? "w-6 h-6" : "w-7 h-7"} rounded-full flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
style={{
|
|
background:
|
|
!editorEmpty ||
|
|
attachedFiles.length > 0
|
|
? "var(--color-accent)"
|
|
: "var(--color-text-muted)",
|
|
color:
|
|
!editorEmpty || attachedFiles.length > 0
|
|
? "white"
|
|
: "var(--color-bg)",
|
|
}}
|
|
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>
|
|
|
|
{/* File picker modal (not in subagent mode) */}
|
|
{!isSubagentMode && (
|
|
<FilePickerModal
|
|
open={showFilePicker}
|
|
onClose={() =>
|
|
setShowFilePicker(false)
|
|
}
|
|
onSelect={handleFilesSelected}
|
|
/>
|
|
)}
|
|
|
|
</div>
|
|
);
|
|
},
|
|
);
|