diff --git a/apps/web/src/app/(dashboard)/chat/page.tsx b/apps/web/src/app/(dashboard)/chat/page.tsx index dec3e62..8a0760b 100644 --- a/apps/web/src/app/(dashboard)/chat/page.tsx +++ b/apps/web/src/app/(dashboard)/chat/page.tsx @@ -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([]); const [activeId, setActiveId] = useState(null); const [messages, setMessages] = useState([]); - const [sidebarOpen, setSidebarOpen] = useState(true); - const [models, setModels] = useState(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([]); + const [selectedModelId, setSelectedModelId] = useState(''); const messagesEndRef = useRef(null); - const activeIdRef = useRef(null); - const streamingTextRef = useRef(''); - const streamingThinkingRef = useRef(''); + const sidebarRef = useRef(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(null); activeIdRef.current = activeId; - const selectedModel = useMemo( - () => models.find((model) => model.id === selectedModelId) ?? models[0] ?? null, - [models, selectedModelId], - ); - const selectedModelRef = useRef(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('/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('/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(`/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('/api/conversations', { + const handleNewConversation = useCallback(async (projectId?: string | null) => { + const conv = await api('/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(`/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(`/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(`/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('/api/conversations', { + const conv = await api('/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(`/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(`/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(`/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(`/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 ( -
- setSidebarOpen(false)} - onSelect={setActiveId} - onNew={handleNewConversation} - onRename={handleRename} - onDelete={handleDelete} - onArchive={handleArchive} +
+ setIsSidebarOpen(false)} + currentConversationId={activeId} + onSelectConversation={(conversationId) => { + setActiveId(conversationId); + setMessages([]); + if (conversationId && window.innerWidth < 768) { + setIsSidebarOpen(false); + } + }} + onNewConversation={(projectId) => { + void handleNewConversation(projectId); + }} /> -
- setSidebarOpen((prev) => !prev)} - /> - -
-
- {messages.length === 0 && !isStreaming ? ( -
-
-
- Mosaic Chat -
-

- Start a new session with a better chat interface. -

-

- Pick a model, send a prompt, and the response area will keep reasoning, - metadata, and streaming status visible without leaving the page. -

-
-
- ) : null} - - {messages.map((message) => ( - - ))} - - {isStreaming ? ( - - ) : null} - -
+
+
+ +
+

+ Mosaic Chat +

+

+ {activeId ? 'Active conversation selected' : 'Choose or start a conversation'} +

-
-
+ {activeId ? ( + <> +
+ {messages.map((msg) => ( + + ))} + {isStreaming && } +
+
+ + ) : ( +
+
+

+ Welcome to Mosaic Chat +

+

+ Select a conversation or start a new one +

+ +
-
+ )}
); } - -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), - }; -} diff --git a/apps/web/src/components/chat/conversation-sidebar.tsx b/apps/web/src/components/chat/conversation-sidebar.tsx new file mode 100644 index 0000000..8572461 --- /dev/null +++ b/apps/web/src/components/chat/conversation-sidebar.tsx @@ -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( + function ConversationSidebar( + { isOpen, onClose, currentConversationId, onSelectConversation, onNewConversation }, + ref, + ): React.ReactElement { + const [conversations, setConversations] = useState([]); + const [projects, setProjects] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [renamingId, setRenamingId] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const [pendingDeleteId, setPendingDeleteId] = useState(null); + const [hoveredId, setHoveredId] = useState(null); + const renameInputRef = useRef(null); + + const loadSidebarData = useCallback(async (): Promise => { + try { + setIsLoading(true); + setError(null); + + const [loadedConversations, loadedProjects] = await Promise.all([ + api('/api/conversations'), + api('/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(() => { + if (projects.length === 0) { + return [ + { + key: 'all', + label: 'All conversations', + projectId: null, + conversations: filteredConversations, + }, + ]; + } + + const byProject = new Map(); + 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 => { + if (!renamingId) return; + + const title = renameValue.trim() || 'Untitled conversation'; + + try { + const updated = await api(`/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 => { + try { + await api(`/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 ? ( + +
+ +
+ + +
+ + + + + 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)', + }} + /> +
+
+ +
+ {isLoading ? ( +
+ Loading conversations... +
+ ) : error ? ( +
+

{error}

+ +
+ ) : filteredConversations.length === 0 ? ( +
+

+ {searchQuery ? 'No matching conversations' : 'No conversations yet'} +

+

+ {searchQuery ? 'Try another title search.' : 'Start a new conversation to begin.'} +

+
+ ) : ( +
+ {groupedConversations.map((group) => ( +
+ {projects.length > 0 ? ( +
+

+ {group.label} +

+ +
+ ) : null} + +
+ {group.conversations.map((conversation) => { + const isActive = currentConversationId === conversation.id; + const isRenaming = renamingId === conversation.id; + const showActions = + hoveredId === conversation.id || + isRenaming || + pendingDeleteId === conversation.id; + + return ( +
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 ? ( + 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)', + }} + /> + ) : ( + + +
+ ) : null} +
+ + )} + + {pendingDeleteId === conversation.id ? ( +
+

+ Delete this conversation? +

+
+ + +
+
+ ) : null} +
+ ); + })} +
+ + ))} +
+ )} +
+ + + ); + }, +);