Compare commits
1 Commits
fix/pnpm-b
...
d3ea964116
| Author | SHA1 | Date | |
|---|---|---|---|
| d3ea964116 |
@@ -1,190 +1,153 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { destroySocket, getSocket } from '@/lib/socket';
|
import { destroySocket, getSocket } from '@/lib/socket';
|
||||||
import type { Conversation, Message, ModelInfo, ProviderInfo } from '@/lib/types';
|
import type { Conversation, Message } from '@/lib/types';
|
||||||
import { ConversationList } from '@/components/chat/conversation-list';
|
import {
|
||||||
|
ConversationSidebar,
|
||||||
|
type ConversationSidebarRef,
|
||||||
|
} from '@/components/chat/conversation-sidebar';
|
||||||
import { MessageBubble } from '@/components/chat/message-bubble';
|
import { MessageBubble } from '@/components/chat/message-bubble';
|
||||||
import { ChatInput } from '@/components/chat/chat-input';
|
import { ChatInput } from '@/components/chat/chat-input';
|
||||||
import { StreamingMessage } from '@/components/chat/streaming-message';
|
import { StreamingMessage } from '@/components/chat/streaming-message';
|
||||||
import { AppHeader } from '@/components/layout/app-header';
|
|
||||||
|
|
||||||
const FALLBACK_MODELS: ModelInfo[] = [
|
interface ModelInfo {
|
||||||
{
|
id: string;
|
||||||
id: 'claude-3-5-sonnet',
|
provider: string;
|
||||||
provider: 'anthropic',
|
name: string;
|
||||||
name: 'claude-3.5-sonnet',
|
reasoning: boolean;
|
||||||
reasoning: true,
|
contextWindow: number;
|
||||||
contextWindow: 200_000,
|
maxTokens: number;
|
||||||
maxTokens: 8_192,
|
inputTypes: ('text' | 'image')[];
|
||||||
inputTypes: ['text'],
|
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
}
|
||||||
},
|
|
||||||
{
|
interface ProviderInfo {
|
||||||
id: 'gpt-4.1',
|
id: string;
|
||||||
provider: 'openai',
|
name: string;
|
||||||
name: 'gpt-4.1',
|
available: boolean;
|
||||||
reasoning: false,
|
models: ModelInfo[];
|
||||||
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 },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ChatPage(): React.ReactElement {
|
export default function ChatPage(): React.ReactElement {
|
||||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
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 [streamingText, setStreamingText] = useState('');
|
||||||
const [streamingThinking, setStreamingThinking] = useState('');
|
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
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 messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const activeIdRef = useRef<string | null>(null);
|
const sidebarRef = useRef<ConversationSidebarRef>(null);
|
||||||
const streamingTextRef = useRef('');
|
|
||||||
const streamingThinkingRef = useRef('');
|
|
||||||
|
|
||||||
|
// 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;
|
activeIdRef.current = activeId;
|
||||||
|
|
||||||
const selectedModel = useMemo(
|
// Accumulate streamed text in a ref so agent:end can read the full content
|
||||||
() => models.find((model) => model.id === selectedModelId) ?? models[0] ?? null,
|
// without stale-closure issues.
|
||||||
[models, selectedModelId],
|
const streamingTextRef = useRef('');
|
||||||
);
|
|
||||||
const selectedModelRef = useRef<ModelInfo | null>(selectedModel);
|
|
||||||
selectedModelRef.current = selectedModel;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api<Conversation[]>('/api/conversations')
|
const savedState = window.localStorage.getItem('mosaic-sidebar-open');
|
||||||
.then(setConversations)
|
if (savedState !== null) {
|
||||||
.catch(() => {});
|
setIsSidebarOpen(savedState === 'true');
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.localStorage.setItem('mosaic-sidebar-open', String(isSidebarOpen));
|
||||||
|
}, [isSidebarOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api<ProviderInfo[]>('/api/providers')
|
api<ProviderInfo[]>('/api/providers')
|
||||||
.then((providers) =>
|
.then((providers) => {
|
||||||
providers.filter((provider) => provider.available).flatMap((provider) => provider.models),
|
const availableModels = providers
|
||||||
)
|
.filter((provider) => provider.available)
|
||||||
.then((availableModels) => {
|
.flatMap((provider) => provider.models);
|
||||||
if (availableModels.length === 0) return;
|
|
||||||
setModels(availableModels);
|
setModels(availableModels);
|
||||||
setSelectedModelId((current) =>
|
setSelectedModelId((current) => current || availableModels[0]?.id || '');
|
||||||
availableModels.some((model) => model.id === current) ? current : availableModels[0]!.id,
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setModels(FALLBACK_MODELS);
|
setModels([]);
|
||||||
|
setSelectedModelId('');
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Load messages when active conversation changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeId) {
|
if (!activeId) {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Clear streaming state when switching conversations
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
setStreamingText('');
|
setStreamingText('');
|
||||||
setStreamingThinking('');
|
|
||||||
streamingTextRef.current = '';
|
streamingTextRef.current = '';
|
||||||
streamingThinkingRef.current = '';
|
|
||||||
|
|
||||||
api<Message[]>(`/api/conversations/${activeId}/messages`)
|
api<Message[]>(`/api/conversations/${activeId}/messages`)
|
||||||
.then((fetchedMessages) => setMessages(fetchedMessages.map(normalizeMessage)))
|
.then(setMessages)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [activeId]);
|
}, [activeId]);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, [messages, streamingText, streamingThinking]);
|
}, [messages, streamingText]);
|
||||||
|
|
||||||
|
// Socket.io setup — connect once for the page lifetime
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
|
|
||||||
function onAgentStart(data: { conversationId: string }): void {
|
function onAgentStart(data: { conversationId: string }): void {
|
||||||
|
// Only update state if the event belongs to the currently viewed conversation
|
||||||
if (activeIdRef.current !== data.conversationId) return;
|
if (activeIdRef.current !== data.conversationId) return;
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
setStreamingText('');
|
setStreamingText('');
|
||||||
setStreamingThinking('');
|
|
||||||
streamingTextRef.current = '';
|
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 (activeIdRef.current !== data.conversationId) return;
|
||||||
if (data.text) {
|
|
||||||
streamingTextRef.current += data.text;
|
streamingTextRef.current += data.text;
|
||||||
setStreamingText((prev) => prev + data.text);
|
setStreamingText((prev) => prev + data.text);
|
||||||
}
|
}
|
||||||
if (data.thinking) {
|
|
||||||
streamingThinkingRef.current += data.thinking;
|
|
||||||
setStreamingThinking((prev) => prev + data.thinking);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onAgentEnd(data: {
|
function onAgentEnd(data: { conversationId: string }): void {
|
||||||
conversationId: string;
|
|
||||||
thinking?: string;
|
|
||||||
model?: string;
|
|
||||||
provider?: string;
|
|
||||||
promptTokens?: number;
|
|
||||||
completionTokens?: number;
|
|
||||||
totalTokens?: number;
|
|
||||||
}): void {
|
|
||||||
if (activeIdRef.current !== data.conversationId) return;
|
if (activeIdRef.current !== data.conversationId) return;
|
||||||
const finalText = streamingTextRef.current;
|
const finalText = streamingTextRef.current;
|
||||||
const finalThinking = data.thinking ?? streamingThinkingRef.current;
|
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
setStreamingText('');
|
setStreamingText('');
|
||||||
setStreamingThinking('');
|
|
||||||
streamingTextRef.current = '';
|
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) {
|
if (finalText) {
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: `assistant-${Date.now()}`,
|
id: `assistant-${Date.now()}`,
|
||||||
conversationId: data.conversationId,
|
conversationId: data.conversationId,
|
||||||
role: 'assistant',
|
role: 'assistant' as const,
|
||||||
content: finalText,
|
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(),
|
createdAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
sidebarRef.current?.refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onError(data: { error: string; conversationId?: string }): void {
|
function onError(data: { error: string; conversationId?: string }): void {
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
setStreamingText('');
|
setStreamingText('');
|
||||||
setStreamingThinking('');
|
|
||||||
streamingTextRef.current = '';
|
streamingTextRef.current = '';
|
||||||
streamingThinkingRef.current = '';
|
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: `error-${Date.now()}`,
|
id: `error-${Date.now()}`,
|
||||||
conversationId: data.conversationId ?? '',
|
conversationId: data.conversationId ?? '',
|
||||||
role: 'system',
|
role: 'system' as const,
|
||||||
content: `Error: ${data.error}`,
|
content: `Error: ${data.error}`,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
@@ -196,6 +159,7 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
socket.on('agent:end', onAgentEnd);
|
socket.on('agent:end', onAgentEnd);
|
||||||
socket.on('error', onError);
|
socket.on('error', onError);
|
||||||
|
|
||||||
|
// Connect if not already connected
|
||||||
if (!socket.connected) {
|
if (!socket.connected) {
|
||||||
socket.connect();
|
socket.connect();
|
||||||
}
|
}
|
||||||
@@ -205,263 +169,197 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
socket.off('agent:text', onAgentText);
|
socket.off('agent:text', onAgentText);
|
||||||
socket.off('agent:end', onAgentEnd);
|
socket.off('agent:end', onAgentEnd);
|
||||||
socket.off('error', onError);
|
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();
|
destroySocket();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleNewConversation = useCallback(async () => {
|
const handleNewConversation = useCallback(async (projectId?: string | null) => {
|
||||||
const conversation = await api<Conversation>('/api/conversations', {
|
const conv = await api<Conversation>('/api/conversations', {
|
||||||
method: 'POST',
|
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([]);
|
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(
|
const handleSend = useCallback(
|
||||||
async (content: string, options?: { modelId?: string }) => {
|
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 autoTitle = content.slice(0, 60);
|
||||||
const conversation = await api<Conversation>('/api/conversations', {
|
const conv = await api<Conversation>('/api/conversations', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { title: autoTitle },
|
body: { title: autoTitle },
|
||||||
});
|
});
|
||||||
setConversations((prev) => [conversation, ...prev]);
|
sidebarRef.current?.addConversation({
|
||||||
setActiveId(conversation.id);
|
id: conv.id,
|
||||||
conversationId = conversation.id;
|
title: conv.title,
|
||||||
} else {
|
projectId: conv.projectId,
|
||||||
const activeConversation = conversations.find(
|
updatedAt: conv.updatedAt,
|
||||||
(conversation) => conversation.id === conversationId,
|
archived: conv.archived,
|
||||||
);
|
});
|
||||||
if (activeConversation?.title === 'New conversation' && messages.length === 0) {
|
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);
|
const autoTitle = content.slice(0, 60);
|
||||||
api<Conversation>(`/api/conversations/${conversationId}`, {
|
api<Conversation>(`/api/conversations/${convId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: { title: autoTitle },
|
body: { title: autoTitle },
|
||||||
})
|
})
|
||||||
.then((updated) => {
|
.then(() => sidebarRef.current?.refresh())
|
||||||
setConversations((prev) =>
|
|
||||||
prev.map((conversation) =>
|
|
||||||
conversation.id === conversationId ? updated : conversation,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
// Optimistic user message in local UI state
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: `user-${Date.now()}`,
|
id: `user-${Date.now()}`,
|
||||||
conversationId,
|
conversationId: convId,
|
||||||
role: 'user',
|
role: 'user' as const,
|
||||||
content,
|
content,
|
||||||
createdAt: new Date().toISOString(),
|
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',
|
method: 'POST',
|
||||||
body: { role: 'user', content },
|
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();
|
const socket = getSocket();
|
||||||
if (!socket.connected) {
|
if (!socket.connected) {
|
||||||
socket.connect();
|
socket.connect();
|
||||||
}
|
}
|
||||||
socket.emit('message', {
|
socket.emit('message', {
|
||||||
conversationId,
|
conversationId: convId,
|
||||||
content,
|
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 (
|
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
|
<div
|
||||||
className="relative flex min-w-0 flex-1 flex-col overflow-hidden"
|
className="-m-6 flex h-[calc(100vh-3.5rem)] overflow-hidden"
|
||||||
style={{
|
style={{ background: 'var(--bg-deep, var(--color-surface-bg, #0a0f1a))' }}
|
||||||
background:
|
|
||||||
'radial-gradient(circle at top, color-mix(in srgb, var(--color-ms-blue-500) 14%, transparent), transparent 35%), var(--color-bg)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<AppHeader
|
<ConversationSidebar
|
||||||
conversationTitle={activeConversation?.title}
|
ref={sidebarRef}
|
||||||
isSidebarOpen={sidebarOpen}
|
isOpen={isSidebarOpen}
|
||||||
onToggleSidebar={() => setSidebarOpen((prev) => !prev)}
|
onClose={() => setIsSidebarOpen(false)}
|
||||||
|
currentConversationId={activeId}
|
||||||
|
onSelectConversation={(conversationId) => {
|
||||||
|
setActiveId(conversationId);
|
||||||
|
setMessages([]);
|
||||||
|
if (conversationId && window.innerWidth < 768) {
|
||||||
|
setIsSidebarOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onNewConversation={(projectId) => {
|
||||||
|
void handleNewConversation(projectId);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-6 md:px-6">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
|
<div
|
||||||
{messages.length === 0 && !isStreaming ? (
|
className="flex items-center gap-3 border-b px-4 py-3"
|
||||||
<div className="flex min-h-full flex-1 items-center justify-center py-16">
|
style={{ borderColor: 'var(--border)' }}
|
||||||
<div className="max-w-xl text-center">
|
>
|
||||||
<div className="mb-4 text-xs uppercase tracking-[0.3em] text-[var(--color-muted)]">
|
<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
|
Mosaic Chat
|
||||||
</div>
|
</h1>
|
||||||
<h2 className="text-3xl font-semibold text-[var(--color-text)]">
|
<p className="text-xs" style={{ color: 'var(--muted)' }}>
|
||||||
Start a new session with a better chat interface.
|
{activeId ? 'Active conversation selected' : 'Choose or start a conversation'}
|
||||||
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{messages.map((message) => (
|
{activeId ? (
|
||||||
<MessageBubble key={message.id} message={message} />
|
<>
|
||||||
|
<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} />}
|
||||||
{isStreaming ? (
|
|
||||||
<StreamingMessage
|
|
||||||
text={streamingText}
|
|
||||||
thinking={streamingThinking}
|
|
||||||
modelName={selectedModel?.name ?? null}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sticky bottom-0">
|
|
||||||
<div className="mx-auto w-full max-w-4xl">
|
|
||||||
<ChatInput
|
<ChatInput
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onStop={handleStop}
|
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
models={models}
|
models={models}
|
||||||
selectedModelId={selectedModelId}
|
selectedModelId={selectedModelId}
|
||||||
onModelChange={setSelectedModelId}
|
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>
|
</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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
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