feat: merge file tree and chat history into single left sidebar with tabs

Made-with: Cursor
This commit is contained in:
Mark 2026-03-12 12:03:29 -07:00
parent c5f392e1fd
commit 45db1bcf54
3 changed files with 182 additions and 155 deletions

View File

@ -9,7 +9,7 @@ import {
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
type WebSession = {
export type WebSession = {
id: string;
title: string;
createdAt: number;
@ -55,6 +55,8 @@ type ChatSessionsSidebarProps = {
onCollapse?: () => void;
/** When true, show a loader instead of empty state (e.g. initial sessions fetch). */
loading?: boolean;
/** When true, renders just the content without the aside wrapper (for embedding in another sidebar). */
embedded?: boolean;
};
/** Format a timestamp into a human-readable relative time string. */
@ -164,6 +166,7 @@ export function ChatSessionsSidebar({
onClose,
width: widthProp,
loading = false,
embedded = false,
}: ChatSessionsSidebarProps) {
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
@ -229,24 +232,13 @@ export function ChatSessionsSidebar({
const grouped = groupSessions(filteredSessions);
const width = mobile ? "280px" : (widthProp ?? 260);
const headerHeight = 40; // px — match padding so list content clears the overlay
const sidebar = (
<aside
className={`flex flex-col h-full shrink-0 ${mobile ? "drawer-right" : "border-l"}`}
style={{
width: typeof width === "number" ? `${width}px` : width,
minWidth: typeof width === "number" ? `${width}px` : width,
borderColor: "var(--color-border)",
background: "var(--color-sidebar-bg)",
}}
>
{/* Scrollable list fills the sidebar; header overlays the top with blur */}
<div className="flex-1 min-h-0 relative">
{/* Session list — scrolls under the header */}
<div
className="absolute inset-0 overflow-y-auto"
style={{ paddingTop: headerHeight }}
>
const headerHeight = 40;
const content = (
<div className="flex-1 min-h-0 relative">
<div
className="absolute inset-0 overflow-y-auto"
style={{ paddingTop: headerHeight }}
>
{loading && sessions.length === 0 ? (
<div className="px-4 py-8 flex flex-col items-center justify-center min-h-[120px]">
<UnicodeSpinner
@ -504,6 +496,23 @@ export function ChatSessionsSidebar({
</button>
</div>
</div>
);
if (embedded) {
return content;
}
const sidebar = (
<aside
className={`flex flex-col h-full shrink-0 ${mobile ? "drawer-right" : "border-l"}`}
style={{
width: typeof width === "number" ? `${width}px` : width,
minWidth: typeof width === "number" ? `${width}px` : width,
borderColor: "var(--color-border)",
background: "var(--color-sidebar-bg)",
}}
>
{content}
</aside>
);

View File

@ -5,6 +5,7 @@ import { FileManagerTree, type TreeNode } from "./file-manager-tree";
import { ProfileSwitcher } from "./profile-switcher";
import { CreateWorkspaceDialog } from "./create-workspace-dialog";
import { UnicodeSpinner } from "../unicode-spinner";
import { ChatSessionsSidebar, type WebSession, type SidebarSubagentInfo } from "./chat-sessions-sidebar";
/** Shape returned by /api/workspace/suggest-files */
type SuggestItem = {
@ -52,6 +53,22 @@ type WorkspaceSidebarProps = {
activeWorkspace?: string | null;
/** Called after workspace switches or workspace creation so parent can refresh state. */
onWorkspaceChanged?: () => void;
/** Chat sessions for the Chats tab. */
chatSessions?: WebSession[];
activeChatSessionId?: string | null;
activeChatSessionTitle?: string;
chatStreamingSessionIds?: Set<string>;
chatSubagents?: SidebarSubagentInfo[];
chatActiveSubagentKey?: string | null;
chatSessionsLoading?: boolean;
onSelectChatSession?: (sessionId: string) => void;
onNewChatSession?: () => void;
onSelectChatSubagent?: (sessionKey: string) => void;
onDeleteChatSession?: (sessionId: string) => void;
onRenameChatSession?: (sessionId: string, newTitle: string) => void;
/** Which tab is active. Controlled from parent if provided. */
activeTab?: "files" | "chats";
onTabChange?: (tab: "files" | "chats") => void;
};
function HomeIcon() {
@ -412,10 +429,31 @@ export function WorkspaceSidebar({
onCollapse,
activeWorkspace,
onWorkspaceChanged,
chatSessions,
activeChatSessionId,
activeChatSessionTitle,
chatStreamingSessionIds,
chatSubagents,
chatActiveSubagentKey,
chatSessionsLoading,
onSelectChatSession,
onNewChatSession,
onSelectChatSubagent,
onDeleteChatSession,
onRenameChatSession,
activeTab: activeTabProp,
onTabChange,
}: WorkspaceSidebarProps) {
const isBrowsing = browseDir != null;
const width = mobile ? "280px" : (widthProp ?? 260);
const [createWorkspaceOpen, setCreateWorkspaceOpen] = useState(false);
const hasChatProps = chatSessions !== undefined;
const [internalTab, setInternalTab] = useState<"files" | "chats">(activeTabProp ?? "files");
const currentTab = activeTabProp ?? internalTab;
const setTab = useCallback((tab: "files" | "chats") => {
setInternalTab(tab);
onTabChange?.(tab);
}, [onTabChange]);
const sidebar = (
<aside
@ -555,35 +593,83 @@ export function WorkspaceSidebar({
)}
</div>
{/* File search */}
{onFileSearchSelect && (
<FileSearch onSelect={onFileSearchSelect} />
{/* Tab switcher */}
{hasChatProps && !isBrowsing && (
<div
className="flex px-3 pt-2 pb-1 gap-1"
>
<button
type="button"
onClick={() => setTab("files")}
className="flex-1 text-[11px] font-medium py-1.5 rounded-md transition-colors"
style={{
color: currentTab === "files" ? "var(--color-text)" : "var(--color-text-muted)",
background: currentTab === "files" ? "var(--color-surface-hover)" : "transparent",
}}
>
Files
</button>
<button
type="button"
onClick={() => setTab("chats")}
className="flex-1 text-[11px] font-medium py-1.5 rounded-md transition-colors"
style={{
color: currentTab === "chats" ? "var(--color-text)" : "var(--color-text-muted)",
background: currentTab === "chats" ? "var(--color-surface-hover)" : "transparent",
}}
>
Chats
</button>
</div>
)}
{/* Tree */}
<div className="flex-1 overflow-y-auto px-1">
{loading ? (
<div className="flex items-center justify-center py-12">
<UnicodeSpinner
name="braille"
className="text-2xl"
style={{ color: "var(--color-text-muted)" }}
/>
{/* Tab content */}
{currentTab === "files" || !hasChatProps ? (
<>
{onFileSearchSelect && (
<FileSearch onSelect={onFileSearchSelect} />
)}
<div className="flex-1 overflow-y-auto px-1">
{loading ? (
<div className="flex items-center justify-center py-12">
<UnicodeSpinner
name="braille"
className="text-2xl"
style={{ color: "var(--color-text-muted)" }}
/>
</div>
) : (
<FileManagerTree
tree={tree}
activePath={activePath}
onSelect={onSelect}
onRefresh={onRefresh}
parentDir={parentDir}
onNavigateUp={onNavigateUp}
browseDir={browseDir}
workspaceRoot={workspaceRoot}
onExternalDrop={onExternalDrop}
/>
)}
</div>
) : (
<FileManagerTree
tree={tree}
activePath={activePath}
onSelect={onSelect}
onRefresh={onRefresh}
parentDir={parentDir}
onNavigateUp={onNavigateUp}
browseDir={browseDir}
workspaceRoot={workspaceRoot}
onExternalDrop={onExternalDrop}
/>
)}
</div>
</>
) : (
<ChatSessionsSidebar
sessions={chatSessions ?? []}
activeSessionId={activeChatSessionId ?? null}
activeSessionTitle={activeChatSessionTitle}
streamingSessionIds={chatStreamingSessionIds}
subagents={chatSubagents}
activeSubagentKey={chatActiveSubagentKey}
loading={chatSessionsLoading}
onSelectSession={onSelectChatSession ?? (() => {})}
onNewSession={onNewChatSession ?? (() => {})}
onSelectSubagent={onSelectChatSubagent}
onDeleteSession={onDeleteChatSession}
onRenameSession={onRenameChatSession}
embedded
/>
)}
{/* Footer */}
<div

View File

@ -23,7 +23,6 @@ import { MediaViewer, detectMediaType, type MediaType } from "../components/work
import { DatabaseViewer, DuckDBMissing } from "../components/workspace/database-viewer";
import { RichDocumentEditor, isDocxFile, isTxtFile, textToHtml } from "../components/workspace/rich-document-editor";
import { Breadcrumbs } from "../components/workspace/breadcrumbs";
import { ChatSessionsSidebar } from "../components/workspace/chat-sessions-sidebar";
import { EmptyState } from "../components/workspace/empty-state";
import { ReportViewer } from "../components/charts/report-viewer";
import { ChatPanel, type ChatPanelHandle, type SubagentSpawnInfo } from "../components/chat-panel";
@ -499,8 +498,6 @@ function WorkspacePageInner() {
// Mobile responsive state
const isMobile = useIsMobile();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [chatSessionsOpen, setChatSessionsOpen] = useState(false);
// Sidebar collapse state (desktop only).
const [leftSidebarCollapsed, setLeftSidebarCollapsed] = useState(false);
const [rightSidebarCollapsed, setRightSidebarCollapsed] = useState(false);
@ -1810,6 +1807,28 @@ function WorkspacePageInner() {
onToggleHidden={() => setShowHidden((v) => !v)}
activeWorkspace={workspaceName}
onWorkspaceChanged={handleWorkspaceChanged}
chatSessions={sessions}
activeChatSessionId={activeSessionId}
activeChatSessionTitle={activeSessionTitle}
chatStreamingSessionIds={streamingSessionIds}
chatSubagents={subagents}
chatActiveSubagentKey={activeSubagentKey}
chatSessionsLoading={sessionsLoading}
onSelectChatSession={(sessionId) => {
setActiveSessionId(sessionId);
setActiveSubagentKey(null);
void chatRef.current?.loadSession(sessionId);
setSidebarOpen(false);
}}
onNewChatSession={() => {
setActiveSessionId(null);
setActiveSubagentKey(null);
void chatRef.current?.newSession();
setSidebarOpen(false);
}}
onSelectChatSubagent={handleSelectSubagent}
onDeleteChatSession={handleDeleteSession}
onRenameChatSession={handleRenameSession}
mobile
onClose={() => setSidebarOpen(false)}
/>
@ -1849,6 +1868,26 @@ function WorkspacePageInner() {
onCollapse={() => setLeftSidebarCollapsed(true)}
activeWorkspace={workspaceName}
onWorkspaceChanged={handleWorkspaceChanged}
chatSessions={sessions}
activeChatSessionId={activeSessionId}
activeChatSessionTitle={activeSessionTitle}
chatStreamingSessionIds={streamingSessionIds}
chatSubagents={subagents}
chatActiveSubagentKey={activeSubagentKey}
chatSessionsLoading={sessionsLoading}
onSelectChatSession={(sessionId) => {
setActiveSessionId(sessionId);
setActiveSubagentKey(null);
void chatRef.current?.loadSession(sessionId);
}}
onNewChatSession={() => {
setActiveSessionId(null);
setActiveSubagentKey(null);
void chatRef.current?.newSession();
}}
onSelectChatSubagent={handleSelectSubagent}
onDeleteChatSession={handleDeleteSession}
onRenameChatSession={handleRenameSession}
/>
</div>
)}
@ -1912,19 +1951,6 @@ function WorkspacePageInner() {
</svg>
</button>
)}
{showMainChat && (
<button
type="button"
onClick={() => setChatSessionsOpen(true)}
className="p-2 rounded-lg flex-shrink-0"
style={{ color: "var(--color-text-muted)" }}
title="Chat sessions"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</button>
)}
</div>
</div>
)}
@ -2019,100 +2045,6 @@ function WorkspacePageInner() {
onBack={activeSubagent ? handleBackFromSubagent : undefined}
/>
</div>
{/* Chat sessions sidebar — static on desktop, drawer overlay on mobile */}
{isMobile ? (
chatSessionsOpen && (
<ChatSessionsSidebar
sessions={sessions}
activeSessionId={activeSessionId}
activeSessionTitle={activeSessionTitle}
streamingSessionIds={streamingSessionIds}
subagents={subagents}
activeSubagentKey={activeSubagentKey}
loading={sessionsLoading}
onSelectSession={(sessionId) => {
setActiveSessionId(sessionId);
setActiveSubagentKey(null);
void chatRef.current?.loadSession(sessionId);
}}
onNewSession={() => {
setActiveSessionId(null);
setActiveSubagentKey(null);
void chatRef.current?.newSession();
setChatSessionsOpen(false);
}}
onSelectSubagent={handleSelectSubagent}
onDeleteSession={handleDeleteSession}
onRenameSession={handleRenameSession}
mobile
onClose={() => setChatSessionsOpen(false)}
/>
)
) : (
<>
{!rightSidebarCollapsed && (
<div
className="flex shrink-0 flex-col relative"
style={{ width: rightSidebarWidth, minWidth: rightSidebarWidth, background: "var(--color-sidebar-bg)" }}
>
<ResizeHandle
mode="right"
containerRef={layoutRef}
min={RIGHT_SIDEBAR_MIN}
max={RIGHT_SIDEBAR_MAX}
onResize={setRightSidebarWidth}
/>
{chatSidebarPreview ? (
<ChatSidebarPreview
preview={chatSidebarPreview}
onClose={() => setChatSidebarPreview(null)}
/>
) : (
<ChatSessionsSidebar
sessions={sessions}
activeSessionId={activeSessionId}
activeSessionTitle={activeSessionTitle}
streamingSessionIds={streamingSessionIds}
subagents={subagents}
activeSubagentKey={activeSubagentKey}
loading={sessionsLoading}
onSelectSession={(sessionId) => {
setActiveSessionId(sessionId);
setActiveSubagentKey(null);
void chatRef.current?.loadSession(sessionId);
}}
onNewSession={() => {
setActiveSessionId(null);
setActiveSubagentKey(null);
void chatRef.current?.newSession();
}}
onSelectSubagent={handleSelectSubagent}
onDeleteSession={handleDeleteSession}
onRenameSession={handleRenameSession}
onCollapse={() => setRightSidebarCollapsed(true)}
width={rightSidebarWidth}
/>
)}
</div>
)}
{rightSidebarCollapsed && (
<div className="shrink-0 flex flex-col items-center pt-2.5 px-1.5">
<button
type="button"
onClick={() => setRightSidebarCollapsed(false)}
className="p-1.5 rounded-md transition-colors hover:bg-black/5"
style={{ color: "var(--color-text-muted)" }}
title="Show chat sidebar (⌘⇧B)"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M15 3v18" />
</svg>
</button>
</div>
)}
</>
)}
</>
) : (
<>