feat(web): port chat UI — model selector, keybindings, thinking display, styled header
This commit is contained in:
@@ -18,6 +18,7 @@
|
|||||||
"next": "^16.0.0",
|
"next": "^16.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"socket.io-client": "^4.8.0",
|
"socket.io-client": "^4.8.0",
|
||||||
"tailwind-merge": "^3.5.0"
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,93 +1,172 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { destroySocket, getSocket } from '@/lib/socket';
|
import { destroySocket, getSocket } from '@/lib/socket';
|
||||||
import type { Conversation, Message } from '@/lib/types';
|
import type { Conversation, Message, ModelInfo, ProviderInfo } from '@/lib/types';
|
||||||
import { ConversationList } from '@/components/chat/conversation-list';
|
import { ConversationList } from '@/components/chat/conversation-list';
|
||||||
import { MessageBubble } from '@/components/chat/message-bubble';
|
import { MessageBubble } from '@/components/chat/message-bubble';
|
||||||
import { ChatInput } from '@/components/chat/chat-input';
|
import { ChatInput } from '@/components/chat/chat-input';
|
||||||
import { StreamingMessage } from '@/components/chat/streaming-message';
|
import { StreamingMessage } from '@/components/chat/streaming-message';
|
||||||
|
import { AppHeader } from '@/components/layout/app-header';
|
||||||
|
|
||||||
|
const FALLBACK_MODELS: ModelInfo[] = [
|
||||||
|
{
|
||||||
|
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 {
|
export default function ChatPage(): React.ReactElement {
|
||||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
const [models, setModels] = useState<ModelInfo[]>(FALLBACK_MODELS);
|
||||||
|
const [selectedModelId, setSelectedModelId] = useState(FALLBACK_MODELS[0]?.id ?? '');
|
||||||
const [streamingText, setStreamingText] = useState('');
|
const [streamingText, setStreamingText] = useState('');
|
||||||
|
const [streamingThinking, setStreamingThinking] = useState('');
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
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 activeIdRef = useRef<string | null>(null);
|
||||||
|
const streamingTextRef = useRef('');
|
||||||
|
const streamingThinkingRef = useRef('');
|
||||||
|
|
||||||
activeIdRef.current = activeId;
|
activeIdRef.current = activeId;
|
||||||
|
|
||||||
// Accumulate streamed text in a ref so agent:end can read the full content
|
const selectedModel = useMemo(
|
||||||
// without stale-closure issues.
|
() => models.find((model) => model.id === selectedModelId) ?? models[0] ?? null,
|
||||||
const streamingTextRef = useRef('');
|
[models, selectedModelId],
|
||||||
|
);
|
||||||
|
const selectedModelRef = useRef<ModelInfo | null>(selectedModel);
|
||||||
|
selectedModelRef.current = selectedModel;
|
||||||
|
|
||||||
// Load conversations on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api<Conversation[]>('/api/conversations')
|
api<Conversation[]>('/api/conversations')
|
||||||
.then(setConversations)
|
.then(setConversations)
|
||||||
.catch(() => {});
|
.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(() => {
|
useEffect(() => {
|
||||||
if (!activeId) {
|
if (!activeId) {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Clear streaming state when switching conversations
|
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
setStreamingText('');
|
setStreamingText('');
|
||||||
|
setStreamingThinking('');
|
||||||
streamingTextRef.current = '';
|
streamingTextRef.current = '';
|
||||||
|
streamingThinkingRef.current = '';
|
||||||
|
|
||||||
api<Message[]>(`/api/conversations/${activeId}/messages`)
|
api<Message[]>(`/api/conversations/${activeId}/messages`)
|
||||||
.then(setMessages)
|
.then((fetchedMessages) => setMessages(fetchedMessages.map(normalizeMessage)))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [activeId]);
|
}, [activeId]);
|
||||||
|
|
||||||
// Auto-scroll to bottom
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, [messages, streamingText]);
|
}, [messages, streamingText, streamingThinking]);
|
||||||
|
|
||||||
// Socket.io setup — connect once for the page lifetime
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
|
|
||||||
function onAgentStart(data: { conversationId: string }): void {
|
function onAgentStart(data: { conversationId: string }): void {
|
||||||
// Only update state if the event belongs to the currently viewed conversation
|
|
||||||
if (activeIdRef.current !== data.conversationId) return;
|
if (activeIdRef.current !== data.conversationId) return;
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
setStreamingText('');
|
setStreamingText('');
|
||||||
|
setStreamingThinking('');
|
||||||
streamingTextRef.current = '';
|
streamingTextRef.current = '';
|
||||||
|
streamingThinkingRef.current = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAgentText(data: { conversationId: string; text: string }): void {
|
function onAgentText(data: { conversationId: string; text?: string; thinking?: string }): void {
|
||||||
if (activeIdRef.current !== data.conversationId) return;
|
if (activeIdRef.current !== data.conversationId) return;
|
||||||
streamingTextRef.current += data.text;
|
if (data.text) {
|
||||||
setStreamingText((prev) => prev + 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;
|
if (activeIdRef.current !== data.conversationId) return;
|
||||||
const finalText = streamingTextRef.current;
|
const finalText = streamingTextRef.current;
|
||||||
|
const finalThinking = data.thinking ?? streamingThinkingRef.current;
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
setStreamingText('');
|
setStreamingText('');
|
||||||
|
setStreamingThinking('');
|
||||||
streamingTextRef.current = '';
|
streamingTextRef.current = '';
|
||||||
// Append the completed assistant message to the local message list.
|
streamingThinkingRef.current = '';
|
||||||
// The Pi agent session is in-memory so the assistant response is not
|
|
||||||
// persisted to the DB — we build the local UI state instead.
|
|
||||||
if (finalText) {
|
if (finalText) {
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: `assistant-${Date.now()}`,
|
id: `assistant-${Date.now()}`,
|
||||||
conversationId: data.conversationId,
|
conversationId: data.conversationId,
|
||||||
role: 'assistant' as const,
|
role: 'assistant',
|
||||||
content: finalText,
|
content: finalText,
|
||||||
|
thinking: finalThinking || undefined,
|
||||||
|
model: data.model ?? selectedModelRef.current?.name,
|
||||||
|
provider: data.provider ?? selectedModelRef.current?.provider,
|
||||||
|
promptTokens: data.promptTokens,
|
||||||
|
completionTokens: data.completionTokens,
|
||||||
|
totalTokens: data.totalTokens,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -97,13 +176,15 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
function onError(data: { error: string; conversationId?: string }): void {
|
function onError(data: { error: string; conversationId?: string }): void {
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
setStreamingText('');
|
setStreamingText('');
|
||||||
|
setStreamingThinking('');
|
||||||
streamingTextRef.current = '';
|
streamingTextRef.current = '';
|
||||||
|
streamingThinkingRef.current = '';
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: `error-${Date.now()}`,
|
id: `error-${Date.now()}`,
|
||||||
conversationId: data.conversationId ?? '',
|
conversationId: data.conversationId ?? '',
|
||||||
role: 'system' as const,
|
role: 'system',
|
||||||
content: `Error: ${data.error}`,
|
content: `Error: ${data.error}`,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
@@ -115,7 +196,6 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
socket.on('agent:end', onAgentEnd);
|
socket.on('agent:end', onAgentEnd);
|
||||||
socket.on('error', onError);
|
socket.on('error', onError);
|
||||||
|
|
||||||
// Connect if not already connected
|
|
||||||
if (!socket.connected) {
|
if (!socket.connected) {
|
||||||
socket.connect();
|
socket.connect();
|
||||||
}
|
}
|
||||||
@@ -125,19 +205,17 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
socket.off('agent:text', onAgentText);
|
socket.off('agent:text', onAgentText);
|
||||||
socket.off('agent:end', onAgentEnd);
|
socket.off('agent:end', onAgentEnd);
|
||||||
socket.off('error', onError);
|
socket.off('error', onError);
|
||||||
// Fully tear down the socket when the chat page unmounts so we get a
|
|
||||||
// fresh authenticated connection next time the page is visited.
|
|
||||||
destroySocket();
|
destroySocket();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleNewConversation = useCallback(async () => {
|
const handleNewConversation = useCallback(async () => {
|
||||||
const conv = await api<Conversation>('/api/conversations', {
|
const conversation = await api<Conversation>('/api/conversations', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { title: 'New conversation' },
|
body: { title: 'New conversation' },
|
||||||
});
|
});
|
||||||
setConversations((prev) => [conv, ...prev]);
|
setConversations((prev) => [conversation, ...prev]);
|
||||||
setActiveId(conv.id);
|
setActiveId(conversation.id);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -146,20 +224,22 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: { title },
|
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(
|
const handleDelete = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await api<void>(`/api/conversations/${id}`, { method: 'DELETE' });
|
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) {
|
if (activeId === id) {
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
console.error('[ChatPage] Failed to delete conversation:', err);
|
console.error('[ChatPage] Failed to delete conversation:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeId],
|
[activeId],
|
||||||
@@ -171,8 +251,9 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: { archived },
|
body: { archived },
|
||||||
});
|
});
|
||||||
setConversations((prev) => prev.map((c) => (c.id === id ? updated : c)));
|
setConversations((prev) =>
|
||||||
// If archiving the active conversation, deselect it
|
prev.map((conversation) => (conversation.id === id ? updated : conversation)),
|
||||||
|
);
|
||||||
if (archived && activeId === id) {
|
if (archived && activeId === id) {
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
@@ -182,75 +263,114 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleSend = useCallback(
|
const handleSend = useCallback(
|
||||||
async (content: string) => {
|
async (content: string, options?: { modelId?: string }) => {
|
||||||
let convId = activeId;
|
let conversationId = activeId;
|
||||||
|
|
||||||
// Auto-create conversation if none selected
|
if (!conversationId) {
|
||||||
if (!convId) {
|
|
||||||
const autoTitle = content.slice(0, 60);
|
const autoTitle = content.slice(0, 60);
|
||||||
const conv = await api<Conversation>('/api/conversations', {
|
const conversation = await api<Conversation>('/api/conversations', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { title: autoTitle },
|
body: { title: autoTitle },
|
||||||
});
|
});
|
||||||
setConversations((prev) => [conv, ...prev]);
|
setConversations((prev) => [conversation, ...prev]);
|
||||||
setActiveId(conv.id);
|
setActiveId(conversation.id);
|
||||||
convId = conv.id;
|
conversationId = conversation.id;
|
||||||
} else {
|
} else {
|
||||||
// Auto-title: if the active conversation still has the default "New
|
const activeConversation = conversations.find(
|
||||||
// conversation" title and this is the first message, update the title
|
(conversation) => conversation.id === conversationId,
|
||||||
// from the message content.
|
);
|
||||||
const activeConv = conversations.find((c) => c.id === convId);
|
if (activeConversation?.title === 'New conversation' && messages.length === 0) {
|
||||||
if (activeConv?.title === 'New conversation' && messages.length === 0) {
|
|
||||||
const autoTitle = content.slice(0, 60);
|
const autoTitle = content.slice(0, 60);
|
||||||
api<Conversation>(`/api/conversations/${convId}`, {
|
api<Conversation>(`/api/conversations/${conversationId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: { title: autoTitle },
|
body: { title: autoTitle },
|
||||||
})
|
})
|
||||||
.then((updated) => {
|
.then((updated) => {
|
||||||
setConversations((prev) => prev.map((c) => (c.id === convId ? updated : c)));
|
setConversations((prev) =>
|
||||||
|
prev.map((conversation) =>
|
||||||
|
conversation.id === conversationId ? updated : conversation,
|
||||||
|
),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimistic user message in local UI state
|
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: `user-${Date.now()}`,
|
id: `user-${Date.now()}`,
|
||||||
conversationId: convId,
|
conversationId,
|
||||||
role: 'user' as const,
|
role: 'user',
|
||||||
content,
|
content,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Persist the user message to the DB so conversation history is
|
api<Message>(`/api/conversations/${conversationId}/messages`, {
|
||||||
// available when the page is reloaded or a new session starts.
|
|
||||||
api<Message>(`/api/conversations/${convId}/messages`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { role: 'user', content },
|
body: { role: 'user', content },
|
||||||
}).catch(() => {
|
}).catch(() => {});
|
||||||
// Non-fatal: the agent can still process the message even if
|
|
||||||
// REST persistence fails.
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send to WebSocket — gateway creates/resumes the agent session and
|
|
||||||
// streams the response back via agent:start / agent:text / agent:end.
|
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
if (!socket.connected) {
|
if (!socket.connected) {
|
||||||
socket.connect();
|
socket.connect();
|
||||||
}
|
}
|
||||||
socket.emit('message', { 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 (
|
return (
|
||||||
<div className="-m-6 flex h-[calc(100vh-3.5rem)]">
|
<div className="-m-6 flex h-[100dvh] overflow-hidden">
|
||||||
<ConversationList
|
<ConversationList
|
||||||
conversations={conversations}
|
conversations={conversations}
|
||||||
activeId={activeId}
|
activeId={activeId}
|
||||||
|
isOpen={sidebarOpen}
|
||||||
|
onClose={() => setSidebarOpen(false)}
|
||||||
onSelect={setActiveId}
|
onSelect={setActiveId}
|
||||||
onNew={handleNewConversation}
|
onNew={handleNewConversation}
|
||||||
onRename={handleRename}
|
onRename={handleRename}
|
||||||
@@ -258,36 +378,90 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
onArchive={handleArchive}
|
onArchive={handleArchive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col">
|
<div
|
||||||
{activeId ? (
|
className="relative flex min-w-0 flex-1 flex-col overflow-hidden"
|
||||||
<>
|
style={{
|
||||||
<div className="flex-1 space-y-4 overflow-y-auto p-6">
|
background:
|
||||||
{messages.map((msg) => (
|
'radial-gradient(circle at top, color-mix(in srgb, var(--color-ms-blue-500) 14%, transparent), transparent 35%), var(--color-bg)',
|
||||||
<MessageBubble key={msg.id} message={msg} />
|
}}
|
||||||
))}
|
>
|
||||||
{isStreaming && <StreamingMessage text={streamingText} />}
|
<AppHeader
|
||||||
<div ref={messagesEndRef} />
|
conversationTitle={activeConversation?.title}
|
||||||
</div>
|
isSidebarOpen={sidebarOpen}
|
||||||
<ChatInput onSend={handleSend} disabled={isStreaming} />
|
onToggleSidebar={() => setSidebarOpen((prev) => !prev)}
|
||||||
</>
|
/>
|
||||||
) : (
|
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<div className="flex-1 overflow-y-auto px-4 py-6 md:px-6">
|
||||||
<div className="text-center">
|
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
|
||||||
<h2 className="text-lg font-medium text-text-secondary">Welcome to Mosaic Chat</h2>
|
{messages.length === 0 && !isStreaming ? (
|
||||||
<p className="mt-1 text-sm text-text-muted">
|
<div className="flex min-h-full flex-1 items-center justify-center py-16">
|
||||||
Select a conversation or start a new one
|
<div className="max-w-xl text-center">
|
||||||
</p>
|
<div className="mb-4 text-xs uppercase tracking-[0.3em] text-[var(--color-muted)]">
|
||||||
<button
|
Mosaic Chat
|
||||||
type="button"
|
</div>
|
||||||
onClick={handleNewConversation}
|
<h2 className="text-3xl font-semibold text-[var(--color-text)]">
|
||||||
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 a new session with a better chat interface.
|
||||||
>
|
</h2>
|
||||||
Start new conversation
|
<p className="mt-3 text-sm leading-7 text-[var(--color-text-2)]">
|
||||||
</button>
|
Pick a model, send a prompt, and the response area will keep reasoning,
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
|
<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>
|
||||||
</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';
|
'use client';
|
||||||
|
|
||||||
import { useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { ModelInfo } from '@/lib/types';
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onSend: (content: string) => void;
|
onSend: (content: string, options?: { modelId?: string }) => void;
|
||||||
disabled?: boolean;
|
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 [value, setValue] = useState('');
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const selectedModel = useMemo(
|
||||||
|
() => models.find((model) => model.id === selectedModelId) ?? models[0],
|
||||||
|
[models, selectedModelId],
|
||||||
|
);
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent): void {
|
useEffect(() => {
|
||||||
e.preventDefault();
|
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();
|
const trimmed = value.trim();
|
||||||
if (!trimmed || disabled) return;
|
if (!trimmed || isStreaming) return;
|
||||||
onSend(trimmed);
|
onSend(trimmed, { modelId: selectedModel?.id });
|
||||||
setValue('');
|
setValue('');
|
||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>): void {
|
function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>): void {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
handleSubmit(e);
|
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 (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="border-t border-surface-border bg-surface-card p-4">
|
<form
|
||||||
<div className="flex items-end gap-3">
|
onSubmit={handleSubmit}
|
||||||
<textarea
|
className="border-t px-4 py-4 backdrop-blur-xl md:px-6"
|
||||||
ref={textareaRef}
|
style={{
|
||||||
value={value}
|
backgroundColor: 'color-mix(in srgb, var(--color-surface) 88%, transparent)',
|
||||||
onChange={(e) => setValue(e.target.value)}
|
borderColor: 'var(--color-border)',
|
||||||
onKeyDown={handleKeyDown}
|
}}
|
||||||
disabled={disabled}
|
>
|
||||||
rows={1}
|
<div
|
||||||
placeholder="Type a message... (Enter to send, Shift+Enter for newline)"
|
className="rounded-[28px] border p-3 shadow-[var(--shadow-ms-lg)]"
|
||||||
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"
|
style={{
|
||||||
/>
|
backgroundColor: 'var(--color-surface-2)',
|
||||||
<button
|
borderColor: 'var(--color-border)',
|
||||||
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"
|
<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)]">
|
||||||
Send
|
<span className="uppercase tracking-[0.18em]">Model</span>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type { Conversation } from '@/lib/types';
|
|||||||
interface ConversationListProps {
|
interface ConversationListProps {
|
||||||
conversations: Conversation[];
|
conversations: Conversation[];
|
||||||
activeId: string | null;
|
activeId: string | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
onNew: () => void;
|
onNew: () => void;
|
||||||
onRename: (id: string, title: string) => void;
|
onRename: (id: string, title: string) => void;
|
||||||
@@ -20,7 +22,6 @@ interface ContextMenuState {
|
|||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format a date as relative time (e.g. "2h ago", "Yesterday"). */
|
|
||||||
function formatRelativeTime(dateStr: string): string {
|
function formatRelativeTime(dateStr: string): string {
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -40,6 +41,8 @@ function formatRelativeTime(dateStr: string): string {
|
|||||||
export function ConversationList({
|
export function ConversationList({
|
||||||
conversations,
|
conversations,
|
||||||
activeId,
|
activeId,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
onSelect,
|
onSelect,
|
||||||
onNew,
|
onNew,
|
||||||
onRename,
|
onRename,
|
||||||
@@ -54,24 +57,24 @@ export function ConversationList({
|
|||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const activeConversations = conversations.filter((c) => !c.archived);
|
const activeConversations = conversations.filter((conversation) => !conversation.archived);
|
||||||
const archivedConversations = conversations.filter((c) => c.archived);
|
const archivedConversations = conversations.filter((conversation) => conversation.archived);
|
||||||
|
|
||||||
const filteredActive = searchQuery
|
const filteredActive = searchQuery
|
||||||
? activeConversations.filter((c) =>
|
? activeConversations.filter((conversation) =>
|
||||||
(c.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
(conversation.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
)
|
)
|
||||||
: activeConversations;
|
: activeConversations;
|
||||||
|
|
||||||
const filteredArchived = searchQuery
|
const filteredArchived = searchQuery
|
||||||
? archivedConversations.filter((c) =>
|
? archivedConversations.filter((conversation) =>
|
||||||
(c.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
(conversation.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
)
|
)
|
||||||
: archivedConversations;
|
: archivedConversations;
|
||||||
|
|
||||||
const handleContextMenu = useCallback((e: React.MouseEvent, conversationId: string) => {
|
const handleContextMenu = useCallback((event: React.MouseEvent, conversationId: string) => {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
setContextMenu({ conversationId, x: e.clientX, y: e.clientY });
|
setContextMenu({ conversationId, x: event.clientX, y: event.clientY });
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -97,7 +100,7 @@ export function ConversationList({
|
|||||||
}
|
}
|
||||||
setRenamingId(null);
|
setRenamingId(null);
|
||||||
setRenameValue('');
|
setRenameValue('');
|
||||||
}, [renamingId, renameValue, onRename]);
|
}, [onRename, renameValue, renamingId]);
|
||||||
|
|
||||||
const cancelRename = useCallback(() => {
|
const cancelRename = useCallback(() => {
|
||||||
setRenamingId(null);
|
setRenamingId(null);
|
||||||
@@ -105,24 +108,20 @@ export function ConversationList({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRenameKeyDown = useCallback(
|
const handleRenameKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === 'Enter') commitRename();
|
if (event.key === 'Enter') commitRename();
|
||||||
if (e.key === 'Escape') cancelRename();
|
if (event.key === 'Escape') cancelRename();
|
||||||
},
|
},
|
||||||
[commitRename, cancelRename],
|
[cancelRename, commitRename],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteClick = useCallback((id: string) => {
|
|
||||||
setDeleteConfirmId(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const confirmDelete = useCallback(
|
const confirmDelete = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
onDelete(id);
|
onDelete(id);
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
closeContextMenu();
|
closeContextMenu();
|
||||||
},
|
},
|
||||||
[onDelete, closeContextMenu],
|
[closeContextMenu, onDelete],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleArchiveToggle = useCallback(
|
const handleArchiveToggle = useCallback(
|
||||||
@@ -130,47 +129,59 @@ export function ConversationList({
|
|||||||
onArchive(id, archived);
|
onArchive(id, archived);
|
||||||
closeContextMenu();
|
closeContextMenu();
|
||||||
},
|
},
|
||||||
[onArchive, closeContextMenu],
|
[closeContextMenu, onArchive],
|
||||||
);
|
);
|
||||||
|
|
||||||
const contextConv = contextMenu
|
const contextConversation = contextMenu
|
||||||
? conversations.find((c) => c.id === contextMenu.conversationId)
|
? conversations.find((conversation) => conversation.id === contextMenu.conversationId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
function renderConversationItem(conv: Conversation): React.ReactElement {
|
function renderConversationItem(conversation: Conversation): React.ReactElement {
|
||||||
const isActive = activeId === conv.id;
|
const isActive = activeId === conversation.id;
|
||||||
const isRenaming = renamingId === conv.id;
|
const isRenaming = renamingId === conversation.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={conv.id} className="group relative">
|
<div key={conversation.id} className="group relative">
|
||||||
{isRenaming ? (
|
{isRenaming ? (
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<input
|
<input
|
||||||
ref={renameInputRef}
|
ref={renameInputRef}
|
||||||
value={renameValue}
|
value={renameValue}
|
||||||
onChange={(e) => setRenameValue(e.target.value)}
|
onChange={(event) => setRenameValue(event.target.value)}
|
||||||
onBlur={commitRename}
|
onBlur={commitRename}
|
||||||
onKeyDown={handleRenameKeyDown}
|
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}
|
maxLength={255}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(conv.id)}
|
onClick={() => {
|
||||||
onDoubleClick={() => startRename(conv.id, conv.title)}
|
onSelect(conversation.id);
|
||||||
onContextMenu={(e) => handleContextMenu(e, conv.id)}
|
if (window.innerWidth < 768) onClose();
|
||||||
|
}}
|
||||||
|
onDoubleClick={() => startRename(conversation.id, conversation.title)}
|
||||||
|
onContextMenu={(event) => handleContextMenu(event, conversation.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full px-3 py-2 text-left text-sm transition-colors',
|
'w-full rounded-2xl px-3 py-2 text-left text-sm transition-colors',
|
||||||
isActive
|
isActive ? 'shadow-[var(--shadow-ms-sm)]' : 'hover:bg-white/5',
|
||||||
? 'bg-blue-600/20 text-blue-400'
|
|
||||||
: 'text-text-secondary hover:bg-surface-elevated',
|
|
||||||
)}
|
)}
|
||||||
|
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 truncate font-medium">{conversation.title ?? 'Untitled'}</span>
|
||||||
<span className="block text-xs text-text-muted">
|
<span className="block text-xs text-[var(--color-muted)]">
|
||||||
{formatRelativeTime(conv.updatedAt)}
|
{formatRelativeTime(conversation.updatedAt)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -180,127 +191,138 @@ export function ConversationList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop to close context menu */}
|
{isOpen ? (
|
||||||
{contextMenu && (
|
<button
|
||||||
<div className="fixed inset-0 z-10" onClick={closeContextMenu} aria-hidden="true" />
|
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">
|
{contextMenu ? (
|
||||||
{/* Header */}
|
<div className="fixed inset-0 z-10" onClick={closeContextMenu} aria-hidden="true" />
|
||||||
<div className="flex items-center justify-between p-3">
|
) : null}
|
||||||
<h2 className="text-sm font-medium text-text-secondary">Conversations</h2>
|
|
||||||
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onNew}
|
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
|
+ New
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search input */}
|
<div className="pb-3">
|
||||||
<div className="px-3 pb-2">
|
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
placeholder="Search conversations\u2026"
|
placeholder="Search conversations…"
|
||||||
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"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Conversation list */}
|
<div className="flex-1 overflow-y-auto space-y-1">
|
||||||
<div className="flex-1 overflow-y-auto">
|
{filteredActive.length === 0 && !searchQuery ? (
|
||||||
{filteredActive.length === 0 && !searchQuery && (
|
<p className="px-1 py-2 text-xs text-[var(--color-muted)]">No conversations yet</p>
|
||||||
<p className="px-3 py-2 text-xs text-text-muted">No conversations yet</p>
|
) : null}
|
||||||
)}
|
{filteredActive.length === 0 && searchQuery ? (
|
||||||
{filteredActive.length === 0 && searchQuery && (
|
<p className="px-1 py-2 text-xs text-[var(--color-muted)]">
|
||||||
<p className="px-3 py-2 text-xs text-text-muted">
|
No results for “{searchQuery}”
|
||||||
No results for “{searchQuery}”
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
) : null}
|
||||||
{filteredActive.map((conv) => renderConversationItem(conv))}
|
{filteredActive.map((conversation) => renderConversationItem(conversation))}
|
||||||
|
|
||||||
{/* Archived section */}
|
{archivedConversations.length > 0 ? (
|
||||||
{archivedConversations.length > 0 && (
|
<div className="pt-2">
|
||||||
<div className="mt-2">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowArchived((v) => !v)}
|
onClick={() => setShowArchived((prev) => !prev)}
|
||||||
className="flex w-full items-center gap-1 px-3 py-1 text-xs text-text-muted transition-colors hover:text-text-secondary"
|
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
|
<span
|
||||||
className={cn(
|
className={cn('inline-block transition-transform', showArchived && 'rotate-90')}
|
||||||
'inline-block transition-transform',
|
|
||||||
showArchived ? 'rotate-90' : '',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
►
|
▶
|
||||||
</span>
|
</span>
|
||||||
Archived ({archivedConversations.length})
|
Archived ({archivedConversations.length})
|
||||||
</button>
|
</button>
|
||||||
{showArchived && (
|
{showArchived ? (
|
||||||
<div className="opacity-60">
|
<div className="mt-1 space-y-1 opacity-70">
|
||||||
{filteredArchived.map((conv) => renderConversationItem(conv))}
|
{filteredArchived.map((conversation) => renderConversationItem(conversation))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Context menu */}
|
{contextMenu && contextConversation ? (
|
||||||
{contextMenu && contextConv && (
|
|
||||||
<div
|
<div
|
||||||
className="fixed z-20 min-w-36 rounded-md border border-surface-border bg-surface-card py-1 shadow-lg"
|
className="fixed z-30 min-w-40 rounded-2xl border py-1 shadow-[var(--shadow-ms-lg)]"
|
||||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
style={{
|
||||||
|
top: contextMenu.y,
|
||||||
|
left: contextMenu.x,
|
||||||
|
backgroundColor: 'var(--color-surface)',
|
||||||
|
borderColor: 'var(--color-border)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full px-3 py-1.5 text-left text-sm text-text-secondary hover:bg-surface-elevated"
|
className="w-full px-3 py-2 text-left text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
|
||||||
onClick={() => startRename(contextConv.id, contextConv.title)}
|
onClick={() => startRename(contextConversation.id, contextConversation.title)}
|
||||||
>
|
>
|
||||||
Rename
|
Rename
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full px-3 py-1.5 text-left text-sm text-text-secondary hover:bg-surface-elevated"
|
className="w-full px-3 py-2 text-left text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
|
||||||
onClick={() => handleArchiveToggle(contextConv.id, !contextConv.archived)}
|
onClick={() =>
|
||||||
|
handleArchiveToggle(contextConversation.id, !contextConversation.archived)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{contextConv.archived ? 'Unarchive' : 'Archive'}
|
{contextConversation.archived ? 'Restore' : 'Archive'}
|
||||||
</button>
|
</button>
|
||||||
<hr className="my-1 border-surface-border" />
|
{deleteConfirmId === contextConversation.id ? (
|
||||||
{deleteConfirmId === contextConv.id ? (
|
<button
|
||||||
<div className="px-3 py-1.5">
|
type="button"
|
||||||
<p className="mb-1.5 text-xs text-red-400">Delete this conversation?</p>
|
className="w-full px-3 py-2 text-left text-sm text-[var(--color-danger)] transition-colors hover:bg-white/5"
|
||||||
<div className="flex gap-2">
|
onClick={() => confirmDelete(contextConversation.id)}
|
||||||
<button
|
>
|
||||||
type="button"
|
Confirm delete
|
||||||
className="rounded bg-red-600 px-2 py-0.5 text-xs text-white hover:bg-red-700"
|
</button>
|
||||||
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>
|
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-surface-elevated"
|
className="w-full px-3 py-2 text-left text-sm text-[var(--color-danger)] transition-colors hover:bg-white/5"
|
||||||
onClick={() => handleDeleteClick(contextConv.id)}
|
onClick={() => setDeleteConfirmId(contextConversation.id)}
|
||||||
>
|
>
|
||||||
Delete
|
Delete…
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import type { Message } from '@/lib/types';
|
import type { Message } from '@/lib/types';
|
||||||
|
|
||||||
@@ -9,27 +11,261 @@ interface MessageBubbleProps {
|
|||||||
|
|
||||||
export function MessageBubble({ message }: MessageBubbleProps): React.ReactElement {
|
export function MessageBubble({ message }: MessageBubbleProps): React.ReactElement {
|
||||||
const isUser = message.role === 'user';
|
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 (
|
return (
|
||||||
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
|
<div className={cn('group flex', isUser ? 'justify-end' : 'justify-start')}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'max-w-[75%] rounded-xl px-4 py-3 text-sm',
|
'flex max-w-[min(78ch,85%)] flex-col gap-2',
|
||||||
isUser
|
isUser ? 'items-end' : 'items-start',
|
||||||
? 'bg-blue-600 text-white'
|
|
||||||
: 'border border-surface-border bg-surface-elevated text-text-primary',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<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
|
<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([], {
|
<div className="max-w-none">
|
||||||
hour: '2-digit',
|
<ReactMarkdown
|
||||||
minute: '2-digit',
|
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>
|
</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';
|
'use client';
|
||||||
|
|
||||||
/** Renders an in-progress assistant message from streaming text. */
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
interface StreamingMessageProps {
|
interface StreamingMessageProps {
|
||||||
text: string;
|
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 (
|
return (
|
||||||
<div className="flex justify-start">
|
<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 ? (
|
{text ? (
|
||||||
<div className="whitespace-pre-wrap break-words">{text}</div>
|
<div className="whitespace-pre-wrap break-words">{text}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-text-muted">
|
<div className="flex items-center gap-2 text-[var(--color-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-[var(--color-ms-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-[var(--color-ms-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]" />
|
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-[var(--color-ms-blue-500)] [animation-delay:0.4s]" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-1 flex items-center gap-1 text-xs text-text-muted">
|
{thinking ? (
|
||||||
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
|
<div
|
||||||
{text ? 'Responding...' : 'Thinking...'}
|
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>
|
</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';
|
'use client';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { useSession, signOut } from '@/lib/auth-client';
|
import { useSession, signOut } from '@/lib/auth-client';
|
||||||
|
|
||||||
export function Topbar(): React.ReactElement {
|
export function Topbar(): React.ReactElement {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
if (pathname.startsWith('/chat')) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSignOut(): Promise<void> {
|
async function handleSignOut(): Promise<void> {
|
||||||
await signOut();
|
await signOut();
|
||||||
|
|||||||
@@ -15,10 +15,41 @@ export interface Message {
|
|||||||
conversationId: string;
|
conversationId: string;
|
||||||
role: 'user' | 'assistant' | 'system';
|
role: 'user' | 'assistant' | 'system';
|
||||||
content: string;
|
content: string;
|
||||||
|
thinking?: string;
|
||||||
|
model?: string;
|
||||||
|
provider?: string;
|
||||||
|
promptTokens?: number;
|
||||||
|
completionTokens?: number;
|
||||||
|
totalTokens?: number;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
createdAt: string;
|
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. */
|
/** Task statuses. */
|
||||||
export type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
export type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
# BUG-CLI Scratchpad
|
# BUG-CLI Scratchpad
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
|
|
||||||
Fix 4 CLI/TUI polish bugs in a single PR (issues #192, #193, #194, #199).
|
Fix 4 CLI/TUI polish bugs in a single PR (issues #192, #193, #194, #199).
|
||||||
|
|
||||||
## Issues
|
## Issues
|
||||||
|
|
||||||
- #192: Ctrl+T leaks 't' into input
|
- #192: Ctrl+T leaks 't' into input
|
||||||
- #193: Duplicate React keys in CommandAutocomplete
|
- #193: Duplicate React keys in CommandAutocomplete
|
||||||
- #194: /provider login false clipboard claim
|
- #194: /provider login false clipboard claim
|
||||||
@@ -12,28 +14,33 @@ Fix 4 CLI/TUI polish bugs in a single PR (issues #192, #193, #194, #199).
|
|||||||
## Plan and Fixes
|
## Plan and Fixes
|
||||||
|
|
||||||
### Bug #192 — Ctrl+T character leak
|
### Bug #192 — Ctrl+T character leak
|
||||||
|
|
||||||
- Location: `packages/cli/src/tui/app.tsx`
|
- Location: `packages/cli/src/tui/app.tsx`
|
||||||
- Fix: Added `ctrlJustFired` ref. Set synchronously in Ctrl+T/L/N/K handlers, cleared via microtask.
|
- Fix: Added `ctrlJustFired` ref. Set synchronously in Ctrl+T/L/N/K handlers, cleared via microtask.
|
||||||
In the `onChange` wrapper passed to `InputBar`, if `ctrlJustFired.current` is true, suppress the
|
In the `onChange` wrapper passed to `InputBar`, if `ctrlJustFired.current` is true, suppress the
|
||||||
leaked character and return early.
|
leaked character and return early.
|
||||||
|
|
||||||
### Bug #193 — Duplicate React keys
|
### Bug #193 — Duplicate React keys
|
||||||
|
|
||||||
- Location: `packages/cli/src/tui/components/command-autocomplete.tsx`
|
- Location: `packages/cli/src/tui/components/command-autocomplete.tsx`
|
||||||
- Fix: Changed `key={cmd.name}` to `key={`${cmd.execution}-${cmd.name}`}` for uniqueness.
|
- Fix: Changed `key={cmd.name}` to `key={`${cmd.execution}-${cmd.name}`}` for uniqueness.
|
||||||
- Also: `packages/cli/src/tui/commands/registry.ts` — `getAll()` now deduplicates gateway commands
|
- Also: `packages/cli/src/tui/commands/registry.ts` — `getAll()` now deduplicates gateway commands
|
||||||
that share a name with local commands. Local commands take precedence.
|
that share a name with local commands. Local commands take precedence.
|
||||||
|
|
||||||
### Bug #194 — False clipboard claim
|
### Bug #194 — False clipboard claim
|
||||||
|
|
||||||
- Location: `apps/gateway/src/commands/command-executor.service.ts`
|
- Location: `apps/gateway/src/commands/command-executor.service.ts`
|
||||||
- Fix: Removed the `\n\n(URL copied to clipboard)` suffix from the provider login message.
|
- Fix: Removed the `\n\n(URL copied to clipboard)` suffix from the provider login message.
|
||||||
|
|
||||||
### Bug #199 — Hardcoded version "0.0.0"
|
### Bug #199 — Hardcoded version "0.0.0"
|
||||||
|
|
||||||
- Location: `packages/cli/src/cli.ts` + `packages/cli/src/tui/app.tsx`
|
- Location: `packages/cli/src/cli.ts` + `packages/cli/src/tui/app.tsx`
|
||||||
- Fix: `cli.ts` reads version from `../package.json` via `createRequire`. Passes `version: CLI_VERSION`
|
- Fix: `cli.ts` reads version from `../package.json` via `createRequire`. Passes `version: CLI_VERSION`
|
||||||
to TuiApp in both render calls. TuiApp has new optional `version` prop (defaults to '0.0.0'),
|
to TuiApp in both render calls. TuiApp has new optional `version` prop (defaults to '0.0.0'),
|
||||||
passes it to TopBar instead of hardcoded `"0.0.0"`.
|
passes it to TopBar instead of hardcoded `"0.0.0"`.
|
||||||
|
|
||||||
## Quality Gates
|
## Quality Gates
|
||||||
|
|
||||||
- CLI typecheck: PASSED
|
- CLI typecheck: PASSED
|
||||||
- CLI lint: PASSED
|
- CLI lint: PASSED
|
||||||
- Prettier format:check: PASSED
|
- Prettier format:check: PASSED
|
||||||
|
|||||||
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