diff --git a/apps/web/app/api/web-sessions/[id]/route.ts b/apps/web/app/api/web-sessions/[id]/route.ts
index 1596f7be30d..e9c6f4f0620 100644
--- a/apps/web/app/api/web-sessions/[id]/route.ts
+++ b/apps/web/app/api/web-sessions/[id]/route.ts
@@ -1,9 +1,28 @@
-import { readFileSync, existsSync } from "node:fs";
+import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { resolveWebChatDir } from "@/lib/workspace";
export const dynamic = "force-dynamic";
+type IndexEntry = { id: string; [k: string]: unknown };
+
+function readIndex(): IndexEntry[] {
+ const dir = resolveWebChatDir();
+ const indexFile = join(dir, "index.json");
+ if (!existsSync(indexFile)) { return []; }
+ try {
+ return JSON.parse(readFileSync(indexFile, "utf-8"));
+ } catch {
+ return [];
+ }
+}
+
+function writeIndex(sessions: IndexEntry[]) {
+ const dir = resolveWebChatDir();
+ if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); }
+ writeFileSync(join(dir, "index.json"), JSON.stringify(sessions, null, 2));
+}
+
export type ChatLine = {
id: string;
role: "user" | "assistant";
@@ -44,3 +63,24 @@ export async function GET(
return Response.json({ id, messages });
}
+
+/** DELETE /api/web-sessions/[id] — remove a web chat session and its messages. */
+export async function DELETE(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params;
+ const dir = resolveWebChatDir();
+ const filePath = join(dir, `${id}.jsonl`);
+
+ const sessions = readIndex();
+ const filtered = sessions.filter((s) => s.id !== id);
+ if (filtered.length === sessions.length) {
+ return Response.json({ error: "Session not found" }, { status: 404 });
+ }
+ writeIndex(filtered);
+ if (existsSync(filePath)) {
+ unlinkSync(filePath);
+ }
+ return Response.json({ ok: true });
+}
diff --git a/apps/web/app/components/chain-of-thought.tsx b/apps/web/app/components/chain-of-thought.tsx
index eb62d212315..51574114132 100644
--- a/apps/web/app/components/chain-of-thought.tsx
+++ b/apps/web/app/components/chain-of-thought.tsx
@@ -841,7 +841,7 @@ function FetchGroup({ items }: { items: ToolPart[] }) {
diff --git a/apps/web/app/components/chat-message.tsx b/apps/web/app/components/chat-message.tsx
index b97a7158a27..082b7b971e6 100644
--- a/apps/web/app/components/chat-message.tsx
+++ b/apps/web/app/components/chat-message.tsx
@@ -671,7 +671,7 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onS
return (
{segment.text}
@@ -792,7 +792,7 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onS
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
- className="chat-prose font-bookerly text-sm"
+ className="chat-prose chat-message-font text-sm"
style={{ color: "var(--color-text)" }}
>
void;
/** Called when user clicks a subagent card in the chat to view its output. */
onSubagentClick?: (task: string) => void;
+ /** Called when user deletes the current session (e.g. from header menu). */
+ onDeleteSession?: (sessionId: string) => void;
};
export const ChatPanel = forwardRef(
@@ -500,6 +503,7 @@ export const ChatPanel = forwardRef(
onSessionsChange,
onSubagentSpawned,
onSubagentClick,
+ onDeleteSession,
},
ref,
) {
@@ -537,6 +541,21 @@ export const ChatPanel = forwardRef(
// ── Message queue (messages to send after current run completes) ──
const [queuedMessages, setQueuedMessages] = useState([]);
+ // ── Header menu (3-dots) ──
+ const [headerMenuOpen, setHeaderMenuOpen] = useState(false);
+ const headerMenuRef = useRef(null);
+ useEffect(() => {
+ function handleClickOutside(e: MouseEvent) {
+ if (headerMenuRef.current && !headerMenuRef.current.contains(e.target as Node)) {
+ setHeaderMenuOpen(false);
+ }
+ }
+ if (headerMenuOpen) {
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }
+ }, [headerMenuOpen]);
+
const filePath = fileContext?.path ?? null;
// ── Ref-based session ID for transport ──
@@ -1193,6 +1212,10 @@ export const ChatPanel = forwardRef(
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
@@ -1332,21 +1355,13 @@ export const ChatPanel = forwardRef(
[],
);
- // ── Status label ──
-
- const statusLabel = loadingSession
- ? "Loading session..."
- : isReconnecting
- ? "Resuming stream..."
- : status === "ready"
- ? "Ready"
- : status === "submitted"
- ? "Thinking..."
- : status === "streaming"
- ? "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 ──
@@ -1365,48 +1380,80 @@ export const ChatPanel = forwardRef(
>
{compact && fileContext ? (
- <>
-
- Chat: {fileContext.filename}
-
-
- {statusLabel}
-
- >
+
+ Chat: {fileContext.filename}
+
) : (
- <>
-
- {currentSessionId
- ? (sessionTitle || "Chat Session")
- : "New Chat"}
-
-
- {statusLabel}
-
- >
+
+ {currentSessionId
+ ? (sessionTitle || "Chat Session")
+ : "New Chat"}
+
)}
-
+
+ {currentSessionId && onDeleteSession && (
+
+
+ {headerMenuOpen && (
+
+
+
+ )}
+
+ )}
{compact && (