Merge pull request 'feat(web): chat interface — model selector, keybindings, thinking display, v0 styled header' (#216) from feat/ui-chat into main
Some checks failed
ci/woodpecker/push/ci Pipeline failed

Reviewed-on: mosaic/mosaic-stack#216
This commit was merged in pull request #216.
This commit is contained in:
2026-03-21 12:29:29 +00:00
10 changed files with 1841 additions and 260 deletions

View File

@@ -18,6 +18,7 @@
"next": "^16.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"socket.io-client": "^4.8.0",
"tailwind-merge": "^3.5.0"
},

View File

@@ -1,93 +1,172 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@/lib/api';
import { destroySocket, getSocket } from '@/lib/socket';
import type { Conversation, Message } from '@/lib/types';
import type { Conversation, Message, ModelInfo, ProviderInfo } from '@/lib/types';
import { ConversationList } from '@/components/chat/conversation-list';
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 },
},
];
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 messagesEndRef = useRef<HTMLDivElement>(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);
const streamingTextRef = useRef('');
const streamingThinkingRef = useRef('');
activeIdRef.current = activeId;
// Accumulate streamed text in a ref so agent:end can read the full content
// without stale-closure issues.
const streamingTextRef = useRef('');
const selectedModel = useMemo(
() => models.find((model) => model.id === selectedModelId) ?? models[0] ?? null,
[models, selectedModelId],
);
const selectedModelRef = useRef<ModelInfo | null>(selectedModel);
selectedModelRef.current = selectedModel;
// Load conversations on mount
useEffect(() => {
api<Conversation[]>('/api/conversations')
.then(setConversations)
.catch(() => {});
}, []);
// Load messages when active conversation changes
useEffect(() => {
api<ProviderInfo[]>('/api/providers')
.then((providers) =>
providers.filter((provider) => provider.available).flatMap((provider) => provider.models),
)
.then((availableModels) => {
if (availableModels.length === 0) return;
setModels(availableModels);
setSelectedModelId((current) =>
availableModels.some((model) => model.id === current) ? current : availableModels[0]!.id,
);
})
.catch(() => {
setModels(FALLBACK_MODELS);
});
}, []);
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(setMessages)
.then((fetchedMessages) => setMessages(fetchedMessages.map(normalizeMessage)))
.catch(() => {});
}, [activeId]);
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingText]);
}, [messages, streamingText, streamingThinking]);
// 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 }): void {
function onAgentText(data: { conversationId: string; text?: string; thinking?: 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);
}
}
function onAgentEnd(data: { conversationId: string }): void {
function onAgentEnd(data: {
conversationId: string;
thinking?: string;
model?: string;
provider?: string;
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
}): void {
if (activeIdRef.current !== data.conversationId) return;
const finalText = streamingTextRef.current;
const finalThinking = data.thinking ?? streamingThinkingRef.current;
setIsStreaming(false);
setStreamingText('');
setStreamingThinking('');
streamingTextRef.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.
streamingThinkingRef.current = '';
if (finalText) {
setMessages((prev) => [
...prev,
{
id: `assistant-${Date.now()}`,
conversationId: data.conversationId,
role: 'assistant' as const,
role: 'assistant',
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(),
},
]);
@@ -97,13 +176,15 @@ export default function ChatPage(): React.ReactElement {
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' as const,
role: 'system',
content: `Error: ${data.error}`,
createdAt: new Date().toISOString(),
},
@@ -115,7 +196,6 @@ 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();
}
@@ -125,19 +205,17 @@ 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 conv = await api<Conversation>('/api/conversations', {
const conversation = await api<Conversation>('/api/conversations', {
method: 'POST',
body: { title: 'New conversation' },
});
setConversations((prev) => [conv, ...prev]);
setActiveId(conv.id);
setConversations((prev) => [conversation, ...prev]);
setActiveId(conversation.id);
setMessages([]);
}, []);
@@ -146,20 +224,22 @@ export default function ChatPage(): React.ReactElement {
method: 'PATCH',
body: { title },
});
setConversations((prev) => prev.map((c) => (c.id === id ? updated : c)));
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((c) => c.id !== id));
setConversations((prev) => prev.filter((conversation) => conversation.id !== id));
if (activeId === id) {
setActiveId(null);
setMessages([]);
}
} catch (err) {
console.error('[ChatPage] Failed to delete conversation:', err);
} catch (error) {
console.error('[ChatPage] Failed to delete conversation:', error);
}
},
[activeId],
@@ -171,8 +251,9 @@ export default function ChatPage(): React.ReactElement {
method: 'PATCH',
body: { archived },
});
setConversations((prev) => prev.map((c) => (c.id === id ? updated : c)));
// If archiving the active conversation, deselect it
setConversations((prev) =>
prev.map((conversation) => (conversation.id === id ? updated : conversation)),
);
if (archived && activeId === id) {
setActiveId(null);
setMessages([]);
@@ -182,75 +263,114 @@ export default function ChatPage(): React.ReactElement {
);
const handleSend = useCallback(
async (content: string) => {
let convId = activeId;
async (content: string, options?: { modelId?: string }) => {
let conversationId = activeId;
// Auto-create conversation if none selected
if (!convId) {
if (!conversationId) {
const autoTitle = content.slice(0, 60);
const conv = await api<Conversation>('/api/conversations', {
const conversation = await api<Conversation>('/api/conversations', {
method: 'POST',
body: { title: autoTitle },
});
setConversations((prev) => [conv, ...prev]);
setActiveId(conv.id);
convId = conv.id;
setConversations((prev) => [conversation, ...prev]);
setActiveId(conversation.id);
conversationId = conversation.id;
} else {
// Auto-title: if the active conversation still has the default "New
// conversation" title and this is the first message, update the title
// from the message content.
const activeConv = conversations.find((c) => c.id === convId);
if (activeConv?.title === 'New conversation' && messages.length === 0) {
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/${convId}`, {
api<Conversation>(`/api/conversations/${conversationId}`, {
method: 'PATCH',
body: { title: autoTitle },
})
.then((updated) => {
setConversations((prev) => prev.map((c) => (c.id === convId ? updated : c)));
setConversations((prev) =>
prev.map((conversation) =>
conversation.id === conversationId ? updated : conversation,
),
);
})
.catch(() => {});
}
}
// Optimistic user message in local UI state
setMessages((prev) => [
...prev,
{
id: `user-${Date.now()}`,
conversationId: convId,
role: 'user' as const,
conversationId,
role: 'user',
content,
createdAt: new Date().toISOString(),
},
]);
// 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`, {
api<Message>(`/api/conversations/${conversationId}/messages`, {
method: 'POST',
body: { role: 'user', content },
}).catch(() => {
// Non-fatal: the agent can still process the message even if
// REST persistence fails.
});
}).catch(() => {});
// 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: convId, content });
socket.emit('message', {
conversationId,
content,
model: options?.modelId ?? selectedModelRef.current?.id,
});
},
[activeId, conversations, messages],
[activeId, conversations, messages.length],
);
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-[calc(100vh-3.5rem)]">
<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}
@@ -258,36 +378,90 @@ export default function ChatPage(): React.ReactElement {
onArchive={handleArchive}
/>
<div className="flex flex-1 flex-col">
{activeId ? (
<>
<div className="flex-1 space-y-4 overflow-y-auto p-6">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
<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} />}
{isStreaming ? (
<StreamingMessage
text={streamingText}
thinking={streamingThinking}
modelName={selectedModel?.name ?? null}
/>
) : null}
<div ref={messagesEndRef} />
</div>
<ChatInput onSend={handleSend} disabled={isStreaming} />
</>
) : (
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<h2 className="text-lg font-medium text-text-secondary">Welcome to Mosaic Chat</h2>
<p className="mt-1 text-sm text-text-muted">
Select a conversation or start a new one
</p>
<button
type="button"
onClick={handleNewConversation}
className="mt-4 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
Start new conversation
</button>
</div>
<div className="sticky bottom-0">
<div className="mx-auto w-full max-w-4xl">
<ChatInput
onSend={handleSend}
onStop={handleStop}
isStreaming={isStreaming}
models={models}
selectedModelId={selectedModelId}
onModelChange={setSelectedModelId}
onRequestEditLastMessage={handleEditLastMessage}
/>
</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),
};
}

View File

@@ -1,52 +1,192 @@
'use client';
import { useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ModelInfo } from '@/lib/types';
interface ChatInputProps {
onSend: (content: string) => void;
disabled?: boolean;
onSend: (content: string, options?: { modelId?: string }) => void;
onStop?: () => void;
isStreaming?: boolean;
models: ModelInfo[];
selectedModelId: string;
onModelChange: (modelId: string) => void;
onRequestEditLastMessage?: () => string | null;
}
export function ChatInput({ onSend, disabled }: ChatInputProps): React.ReactElement {
const MAX_HEIGHT = 220;
export function ChatInput({
onSend,
onStop,
isStreaming = false,
models,
selectedModelId,
onModelChange,
onRequestEditLastMessage,
}: ChatInputProps): React.ReactElement {
const [value, setValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const selectedModel = useMemo(
() => models.find((model) => model.id === selectedModelId) ?? models[0],
[models, selectedModelId],
);
function handleSubmit(e: React.FormEvent): void {
e.preventDefault();
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = `${Math.min(textarea.scrollHeight, MAX_HEIGHT)}px`;
}, [value]);
useEffect(() => {
function handleGlobalFocus(event: KeyboardEvent): void {
if (
(event.metaKey || event.ctrlKey) &&
(event.key === '/' || event.key.toLowerCase() === 'k')
) {
const target = event.target as HTMLElement | null;
if (target?.closest('input, textarea, [contenteditable="true"]')) return;
event.preventDefault();
textareaRef.current?.focus();
}
}
document.addEventListener('keydown', handleGlobalFocus);
return () => document.removeEventListener('keydown', handleGlobalFocus);
}, []);
function handleSubmit(event: React.FormEvent): void {
event.preventDefault();
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
if (!trimmed || isStreaming) return;
onSend(trimmed, { modelId: selectedModel?.id });
setValue('');
textareaRef.current?.focus();
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>): void {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>): void {
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
handleSubmit(event);
return;
}
if (event.key === 'ArrowUp' && value.length === 0 && onRequestEditLastMessage) {
const lastMessage = onRequestEditLastMessage();
if (lastMessage) {
event.preventDefault();
setValue(lastMessage);
}
}
}
const charCount = value.length;
const tokenEstimate = Math.ceil(charCount / 4);
return (
<form onSubmit={handleSubmit} className="border-t border-surface-border bg-surface-card p-4">
<form
onSubmit={handleSubmit}
className="border-t px-4 py-4 backdrop-blur-xl md:px-6"
style={{
backgroundColor: 'color-mix(in srgb, var(--color-surface) 88%, transparent)',
borderColor: 'var(--color-border)',
}}
>
<div
className="rounded-[28px] border p-3 shadow-[var(--shadow-ms-lg)]"
style={{
backgroundColor: 'var(--color-surface-2)',
borderColor: 'var(--color-border)',
}}
>
<div className="mb-3 flex flex-wrap items-center gap-3">
<label className="flex min-w-0 items-center gap-2 text-xs text-[var(--color-muted)]">
<span className="uppercase tracking-[0.18em]">Model</span>
<select
value={selectedModelId}
onChange={(event) => onModelChange(event.target.value)}
className="rounded-full border px-3 py-1.5 text-sm outline-none"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
}}
>
{models.map((model) => (
<option key={`${model.provider}:${model.id}`} value={model.id}>
{model.name} · {model.provider}
</option>
))}
</select>
</label>
<div className="ml-auto hidden items-center gap-2 text-xs text-[var(--color-muted)] md:flex">
<span className="rounded-full border border-[var(--color-border)] px-2 py-1">
/ focus
</span>
<span className="rounded-full border border-[var(--color-border)] px-2 py-1">
K focus
</span>
<span className="rounded-full border border-[var(--color-border)] px-2 py-1">
send
</span>
</div>
</div>
<div className="flex items-end gap-3">
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onChange={(event) => setValue(event.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
disabled={isStreaming}
rows={1}
placeholder="Type a message... (Enter to send, Shift+Enter for newline)"
className="max-h-32 min-h-[2.5rem] flex-1 resize-none rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
placeholder="Ask Mosaic something..."
className="min-h-[3.25rem] flex-1 resize-none bg-transparent px-1 py-2 text-sm outline-none placeholder:text-[var(--color-muted)] disabled:opacity-60"
style={{
color: 'var(--color-text)',
maxHeight: `${MAX_HEIGHT}px`,
}}
/>
{isStreaming ? (
<button
type="button"
onClick={onStop}
className="inline-flex h-11 items-center gap-2 rounded-full border px-4 text-sm font-medium transition-colors"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
}}
>
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-[var(--color-danger)]" />
Stop
</button>
) : (
<button
type="submit"
disabled={disabled || !value.trim()}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
disabled={!value.trim()}
className="inline-flex h-11 items-center gap-2 rounded-full px-4 text-sm font-semibold text-white transition-all disabled:cursor-not-allowed disabled:opacity-45"
style={{ backgroundColor: 'var(--color-ms-blue-500)' }}
>
Send
<span>Send</span>
<span aria-hidden="true"></span>
</button>
)}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-[var(--color-muted)]">
<span>{charCount.toLocaleString()} chars</span>
<span>·</span>
<span>~{tokenEstimate.toLocaleString()} tokens</span>
{selectedModel ? (
<>
<span>·</span>
<span>{selectedModel.reasoning ? 'Reasoning on' : 'Fast response'}</span>
</>
) : null}
<span className="ml-auto">Shift+Enter newline · Arrow edit last</span>
</div>
</div>
</form>
);

View File

@@ -7,6 +7,8 @@ import type { Conversation } from '@/lib/types';
interface ConversationListProps {
conversations: Conversation[];
activeId: string | null;
isOpen: boolean;
onClose: () => void;
onSelect: (id: string) => void;
onNew: () => void;
onRename: (id: string, title: string) => void;
@@ -20,7 +22,6 @@ interface ContextMenuState {
y: number;
}
/** Format a date as relative time (e.g. "2h ago", "Yesterday"). */
function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
@@ -40,6 +41,8 @@ function formatRelativeTime(dateStr: string): string {
export function ConversationList({
conversations,
activeId,
isOpen,
onClose,
onSelect,
onNew,
onRename,
@@ -54,24 +57,24 @@ export function ConversationList({
const [showArchived, setShowArchived] = useState(false);
const renameInputRef = useRef<HTMLInputElement>(null);
const activeConversations = conversations.filter((c) => !c.archived);
const archivedConversations = conversations.filter((c) => c.archived);
const activeConversations = conversations.filter((conversation) => !conversation.archived);
const archivedConversations = conversations.filter((conversation) => conversation.archived);
const filteredActive = searchQuery
? activeConversations.filter((c) =>
(c.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
? activeConversations.filter((conversation) =>
(conversation.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
)
: activeConversations;
const filteredArchived = searchQuery
? archivedConversations.filter((c) =>
(c.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
? archivedConversations.filter((conversation) =>
(conversation.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
)
: archivedConversations;
const handleContextMenu = useCallback((e: React.MouseEvent, conversationId: string) => {
e.preventDefault();
setContextMenu({ conversationId, x: e.clientX, y: e.clientY });
const handleContextMenu = useCallback((event: React.MouseEvent, conversationId: string) => {
event.preventDefault();
setContextMenu({ conversationId, x: event.clientX, y: event.clientY });
setDeleteConfirmId(null);
}, []);
@@ -97,7 +100,7 @@ export function ConversationList({
}
setRenamingId(null);
setRenameValue('');
}, [renamingId, renameValue, onRename]);
}, [onRename, renameValue, renamingId]);
const cancelRename = useCallback(() => {
setRenamingId(null);
@@ -105,24 +108,20 @@ export function ConversationList({
}, []);
const handleRenameKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') commitRename();
if (e.key === 'Escape') cancelRename();
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') commitRename();
if (event.key === 'Escape') cancelRename();
},
[commitRename, cancelRename],
[cancelRename, commitRename],
);
const handleDeleteClick = useCallback((id: string) => {
setDeleteConfirmId(id);
}, []);
const confirmDelete = useCallback(
(id: string) => {
onDelete(id);
setDeleteConfirmId(null);
closeContextMenu();
},
[onDelete, closeContextMenu],
[closeContextMenu, onDelete],
);
const handleArchiveToggle = useCallback(
@@ -130,47 +129,59 @@ export function ConversationList({
onArchive(id, archived);
closeContextMenu();
},
[onArchive, closeContextMenu],
[closeContextMenu, onArchive],
);
const contextConv = contextMenu
? conversations.find((c) => c.id === contextMenu.conversationId)
const contextConversation = contextMenu
? conversations.find((conversation) => conversation.id === contextMenu.conversationId)
: null;
function renderConversationItem(conv: Conversation): React.ReactElement {
const isActive = activeId === conv.id;
const isRenaming = renamingId === conv.id;
function renderConversationItem(conversation: Conversation): React.ReactElement {
const isActive = activeId === conversation.id;
const isRenaming = renamingId === conversation.id;
return (
<div key={conv.id} className="group relative">
<div key={conversation.id} className="group relative">
{isRenaming ? (
<div className="px-3 py-2">
<input
ref={renameInputRef}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onChange={(event) => setRenameValue(event.target.value)}
onBlur={commitRename}
onKeyDown={handleRenameKeyDown}
className="w-full rounded border border-blue-500 bg-surface-elevated px-2 py-0.5 text-sm text-text-primary outline-none"
className="w-full rounded-xl border px-3 py-2 text-sm outline-none"
style={{
borderColor: 'var(--color-ms-blue-500)',
backgroundColor: 'var(--color-surface-2)',
color: 'var(--color-text)',
}}
maxLength={255}
/>
</div>
) : (
<button
type="button"
onClick={() => onSelect(conv.id)}
onDoubleClick={() => startRename(conv.id, conv.title)}
onContextMenu={(e) => handleContextMenu(e, conv.id)}
onClick={() => {
onSelect(conversation.id);
if (window.innerWidth < 768) onClose();
}}
onDoubleClick={() => startRename(conversation.id, conversation.title)}
onContextMenu={(event) => handleContextMenu(event, conversation.id)}
className={cn(
'w-full px-3 py-2 text-left text-sm transition-colors',
isActive
? 'bg-blue-600/20 text-blue-400'
: 'text-text-secondary hover:bg-surface-elevated',
'w-full rounded-2xl px-3 py-2 text-left text-sm transition-colors',
isActive ? 'shadow-[var(--shadow-ms-sm)]' : 'hover:bg-white/5',
)}
style={{
backgroundColor: isActive
? 'color-mix(in srgb, var(--color-ms-blue-500) 22%, transparent)'
: 'transparent',
color: isActive ? 'var(--color-text)' : 'var(--color-text-2)',
}}
>
<span className="block truncate">{conv.title ?? 'Untitled'}</span>
<span className="block text-xs text-text-muted">
{formatRelativeTime(conv.updatedAt)}
<span className="block truncate font-medium">{conversation.title ?? 'Untitled'}</span>
<span className="block text-xs text-[var(--color-muted)]">
{formatRelativeTime(conversation.updatedAt)}
</span>
</button>
)}
@@ -180,127 +191,138 @@ export function ConversationList({
return (
<>
{/* Backdrop to close context menu */}
{contextMenu && (
<div className="fixed inset-0 z-10" onClick={closeContextMenu} aria-hidden="true" />
)}
{isOpen ? (
<button
type="button"
className="fixed inset-0 z-20 bg-black/45 md:hidden"
onClick={onClose}
aria-label="Close conversation sidebar"
/>
) : null}
<div className="flex h-full w-64 flex-col border-r border-surface-border bg-surface-card">
{/* Header */}
<div className="flex items-center justify-between p-3">
<h2 className="text-sm font-medium text-text-secondary">Conversations</h2>
{contextMenu ? (
<div className="fixed inset-0 z-10" onClick={closeContextMenu} aria-hidden="true" />
) : null}
<div
className={cn(
'fixed inset-y-0 left-0 z-30 flex h-full w-[18.5rem] flex-col border-r px-3 py-3 transition-transform duration-200 md:static md:z-auto',
isOpen
? 'translate-x-0'
: '-translate-x-full md:w-0 md:min-w-0 md:overflow-hidden md:border-r-0 md:px-0 md:py-0',
)}
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
}}
>
<div className="flex items-center justify-between px-1 pb-3">
<h2 className="text-sm font-medium text-[var(--color-text-2)]">Conversations</h2>
<button
type="button"
onClick={onNew}
className="rounded-md px-2 py-1 text-xs text-blue-400 transition-colors hover:bg-surface-elevated"
className="rounded-full px-3 py-1 text-xs transition-colors hover:bg-white/5"
style={{ color: 'var(--color-ms-blue-400)' }}
>
+ New
</button>
</div>
{/* Search input */}
<div className="px-3 pb-2">
<div className="pb-3">
<input
type="search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search conversations\u2026"
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-xs text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none"
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search conversations"
className="w-full rounded-2xl border px-3 py-2 text-xs placeholder:text-[var(--color-muted)] focus:outline-none"
style={{
backgroundColor: 'var(--color-surface-2)',
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
}}
/>
</div>
{/* Conversation list */}
<div className="flex-1 overflow-y-auto">
{filteredActive.length === 0 && !searchQuery && (
<p className="px-3 py-2 text-xs text-text-muted">No conversations yet</p>
)}
{filteredActive.length === 0 && searchQuery && (
<p className="px-3 py-2 text-xs text-text-muted">
No results for &ldquo;{searchQuery}&rdquo;
<div className="flex-1 overflow-y-auto space-y-1">
{filteredActive.length === 0 && !searchQuery ? (
<p className="px-1 py-2 text-xs text-[var(--color-muted)]">No conversations yet</p>
) : null}
{filteredActive.length === 0 && searchQuery ? (
<p className="px-1 py-2 text-xs text-[var(--color-muted)]">
No results for {searchQuery}
</p>
)}
{filteredActive.map((conv) => renderConversationItem(conv))}
) : null}
{filteredActive.map((conversation) => renderConversationItem(conversation))}
{/* Archived section */}
{archivedConversations.length > 0 && (
<div className="mt-2">
{archivedConversations.length > 0 ? (
<div className="pt-2">
<button
type="button"
onClick={() => setShowArchived((v) => !v)}
className="flex w-full items-center gap-1 px-3 py-1 text-xs text-text-muted transition-colors hover:text-text-secondary"
onClick={() => setShowArchived((prev) => !prev)}
className="flex w-full items-center gap-2 px-1 py-1 text-xs text-[var(--color-muted)] transition-colors hover:text-[var(--color-text-2)]"
>
<span
className={cn(
'inline-block transition-transform',
showArchived ? 'rotate-90' : '',
)}
className={cn('inline-block transition-transform', showArchived && 'rotate-90')}
>
&#9658;
</span>
Archived ({archivedConversations.length})
</button>
{showArchived && (
<div className="opacity-60">
{filteredArchived.map((conv) => renderConversationItem(conv))}
{showArchived ? (
<div className="mt-1 space-y-1 opacity-70">
{filteredArchived.map((conversation) => renderConversationItem(conversation))}
</div>
)}
) : null}
</div>
)}
) : null}
</div>
</div>
{/* Context menu */}
{contextMenu && contextConv && (
{contextMenu && contextConversation ? (
<div
className="fixed z-20 min-w-36 rounded-md border border-surface-border bg-surface-card py-1 shadow-lg"
style={{ top: contextMenu.y, left: contextMenu.x }}
className="fixed z-30 min-w-40 rounded-2xl border py-1 shadow-[var(--shadow-ms-lg)]"
style={{
top: contextMenu.y,
left: contextMenu.x,
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
}}
>
<button
type="button"
className="w-full px-3 py-1.5 text-left text-sm text-text-secondary hover:bg-surface-elevated"
onClick={() => startRename(contextConv.id, contextConv.title)}
className="w-full px-3 py-2 text-left text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
onClick={() => startRename(contextConversation.id, contextConversation.title)}
>
Rename
</button>
<button
type="button"
className="w-full px-3 py-1.5 text-left text-sm text-text-secondary hover:bg-surface-elevated"
onClick={() => handleArchiveToggle(contextConv.id, !contextConv.archived)}
className="w-full px-3 py-2 text-left text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
onClick={() =>
handleArchiveToggle(contextConversation.id, !contextConversation.archived)
}
>
{contextConv.archived ? 'Unarchive' : 'Archive'}
{contextConversation.archived ? 'Restore' : 'Archive'}
</button>
<hr className="my-1 border-surface-border" />
{deleteConfirmId === contextConv.id ? (
<div className="px-3 py-1.5">
<p className="mb-1.5 text-xs text-red-400">Delete this conversation?</p>
<div className="flex gap-2">
{deleteConfirmId === contextConversation.id ? (
<button
type="button"
className="rounded bg-red-600 px-2 py-0.5 text-xs text-white hover:bg-red-700"
onClick={() => confirmDelete(contextConv.id)}
className="w-full px-3 py-2 text-left text-sm text-[var(--color-danger)] transition-colors hover:bg-white/5"
onClick={() => confirmDelete(contextConversation.id)}
>
Delete
Confirm delete
</button>
<button
type="button"
className="rounded px-2 py-0.5 text-xs text-text-muted hover:bg-surface-elevated"
onClick={() => setDeleteConfirmId(null)}
>
Cancel
</button>
</div>
</div>
) : (
<button
type="button"
className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-surface-elevated"
onClick={() => handleDeleteClick(contextConv.id)}
className="w-full px-3 py-2 text-left text-sm text-[var(--color-danger)] transition-colors hover:bg-white/5"
onClick={() => setDeleteConfirmId(contextConversation.id)}
>
Delete
Delete
</button>
)}
</div>
)}
) : null}
</>
);
}

View File

@@ -1,5 +1,7 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import { cn } from '@/lib/cn';
import type { Message } from '@/lib/types';
@@ -9,27 +11,261 @@ interface MessageBubbleProps {
export function MessageBubble({ message }: MessageBubbleProps): React.ReactElement {
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
const [copied, setCopied] = useState(false);
const [thinkingExpanded, setThinkingExpanded] = useState(false);
const { response, thinking } = useMemo(
() => parseThinking(message.content, message.thinking),
[message.content, message.thinking],
);
const handleCopy = useCallback(async (): Promise<void> => {
try {
await navigator.clipboard.writeText(response);
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
} catch (error) {
console.error('[MessageBubble] Failed to copy message:', error);
}
}, [response]);
if (isSystem) {
return (
<div className="flex justify-center">
<div
className="max-w-[42rem] rounded-full border px-3 py-1.5 text-xs backdrop-blur-sm"
style={{
borderColor: 'var(--color-border)',
backgroundColor: 'color-mix(in srgb, var(--color-surface) 70%, transparent)',
color: 'var(--color-muted)',
}}
>
<span>{response}</span>
</div>
</div>
);
}
return (
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
<div className={cn('group flex', isUser ? 'justify-end' : 'justify-start')}>
<div
className={cn(
'max-w-[75%] rounded-xl px-4 py-3 text-sm',
isUser
? 'bg-blue-600 text-white'
: 'border border-surface-border bg-surface-elevated text-text-primary',
'flex max-w-[min(78ch,85%)] flex-col gap-2',
isUser ? 'items-end' : 'items-start',
)}
>
<div className="whitespace-pre-wrap break-words">{message.content}</div>
<div
className={cn('mt-1 text-right text-xs', isUser ? 'text-blue-200' : 'text-text-muted')}
<div className={cn('flex items-center gap-2 text-[11px]', isUser && 'flex-row-reverse')}>
<span className="font-medium text-[var(--color-text-2)]">
{isUser ? 'You' : 'Assistant'}
</span>
{!isUser && message.model ? (
<span
className="rounded-full border px-2 py-0.5 font-medium text-[var(--color-text-2)]"
style={{
backgroundColor: 'color-mix(in srgb, var(--color-surface-2) 82%, transparent)',
borderColor: 'var(--color-border)',
}}
title={message.provider ? `Provider: ${message.provider}` : undefined}
>
{new Date(message.createdAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
{message.model}
</span>
) : null}
{!isUser && typeof message.totalTokens === 'number' && message.totalTokens > 0 ? (
<span
className="rounded-full border px-2 py-0.5 text-[var(--color-muted)]"
style={{ borderColor: 'var(--color-border)' }}
>
{formatTokenCount(message.totalTokens)}
</span>
) : null}
<span className="text-[var(--color-muted)]">{formatTimestamp(message.createdAt)}</span>
</div>
{thinking && !isUser ? (
<div
className="w-full overflow-hidden rounded-2xl border"
style={{
backgroundColor: 'color-mix(in srgb, var(--color-surface-2) 88%, transparent)',
borderColor: 'var(--color-border)',
}}
>
<button
type="button"
onClick={() => setThinkingExpanded((prev) => !prev)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-[var(--color-text-2)] transition-colors hover:bg-black/5"
aria-expanded={thinkingExpanded}
>
<span
className={cn(
'inline-block text-[10px] transition-transform',
thinkingExpanded && 'rotate-90',
)}
>
</span>
<span>Chain of thought</span>
<span className="ml-auto text-[var(--color-muted)]">
{thinkingExpanded ? 'Hide' : 'Show'}
</span>
</button>
{thinkingExpanded ? (
<pre
className="overflow-x-auto border-t px-3 py-3 font-mono text-xs leading-6 whitespace-pre-wrap"
style={{
borderColor: 'var(--color-border)',
backgroundColor: 'var(--color-bg-deep)',
color: 'var(--color-text-2)',
}}
>
{thinking}
</pre>
) : null}
</div>
) : null}
<div
className={cn(
'relative w-full rounded-3xl px-4 py-3 text-sm shadow-[var(--shadow-ms-sm)]',
!isUser && 'border',
)}
style={{
backgroundColor: isUser ? 'var(--color-ms-blue-500)' : 'var(--color-surface)',
color: isUser ? '#fff' : 'var(--color-text)',
borderColor: isUser ? 'transparent' : 'var(--color-border)',
}}
>
<div className="max-w-none">
<ReactMarkdown
components={{
p: ({ children }) => <p className="mb-3 leading-7 last:mb-0">{children}</p>,
ul: ({ children }) => <ul className="mb-3 list-disc pl-5 last:mb-0">{children}</ul>,
ol: ({ children }) => (
<ol className="mb-3 list-decimal pl-5 last:mb-0">{children}</ol>
),
li: ({ children }) => <li className="mb-1">{children}</li>,
a: ({ href, children }) => (
<a
href={href}
className="font-medium underline underline-offset-4"
target="_blank"
rel="noreferrer"
>
{children}
</a>
),
pre: ({ children }) => <div className="mb-3 last:mb-0">{children}</div>,
code: ({ className, children, ...props }) => {
const language = className?.replace('language-', '');
const content = String(children).replace(/\n$/, '');
const isInline = !className;
if (isInline) {
return (
<code
className="rounded-md px-1.5 py-0.5 font-mono text-[0.9em]"
style={{
backgroundColor:
'color-mix(in srgb, var(--color-bg-deep) 76%, transparent)',
}}
{...props}
>
{children}
</code>
);
}
return (
<div
className="overflow-hidden rounded-2xl border"
style={{
backgroundColor: 'var(--color-bg-deep)',
borderColor: 'var(--color-border)',
}}
>
<div
className="border-b px-3 py-2 font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--color-muted)]"
style={{ borderColor: 'var(--color-border)' }}
>
{language || 'code'}
</div>
<pre className="overflow-x-auto p-3">
<code
className={cn('font-mono text-[13px] leading-6', className)}
{...props}
>
{content}
</code>
</pre>
</div>
);
},
blockquote: ({ children }) => (
<blockquote
className="mb-3 border-l-2 pl-4 italic last:mb-0"
style={{ borderColor: 'var(--color-ms-blue-500)' }}
>
{children}
</blockquote>
),
}}
>
{response}
</ReactMarkdown>
</div>
<button
type="button"
onClick={() => void handleCopy()}
className="absolute -right-2 -top-2 rounded-full border p-2 opacity-0 shadow-[var(--shadow-ms-md)] transition-all group-hover:opacity-100 focus:opacity-100"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
color: copied ? 'var(--color-success)' : 'var(--color-text-2)',
}}
aria-label={copied ? 'Copied' : 'Copy message'}
title={copied ? 'Copied' : 'Copy message'}
>
{copied ? '✓' : '⧉'}
</button>
</div>
</div>
</div>
);
}
function parseThinking(
content: string,
thinking?: string,
): { response: string; thinking: string | null } {
if (thinking) {
return { response: content, thinking };
}
const regex = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi;
const matches = [...content.matchAll(regex)];
if (matches.length === 0) {
return { response: content, thinking: null };
}
return {
response: content.replace(regex, '').trim(),
thinking:
matches
.map((match) => match[1]?.trim() ?? '')
.filter(Boolean)
.join('\n\n') || null,
};
}
function formatTimestamp(createdAt: string): string {
return new Date(createdAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
}
function formatTokenCount(totalTokens: number): string {
if (totalTokens >= 1_000_000) return `${(totalTokens / 1_000_000).toFixed(1)}M tokens`;
if (totalTokens >= 1_000) return `${(totalTokens / 1_000).toFixed(1)}k tokens`;
return `${totalTokens} tokens`;
}

View File

@@ -1,26 +1,97 @@
'use client';
/** Renders an in-progress assistant message from streaming text. */
import { useEffect, useMemo, useState } from 'react';
interface StreamingMessageProps {
text: string;
modelName?: string | null;
thinking?: string;
}
export function StreamingMessage({ text }: StreamingMessageProps): React.ReactElement {
const WAITING_QUIPS = [
'The AI is warming up... give it a moment.',
'Brewing some thoughts...',
'Summoning intelligence from the void...',
'Consulting the silicon oracle...',
'Teaching electrons to think...',
];
const TIMEOUT_QUIPS = [
'The model wandered off. Lets try to find it again.',
'Response is taking the scenic route.',
'That answer is clearly overthinking things.',
'Still working. Either brilliance or a detour.',
];
export function StreamingMessage({
text,
modelName,
thinking,
}: StreamingMessageProps): React.ReactElement {
const [elapsedMs, setElapsedMs] = useState(0);
useEffect(() => {
setElapsedMs(0);
const startedAt = Date.now();
const timer = window.setInterval(() => {
setElapsedMs(Date.now() - startedAt);
}, 1000);
return () => window.clearInterval(timer);
}, [text, modelName, thinking]);
const quip = useMemo(() => {
if (elapsedMs >= 18_000) {
return TIMEOUT_QUIPS[Math.floor((elapsedMs / 1000) % TIMEOUT_QUIPS.length)];
}
if (elapsedMs >= 4_000) {
return WAITING_QUIPS[Math.floor((elapsedMs / 1000) % WAITING_QUIPS.length)];
}
return null;
}, [elapsedMs]);
return (
<div className="flex justify-start">
<div className="max-w-[75%] rounded-xl border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-primary">
<div
className="max-w-[min(78ch,85%)] rounded-3xl border px-4 py-3 text-sm shadow-[var(--shadow-ms-sm)]"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
}}
>
<div className="mb-2 flex items-center gap-2 text-[11px]">
<span className="font-medium text-[var(--color-text-2)]">Assistant</span>
{modelName ? (
<span className="rounded-full border border-[var(--color-border)] px-2 py-0.5 text-[var(--color-text-2)]">
{modelName}
</span>
) : null}
<span className="text-[var(--color-muted)]">{text ? 'Responding…' : 'Thinking…'}</span>
</div>
{text ? (
<div className="whitespace-pre-wrap break-words">{text}</div>
) : (
<div className="flex items-center gap-2 text-text-muted">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500" />
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500 [animation-delay:0.2s]" />
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500 [animation-delay:0.4s]" />
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-[var(--color-ms-blue-500)]" />
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-[var(--color-ms-blue-500)] [animation-delay:0.2s]" />
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-[var(--color-ms-blue-500)] [animation-delay:0.4s]" />
</div>
)}
<div className="mt-1 flex items-center gap-1 text-xs text-text-muted">
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
{text ? 'Responding...' : 'Thinking...'}
{thinking ? (
<div
className="mt-3 rounded-2xl border px-3 py-2 font-mono text-xs whitespace-pre-wrap"
style={{
backgroundColor: 'var(--color-bg-deep)',
borderColor: 'var(--color-border)',
color: 'var(--color-text-2)',
}}
>
{thinking}
</div>
) : null}
<div className="mt-2 flex items-center gap-2 text-xs text-[var(--color-muted)]">
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-[var(--color-ms-blue-500)]" />
<span>{quip ?? (text ? 'Responding…' : 'Thinking…')}</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,239 @@
'use client';
import Link from 'next/link';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { signOut, useSession } from '@/lib/auth-client';
interface AppHeaderProps {
conversationTitle?: string | null;
isSidebarOpen: boolean;
onToggleSidebar: () => void;
}
type ThemeMode = 'dark' | 'light';
const THEME_STORAGE_KEY = 'mosaic-chat-theme';
export function AppHeader({
conversationTitle,
isSidebarOpen,
onToggleSidebar,
}: AppHeaderProps): React.ReactElement {
const { data: session } = useSession();
const [currentTime, setCurrentTime] = useState('');
const [version, setVersion] = useState<string | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [theme, setTheme] = useState<ThemeMode>('dark');
useEffect(() => {
function updateTime(): void {
setCurrentTime(
new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
}),
);
}
updateTime();
const interval = window.setInterval(updateTime, 60_000);
return () => window.clearInterval(interval);
}, []);
useEffect(() => {
fetch('/version.json')
.then(async (res) => res.json() as Promise<{ version?: string; commit?: string }>)
.then((data) => {
if (data.version) {
setVersion(data.commit ? `${data.version}+${data.commit}` : data.version);
}
})
.catch(() => setVersion(null));
}, []);
useEffect(() => {
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
const nextTheme = storedTheme === 'light' ? 'light' : 'dark';
applyTheme(nextTheme);
setTheme(nextTheme);
}, []);
const handleThemeToggle = useCallback(() => {
const nextTheme = theme === 'dark' ? 'light' : 'dark';
applyTheme(nextTheme);
window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
setTheme(nextTheme);
}, [theme]);
const handleSignOut = useCallback(async (): Promise<void> => {
await signOut();
window.location.href = '/login';
}, []);
const userLabel = session?.user.name ?? session?.user.email ?? 'Mosaic User';
const initials = useMemo(() => getInitials(userLabel), [userLabel]);
return (
<header
className="sticky top-0 z-20 border-b backdrop-blur-xl"
style={{
backgroundColor: 'color-mix(in srgb, var(--color-surface) 82%, transparent)',
borderColor: 'var(--color-border)',
}}
>
<div className="flex items-center justify-between gap-3 px-4 py-3 md:px-6">
<div className="flex min-w-0 items-center gap-3">
<button
type="button"
onClick={onToggleSidebar}
className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text)' }}
aria-label="Toggle conversation sidebar"
aria-expanded={isSidebarOpen}
>
</button>
<Link href="/chat" className="flex min-w-0 items-center gap-3">
<div
className="flex h-10 w-10 items-center justify-center rounded-2xl text-sm font-semibold text-white shadow-[var(--shadow-ms-md)]"
style={{
background:
'linear-gradient(135deg, var(--color-ms-blue-500), var(--color-ms-teal-500))',
}}
>
M
</div>
<div className="flex min-w-0 items-center gap-3">
<div className="text-sm font-semibold text-[var(--color-text)]">Mosaic</div>
<div className="hidden h-5 w-px bg-[var(--color-border)] md:block" />
<div className="hidden items-center gap-2 md:flex">
<span className="relative flex h-2.5 w-2.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--color-ms-teal-500)] opacity-60" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-[var(--color-ms-teal-500)]" />
</span>
<span className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]">
Online
</span>
</div>
</div>
</Link>
</div>
<div className="hidden min-w-0 items-center gap-3 md:flex">
<div className="rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-2)]">
{currentTime || '--:--'}
</div>
<div className="max-w-[24rem] truncate text-sm font-medium text-[var(--color-text)]">
{conversationTitle?.trim() || 'New Session'}
</div>
{version ? (
<div className="rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-muted)]">
v{version}
</div>
) : null}
</div>
<div className="flex items-center gap-2">
<div className="hidden items-center gap-2 lg:flex">
<ShortcutHint label="⌘/" text="focus" />
<ShortcutHint label="⌘K" text="focus" />
</div>
<button
type="button"
onClick={handleThemeToggle}
className="inline-flex h-10 items-center justify-center rounded-2xl border px-3 text-sm transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text)' }}
aria-label="Toggle theme"
>
{theme === 'dark' ? '☀︎' : '☾'}
</button>
<div className="relative">
<button
type="button"
onClick={() => setMenuOpen((prev) => !prev)}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border text-sm font-semibold transition-colors hover:bg-white/5"
style={{
backgroundColor: 'var(--color-surface-2)',
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
}}
aria-expanded={menuOpen}
aria-label="Open user menu"
>
{session?.user.image ? (
<img
src={session.user.image}
alt={userLabel}
className="h-full w-full rounded-full object-cover"
/>
) : (
initials
)}
</button>
{menuOpen ? (
<div
className="absolute right-0 top-12 min-w-56 rounded-3xl border p-2 shadow-[var(--shadow-ms-lg)]"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
}}
>
<div className="border-b px-3 py-2" style={{ borderColor: 'var(--color-border)' }}>
<div className="text-sm font-medium text-[var(--color-text)]">{userLabel}</div>
{session?.user.email ? (
<div className="text-xs text-[var(--color-muted)]">{session.user.email}</div>
) : null}
</div>
<div className="p-1">
<Link
href="/settings"
className="flex rounded-2xl px-3 py-2 text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
onClick={() => setMenuOpen(false)}
>
Settings
</Link>
<button
type="button"
onClick={() => void handleSignOut()}
className="flex w-full rounded-2xl px-3 py-2 text-left text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
>
Sign out
</button>
</div>
</div>
) : null}
</div>
</div>
</div>
</header>
);
}
function ShortcutHint({ label, text }: { label: string; text: string }): React.ReactElement {
return (
<span className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-muted)]">
<span className="font-medium text-[var(--color-text-2)]">{label}</span>
<span>{text}</span>
</span>
);
}
function getInitials(label: string): string {
const words = label.split(/\s+/).filter(Boolean).slice(0, 2);
if (words.length === 0) return 'M';
return words.map((word) => word.charAt(0).toUpperCase()).join('');
}
function applyTheme(theme: ThemeMode): void {
const root = document.documentElement;
if (theme === 'light') {
root.setAttribute('data-theme', 'light');
root.classList.remove('dark');
} else {
root.removeAttribute('data-theme');
root.classList.add('dark');
}
}

View File

@@ -1,11 +1,16 @@
'use client';
import { useRouter } from 'next/navigation';
import { usePathname, useRouter } from 'next/navigation';
import { useSession, signOut } from '@/lib/auth-client';
export function Topbar(): React.ReactElement {
const { data: session } = useSession();
const router = useRouter();
const pathname = usePathname();
if (pathname.startsWith('/chat')) {
return <></>;
}
async function handleSignOut(): Promise<void> {
await signOut();

View File

@@ -15,10 +15,41 @@ export interface Message {
conversationId: string;
role: 'user' | 'assistant' | 'system';
content: string;
thinking?: string;
model?: string;
provider?: string;
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
metadata?: Record<string, unknown>;
createdAt: string;
}
/** Model definition returned by provider APIs. */
export interface ModelInfo {
id: string;
provider: string;
name: string;
reasoning: boolean;
contextWindow: number;
maxTokens: number;
inputTypes: Array<'text' | 'image'>;
cost: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
};
}
/** Provider with associated models. */
export interface ProviderInfo {
id: string;
name: string;
available: boolean;
models: ModelInfo[];
}
/** Task statuses. */
export type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';

662
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff