feat(web): port chat UI — model selector, keybindings, thinking display, styled header
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user