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
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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
streamingTextRef.current += data.text;
|
||||
setStreamingText((prev) => prev + data.text);
|
||||
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} />
|
||||
))}
|
||||
{isStreaming && <StreamingMessage text={streamingText} />}
|
||||
<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="relative flex min-w-0 flex-1 flex-col overflow-hidden"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(circle at top, color-mix(in srgb, var(--color-ms-blue-500) 14%, transparent), transparent 35%), var(--color-bg)',
|
||||
}}
|
||||
>
|
||||
<AppHeader
|
||||
conversationTitle={activeConversation?.title}
|
||||
isSidebarOpen={sidebarOpen}
|
||||
onToggleSidebar={() => setSidebarOpen((prev) => !prev)}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6 md:px-6">
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
|
||||
{messages.length === 0 && !isStreaming ? (
|
||||
<div className="flex min-h-full flex-1 items-center justify-center py-16">
|
||||
<div className="max-w-xl text-center">
|
||||
<div className="mb-4 text-xs uppercase tracking-[0.3em] text-[var(--color-muted)]">
|
||||
Mosaic Chat
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold text-[var(--color-text)]">
|
||||
Start a new session with a better chat interface.
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-[var(--color-text-2)]">
|
||||
Pick a model, send a prompt, and the response area will keep reasoning,
|
||||
metadata, and streaming status visible without leaving the page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
|
||||
{isStreaming ? (
|
||||
<StreamingMessage
|
||||
text={streamingText}
|
||||
thinking={streamingThinking}
|
||||
modelName={selectedModel?.name ?? null}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<div className="flex items-end gap-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
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"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
<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={(event) => setValue(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isStreaming}
|
||||
rows={1}
|
||||
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={!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)' }}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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 “{searchQuery}”
|
||||
<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')}
|
||||
>
|
||||
►
|
||||
▶
|
||||
</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">
|
||||
<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)}
|
||||
>
|
||||
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>
|
||||
{deleteConfirmId === contextConversation.id ? (
|
||||
<button
|
||||
type="button"
|
||||
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)}
|
||||
>
|
||||
Confirm delete
|
||||
</button>
|
||||
) : (
|
||||
<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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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('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}
|
||||
>
|
||||
{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('mt-1 text-right text-xs', isUser ? 'text-blue-200' : 'text-text-muted')}
|
||||
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)',
|
||||
}}
|
||||
>
|
||||
{new Date(message.createdAt).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
<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`;
|
||||
}
|
||||
|
||||
@@ -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. Let’s 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>
|
||||
|
||||
239
apps/web/src/components/layout/app-header.tsx
Normal file
239
apps/web/src/components/layout/app-header.tsx
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
662
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user