feat: collapsible chat sidebar with top bar mode
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>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { Plus, Pin, Trash2, MessageSquare } from 'lucide-react'
|
import { Plus, Pin, Trash2, MessageSquare, History, X } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import type { ChatListItem } from '@/types/assistant-chat'
|
import type { ChatListItem } from '@/types/assistant-chat'
|
||||||
|
|
||||||
@@ -11,6 +11,8 @@ interface ChatSidebarProps {
|
|||||||
onTogglePin: (id: string, pinned: boolean) => void
|
onTogglePin: (id: string, pinned: boolean) => void
|
||||||
mobileOpen?: boolean
|
mobileOpen?: boolean
|
||||||
onMobileClose?: () => void
|
onMobileClose?: () => void
|
||||||
|
collapsed?: boolean
|
||||||
|
onToggleCollapse?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatSidebar({
|
export function ChatSidebar({
|
||||||
@@ -22,6 +24,8 @@ export function ChatSidebar({
|
|||||||
onTogglePin,
|
onTogglePin,
|
||||||
mobileOpen = false,
|
mobileOpen = false,
|
||||||
onMobileClose,
|
onMobileClose,
|
||||||
|
collapsed = false,
|
||||||
|
onToggleCollapse,
|
||||||
}: ChatSidebarProps) {
|
}: ChatSidebarProps) {
|
||||||
const pinnedChats = chats.filter(c => c.pinned)
|
const pinnedChats = chats.filter(c => c.pinned)
|
||||||
const unpinnedChats = chats.filter(c => !c.pinned)
|
const unpinnedChats = chats.filter(c => !c.pinned)
|
||||||
@@ -36,6 +40,11 @@ export function ChatSidebar({
|
|||||||
onMobileClose?.()
|
onMobileClose?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When collapsed on desktop, render nothing — parent renders the top bar
|
||||||
|
if (collapsed && !mobileOpen) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Mobile overlay */}
|
{/* Mobile overlay */}
|
||||||
@@ -52,14 +61,23 @@ export function ChatSidebar({
|
|||||||
style={{ background: 'var(--color-bg-sidebar)' }}
|
style={{ background: 'var(--color-bg-sidebar)' }}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-4 py-3 border-b shrink-0" style={{ borderColor: 'var(--color-border-default)' }}>
|
<div className="px-3 py-3 border-b shrink-0 flex items-center gap-2" style={{ borderColor: 'var(--color-border-default)' }}>
|
||||||
<button
|
<button
|
||||||
onClick={handleNewChat}
|
onClick={handleNewChat}
|
||||||
className="w-full 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"
|
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} />
|
<Plus size={16} />
|
||||||
New Chat
|
New Chat
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Chat list */}
|
{/* Chat list */}
|
||||||
@@ -108,6 +126,51 @@ export function ChatSidebar({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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({
|
function ChatItem({
|
||||||
chat,
|
chat,
|
||||||
isActive,
|
isActive,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { aiSessionsApi } from '@/api/aiSessions'
|
|||||||
import { useBranching } from '@/hooks/useBranching'
|
import { useBranching } from '@/hooks/useBranching'
|
||||||
import { analytics } from '@/lib/analytics'
|
import { analytics } from '@/lib/analytics'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import { ChatSidebar } from '@/components/assistant/ChatSidebar'
|
import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/ChatSidebar'
|
||||||
import { ChatMessage } from '@/components/assistant/ChatMessage'
|
import { ChatMessage } from '@/components/assistant/ChatMessage'
|
||||||
import { TaskLane } from '@/components/assistant/TaskLane'
|
import { TaskLane } from '@/components/assistant/TaskLane'
|
||||||
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
|
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
|
||||||
@@ -45,6 +45,14 @@ export default function AssistantChatPage() {
|
|||||||
const [activeQuestions, setActiveQuestions] = useState<QuestionItem[]>([])
|
const [activeQuestions, setActiveQuestions] = useState<QuestionItem[]>([])
|
||||||
const [activeActions, setActiveActions] = useState<ActionItem[]>([])
|
const [activeActions, setActiveActions] = useState<ActionItem[]>([])
|
||||||
const [showTaskLane, setShowTaskLane] = useState(false)
|
const [showTaskLane, setShowTaskLane] = useState(false)
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() =>
|
||||||
|
localStorage.getItem('rf-chat-sidebar-collapsed') === 'true'
|
||||||
|
)
|
||||||
|
const toggleSidebarCollapse = () => {
|
||||||
|
const next = !sidebarCollapsed
|
||||||
|
setSidebarCollapsed(next)
|
||||||
|
localStorage.setItem('rf-chat-sidebar-collapsed', String(next))
|
||||||
|
}
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -470,17 +478,20 @@ export default function AssistantChatPage() {
|
|||||||
<>
|
<>
|
||||||
<PageMeta title="AI Assistant" />
|
<PageMeta title="AI Assistant" />
|
||||||
<div className="flex h-[calc(100vh-3.5rem)]">
|
<div className="flex h-[calc(100vh-3.5rem)]">
|
||||||
{/* Sidebar — hidden on mobile, slide-out via toggle */}
|
{/* Sidebar — hidden on mobile, collapsed to top bar or full sidebar on desktop */}
|
||||||
<div className="hidden sm:block">
|
{!sidebarCollapsed && (
|
||||||
<ChatSidebar
|
<div className="hidden sm:block">
|
||||||
chats={chats}
|
<ChatSidebar
|
||||||
activeChatId={activeChatId}
|
chats={chats}
|
||||||
onSelectChat={selectChat}
|
activeChatId={activeChatId}
|
||||||
onNewChat={handleNewChat}
|
onSelectChat={selectChat}
|
||||||
onDeleteChat={handleDeleteChat}
|
onNewChat={handleNewChat}
|
||||||
onTogglePin={handleTogglePin}
|
onDeleteChat={handleDeleteChat}
|
||||||
/>
|
onTogglePin={handleTogglePin}
|
||||||
</div>
|
onToggleCollapse={toggleSidebarCollapse}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="sm:hidden">
|
<div className="sm:hidden">
|
||||||
<ChatSidebar
|
<ChatSidebar
|
||||||
chats={chats}
|
chats={chats}
|
||||||
@@ -495,7 +506,22 @@ export default function AssistantChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main chat area + optional branch sidebar */}
|
{/* Main chat area + optional branch sidebar */}
|
||||||
<div className="flex-1 flex min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
|
||||||
|
{/* Collapsed sidebar top bar — desktop only */}
|
||||||
|
{sidebarCollapsed && (
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<ChatSidebarCollapsedBar
|
||||||
|
chats={chats}
|
||||||
|
activeChatId={activeChatId}
|
||||||
|
onNewChat={handleNewChat}
|
||||||
|
onExpand={toggleSidebarCollapse}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chat content row: chat column + TaskLane side by side */}
|
||||||
|
<div className="flex-1 flex min-w-0 min-h-0">
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
{/* Mobile header with chat history toggle */}
|
{/* Mobile header with chat history toggle */}
|
||||||
<div className="sm:hidden flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
<div className="sm:hidden flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||||||
@@ -555,7 +581,7 @@ export default function AssistantChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rich Input */}
|
{/* Rich Input */}
|
||||||
<div className={cn("px-3 sm:px-6 py-3 shrink-0", !showTaskLane && "border-t border-border")}>
|
<div className="px-3 sm:px-6 py-3 shrink-0">
|
||||||
<div
|
<div
|
||||||
className="max-w-3xl mx-auto"
|
className="max-w-3xl mx-auto"
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
@@ -710,7 +736,8 @@ export default function AssistantChatPage() {
|
|||||||
{/* Branch map hidden — branching is now silent/background only.
|
{/* Branch map hidden — branching is now silent/background only.
|
||||||
Branches are tracked in the DB but not shown to the user.
|
Branches are tracked in the DB but not shown to the user.
|
||||||
The AI manages branch context internally. */}
|
The AI manages branch context internally. */}
|
||||||
</div>
|
</div>{/* close chat content row */}
|
||||||
|
</div>{/* close outer flex-col */}
|
||||||
|
|
||||||
{/* Conclude Session Modal */}
|
{/* Conclude Session Modal */}
|
||||||
<ConcludeSessionModal
|
<ConcludeSessionModal
|
||||||
|
|||||||
Reference in New Issue
Block a user