feat(web): port chat UI — model selector, keybindings, thinking display, styled header
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful

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