feat(web): conversation sidebar with search, rename, delete (#222)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #222.
This commit is contained in:
2026-03-21 13:10:03 +00:00
committed by jason.woltje
parent de64695ac5
commit 02e40f6c3c
2 changed files with 778 additions and 304 deletions

View File

@@ -1,190 +1,153 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '@/lib/api';
import { destroySocket, getSocket } from '@/lib/socket';
import type { Conversation, Message, ModelInfo, ProviderInfo } from '@/lib/types';
import { ConversationList } from '@/components/chat/conversation-list';
import type { Conversation, Message } from '@/lib/types';
import {
ConversationSidebar,
type ConversationSidebarRef,
} from '@/components/chat/conversation-sidebar';
import { MessageBubble } from '@/components/chat/message-bubble';
import { ChatInput } from '@/components/chat/chat-input';
import { StreamingMessage } from '@/components/chat/streaming-message';
import { AppHeader } from '@/components/layout/app-header';
const FALLBACK_MODELS: ModelInfo[] = [
{
id: 'claude-3-5-sonnet',
provider: 'anthropic',
name: 'claude-3.5-sonnet',
reasoning: true,
contextWindow: 200_000,
maxTokens: 8_192,
inputTypes: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
{
id: 'gpt-4.1',
provider: 'openai',
name: 'gpt-4.1',
reasoning: false,
contextWindow: 128_000,
maxTokens: 8_192,
inputTypes: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
{
id: 'gemini-2.0-flash',
provider: 'google',
name: 'gemini-2.0-flash',
reasoning: false,
contextWindow: 1_000_000,
maxTokens: 8_192,
inputTypes: ['text', 'image'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
];
interface ModelInfo {
id: string;
provider: string;
name: string;
reasoning: boolean;
contextWindow: number;
maxTokens: number;
inputTypes: ('text' | 'image')[];
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
}
interface ProviderInfo {
id: string;
name: string;
available: boolean;
models: ModelInfo[];
}
export default function ChatPage(): React.ReactElement {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [models, setModels] = useState<ModelInfo[]>(FALLBACK_MODELS);
const [selectedModelId, setSelectedModelId] = useState(FALLBACK_MODELS[0]?.id ?? '');
const [streamingText, setStreamingText] = useState('');
const [streamingThinking, setStreamingThinking] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [models, setModels] = useState<ModelInfo[]>([]);
const [selectedModelId, setSelectedModelId] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const activeIdRef = useRef<string | null>(null);
const streamingTextRef = useRef('');
const streamingThinkingRef = useRef('');
const sidebarRef = useRef<ConversationSidebarRef>(null);
// Track the active conversation ID in a ref so socket event handlers always
// see the current value without needing to be re-registered.
const activeIdRef = useRef<string | null>(null);
activeIdRef.current = activeId;
const selectedModel = useMemo(
() => models.find((model) => model.id === selectedModelId) ?? models[0] ?? null,
[models, selectedModelId],
);
const selectedModelRef = useRef<ModelInfo | null>(selectedModel);
selectedModelRef.current = selectedModel;
// Accumulate streamed text in a ref so agent:end can read the full content
// without stale-closure issues.
const streamingTextRef = useRef('');
useEffect(() => {
api<Conversation[]>('/api/conversations')
.then(setConversations)
.catch(() => {});
const savedState = window.localStorage.getItem('mosaic-sidebar-open');
if (savedState !== null) {
setIsSidebarOpen(savedState === 'true');
}
}, []);
useEffect(() => {
window.localStorage.setItem('mosaic-sidebar-open', String(isSidebarOpen));
}, [isSidebarOpen]);
useEffect(() => {
api<ProviderInfo[]>('/api/providers')
.then((providers) =>
providers.filter((provider) => provider.available).flatMap((provider) => provider.models),
)
.then((availableModels) => {
if (availableModels.length === 0) return;
.then((providers) => {
const availableModels = providers
.filter((provider) => provider.available)
.flatMap((provider) => provider.models);
setModels(availableModels);
setSelectedModelId((current) =>
availableModels.some((model) => model.id === current) ? current : availableModels[0]!.id,
);
setSelectedModelId((current) => current || availableModels[0]?.id || '');
})
.catch(() => {
setModels(FALLBACK_MODELS);
setModels([]);
setSelectedModelId('');
});
}, []);
// Load messages when active conversation changes
useEffect(() => {
if (!activeId) {
setMessages([]);
return;
}
// Clear streaming state when switching conversations
setIsStreaming(false);
setStreamingText('');
setStreamingThinking('');
streamingTextRef.current = '';
streamingThinkingRef.current = '';
api<Message[]>(`/api/conversations/${activeId}/messages`)
.then((fetchedMessages) => setMessages(fetchedMessages.map(normalizeMessage)))
.then(setMessages)
.catch(() => {});
}, [activeId]);
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingText, streamingThinking]);
}, [messages, streamingText]);
// Socket.io setup — connect once for the page lifetime
useEffect(() => {
const socket = getSocket();
function onAgentStart(data: { conversationId: string }): void {
// Only update state if the event belongs to the currently viewed conversation
if (activeIdRef.current !== data.conversationId) return;
setIsStreaming(true);
setStreamingText('');
setStreamingThinking('');
streamingTextRef.current = '';
streamingThinkingRef.current = '';
}
function onAgentText(data: { conversationId: string; text?: string; thinking?: string }): void {
function onAgentText(data: { conversationId: string; text: string }): void {
if (activeIdRef.current !== data.conversationId) return;
if (data.text) {
streamingTextRef.current += data.text;
setStreamingText((prev) => prev + data.text);
}
if (data.thinking) {
streamingThinkingRef.current += data.thinking;
setStreamingThinking((prev) => prev + data.thinking);
}
streamingTextRef.current += data.text;
setStreamingText((prev) => prev + data.text);
}
function onAgentEnd(data: {
conversationId: string;
thinking?: string;
model?: string;
provider?: string;
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
}): void {
function onAgentEnd(data: { conversationId: string }): void {
if (activeIdRef.current !== data.conversationId) return;
const finalText = streamingTextRef.current;
const finalThinking = data.thinking ?? streamingThinkingRef.current;
setIsStreaming(false);
setStreamingText('');
setStreamingThinking('');
streamingTextRef.current = '';
streamingThinkingRef.current = '';
// Append the completed assistant message to the local message list.
// The Pi agent session is in-memory so the assistant response is not
// persisted to the DB — we build the local UI state instead.
if (finalText) {
setMessages((prev) => [
...prev,
{
id: `assistant-${Date.now()}`,
conversationId: data.conversationId,
role: 'assistant',
role: 'assistant' as const,
content: finalText,
thinking: finalThinking || undefined,
model: data.model ?? selectedModelRef.current?.name,
provider: data.provider ?? selectedModelRef.current?.provider,
promptTokens: data.promptTokens,
completionTokens: data.completionTokens,
totalTokens: data.totalTokens,
createdAt: new Date().toISOString(),
},
]);
sidebarRef.current?.refresh();
}
}
function onError(data: { error: string; conversationId?: string }): void {
setIsStreaming(false);
setStreamingText('');
setStreamingThinking('');
streamingTextRef.current = '';
streamingThinkingRef.current = '';
setMessages((prev) => [
...prev,
{
id: `error-${Date.now()}`,
conversationId: data.conversationId ?? '',
role: 'system',
role: 'system' as const,
content: `Error: ${data.error}`,
createdAt: new Date().toISOString(),
},
@@ -196,6 +159,7 @@ export default function ChatPage(): React.ReactElement {
socket.on('agent:end', onAgentEnd);
socket.on('error', onError);
// Connect if not already connected
if (!socket.connected) {
socket.connect();
}
@@ -205,263 +169,197 @@ export default function ChatPage(): React.ReactElement {
socket.off('agent:text', onAgentText);
socket.off('agent:end', onAgentEnd);
socket.off('error', onError);
// Fully tear down the socket when the chat page unmounts so we get a
// fresh authenticated connection next time the page is visited.
destroySocket();
};
}, []);
const handleNewConversation = useCallback(async () => {
const conversation = await api<Conversation>('/api/conversations', {
const handleNewConversation = useCallback(async (projectId?: string | null) => {
const conv = await api<Conversation>('/api/conversations', {
method: 'POST',
body: { title: 'New conversation' },
body: { title: 'New conversation', projectId: projectId ?? null },
});
setConversations((prev) => [conversation, ...prev]);
setActiveId(conversation.id);
sidebarRef.current?.addConversation({
id: conv.id,
title: conv.title,
projectId: conv.projectId,
updatedAt: conv.updatedAt,
archived: conv.archived,
});
setActiveId(conv.id);
setMessages([]);
setIsSidebarOpen(true);
}, []);
const handleRename = useCallback(async (id: string, title: string) => {
const updated = await api<Conversation>(`/api/conversations/${id}`, {
method: 'PATCH',
body: { title },
});
setConversations((prev) =>
prev.map((conversation) => (conversation.id === id ? updated : conversation)),
);
}, []);
const handleDelete = useCallback(
async (id: string) => {
try {
await api<void>(`/api/conversations/${id}`, { method: 'DELETE' });
setConversations((prev) => prev.filter((conversation) => conversation.id !== id));
if (activeId === id) {
setActiveId(null);
setMessages([]);
}
} catch (error) {
console.error('[ChatPage] Failed to delete conversation:', error);
}
},
[activeId],
);
const handleArchive = useCallback(
async (id: string, archived: boolean) => {
const updated = await api<Conversation>(`/api/conversations/${id}`, {
method: 'PATCH',
body: { archived },
});
setConversations((prev) =>
prev.map((conversation) => (conversation.id === id ? updated : conversation)),
);
if (archived && activeId === id) {
setActiveId(null);
setMessages([]);
}
},
[activeId],
);
const handleSend = useCallback(
async (content: string, options?: { modelId?: string }) => {
let conversationId = activeId;
let convId = activeId;
if (!conversationId) {
// Auto-create conversation if none selected
if (!convId) {
const autoTitle = content.slice(0, 60);
const conversation = await api<Conversation>('/api/conversations', {
const conv = await api<Conversation>('/api/conversations', {
method: 'POST',
body: { title: autoTitle },
});
setConversations((prev) => [conversation, ...prev]);
setActiveId(conversation.id);
conversationId = conversation.id;
} else {
const activeConversation = conversations.find(
(conversation) => conversation.id === conversationId,
);
if (activeConversation?.title === 'New conversation' && messages.length === 0) {
const autoTitle = content.slice(0, 60);
api<Conversation>(`/api/conversations/${conversationId}`, {
method: 'PATCH',
body: { title: autoTitle },
})
.then((updated) => {
setConversations((prev) =>
prev.map((conversation) =>
conversation.id === conversationId ? updated : conversation,
),
);
})
.catch(() => {});
}
sidebarRef.current?.addConversation({
id: conv.id,
title: conv.title,
projectId: conv.projectId,
updatedAt: conv.updatedAt,
archived: conv.archived,
});
setActiveId(conv.id);
convId = conv.id;
} else if (messages.length === 0) {
// Auto-title the initial placeholder conversation from the first user message.
const autoTitle = content.slice(0, 60);
api<Conversation>(`/api/conversations/${convId}`, {
method: 'PATCH',
body: { title: autoTitle },
})
.then(() => sidebarRef.current?.refresh())
.catch(() => {});
}
// Optimistic user message in local UI state
setMessages((prev) => [
...prev,
{
id: `user-${Date.now()}`,
conversationId,
role: 'user',
conversationId: convId,
role: 'user' as const,
content,
createdAt: new Date().toISOString(),
},
]);
api<Message>(`/api/conversations/${conversationId}/messages`, {
// Persist the user message to the DB so conversation history is
// available when the page is reloaded or a new session starts.
api<Message>(`/api/conversations/${convId}/messages`, {
method: 'POST',
body: { role: 'user', content },
}).catch(() => {});
}).catch(() => {
// Non-fatal: the agent can still process the message even if
// REST persistence fails.
});
// Send to WebSocket — gateway creates/resumes the agent session and
// streams the response back via agent:start / agent:text / agent:end.
const socket = getSocket();
if (!socket.connected) {
socket.connect();
}
socket.emit('message', {
conversationId,
conversationId: convId,
content,
model: options?.modelId ?? selectedModelRef.current?.id,
modelId: (options?.modelId ?? selectedModelId) || undefined,
});
},
[activeId, conversations, messages.length],
[activeId, messages, selectedModelId],
);
const handleStop = useCallback(() => {
const socket = getSocket();
socket.emit('cancel', { conversationId: activeIdRef.current });
const partialText = streamingTextRef.current.trim();
const partialThinking = streamingThinkingRef.current.trim();
if (partialText) {
setMessages((prev) => [
...prev,
{
id: `assistant-partial-${Date.now()}`,
conversationId: activeIdRef.current ?? '',
role: 'assistant',
content: partialText,
thinking: partialThinking || undefined,
model: selectedModelRef.current?.name,
provider: selectedModelRef.current?.provider,
createdAt: new Date().toISOString(),
},
]);
}
setIsStreaming(false);
setStreamingText('');
setStreamingThinking('');
streamingTextRef.current = '';
streamingThinkingRef.current = '';
destroySocket();
}, []);
const handleEditLastMessage = useCallback((): string | null => {
const lastUserMessage = [...messages].reverse().find((message) => message.role === 'user');
return lastUserMessage?.content ?? null;
}, [messages]);
const activeConversation =
conversations.find((conversation) => conversation.id === activeId) ?? null;
return (
<div className="-m-6 flex h-[100dvh] overflow-hidden">
<ConversationList
conversations={conversations}
activeId={activeId}
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
onSelect={setActiveId}
onNew={handleNewConversation}
onRename={handleRename}
onDelete={handleDelete}
onArchive={handleArchive}
<div
className="-m-6 flex h-[calc(100vh-3.5rem)] overflow-hidden"
style={{ background: 'var(--bg-deep, var(--color-surface-bg, #0a0f1a))' }}
>
<ConversationSidebar
ref={sidebarRef}
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
currentConversationId={activeId}
onSelectConversation={(conversationId) => {
setActiveId(conversationId);
setMessages([]);
if (conversationId && window.innerWidth < 768) {
setIsSidebarOpen(false);
}
}}
onNewConversation={(projectId) => {
void handleNewConversation(projectId);
}}
/>
<div
className="relative flex min-w-0 flex-1 flex-col overflow-hidden"
style={{
background:
'radial-gradient(circle at top, color-mix(in srgb, var(--color-ms-blue-500) 14%, transparent), transparent 35%), var(--color-bg)',
}}
>
<AppHeader
conversationTitle={activeConversation?.title}
isSidebarOpen={sidebarOpen}
onToggleSidebar={() => setSidebarOpen((prev) => !prev)}
/>
<div className="flex-1 overflow-y-auto px-4 py-6 md:px-6">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
{messages.length === 0 && !isStreaming ? (
<div className="flex min-h-full flex-1 items-center justify-center py-16">
<div className="max-w-xl text-center">
<div className="mb-4 text-xs uppercase tracking-[0.3em] text-[var(--color-muted)]">
Mosaic Chat
</div>
<h2 className="text-3xl font-semibold text-[var(--color-text)]">
Start a new session with a better chat interface.
</h2>
<p className="mt-3 text-sm leading-7 text-[var(--color-text-2)]">
Pick a model, send a prompt, and the response area will keep reasoning,
metadata, and streaming status visible without leaving the page.
</p>
</div>
</div>
) : null}
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{isStreaming ? (
<StreamingMessage
text={streamingText}
thinking={streamingThinking}
modelName={selectedModel?.name ?? null}
/>
) : null}
<div ref={messagesEndRef} />
<div className="flex min-w-0 flex-1 flex-col">
<div
className="flex items-center gap-3 border-b px-4 py-3"
style={{ borderColor: 'var(--border)' }}
>
<button
type="button"
onClick={() => setIsSidebarOpen((open) => !open)}
className="rounded-lg border p-2 transition-colors"
style={{
borderColor: 'var(--border)',
background: 'var(--surface)',
color: 'var(--text)',
}}
aria-label={isSidebarOpen ? 'Close conversation sidebar' : 'Open conversation sidebar'}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor">
<path strokeWidth="2" strokeLinecap="round" d="M4 7h16M4 12h16M4 17h16" />
</svg>
</button>
<div>
<h1 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
Mosaic Chat
</h1>
<p className="text-xs" style={{ color: 'var(--muted)' }}>
{activeId ? 'Active conversation selected' : 'Choose or start a conversation'}
</p>
</div>
</div>
<div className="sticky bottom-0">
<div className="mx-auto w-full max-w-4xl">
{activeId ? (
<>
<div className="flex-1 space-y-4 overflow-y-auto p-6">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
{isStreaming && <StreamingMessage text={streamingText} />}
<div ref={messagesEndRef} />
</div>
<ChatInput
onSend={handleSend}
onStop={handleStop}
isStreaming={isStreaming}
models={models}
selectedModelId={selectedModelId}
onModelChange={setSelectedModelId}
onRequestEditLastMessage={handleEditLastMessage}
/>
</>
) : (
<div className="flex flex-1 items-center justify-center px-6">
<div
className="max-w-md rounded-2xl border px-8 py-10 text-center"
style={{
borderColor: 'var(--border)',
background: 'var(--surface)',
}}
>
<h2 className="text-lg font-medium" style={{ color: 'var(--text)' }}>
Welcome to Mosaic Chat
</h2>
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}>
Select a conversation or start a new one
</p>
<button
type="button"
onClick={() => {
void handleNewConversation();
}}
className="mt-4 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors"
style={{ background: 'var(--primary)' }}
>
Start new conversation
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}
function normalizeMessage(message: Message): Message {
const metadata = message.metadata ?? {};
return {
...message,
thinking:
message.thinking ?? (typeof metadata.thinking === 'string' ? metadata.thinking : undefined),
model: message.model ?? (typeof metadata.model === 'string' ? metadata.model : undefined),
provider:
message.provider ?? (typeof metadata.provider === 'string' ? metadata.provider : undefined),
promptTokens:
message.promptTokens ??
(typeof metadata.prompt_tokens === 'number' ? metadata.prompt_tokens : undefined),
completionTokens:
message.completionTokens ??
(typeof metadata.completion_tokens === 'number' ? metadata.completion_tokens : undefined),
totalTokens:
message.totalTokens ??
(typeof metadata.total_tokens === 'number' ? metadata.total_tokens : undefined),
};
}