Resizable sidebar

This commit is contained in:
Mark 2026-02-19 15:42:23 -08:00
parent 68f09660e9
commit 027593b350
4 changed files with 290 additions and 122 deletions

View File

@ -38,6 +38,8 @@ type ChatSessionsSidebarProps = {
mobile?: boolean;
/** Close the mobile drawer. */
onClose?: () => void;
/** Fixed width in px when not mobile (overrides default 260). */
width?: number;
};
/** Format a timestamp into a human-readable relative time string. */
@ -123,6 +125,7 @@ export function ChatSessionsSidebar({
onSelectSubagent,
mobile,
onClose,
width: widthProp,
}: ChatSessionsSidebarProps) {
const [hoveredId, setHoveredId] = useState<string | null>(null);
@ -160,11 +163,12 @@ export function ChatSessionsSidebar({
// Group sessions: today, yesterday, this week, this month, older
const grouped = groupSessions(sessions);
const width = mobile ? "280px" : (widthProp ?? 260);
const sidebar = (
<aside
className={`flex flex-col h-full flex-shrink-0 ${mobile ? "drawer-right" : "border-l"}`}
style={{
width: mobile ? "280px" : 260,
width: typeof width === "number" ? `${width}px` : width,
borderColor: "var(--color-border)",
background: "var(--color-surface)",
}}

View File

@ -10,12 +10,21 @@ export type ProfileInfo = {
hasConfig: boolean;
};
export type ProfileSwitcherTriggerProps = {
isOpen: boolean;
onClick: () => void;
activeProfile: string;
switching: boolean;
};
type ProfileSwitcherProps = {
onProfileSwitch?: () => void;
onCreateWorkspace?: () => void;
/** When set, this renders instead of the default button; dropdown still opens below. */
trigger?: (props: ProfileSwitcherTriggerProps) => React.ReactNode;
};
export function ProfileSwitcher({ onProfileSwitch, onCreateWorkspace }: ProfileSwitcherProps) {
export function ProfileSwitcher({ onProfileSwitch, onCreateWorkspace, trigger }: ProfileSwitcherProps) {
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
const [activeProfile, setActiveProfile] = useState("default");
const [isOpen, setIsOpen] = useState(false);
@ -78,35 +87,51 @@ export function ProfileSwitcher({ onProfileSwitch, onCreateWorkspace }: ProfileS
// Don't show the switcher if there's only one profile and no way to create more
const showSwitcher = profiles.length > 0;
if (!showSwitcher) {return null;}
const handleToggle = () => {
if (showSwitcher) { setIsOpen((o) => !o); }
};
if (!trigger && !showSwitcher) { return null; }
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
disabled={switching}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
style={{ color: "var(--color-text-secondary)" }}
title="Switch workspace profile"
>
{/* Workspace icon */}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
<span className="truncate max-w-[120px]">
{activeProfile === "default" ? "Default" : activeProfile}
</span>
<svg
className={`w-3 h-3 transition-transform ${isOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<div
className={`relative ${trigger ? "flex-1 min-w-0" : ""}`}
ref={dropdownRef}
>
{trigger ? (
trigger({
isOpen,
onClick: handleToggle,
activeProfile,
switching,
})
) : (
<button
onClick={handleToggle}
disabled={switching}
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
style={{ color: "var(--color-text-secondary)" }}
title="Switch workspace profile"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Workspace icon */}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
<span className="truncate max-w-[120px]">
{activeProfile === "default" ? "Default" : activeProfile}
</span>
<svg
className={`w-3 h-3 transition-transform ${isOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
)}
{isOpen && (
{showSwitcher && isOpen && (
<div
className="absolute left-0 top-full mt-1 w-64 rounded-lg overflow-hidden z-50"
style={{

View File

@ -41,6 +41,8 @@ type WorkspaceSidebarProps = {
onClose?: () => void;
/** Active workspace profile name (null = default). */
activeProfile?: string | null;
/** Fixed width in px when not mobile (overrides default 260). */
width?: number;
/** Called after the user switches to a different profile. */
onProfileSwitch?: () => void;
};
@ -401,15 +403,17 @@ export function WorkspaceSidebar({
onClose,
activeProfile,
onProfileSwitch,
width: widthProp,
}: WorkspaceSidebarProps) {
const isBrowsing = browseDir != null;
const [showCreateWorkspace, setShowCreateWorkspace] = useState(false);
const width = mobile ? "280px" : (widthProp ?? 260);
const sidebar = (
<aside
className={`flex flex-col h-screen flex-shrink-0 ${mobile ? "drawer-left" : "border-r"}`}
style={{
width: mobile ? "280px" : "260px",
width: typeof width === "number" ? `${width}px` : width,
background: "var(--color-surface)",
borderColor: "var(--color-border)",
}}
@ -466,7 +470,7 @@ export function WorkspaceSidebar({
<button
type="button"
onClick={() => void onGoToChat?.()}
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 cursor-pointer transition-opacity"
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 cursor-pointer transition-opacity"
style={{
background: "var(--color-accent-light)",
color: "var(--color-accent)",
@ -480,50 +484,59 @@ export function WorkspaceSidebar({
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
</button>
<div className="flex-1 min-w-0">
<div
className="text-sm font-medium truncate"
style={{ color: "var(--color-text)" }}
>
{orgName || "Workspace"}
</div>
<div
className="text-[11px] flex items-center gap-1"
style={{
color: "var(--color-text-muted)",
}}
>
<span>Ironclaw</span>
{activeProfile && activeProfile !== "default" && (
<span
className="px-1 py-0.5 rounded text-[10px]"
style={{
background: "var(--color-accent-light)",
color: "var(--color-accent)",
}}
<ProfileSwitcher
onProfileSwitch={onProfileSwitch}
onCreateWorkspace={() => setShowCreateWorkspace(true)}
trigger={({ isOpen, onClick, activeProfile: profileName, switching }) => (
<button
type="button"
onClick={onClick}
disabled={switching}
className="flex-1 min-w-0 w-full flex items-center justify-between gap-2 text-left rounded-lg py-1.5 px-2 transition-colors hover:bg-(--color-surface-hover) disabled:opacity-50"
style={{ color: "var(--color-text)" }}
title="Switch workspace profile"
>
<div className="min-w-0 truncate">
<div
className="text-sm font-medium truncate"
style={{ color: "var(--color-text)" }}
>
{orgName || "Workspace"}
</div>
<div
className="text-[11px] flex items-center gap-1 truncate"
style={{ color: "var(--color-text-muted)" }}
>
<span>Ironclaw</span>
{profileName && profileName !== "default" && (
<span
className="px-1 py-0.5 rounded text-[10px] shrink-0"
style={{
background: "var(--color-accent-light)",
color: "var(--color-accent)",
}}
>
{profileName}
</span>
)}
</div>
</div>
<svg
className={`w-3.5 h-3.5 shrink-0 transition-transform ${isOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
style={{ color: "var(--color-text-muted)" }}
>
{activeProfile}
</span>
)}
</div>
</div>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
)}
/>
</>
)}
</div>
{/* Profile switcher — only in workspace mode */}
{!isBrowsing && (
<div
className="px-3 py-1.5 border-b"
style={{ borderColor: "var(--color-border)" }}
>
<ProfileSwitcher
onProfileSwitch={onProfileSwitch}
onCreateWorkspace={() => setShowCreateWorkspace(true)}
/>
</div>
)}
{/* Create workspace dialog */}
<CreateWorkspaceDialog
isOpen={showCreateWorkspace}

View File

@ -142,6 +142,72 @@ function rawFileUrl(path: string): string {
return `/api/workspace/raw-file?path=${encodeURIComponent(path)}`;
}
const LEFT_SIDEBAR_MIN = 200;
const LEFT_SIDEBAR_MAX = 480;
const RIGHT_SIDEBAR_MIN = 260;
const RIGHT_SIDEBAR_MAX = 600;
const STORAGE_LEFT = "ironclaw-workspace-left-sidebar-width";
const STORAGE_RIGHT = "ironclaw-workspace-right-sidebar-width";
function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n));
}
/** Vertical resize handle; uses cursor position so the handle follows the mouse (no stuck-at-limit). */
function ResizeHandle({
mode,
containerRef,
min,
max,
onResize,
}: {
mode: "left" | "right";
containerRef: React.RefObject<HTMLElement | null>;
min: number;
max: number;
onResize: (width: number) => void;
}) {
const [isDragging, setIsDragging] = useState(false);
const onMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
setIsDragging(true);
const move = (ev: MouseEvent) => {
const el = containerRef.current;
if (!el) {return;}
const rect = el.getBoundingClientRect();
const width =
mode === "left"
? ev.clientX - rect.left
: rect.right - ev.clientX;
onResize(clamp(width, min, max));
};
const up = () => {
setIsDragging(false);
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
document.body.style.removeProperty("user-select");
document.body.style.removeProperty("cursor");
};
document.body.style.setProperty("user-select", "none");
document.body.style.setProperty("cursor", "col-resize");
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", up);
},
[containerRef, mode, min, max, onResize],
);
const showHover = isDragging || undefined;
return (
<div
role="separator"
aria-orientation="vertical"
onMouseDown={onMouseDown}
className={`shrink-0 w-1 cursor-col-resize flex justify-center transition-colors ${showHover ? "bg-blue-600/30" : "hover:bg-blue-600/30"}`}
style={{ minWidth: 4 }}
/>
);
}
/** Find a node in the tree by exact path. */
function findNode(
tree: TreeNode[],
@ -229,6 +295,8 @@ function WorkspacePageInner() {
const chatRef = useRef<ChatPanelHandle>(null);
// Compact (file-scoped) chat panel ref for sidebar drag-and-drop
const compactChatRef = useRef<ChatPanelHandle>(null);
// Root layout ref for resize handle position (handle follows cursor)
const layoutRef = useRef<HTMLDivElement>(null);
// Live-reactive tree via SSE watcher (with browse-mode support)
const {
@ -308,6 +376,26 @@ function WorkspacePageInner() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [chatSessionsOpen, setChatSessionsOpen] = useState(false);
// Resizable sidebar widths (desktop only; persisted in localStorage)
const [leftSidebarWidth, setLeftSidebarWidth] = useState(() => {
if (typeof window === "undefined") {return 260;}
const v = window.localStorage.getItem(STORAGE_LEFT);
const n = v ? parseInt(v, 10) : NaN;
return Number.isFinite(n) ? clamp(n, LEFT_SIDEBAR_MIN, LEFT_SIDEBAR_MAX) : 260;
});
const [rightSidebarWidth, setRightSidebarWidth] = useState(() => {
if (typeof window === "undefined") {return 320;}
const v = window.localStorage.getItem(STORAGE_RIGHT);
const n = v ? parseInt(v, 10) : NaN;
return Number.isFinite(n) ? clamp(n, RIGHT_SIDEBAR_MIN, RIGHT_SIDEBAR_MAX) : 320;
});
useEffect(() => {
window.localStorage.setItem(STORAGE_LEFT, String(leftSidebarWidth));
}, [leftSidebarWidth]);
useEffect(() => {
window.localStorage.setItem(STORAGE_RIGHT, String(rightSidebarWidth));
}, [rightSidebarWidth]);
// Derive file context for chat sidebar directly from activePath (stable across loading).
// Exclude reserved virtual paths (~chats, ~cron, etc.) where file-scoped chat is irrelevant.
const fileContext = useMemo(() => {
@ -919,8 +1007,13 @@ function WorkspacePageInner() {
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div className="flex h-screen" style={{ background: "var(--color-bg)" }} onClick={handleContainerClick}>
{/* Sidebar — static on desktop, drawer overlay on mobile */}
<div
ref={layoutRef}
className="flex h-screen"
style={{ background: "var(--color-bg)" }}
onClick={handleContainerClick}
>
{/* Left sidebar — static on desktop (resizable), drawer overlay on mobile */}
{isMobile ? (
sidebarOpen && (
<WorkspaceSidebar
@ -945,24 +1038,36 @@ function WorkspacePageInner() {
/>
)
) : (
<WorkspaceSidebar
tree={enhancedTree}
activePath={activePath}
onSelect={handleNodeSelect}
onRefresh={refreshTree}
orgName={context?.organization?.name}
loading={treeLoading}
browseDir={browseDir}
parentDir={effectiveParentDir}
onNavigateUp={handleNavigateUp}
onGoHome={handleGoHome}
onFileSearchSelect={handleFileSearchSelect}
workspaceRoot={workspaceRoot}
onGoToChat={handleGoToChat}
onExternalDrop={handleSidebarExternalDrop}
activeProfile={activeProfile}
onProfileSwitch={handleProfileSwitch}
/>
<>
<div className="flex shrink-0 flex-col" style={{ width: leftSidebarWidth }}>
<WorkspaceSidebar
tree={enhancedTree}
activePath={activePath}
onSelect={handleNodeSelect}
onRefresh={refreshTree}
orgName={context?.organization?.name}
loading={treeLoading}
browseDir={browseDir}
parentDir={effectiveParentDir}
onNavigateUp={handleNavigateUp}
onGoHome={handleGoHome}
onFileSearchSelect={handleFileSearchSelect}
workspaceRoot={workspaceRoot}
onGoToChat={handleGoToChat}
onExternalDrop={handleSidebarExternalDrop}
activeProfile={activeProfile}
onProfileSwitch={handleProfileSwitch}
width={leftSidebarWidth}
/>
</div>
<ResizeHandle
mode="left"
containerRef={layoutRef}
min={LEFT_SIDEBAR_MIN}
max={LEFT_SIDEBAR_MAX}
onResize={setLeftSidebarWidth}
/>
</>
)}
{/* Main content */}
@ -1127,26 +1232,38 @@ function WorkspacePageInner() {
/>
)
) : (
<ChatSessionsSidebar
sessions={sessions}
activeSessionId={activeSessionId}
activeSessionTitle={activeSessionTitle}
streamingSessionIds={streamingSessionIds}
subagents={subagents}
activeSubagentKey={activeSubagentKey}
onSelectSession={(sessionId) => {
setActiveSessionId(sessionId);
setActiveSubagentKey(null);
void chatRef.current?.loadSession(sessionId);
}}
onNewSession={() => {
setActiveSessionId(null);
setActiveSubagentKey(null);
void chatRef.current?.newSession();
router.replace("/workspace", { scroll: false });
}}
onSelectSubagent={handleSelectSubagent}
/>
<>
<ResizeHandle
mode="right"
containerRef={layoutRef}
min={RIGHT_SIDEBAR_MIN}
max={RIGHT_SIDEBAR_MAX}
onResize={setRightSidebarWidth}
/>
<div className="flex shrink-0 flex-col" style={{ width: rightSidebarWidth }}>
<ChatSessionsSidebar
sessions={sessions}
activeSessionId={activeSessionId}
activeSessionTitle={activeSessionTitle}
streamingSessionIds={streamingSessionIds}
subagents={subagents}
activeSubagentKey={activeSubagentKey}
onSelectSession={(sessionId) => {
setActiveSessionId(sessionId);
setActiveSubagentKey(null);
void chatRef.current?.loadSession(sessionId);
}}
onNewSession={() => {
setActiveSessionId(null);
setActiveSubagentKey(null);
void chatRef.current?.newSession();
router.replace("/workspace", { scroll: false });
}}
onSelectSubagent={handleSelectSubagent}
width={rightSidebarWidth}
/>
</div>
</>
)}
</>
) : (
@ -1176,21 +1293,30 @@ function WorkspacePageInner() {
{/* Chat sidebar (file/folder-scoped) — hidden for reserved paths, hidden on mobile */}
{!isMobile && fileContext && showChatSidebar && (
<aside
className="flex-shrink-0 border-l"
style={{
width: 380,
borderColor: "var(--color-border)",
background: "var(--color-bg)",
}}
>
<ChatPanel
ref={compactChatRef}
compact
fileContext={fileContext}
onFileChanged={handleFileChanged}
<>
<ResizeHandle
mode="right"
containerRef={layoutRef}
min={RIGHT_SIDEBAR_MIN}
max={RIGHT_SIDEBAR_MAX}
onResize={setRightSidebarWidth}
/>
</aside>
<aside
className="flex-shrink-0 border-l flex flex-col"
style={{
width: rightSidebarWidth,
borderColor: "var(--color-border)",
background: "var(--color-bg)",
}}
>
<ChatPanel
ref={compactChatRef}
compact
fileContext={fileContext}
onFileChanged={handleFileChanged}
/>
</aside>
</>
)}
</>
)}