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