feat(web): port conversation sidebar with search, rename, delete actions
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,6 +86,22 @@
|
||||
--spacing-sidebar: 16rem;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: var(--color-surface-bg);
|
||||
--bg-deep: var(--color-gray-950);
|
||||
--surface: var(--color-surface-card);
|
||||
--surface-2: var(--color-surface-elevated);
|
||||
--border: var(--color-surface-border);
|
||||
--text: var(--color-text-primary);
|
||||
--text-2: var(--color-text-secondary);
|
||||
--muted: var(--color-text-muted);
|
||||
--primary: var(--color-blue-500);
|
||||
--danger: var(--color-error);
|
||||
--ms-blue-500: var(--color-blue-500);
|
||||
--sidebar-w: 260px;
|
||||
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* ─── Base styles ─── */
|
||||
body {
|
||||
background-color: var(--color-surface-bg);
|
||||
|
||||
576
apps/web/src/components/chat/conversation-sidebar.tsx
Normal file
576
apps/web/src/components/chat/conversation-sidebar.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Conversation, Project } from '@/lib/types';
|
||||
|
||||
export interface ConversationSummary {
|
||||
id: string;
|
||||
title: string | null;
|
||||
projectId: string | null;
|
||||
updatedAt: string;
|
||||
archived?: boolean;
|
||||
}
|
||||
|
||||
export interface ConversationSidebarRef {
|
||||
refresh: () => void;
|
||||
addConversation: (conversation: ConversationSummary) => void;
|
||||
}
|
||||
|
||||
interface ConversationSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentConversationId: string | null;
|
||||
onSelectConversation: (conversationId: string | null) => void;
|
||||
onNewConversation: (projectId?: string | null) => void;
|
||||
}
|
||||
|
||||
interface GroupedConversations {
|
||||
key: string;
|
||||
label: string;
|
||||
projectId: string | null;
|
||||
conversations: ConversationSummary[];
|
||||
}
|
||||
|
||||
function toSummary(conversation: Conversation): ConversationSummary {
|
||||
return {
|
||||
id: conversation.id,
|
||||
title: conversation.title,
|
||||
projectId: conversation.projectId,
|
||||
updatedAt: conversation.updatedAt,
|
||||
archived: conversation.archived,
|
||||
};
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMinutes = Math.floor(diffMs / 60_000);
|
||||
const diffHours = Math.floor(diffMs / 3_600_000);
|
||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||
|
||||
if (diffMinutes < 1) return 'Just now';
|
||||
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export const ConversationSidebar = forwardRef<ConversationSidebarRef, ConversationSidebarProps>(
|
||||
function ConversationSidebar(
|
||||
{ isOpen, onClose, currentConversationId, onSelectConversation, onNewConversation },
|
||||
ref,
|
||||
): React.ReactElement {
|
||||
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadSidebarData = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const [loadedConversations, loadedProjects] = await Promise.all([
|
||||
api<Conversation[]>('/api/conversations'),
|
||||
api<Project[]>('/api/projects').catch(() => [] as Project[]),
|
||||
]);
|
||||
|
||||
setConversations(
|
||||
loadedConversations
|
||||
.filter((conversation) => !conversation.archived)
|
||||
.map(toSummary)
|
||||
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)),
|
||||
);
|
||||
setProjects(loadedProjects);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load conversations');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSidebarData();
|
||||
}, [loadSidebarData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!renamingId) return;
|
||||
const timer = window.setTimeout(() => renameInputRef.current?.focus(), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [renamingId]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
refresh: () => {
|
||||
void loadSidebarData();
|
||||
},
|
||||
addConversation: (conversation) => {
|
||||
setConversations((prev) => {
|
||||
const next = [conversation, ...prev.filter((item) => item.id !== conversation.id)];
|
||||
return next.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
||||
});
|
||||
},
|
||||
}),
|
||||
[loadSidebarData],
|
||||
);
|
||||
|
||||
const filteredConversations = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
if (!query) return conversations;
|
||||
|
||||
return conversations.filter((conversation) =>
|
||||
(conversation.title ?? 'Untitled conversation').toLowerCase().includes(query),
|
||||
);
|
||||
}, [conversations, searchQuery]);
|
||||
|
||||
const groupedConversations = useMemo<GroupedConversations[]>(() => {
|
||||
if (projects.length === 0) {
|
||||
return [
|
||||
{
|
||||
key: 'all',
|
||||
label: 'All conversations',
|
||||
projectId: null,
|
||||
conversations: filteredConversations,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const byProject = new Map<string | null, ConversationSummary[]>();
|
||||
for (const conversation of filteredConversations) {
|
||||
const key = conversation.projectId ?? null;
|
||||
const items = byProject.get(key) ?? [];
|
||||
items.push(conversation);
|
||||
byProject.set(key, items);
|
||||
}
|
||||
|
||||
const groups: GroupedConversations[] = [];
|
||||
|
||||
for (const project of projects) {
|
||||
const projectConversations = byProject.get(project.id);
|
||||
if (!projectConversations?.length) continue;
|
||||
|
||||
groups.push({
|
||||
key: project.id,
|
||||
label: project.name,
|
||||
projectId: project.id,
|
||||
conversations: projectConversations,
|
||||
});
|
||||
}
|
||||
|
||||
const ungrouped = byProject.get(null);
|
||||
if (ungrouped?.length) {
|
||||
groups.push({
|
||||
key: 'general',
|
||||
label: 'General',
|
||||
projectId: null,
|
||||
conversations: ungrouped,
|
||||
});
|
||||
}
|
||||
|
||||
if (groups.length === 0) {
|
||||
groups.push({
|
||||
key: 'all',
|
||||
label: 'All conversations',
|
||||
projectId: null,
|
||||
conversations: filteredConversations,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [filteredConversations, projects]);
|
||||
|
||||
const startRename = useCallback((conversation: ConversationSummary): void => {
|
||||
setPendingDeleteId(null);
|
||||
setRenamingId(conversation.id);
|
||||
setRenameValue(conversation.title ?? '');
|
||||
}, []);
|
||||
|
||||
const cancelRename = useCallback((): void => {
|
||||
setRenamingId(null);
|
||||
setRenameValue('');
|
||||
}, []);
|
||||
|
||||
const commitRename = useCallback(async (): Promise<void> => {
|
||||
if (!renamingId) return;
|
||||
|
||||
const title = renameValue.trim() || 'Untitled conversation';
|
||||
|
||||
try {
|
||||
const updated = await api<Conversation>(`/api/conversations/${renamingId}`, {
|
||||
method: 'PATCH',
|
||||
body: { title },
|
||||
});
|
||||
|
||||
const summary = toSummary(updated);
|
||||
setConversations((prev) =>
|
||||
prev
|
||||
.map((conversation) => (conversation.id === renamingId ? summary : conversation))
|
||||
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)),
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to rename conversation');
|
||||
} finally {
|
||||
setRenamingId(null);
|
||||
setRenameValue('');
|
||||
}
|
||||
}, [renameValue, renamingId]);
|
||||
|
||||
const deleteConversation = useCallback(
|
||||
async (conversationId: string): Promise<void> => {
|
||||
try {
|
||||
await api<void>(`/api/conversations/${conversationId}`, { method: 'DELETE' });
|
||||
setConversations((prev) =>
|
||||
prev.filter((conversation) => conversation.id !== conversationId),
|
||||
);
|
||||
if (currentConversationId === conversationId) {
|
||||
onSelectConversation(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete conversation');
|
||||
} finally {
|
||||
setPendingDeleteId(null);
|
||||
}
|
||||
},
|
||||
[currentConversationId, onSelectConversation],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close conversation sidebar"
|
||||
className="fixed inset-0 z-30 bg-black/50 md:hidden"
|
||||
onClick={onClose}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<aside
|
||||
aria-label="Conversation sidebar"
|
||||
className="fixed left-0 top-0 z-40 flex h-full flex-col border-r md:relative md:z-0"
|
||||
style={{
|
||||
width: 'var(--sidebar-w)',
|
||||
background: 'var(--bg)',
|
||||
borderColor: 'var(--border)',
|
||||
transform: isOpen ? 'translateX(0)' : 'translateX(calc(-1 * var(--sidebar-w)))',
|
||||
transition: 'transform 220ms var(--ease)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between border-b px-4 py-3"
|
||||
style={{ borderColor: 'var(--border)' }}
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
|
||||
Conversations
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: 'var(--muted)' }}>
|
||||
Search, rename, and manage threads
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-md p-2 md:hidden"
|
||||
style={{ color: 'var(--text-2)' }}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor">
|
||||
<path strokeWidth="2" strokeLinecap="round" d="M6 6l12 12M18 6 6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-b p-3" style={{ borderColor: 'var(--border)' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNewConversation(null)}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors"
|
||||
style={{
|
||||
borderColor: 'var(--primary)',
|
||||
background: 'color-mix(in srgb, var(--primary) 12%, transparent)',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor">
|
||||
<path strokeWidth="2" strokeLinecap="round" d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
New conversation
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
style={{ color: 'var(--muted)' }}
|
||||
>
|
||||
<circle cx="11" cy="11" r="7" strokeWidth="2" />
|
||||
<path d="m20 20-3.5-3.5" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Search conversations"
|
||||
className="w-full rounded-lg border px-9 py-2 text-sm outline-none"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-center text-sm" style={{ color: 'var(--muted)' }}>
|
||||
Loading conversations...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div
|
||||
className="space-y-3 rounded-xl border p-4 text-sm"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--danger) 10%, var(--surface))',
|
||||
borderColor: 'color-mix(in srgb, var(--danger) 35%, var(--border))',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
<p>{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadSidebarData()}
|
||||
className="rounded-md px-3 py-1.5 text-xs font-medium"
|
||||
style={{ background: 'var(--danger)', color: 'white' }}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : filteredConversations.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<p className="text-sm" style={{ color: 'var(--text-2)' }}>
|
||||
{searchQuery ? 'No matching conversations' : 'No conversations yet'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--muted)' }}>
|
||||
{searchQuery ? 'Try another title search.' : 'Start a new conversation to begin.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groupedConversations.map((group) => (
|
||||
<section key={group.key} className="space-y-2">
|
||||
{projects.length > 0 ? (
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<h3
|
||||
className="text-[11px] font-semibold uppercase tracking-[0.16em]"
|
||||
style={{ color: 'var(--muted)' }}
|
||||
>
|
||||
{group.label}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNewConversation(group.projectId)}
|
||||
className="rounded-md px-2 py-1 text-[11px] font-medium"
|
||||
style={{ color: 'var(--ms-blue-500)' }}
|
||||
>
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1">
|
||||
{group.conversations.map((conversation) => {
|
||||
const isActive = currentConversationId === conversation.id;
|
||||
const isRenaming = renamingId === conversation.id;
|
||||
const showActions =
|
||||
hoveredId === conversation.id ||
|
||||
isRenaming ||
|
||||
pendingDeleteId === conversation.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={conversation.id}
|
||||
onMouseEnter={() => setHoveredId(conversation.id)}
|
||||
onMouseLeave={() =>
|
||||
setHoveredId((current) =>
|
||||
current === conversation.id ? null : current,
|
||||
)
|
||||
}
|
||||
className="rounded-xl border p-2 transition-colors"
|
||||
style={{
|
||||
borderColor: isActive
|
||||
? 'color-mix(in srgb, var(--primary) 60%, var(--border))'
|
||||
: 'transparent',
|
||||
background: isActive ? 'var(--surface-2)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
onChange={(event) => setRenameValue(event.target.value)}
|
||||
onBlur={() => void commitRename()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void commitRename();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
maxLength={255}
|
||||
className="w-full rounded-md border px-2 py-1.5 text-sm outline-none"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
borderColor: 'var(--ms-blue-500)',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectConversation(conversation.id)}
|
||||
className="block w-full text-left"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className="truncate text-sm font-medium"
|
||||
style={{
|
||||
color: isActive ? 'var(--text)' : 'var(--text-2)',
|
||||
}}
|
||||
>
|
||||
{conversation.title ?? 'Untitled conversation'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--muted)' }}>
|
||||
{formatRelativeTime(conversation.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showActions ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
startRename(conversation);
|
||||
}}
|
||||
className="rounded-md p-1.5 transition-colors"
|
||||
style={{ color: 'var(--text-2)' }}
|
||||
aria-label={`Rename ${conversation.title ?? 'conversation'}`}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M4 20h4l10.5-10.5a1.4 1.4 0 0 0 0-2L16.5 5.5a1.4 1.4 0 0 0-2 0L4 16v4Z"
|
||||
strokeWidth="1.8"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setPendingDeleteId((current) =>
|
||||
current === conversation.id ? null : conversation.id,
|
||||
);
|
||||
setRenamingId(null);
|
||||
}}
|
||||
className="rounded-md p-1.5 transition-colors"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
aria-label={`Delete ${conversation.title ?? 'conversation'}`}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M4 7h16M10 11v6M14 11v6M6 7l1 12h10l1-12M9 7V4h6v3"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{pendingDeleteId === conversation.id ? (
|
||||
<div
|
||||
className="mt-2 flex items-center justify-between rounded-lg border px-2 py-2"
|
||||
style={{
|
||||
borderColor:
|
||||
'color-mix(in srgb, var(--danger) 45%, var(--border))',
|
||||
background:
|
||||
'color-mix(in srgb, var(--danger) 10%, var(--surface))',
|
||||
}}
|
||||
>
|
||||
<p className="text-xs" style={{ color: 'var(--text-2)' }}>
|
||||
Delete this conversation?
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDeleteId(null)}
|
||||
className="rounded-md px-2 py-1 text-xs"
|
||||
style={{ color: 'var(--text-2)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void deleteConversation(conversation.id)}
|
||||
className="rounded-md px-2 py-1 text-xs font-medium"
|
||||
style={{ background: 'var(--danger)', color: 'white' }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user