Unicode + Session delete (API + sidebar + chat header), sidebar loading state and layout, inline “thinking” spinner, chat sidebar CSS variables, and font/heading tweaks in the web app.

This commit is contained in:
Mark 2026-02-19 16:50:52 -08:00
parent 027593b350
commit 4f80c60f88
13 changed files with 542 additions and 228 deletions

View File

@ -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 });
}

View File

@ -841,7 +841,7 @@ function FetchGroup({ items }: { items: ToolPart[] }) {
<div
className="rounded-2xl overflow-hidden"
style={{
background: "rgba(255, 255, 255, 0.5)",
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
}}
>

View File

@ -671,7 +671,7 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onS
return (
<div className="flex justify-end py-2">
<div
className="font-bookerly max-w-[80%] min-w-0 rounded-2xl rounded-br-sm px-3 py-2 text-sm leading-6 overflow-hidden break-all"
className="max-w-[80%] min-w-0 rounded-2xl rounded-br-sm px-3 py-2 text-sm leading-6 overflow-hidden break-all chat-message-font"
style={{
background: "var(--color-user-bubble)",
color: "var(--color-user-bubble-text)",
@ -721,7 +721,7 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onS
return (
<div
key={index}
className="font-bookerly flex items-start gap-2 rounded-xl px-3 py-2 text-[13px] leading-relaxed overflow-hidden"
className="chat-message-font flex items-start gap-2 rounded-xl px-3 py-2 text-[13px] leading-relaxed overflow-hidden"
style={{
background: `color-mix(in srgb, var(--color-error) 6%, var(--color-surface))`,
color: "var(--color-error)",
@ -778,7 +778,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 whitespace-pre-wrap break-all"
className="chat-prose chat-message-font text-sm whitespace-pre-wrap break-all"
style={{ color: "var(--color-text)" }}
>
{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)" }}
>
<ReactMarkdown

View File

@ -17,6 +17,7 @@ import {
type SelectedFile,
} from "./file-picker-modal";
import { ChatEditor, type ChatEditorHandle } from "./tiptap/chat-editor";
import { UnicodeSpinner } from "./unicode-spinner";
// ── Attachment types & helpers ──
@ -486,6 +487,8 @@ type ChatPanelProps = {
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 deletes the current session (e.g. from header menu). */
onDeleteSession?: (sessionId: string) => void;
};
export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
@ -500,6 +503,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
onSessionsChange,
onSubagentSpawned,
onSubagentClick,
onDeleteSession,
},
ref,
) {
@ -537,6 +541,21 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
// ── Message queue (messages to send after current run completes) ──
const [queuedMessages, setQueuedMessages] = useState<QueuedMessage[]>([]);
// ── Header menu (3-dots) ──
const [headerMenuOpen, setHeaderMenuOpen] = useState(false);
const headerMenuRef = useRef<HTMLDivElement>(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<ChatPanelHandle, ChatPanelProps>(
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<ChatPanelHandle, ChatPanelProps>(
[],
);
// ── 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<ChatPanelHandle, ChatPanelProps>(
>
<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>
<p
className="text-[10px]"
style={{
color: "var(--color-text-muted)",
}}
>
{statusLabel}
</p>
</>
<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>
<p
className="text-xs"
style={{
color: "var(--color-text-muted)",
}}
>
{statusLabel}
</p>
</>
<h2
className="text-sm font-semibold"
style={{
color: "var(--color-text)",
}}
>
{currentSessionId
? (sessionTitle || "Chat Session")
: "New Chat"}
</h2>
)}
</div>
<div className="flex gap-1 shrink-0">
<div className="flex items-center gap-1 shrink-0" ref={headerMenuRef}>
{currentSessionId && onDeleteSession && (
<div className="relative">
<button
type="button"
onClick={() => setHeaderMenuOpen((open) => !open)}
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>
</button>
{headerMenuOpen && (
<div
className="absolute right-0 top-full z-50 mt-0.5 py-1 rounded-lg shadow-lg border whitespace-nowrap"
style={{
background: "var(--color-surface)",
borderColor: "var(--color-border)",
}}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setHeaderMenuOpen(false);
onDeleteSession(currentSessionId);
}}
className="w-full text-left px-3 py-2 text-sm transition-colors rounded-md hover:opacity-90"
style={{
color: "var(--color-error)",
background: "transparent",
}}
>
Delete
</button>
</div>
)}
</div>
)}
{compact && (
<button
type="button"
@ -1482,14 +1529,10 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
{loadingSession ? (
<div className="flex items-center justify-center h-full min-h-[60vh]">
<div className="text-center">
<div
className="w-6 h-6 border-2 rounded-full animate-spin mx-auto mb-3"
style={{
borderColor:
"var(--color-border)",
borderTopColor:
"var(--color-accent)",
}}
<UnicodeSpinner
name="braille"
className="block text-2xl mx-auto mb-3"
style={{ color: "var(--color-text-muted)" }}
/>
<p
className="text-xs"
@ -1549,6 +1592,15 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
onSubagentClick={onSubagentClick}
/>
))}
{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>
)}
@ -1603,11 +1655,10 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
>
<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)]!"
className="rounded-3xl overflow-hidden border-2 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-chat-input-bg)",
borderColor: "var(--color-border)",
background: "var(--color-surface)",
borderColor: "var(--color-border-strong)",
}}
onDragOver={(e) => {
if (
@ -1656,43 +1707,45 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
>
{/* Queued messages indicator */}
{queuedMessages.length > 0 && (
<div className={compact ? "px-2 pt-2" : "px-3 pt-3"}>
<div className={compact ? "px-2 pt-2" : "px-3 pt-3"}>
<div
className="rounded-xl overflow-hidden"
className="rounded-xl border overflow-hidden"
style={{
border: "1px dashed var(--color-border-strong)",
background: "var(--color-bg-elevated)",
background: "var(--color-surface)",
borderColor: "var(--color-border)",
boxShadow: "var(--shadow-sm)",
}}
>
<div
className="flex items-center justify-between px-3 py-1.5"
style={{ borderBottom: "1px solid var(--color-border)" }}
className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider"
style={{ color: "var(--color-text-muted)", background: "var(--color-surface-hover)" }}
>
<span
className="text-[11px] font-medium tracking-wide uppercase"
style={{ color: "var(--color-text-muted)", fontFamily: "var(--font-mono, monospace)" }}
>
Queued ({queuedMessages.length})
</span>
Queue ({queuedMessages.length})
</div>
<div className="flex flex-col gap-1 p-1.5">
{queuedMessages.map((msg) => (
<div className="flex flex-col p-2">
{queuedMessages.map((msg, idx) => (
<div
key={msg.id}
className="flex items-start gap-2 rounded-lg px-2.5 py-2 group"
style={{ background: "var(--color-bg-secondary)" }}
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>
<p
className="flex-1 text-[13px] leading-[1.45] line-clamp-3"
style={{ color: "var(--color-text)", whiteSpace: "pre-wrap" }}
className="flex-1 text-[13px] leading-[1.45] line-clamp-2 min-w-0"
style={{ color: "var(--color-text-secondary)", whiteSpace: "pre-wrap" }}
>
{msg.text || (msg.attachedFiles.length > 0 ? `${msg.attachedFiles.length} file(s)` : "")}
</p>
<div className="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
className="rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors hover:bg-[var(--color-bg)]"
style={{ color: "var(--color-accent)" }}
className="rounded-md px-1.5 py-0.5 text-[11px] font-medium transition-colors"
style={{ color: "var(--color-accent)", background: "var(--color-accent-light)" }}
title="Stop agent and send this message now"
onClick={() => forceSendQueuedMessage(msg.id)}
>
@ -1700,12 +1753,12 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
</button>
<button
type="button"
className="rounded-md p-1 transition-colors hover:bg-[var(--color-bg)]"
className="rounded-md p-0.5 transition-colors hover:opacity-80"
style={{ color: "var(--color-text-muted)" }}
title="Remove from queue"
onClick={() => removeQueuedMessage(msg.id)}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
@ -1818,14 +1871,17 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
attachedFiles.length === 0) ||
loadingSession
}
className={`${compact ? "w-6 h-6" : "w-7 h-7"} rounded-full flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed`}
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-border-strong)",
color: "white",
: "var(--color-text-muted)",
color:
!editorEmpty || attachedFiles.length > 0
? "white"
: "var(--color-bg)",
}}
title="Send message"
>

View File

@ -2,6 +2,7 @@
import { useEffect, useRef, useState, useMemo } from "react";
import { ChatMessage } from "./chat-message";
import { UnicodeSpinner } from "./unicode-spinner";
import type { UIMessage } from "ai";
type ParsedPart =
@ -230,8 +231,8 @@ export function SubagentPanel({ sessionKey, task, label, onBack }: SubagentPanel
}, [sessionKey]);
const statusLabel = useMemo(() => {
if (!connected && isStreaming) {return "Connecting...";}
if (isStreaming) {return "Streaming...";}
if (!connected && isStreaming) {return <UnicodeSpinner name="braille">Connecting</UnicodeSpinner>;}
if (isStreaming) {return <UnicodeSpinner name="braille" />;}
return "Completed";
}, [connected, isStreaming]);

View File

@ -396,7 +396,7 @@ export const ChatEditor = forwardRef<ChatEditorHandle, ChatEditorProps>(
<style>{`
.chat-editor-content {
outline: none;
min-height: 20px;
min-height: ${compact ? "16px" : "28px"};
max-height: 200px;
overflow-y: auto;
padding: ${compact ? "10px 12px" : "14px 16px"};

View File

@ -0,0 +1,36 @@
"use client";
import { useState, useEffect } from "react";
import spinners from "unicode-animations";
type SpinnerName = keyof typeof spinners;
export function UnicodeSpinner({
name = "braille",
children,
className,
style,
}: {
name?: SpinnerName;
children?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}) {
const [frame, setFrame] = useState(0);
const s = spinners[name];
useEffect(() => {
const timer = setInterval(
() => setFrame((f) => (f + 1) % s.frames.length),
s.interval,
);
return () => clearInterval(timer);
}, [name, s.frames.length, s.interval]);
return (
<span className={className} style={{ fontFamily: "monospace", ...style }}>
{s.frames[frame]}
{children != null && <> {children}</>}
</span>
);
}

View File

@ -1,6 +1,7 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { UnicodeSpinner } from "../unicode-spinner";
type WebSession = {
id: string;
@ -40,6 +41,10 @@ type ChatSessionsSidebarProps = {
onClose?: () => void;
/** Fixed width in px when not mobile (overrides default 260). */
width?: number;
/** Called when the user deletes a session from the sidebar menu. */
onDeleteSession?: (sessionId: string) => void;
/** When true, show a loader instead of empty state (e.g. initial sessions fetch). */
loading?: boolean;
};
/** Format a timestamp into a human-readable relative time string. */
@ -113,6 +118,25 @@ function ChatBubbleIcon() {
);
}
function MoreHorizontalIcon() {
return (
<svg
width="14"
height="14"
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>
);
}
export function ChatSessionsSidebar({
sessions,
activeSessionId,
@ -123,11 +147,28 @@ export function ChatSessionsSidebar({
onSelectSession,
onNewSession,
onSelectSubagent,
onDeleteSession,
mobile,
onClose,
width: widthProp,
loading = false,
}: ChatSessionsSidebarProps) {
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
// Close menu on outside click
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpenId(null);
}
}
if (menuOpenId !== null) {
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}
}, [menuOpenId]);
const handleSelect = useCallback(
(id: string) => {
@ -145,6 +186,15 @@ export function ChatSessionsSidebar({
[onSelectSubagent, onClose],
);
const handleDeleteSession = useCallback(
(sessionId: string, e: React.MouseEvent) => {
e.stopPropagation();
setMenuOpenId(null);
onDeleteSession?.(sessionId);
},
[onDeleteSession],
);
// Index subagents by parent session ID
const subagentsByParent = useMemo(() => {
const map = new Map<string, SidebarSubagentInfo[]>();
@ -164,45 +214,39 @@ export function ChatSessionsSidebar({
const grouped = groupSessions(sessions);
const width = mobile ? "280px" : (widthProp ?? 260);
const headerHeight = 40; // px — match padding so list content clears the overlay
const sidebar = (
<aside
className={`flex flex-col h-full flex-shrink-0 ${mobile ? "drawer-right" : "border-l"}`}
className={`flex flex-col h-full shrink-0 ${mobile ? "drawer-right" : "border-l"}`}
style={{
width: typeof width === "number" ? `${width}px` : width,
minWidth: typeof width === "number" ? `${width}px` : width,
borderColor: "var(--color-border)",
background: "var(--color-surface)",
}}
>
<div
className="flex items-center justify-between px-6 py-4 border-b flex-shrink-0"
style={{ borderColor: "var(--color-border)" }}
>
<div className="min-w-0 flex-1">
<span
className="text-sm font-medium truncate block"
style={{ color: "var(--color-text)" }}
>
Chats
</span>
</div>
<button
type="button"
onClick={onNewSession}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer flex-shrink-0 ml-2"
style={{
color: "var(--color-accent)",
background: "var(--color-accent-light)",
}}
title="New chat"
{/* Scrollable list fills the sidebar; header overlays the top with blur */}
<div className="flex-1 min-h-0 relative">
{/* Session list — scrolls under the header */}
<div
className="absolute inset-0 overflow-y-auto"
style={{ paddingTop: headerHeight }}
>
<PlusIcon />
New
</button>
</div>
{/* Session list */}
<div className="flex-1 overflow-y-auto">
{sessions.length === 0 ? (
{loading && sessions.length === 0 ? (
<div className="px-4 py-8 flex flex-col items-center justify-center min-h-[120px]">
<UnicodeSpinner
name="braille"
className="text-xl mb-2"
style={{ color: "var(--color-text-muted)" }}
/>
<p
className="text-xs"
style={{ color: "var(--color-text-muted)" }}
>
Loading
</p>
</div>
) : sessions.length === 0 ? (
<div className="px-4 py-8 text-center">
<div
className="mx-auto w-10 h-10 rounded-xl flex items-center justify-center mb-3"
@ -235,68 +279,109 @@ export function ChatSessionsSidebar({
{group.sessions.map((session) => {
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 (
<div key={session.id}>
<button
type="button"
onClick={() => handleSelect(session.id)}
<div
key={session.id}
ref={isMenuOpen ? menuRef : undefined}
className="group relative"
onMouseEnter={() => setHoveredId(session.id)}
onMouseLeave={() => setHoveredId(null)}
className="w-full text-left px-2 py-2 rounded-lg transition-colors cursor-pointer"
>
<div
className="flex items-stretch w-full rounded-lg"
style={{
background: isActive
? "var(--color-accent-light)"
? "var(--color-chat-sidebar-active-bg)"
: isHovered
? "var(--color-surface-hover)"
: "transparent",
}}
>
<div className="flex items-center gap-1.5">
{isStreamingSession && (
<span
className="inline-block w-1.5 h-1.5 rounded-full flex-shrink-0 animate-pulse"
style={{ background: "var(--color-accent)" }}
title="Agent is running"
/>
)}
<div
className="text-xs font-medium truncate"
style={{
color: isActive
? "var(--color-accent)"
: "var(--color-text)",
}}
>
{session.title || "Untitled chat"}
</div>
</div>
<div className="flex items-center gap-2 mt-0.5" style={{ paddingLeft: isStreamingSession ? "calc(0.375rem + 6px)" : undefined }}>
{isStreamingSession && (
<span
className="text-[10px] font-medium"
style={{ color: "var(--color-accent)" }}
<button
type="button"
onClick={() => handleSelect(session.id)}
className="flex-1 min-w-0 text-left px-2 py-2 rounded-l-lg transition-colors cursor-pointer"
>
<div className="flex items-center gap-1.5">
{isStreamingSession && (
<UnicodeSpinner
name="braille"
className="text-[10px] flex-shrink-0"
style={{ color: "var(--color-chat-sidebar-muted)" }}
/>
)}
<div
className="text-xs font-medium truncate"
style={{
color: isActive
? "var(--color-chat-sidebar-active-text)"
: "var(--color-text)",
}}
>
Streaming
</span>
)}
<span
className="text-[10px]"
style={{ color: "var(--color-text-muted)" }}
>
{timeAgo(session.updatedAt)}
</span>
{session.messageCount > 0 && (
{session.title || "Untitled chat"}
</div>
</div>
<div className="flex items-center gap-2 mt-0.5" style={{ paddingLeft: isStreamingSession ? "calc(0.375rem + 6px)" : undefined }}>
<span
className="text-[10px]"
style={{ color: "var(--color-text-muted)" }}
>
{session.messageCount} msg{session.messageCount !== 1 ? "s" : ""}
{timeAgo(session.updatedAt)}
</span>
)}
</div>
</button>
{session.messageCount > 0 && (
<span
className="text-[10px]"
style={{ color: "var(--color-text-muted)" }}
>
{session.messageCount} msg{session.messageCount !== 1 ? "s" : ""}
</span>
)}
</div>
</button>
{onDeleteSession && (
<div className="relative w-7 shrink-0 flex flex-col items-end">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setMenuOpenId((id) => (id === session.id ? null : session.id));
}}
className={`flex items-center justify-center w-7 h-full rounded-r-lg transition-opacity ${showMore ? "opacity-100" : "opacity-0"}`}
style={{ color: "var(--color-text-muted)" }}
title="More options"
aria-label="More options"
>
<MoreHorizontalIcon />
</button>
{isMenuOpen && (
<div
className="absolute top-full right-0 z-50 mt-0.5 py-1 rounded-lg shadow-lg border whitespace-nowrap"
style={{
background: "var(--color-surface)",
borderColor: "var(--color-border)",
}}
>
<button
type="button"
onClick={(e) => handleDeleteSession(session.id, e)}
className="w-full text-left px-3 py-1.5 text-xs transition-colors rounded-md hover:opacity-90"
style={{
color: "var(--color-error)",
background: "transparent",
}}
>
Delete
</button>
</div>
)}
</div>
)}
</div>
{/* Subagent sub-items */}
{sessionSubagents && sessionSubagents.length > 0 && (
<div className="ml-4 border-l" style={{ borderColor: "var(--color-border)" }}>
@ -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",
}}
>
<div className="flex items-center gap-1.5">
{isSubRunning && (
<span
className="inline-block w-1 h-1 rounded-full flex-shrink-0 animate-pulse"
style={{ background: "var(--color-accent)" }}
title="Subagent running"
<UnicodeSpinner
name="braille"
className="text-[9px] flex-shrink-0"
style={{ color: "var(--color-chat-sidebar-muted)" }}
/>
)}
<SubagentIcon />
@ -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({
</div>
)}
</div>
{/* Header overlay: backdrop blur + 80% bg; list scrolls under it */}
<div
className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between border-b px-4 py-2 backdrop-blur-md"
style={{
height: headerHeight,
borderColor: "var(--color-border)",
background: "color-mix(in srgb, var(--color-surface) 80%, transparent)",
}}
>
<div className="min-w-0 flex-1">
<span
className="text-xs font-medium truncate block"
style={{ color: "var(--color-text)" }}
>
Chats
</span>
</div>
<button
type="button"
onClick={onNewSession}
className="flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium transition-colors cursor-pointer shrink-0 ml-1.5"
style={{
color: "var(--color-chat-sidebar-active-text)",
background: "var(--color-chat-sidebar-active-bg)",
}}
title="New chat"
>
<PlusIcon />
New
</button>
</div>
</div>
</aside>
);

View File

@ -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 && (
<span className="absolute right-2.5 top-1/2 -translate-y-1/2">
<div
className="w-3 h-3 border border-t-transparent rounded-full animate-spin"
style={{ borderColor: "var(--color-text-muted)" }}
<UnicodeSpinner
name="braille"
className="text-sm"
style={{ color: "var(--color-text-muted)" }}
/>
</span>
)}
@ -411,9 +413,10 @@ export function WorkspaceSidebar({
const sidebar = (
<aside
className={`flex flex-col h-screen flex-shrink-0 ${mobile ? "drawer-left" : "border-r"}`}
className={`flex flex-col h-screen shrink-0 ${mobile ? "drawer-left" : "border-r"}`}
style={{
width: typeof width === "number" ? `${width}px` : width,
minWidth: typeof width === "number" ? `${width}px` : width,
background: "var(--color-surface)",
borderColor: "var(--color-border)",
}}
@ -553,13 +556,10 @@ export function WorkspaceSidebar({
<div className="flex-1 overflow-y-auto px-1">
{loading ? (
<div className="flex items-center justify-center py-12">
<div
className="w-5 h-5 border-2 rounded-full animate-spin"
style={{
borderColor: "var(--color-border)",
borderTopColor:
"var(--color-accent)",
}}
<UnicodeSpinner
name="braille"
className="text-2xl"
style={{ color: "var(--color-text-muted)" }}
/>
</div>
) : (

View File

@ -29,6 +29,10 @@
--color-user-bubble: #eae8e4;
--color-user-bubble-text: #1c1c1a;
--color-chat-input-bg: rgba(255, 255, 255, 0.8);
/* Chat sidebar (right) — stone-style selected/active, light theme */
--color-chat-sidebar-active-bg: #f5f5f4;
--color-chat-sidebar-active-text: #44403c;
--color-chat-sidebar-muted: #57534e;
/* Semantic */
--color-success: #16a34a;
@ -91,6 +95,10 @@
--color-user-bubble: #1e1e1c;
--color-user-bubble-text: #ececea;
--color-chat-input-bg: #1e1e1c;
/* Chat sidebar (right) — stone-style selected/active, dark theme */
--color-chat-sidebar-active-bg: #1e1e1c;
--color-chat-sidebar-active-text: #ececea;
--color-chat-sidebar-muted: #78776f;
/* Semantic */
--color-success: #22c55e;
@ -192,6 +200,17 @@ body {
font-family: "Bookerly", Georgia, "Times New Roman", serif;
}
/* Message bubbles and assistant text: use body font so they render immediately (no FOUT). */
.chat-message-font {
font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
sans-serif;
}
/* Smooth theme transitions */
*,
*::before,
@ -865,6 +884,13 @@ a,
line-height: 1.8;
overflow-wrap: anywhere;
word-break: break-word;
font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
sans-serif;
}
.chat-prose > *:first-child {
@ -889,15 +915,15 @@ a,
}
.chat-prose h1 {
font-family: "Instrument Serif", serif;
font-family: inherit;
font-size: 1.6em;
font-weight: 400;
font-weight: 600;
}
.chat-prose h2 {
font-family: "Instrument Serif", serif;
font-family: inherit;
font-size: 1.35em;
font-weight: 400;
font-weight: 600;
}
.chat-prose h3 {

View File

@ -28,6 +28,7 @@ import type { CronJob, CronJobsResponse } from "../types/cron";
import { useIsMobile } from "../hooks/use-mobile";
import { ObjectFilterBar } from "../components/workspace/object-filter-bar";
import { type FilterGroup, type SortRule, type SavedView, emptyFilterGroup, serializeFilters } from "@/lib/object-filters";
import { UnicodeSpinner } from "../components/unicode-spinner";
// --- Types ---
@ -278,7 +279,7 @@ export default function WorkspacePage() {
return (
<Suspense fallback={
<div className="flex h-screen items-center justify-center" style={{ background: "var(--color-bg)" }}>
<div className="w-6 h-6 border-2 rounded-full animate-spin" style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }} />
<UnicodeSpinner name="braille" className="text-2xl" style={{ color: "var(--color-text-muted)" }} />
</div>
}>
<WorkspacePageInner />
@ -319,6 +320,7 @@ function WorkspacePageInner() {
// Chat session state
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [sessions, setSessions] = useState<WebSession[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(true);
const [sidebarRefreshKey, setSidebarRefreshKey] = useState(0);
const [streamingSessionIds, setStreamingSessionIds] = useState<Set<string>>(new Set());
@ -376,19 +378,22 @@ function WorkspacePageInner() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [chatSessionsOpen, setChatSessionsOpen] = useState(false);
// Resizable sidebar widths (desktop only; persisted in localStorage)
const [leftSidebarWidth, setLeftSidebarWidth] = useState(() => {
if (typeof window === "undefined") {return 260;}
const v = window.localStorage.getItem(STORAGE_LEFT);
const n = v ? parseInt(v, 10) : NaN;
return Number.isFinite(n) ? clamp(n, LEFT_SIDEBAR_MIN, LEFT_SIDEBAR_MAX) : 260;
});
const [rightSidebarWidth, setRightSidebarWidth] = useState(() => {
if (typeof window === "undefined") {return 320;}
const v = window.localStorage.getItem(STORAGE_RIGHT);
const n = v ? parseInt(v, 10) : NaN;
return Number.isFinite(n) ? clamp(n, RIGHT_SIDEBAR_MIN, RIGHT_SIDEBAR_MAX) : 320;
});
// Resizable sidebar widths (desktop only; persisted in localStorage).
// Use static defaults so server and client match on first render (avoid hydration mismatch).
const [leftSidebarWidth, setLeftSidebarWidth] = useState(260);
const [rightSidebarWidth, setRightSidebarWidth] = useState(320);
useEffect(() => {
const left = window.localStorage.getItem(STORAGE_LEFT);
const nLeft = left ? parseInt(left, 10) : NaN;
if (Number.isFinite(nLeft)) {
setLeftSidebarWidth(clamp(nLeft, LEFT_SIDEBAR_MIN, LEFT_SIDEBAR_MAX));
}
const right = window.localStorage.getItem(STORAGE_RIGHT);
const nRight = right ? parseInt(right, 10) : NaN;
if (Number.isFinite(nRight)) {
setRightSidebarWidth(clamp(nRight, RIGHT_SIDEBAR_MIN, RIGHT_SIDEBAR_MAX));
}
}, []);
useEffect(() => {
window.localStorage.setItem(STORAGE_LEFT, String(leftSidebarWidth));
}, [leftSidebarWidth]);
@ -436,12 +441,15 @@ function WorkspacePageInner() {
// Fetch chat sessions
const fetchSessions = useCallback(async () => {
setSessionsLoading(true);
try {
const res = await fetch("/api/web-sessions");
const data = await res.json();
setSessions(data.sessions ?? []);
} catch {
// ignore
} finally {
setSessionsLoading(false);
}
}, []);
@ -453,6 +461,27 @@ function WorkspacePageInner() {
setSidebarRefreshKey((k) => k + 1);
}, []);
const handleDeleteSession = useCallback(
async (sessionId: string) => {
const res = await fetch(`/api/web-sessions/${sessionId}`, { method: "DELETE" });
if (!res.ok) {return;}
if (activeSessionId === sessionId) {
setActiveSessionId(null);
setActiveSubagentKey(null);
const remaining = sessions.filter((s) => s.id !== sessionId);
if (remaining.length > 0) {
const next = remaining[0];
setActiveSessionId(next.id);
void chatRef.current?.loadSession(next.id);
} else {
void chatRef.current?.newSession();
}
}
void fetchSessions();
},
[activeSessionId, sessions, fetchSessions],
);
// Poll for active (streaming) agent runs so the sidebar can show indicators.
useEffect(() => {
let cancelled = false;
@ -1039,7 +1068,10 @@ function WorkspacePageInner() {
)
) : (
<>
<div className="flex shrink-0 flex-col" style={{ width: leftSidebarWidth }}>
<div
className="flex shrink-0 flex-col"
style={{ width: leftSidebarWidth, minWidth: leftSidebarWidth }}
>
<WorkspaceSidebar
tree={enhancedTree}
activePath={activePath}
@ -1200,6 +1232,7 @@ function WorkspacePageInner() {
onSessionsChange={refreshSessions}
onSubagentSpawned={handleSubagentSpawned}
onSubagentClick={handleSubagentClickFromChat}
onDeleteSession={handleDeleteSession}
compact={isMobile}
/>
)}
@ -1214,6 +1247,7 @@ function WorkspacePageInner() {
streamingSessionIds={streamingSessionIds}
subagents={subagents}
activeSubagentKey={activeSubagentKey}
loading={sessionsLoading}
onSelectSession={(sessionId) => {
setActiveSessionId(sessionId);
setActiveSubagentKey(null);
@ -1227,6 +1261,7 @@ function WorkspacePageInner() {
setChatSessionsOpen(false);
}}
onSelectSubagent={handleSelectSubagent}
onDeleteSession={handleDeleteSession}
mobile
onClose={() => setChatSessionsOpen(false)}
/>
@ -1240,7 +1275,10 @@ function WorkspacePageInner() {
max={RIGHT_SIDEBAR_MAX}
onResize={setRightSidebarWidth}
/>
<div className="flex shrink-0 flex-col" style={{ width: rightSidebarWidth }}>
<div
className="flex shrink-0 flex-col"
style={{ width: rightSidebarWidth, minWidth: rightSidebarWidth }}
>
<ChatSessionsSidebar
sessions={sessions}
activeSessionId={activeSessionId}
@ -1248,6 +1286,7 @@ function WorkspacePageInner() {
streamingSessionIds={streamingSessionIds}
subagents={subagents}
activeSubagentKey={activeSubagentKey}
loading={sessionsLoading}
onSelectSession={(sessionId) => {
setActiveSessionId(sessionId);
setActiveSubagentKey(null);
@ -1260,6 +1299,7 @@ function WorkspacePageInner() {
router.replace("/workspace", { scroll: false });
}}
onSelectSubagent={handleSelectSubagent}
onDeleteSession={handleDeleteSession}
width={rightSidebarWidth}
/>
</div>
@ -1388,13 +1428,7 @@ function ContentRenderer({
case "loading":
return (
<div className="flex items-center justify-center h-full">
<div
className="w-6 h-6 border-2 rounded-full animate-spin"
style={{
borderColor: "var(--color-border)",
borderTopColor: "var(--color-accent)",
}}
/>
<UnicodeSpinner name="braille" className="text-2xl" style={{ color: "var(--color-text-muted)" }} />
</div>
);
@ -1472,13 +1506,7 @@ function ContentRenderer({
if (isBrowseLive && treeLoading) {
return (
<div className="flex items-center justify-center h-full">
<div
className="w-6 h-6 border-2 rounded-full animate-spin"
style={{
borderColor: "var(--color-border)",
borderTopColor: "var(--color-accent)",
}}
/>
<UnicodeSpinner name="braille" className="text-2xl" style={{ color: "var(--color-text-muted)" }} />
</div>
);
}

View File

@ -42,7 +42,8 @@
"react-markdown": "^10.1.0",
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1",
"shiki": "^3.22.0"
"shiki": "^3.22.0",
"unicode-animations": "^1.0.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.8",

9
pnpm-lock.yaml generated
View File

@ -392,6 +392,9 @@ importers:
shiki:
specifier: ^3.22.0
version: 3.22.0
unicode-animations:
specifier: ^1.0.3
version: 1.0.3
devDependencies:
'@tailwindcss/postcss':
specifier: ^4.1.8
@ -6828,6 +6831,10 @@ packages:
resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
engines: {node: '>=20.18.1'}
unicode-animations@1.0.3:
resolution: {integrity: sha512-+klB2oWwcYZjYWhwP4Pr8UZffWDFVx6jKeIahE6z0QYyM2dwDeDPyn5nevCYbyotxvtT9lh21cVURO1RX0+YMg==}
hasBin: true
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@ -14178,6 +14185,8 @@ snapshots:
undici@7.22.0: {}
unicode-animations@1.0.3: {}
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3