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:
parent
027593b350
commit
4f80c60f88
@ -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 });
|
||||
}
|
||||
|
||||
@ -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)",
|
||||
}}
|
||||
>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -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"};
|
||||
|
||||
36
apps/web/app/components/unicode-spinner.tsx
Normal file
36
apps/web/app/components/unicode-spinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
9
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user