- .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>
250 lines
7.8 KiB
TypeScript
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>
|
|
)
|
|
}
|