Sidebar collapses to a horizontal top bar with "New" and "History" buttons plus active chat title. Persists to localStorage. Fixes layout nesting so TaskLane renders correctly when sidebar is collapsed. Also removes chat input separator line. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
223 lines
6.7 KiB
TypeScript
223 lines
6.7 KiB
TypeScript
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-xs 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
|
|
}) {
|
|
return (
|
|
<div
|
|
onClick={onSelect}
|
|
className={cn(
|
|
'group flex items-center gap-2 px-3 py-2.5 mx-1.5 rounded-lg cursor-pointer transition-colors',
|
|
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">
|
|
<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>
|
|
<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-white/[0.08]"
|
|
title={chat.pinned ? 'Unpin' : 'Pin'}
|
|
>
|
|
<Pin size={12} className={chat.pinned ? 'text-primary' : ''} />
|
|
</button>
|
|
<button
|
|
onClick={e => { e.stopPropagation(); onDelete() }}
|
|
className="p-1 rounded hover:bg-white/[0.08] text-muted-foreground hover:text-rose-400"
|
|
title="Delete"
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|