From 4f80c60f88db208807fe7e47f86caba360d5c09d Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 19 Feb 2026 16:50:52 -0800 Subject: [PATCH] =?UTF-8?q?Unicode=20+=20Session=20delete=20(API=20+=20sid?= =?UTF-8?q?ebar=20+=20chat=20header),=20sidebar=20loading=20state=20and=20?= =?UTF-8?q?layout,=20inline=20=E2=80=9Cthinking=E2=80=9D=20spinner,=20chat?= =?UTF-8?q?=20sidebar=20CSS=20variables,=20and=20font/heading=20tweaks=20i?= =?UTF-8?q?n=20the=20web=20app.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/api/web-sessions/[id]/route.ts | 42 ++- apps/web/app/components/chain-of-thought.tsx | 2 +- apps/web/app/components/chat-message.tsx | 8 +- apps/web/app/components/chat-panel.tsx | 240 +++++++++------ apps/web/app/components/subagent-panel.tsx | 5 +- .../web/app/components/tiptap/chat-editor.tsx | 2 +- apps/web/app/components/unicode-spinner.tsx | 36 +++ .../workspace/chat-sessions-sidebar.tsx | 279 +++++++++++++----- .../workspace/workspace-sidebar.tsx | 22 +- apps/web/app/globals.css | 34 ++- apps/web/app/workspace/page.tsx | 88 ++++-- apps/web/package.json | 3 +- pnpm-lock.yaml | 9 + 13 files changed, 542 insertions(+), 228 deletions(-) create mode 100644 apps/web/app/components/unicode-spinner.tsx 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 && ( -
- - {/* Session list */} -
- {sessions.length === 0 ? ( + {loading && sessions.length === 0 ? ( +
+ +

+ Loading… +

+
+ ) : sessions.length === 0 ? (
{ const isActive = session.id === activeSessionId && !activeSubagentKey; const isHovered = session.id === hoveredId; + const isMenuOpen = menuOpenId === session.id; + const showMore = isHovered || isMenuOpen; const isStreamingSession = streamingSessionIds?.has(session.id) ?? false; const sessionSubagents = subagentsByParent.get(session.id); return ( -
- + {session.messageCount > 0 && ( + + {session.messageCount} msg{session.messageCount !== 1 ? "s" : ""} + + )} +
+ + {onDeleteSession && ( +
+ + {isMenuOpen && ( +
+ +
+ )} +
+ )} +
{/* Subagent sub-items */} {sessionSubagents && sessionSubagents.length > 0 && (
@@ -313,16 +398,16 @@ export function ChatSessionsSidebar({ className="w-full text-left pl-3 pr-2 py-1.5 rounded-r-lg transition-colors cursor-pointer" style={{ background: isSubActive - ? "var(--color-accent-light)" + ? "var(--color-chat-sidebar-active-bg)" : "transparent", }} >
{isSubRunning && ( - )} @@ -330,7 +415,7 @@ export function ChatSessionsSidebar({ className="text-[11px] truncate" style={{ color: isSubActive - ? "var(--color-accent)" + ? "var(--color-chat-sidebar-active-text)" : "var(--color-text-muted)", }} > @@ -350,6 +435,38 @@ export function ChatSessionsSidebar({
)}
+ {/* Header overlay: backdrop blur + 80% bg; list scrolls under it */} +
+
+ + Chats + +
+ +
+
); diff --git a/apps/web/app/components/workspace/workspace-sidebar.tsx b/apps/web/app/components/workspace/workspace-sidebar.tsx index 0e009608bf3..d68bddec5a4 100644 --- a/apps/web/app/components/workspace/workspace-sidebar.tsx +++ b/apps/web/app/components/workspace/workspace-sidebar.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, useRef, useCallback } from "react"; import { FileManagerTree, type TreeNode } from "./file-manager-tree"; import { ProfileSwitcher } from "./profile-switcher"; import { CreateWorkspaceDialog } from "./create-workspace-dialog"; +import { UnicodeSpinner } from "../unicode-spinner"; /** Shape returned by /api/workspace/suggest-files */ type SuggestItem = { @@ -319,9 +320,10 @@ function FileSearch({ onSelect }: { onSelect: (item: SuggestItem) => void }) { /> {loading && ( -
)} @@ -411,9 +413,10 @@ export function WorkspaceSidebar({ const sidebar = (