Files
resolutionflow/frontend/src/components/assistant/ChatSidebar.tsx
Michael Chihlas f45b045943 refactor: resolve merge conflicts — combine main improvements with token normalization
- .gitignore: keep both graphify-out/ entries and main's .gitnexus entry
- ScriptCodeBlock/ScriptPreviewModal: take main's border-border and text-accent-text
  for filename labels; use neutral ghost style for Save button in ScriptCodeBlock;
  use bg-accent (normalized from bg-primary) for Save button in ScriptPreviewModal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:23:36 -04:00

250 lines
7.8 KiB
TypeScript

import { useState } from 'react'
import { Plus, Pin, Trash2, MessageSquare, History, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { ChatListItem } from '@/types/assistant-chat'
interface ChatSidebarProps {
chats: ChatListItem[]
activeChatId: string | null
onSelectChat: (id: string) => void
onNewChat: () => void
onDeleteChat: (id: string) => void
onTogglePin: (id: string, pinned: boolean) => void
mobileOpen?: boolean
onMobileClose?: () => void
collapsed?: boolean
onToggleCollapse?: () => void
}
export function ChatSidebar({
chats,
activeChatId,
onSelectChat,
onNewChat,
onDeleteChat,
onTogglePin,
mobileOpen = false,
onMobileClose,
collapsed = false,
onToggleCollapse,
}: ChatSidebarProps) {
const pinnedChats = chats.filter(c => c.pinned)
const unpinnedChats = chats.filter(c => !c.pinned)
const handleSelectChat = (id: string) => {
onSelectChat(id)
onMobileClose?.()
}
const handleNewChat = () => {
onNewChat()
onMobileClose?.()
}
// When collapsed on desktop, render nothing — parent renders the top bar
if (collapsed && !mobileOpen) {
return null
}
return (
<>
{/* Mobile overlay */}
{mobileOpen && (
<div className="fixed inset-0 z-40 bg-black/50 sm:hidden" onClick={onMobileClose} />
)}
<div
className={cn(
'w-72 shrink-0 flex flex-col border-r h-full',
'fixed inset-y-0 left-0 z-50 sm:static sm:z-auto',
'transition-transform duration-200',
mobileOpen ? 'translate-x-0' : '-translate-x-full sm:translate-x-0',
)}
style={{ background: 'var(--color-bg-sidebar)' }}
>
{/* Header */}
<div className="px-3 py-3 border-b shrink-0 flex items-center gap-2" style={{ borderColor: 'var(--color-border-default)' }}>
<button
onClick={handleNewChat}
className="flex-1 flex items-center justify-center gap-2 bg-primary text-white font-semibold text-sm rounded-lg px-4 py-2.5 hover:brightness-110 active:scale-[0.98] transition-all"
>
<Plus size={16} />
New Chat
</button>
{onToggleCollapse && (
<button
onClick={onToggleCollapse}
className="hidden sm:flex p-1.5 rounded-lg text-muted-foreground hover:text-heading hover:bg-elevated transition-colors"
title="Collapse to top bar"
>
<X size={16} />
</button>
)}
</div>
{/* Chat list */}
<div className="flex-1 overflow-y-auto py-2">
{pinnedChats.length > 0 && (
<div className="px-3 mb-1">
<span className="font-sans text-[0.625rem] uppercase tracking-widest text-muted-foreground">
Pinned
</span>
</div>
)}
{pinnedChats.map(chat => (
<ChatItem
key={chat.id}
chat={chat}
isActive={chat.id === activeChatId}
onSelect={() => handleSelectChat(chat.id)}
onDelete={() => onDeleteChat(chat.id)}
onTogglePin={() => onTogglePin(chat.id, !chat.pinned)}
/>
))}
{pinnedChats.length > 0 && unpinnedChats.length > 0 && (
<div className="mx-3 my-2 border-b" style={{ borderColor: 'var(--color-border-default)' }} />
)}
{unpinnedChats.map(chat => (
<ChatItem
key={chat.id}
chat={chat}
isActive={chat.id === activeChatId}
onSelect={() => handleSelectChat(chat.id)}
onDelete={() => onDeleteChat(chat.id)}
onTogglePin={() => onTogglePin(chat.id, !chat.pinned)}
/>
))}
{chats.length === 0 && (
<div className="px-4 py-8 text-center text-muted-foreground text-sm">
No conversations yet
</div>
)}
</div>
</div>
</>
)
}
/** Collapsed top bar — rendered by the parent page above the chat area */
export function ChatSidebarCollapsedBar({
chats,
activeChatId,
onNewChat,
onExpand,
}: {
chats: ChatListItem[]
activeChatId: string | null
onNewChat: () => void
onExpand: () => void
}) {
return (
<div
className="flex items-center gap-2 px-3 py-2 border-b shrink-0"
style={{ background: 'var(--color-bg-sidebar)', borderColor: 'var(--color-border-default)' }}
>
<button
onClick={onNewChat}
className="flex items-center gap-1.5 bg-primary text-white font-semibold text-xs rounded-md px-3 py-1.5 hover:brightness-110 active:scale-[0.98] transition-all"
>
<Plus size={14} />
New
</button>
<button
onClick={onExpand}
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:text-heading hover:bg-elevated transition-colors"
title="Show chat history"
>
<History size={14} />
<span>History</span>
{chats.length > 0 && (
<span className="text-[10px] bg-elevated rounded-full px-1.5 py-0.5 font-medium">{chats.length}</span>
)}
</button>
<div className="flex-1" />
{activeChatId && (
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{chats.find(c => c.id === activeChatId)?.title}
</span>
)}
</div>
)
}
function ChatItem({
chat,
isActive,
onSelect,
onDelete,
onTogglePin,
}: {
chat: ChatListItem
isActive: boolean
onSelect: () => void
onDelete: () => void
onTogglePin: () => void
}) {
const [confirming, setConfirming] = useState(false)
return (
<div
onClick={confirming ? e => e.stopPropagation() : onSelect}
className={cn(
'group flex items-center gap-2 px-3 py-2.5 mx-1.5 rounded-lg cursor-pointer transition-colors',
confirming
? 'bg-danger-dim border border-danger/20'
: isActive
? 'bg-accent-dim text-foreground'
: 'text-muted-foreground hover:bg-input hover:text-foreground'
)}
>
<MessageSquare size={14} className="shrink-0" />
<div className="flex-1 min-w-0">
{confirming ? (
<div className="flex items-center gap-2">
<span className="text-[0.75rem] text-danger font-medium">Delete?</span>
<button
onClick={e => { e.stopPropagation(); onDelete(); setConfirming(false) }}
className="text-[0.6875rem] font-medium text-danger hover:text-danger px-1.5 py-0.5 rounded bg-danger/15 hover:bg-danger/25 transition-colors"
>
Yes
</button>
<button
onClick={e => { e.stopPropagation(); setConfirming(false) }}
className="text-[0.6875rem] text-muted-foreground hover:text-foreground px-1.5 py-0.5"
>
No
</button>
</div>
) : (
<>
<div className="text-[0.8125rem] font-medium truncate">{chat.title}</div>
<div className="text-[0.6875rem] text-muted-foreground">
{chat.message_count} messages
</div>
</>
)}
</div>
{!confirming && (
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={e => { e.stopPropagation(); onTogglePin() }}
className="p-1 rounded hover:bg-elevated"
title={chat.pinned ? 'Unpin' : 'Pin'}
>
<Pin size={12} className={chat.pinned ? 'text-primary' : ''} />
</button>
<button
onClick={e => { e.stopPropagation(); setConfirming(true) }}
className="p-1 rounded hover:bg-elevated text-muted-foreground hover:text-danger"
title="Delete"
>
<Trash2 size={12} />
</button>
</div>
)}
</div>
)
}