feat: merge file tree and chat history into single left sidebar with tabs
Made-with: Cursor
This commit is contained in:
parent
c5f392e1fd
commit
45db1bcf54
@ -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>
|
||||
);
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user