From e027305dff6d4f77ad7d34230ff9b246b779c4c9 Mon Sep 17 00:00:00 2001 From: coder-hhx Date: Mon, 1 Jun 2026 20:23:43 +0800 Subject: [PATCH] refactor(sidebar): rewrite collapsible sections with CSS Grid pixel tracks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace flex + timer-based layout with CSS Grid (grid-template-rows) using pixel tracks for smooth, symmetric collapse/expand animations - Remove isRecentCollapseLayoutSettled timer hack, shouldExpandProjectSection flags, and flexBasis inline style approach - Use ResizeObserver to measure container/header/handle/content heights and derive pixel tracks in a single useMemo - Collapse/expand now animates grid-template-rows (px ↔ px) at 300ms ease-out with opacity + translateY on content for visible fade effect - Resize drag range is dynamic: min = workspace content height (hug), max = even split with recent section - Clicking the resize handle no longer causes a layout jump - Both gui (Tauri) and webui (gateway) are updated consistently --- .../components/chat/ChatHistorySidebar.tsx | 523 +++++++-------- .../components/chat/ChatHistorySidebar.tsx | 605 +++++++++--------- 2 files changed, 572 insertions(+), 556 deletions(-) diff --git a/crates/agent-gateway/web/src/components/chat/ChatHistorySidebar.tsx b/crates/agent-gateway/web/src/components/chat/ChatHistorySidebar.tsx index 0de88184..465cf6e7 100644 --- a/crates/agent-gateway/web/src/components/chat/ChatHistorySidebar.tsx +++ b/crates/agent-gateway/web/src/components/chat/ChatHistorySidebar.tsx @@ -112,17 +112,12 @@ const PROJECT_HEADER_BUTTON_CLASS = "transition-colors hover:!bg-foreground/[0.06] hover:text-foreground active:!bg-foreground/[0.1] focus-visible:!bg-foreground/[0.08] focus-visible:ring-2 focus-visible:ring-ring"; const PROJECT_ICON_BUTTON_CLASS = "h-7 w-7 rounded-lg text-muted-foreground transition-colors hover:!bg-foreground/[0.08] hover:text-foreground active:!bg-foreground/[0.1] focus-visible:!bg-foreground/[0.08] data-[state=open]:!bg-foreground/[0.08] data-[state=open]:text-foreground"; -const SIDEBAR_SECTION_TRANSITION_MS = 200; -const SIDEBAR_SECTION_TRANSITION_CLASS = - "overflow-hidden transition-[flex-grow,flex-basis,opacity,border-color] duration-200 ease-out motion-reduce:transition-none"; -const SIDEBAR_COLLAPSIBLE_PANEL_CLASS = - "grid min-h-0 overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out motion-reduce:transition-none"; -const SIDEBAR_COLLAPSIBLE_CONTENT_CLASS = - "min-h-0 transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none"; +const SIDEBAR_SECTION_ROWS_TRANSITION_CLASS = + "transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none"; +const SIDEBAR_SECTION_BODY_CLASS = + "min-h-0 overflow-hidden transition-opacity duration-300 ease-out motion-reduce:transition-none"; const SIDEBAR_SECTION_CHEVRON_CLASS = - "h-3.5 w-3.5 shrink-0 transition-transform duration-200 ease-out motion-reduce:transition-none"; -const SIDEBAR_PROJECTS_RESIZE_MIN_HEIGHT = 92; -const SIDEBAR_RECENT_RESIZE_MIN_HEIGHT = 156; + "h-3.5 w-3.5 shrink-0 transition-transform duration-300 ease-out motion-reduce:transition-none"; const EMPTY_PROJECT_PATH_KEYS = new Set(); const EMPTY_PROJECT_ACTIVITY_UPDATED_ATS = new Map(); const HISTORY_LOADING_SKELETON_ROWS = [ @@ -1079,14 +1074,25 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi const [openMenuId, setOpenMenuId] = useState(null); const [isMobileMenuLayout, setIsMobileMenuLayout] = useState(isMobileSidebarLayout); const [projectSectionHeight, setProjectSectionHeight] = useState(null); - const [sidebarSectionsContainerHeight, setSidebarSectionsContainerHeight] = useState(0); const [isProjectSectionResizing, setIsProjectSectionResizing] = useState(false); - const [isRecentCollapseLayoutSettled, setIsRecentCollapseLayoutSettled] = - useState(recentCollapsed); + const [sidebarSectionMetrics, setSidebarSectionMetrics] = useState({ + containerHeight: 0, + projectsHeaderHeight: 0, + recentHeaderHeight: 0, + handleHeight: 0, + projectsContentHeight: 0, + }); const sidebarSectionsRef = useRef(null); - const projectsSectionRef = useRef(null); + const projectsHeaderRef = useRef(null); + const recentHeaderRef = useRef(null); + const sectionResizeHandleRef = useRef(null); + const projectsBodyRef = useRef(null); + const sidebarSectionLayoutRef = useRef({ + projectsBodyHeight: 0, + resizeMinHeight: 0, + resizeMaxHeight: 0, + }); const projectSectionResizeFrameRef = useRef(null); - const pendingProjectSectionHeightRef = useRef(null); const projectSectionResizeCleanupRef = useRef<(() => void) | null>(null); const handleSelectConversation = useStableEvent(onSelectConversation); const handleStartRenaming = useStableEvent(onStartRenaming); @@ -1135,34 +1141,66 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi }); }, [projectActivityUpdatedAts, projects, runningProjectPathKeys]); const canResizeProjectSections = showProjects && !projectsCollapsed && !recentCollapsed; - const maxProjectSectionHeight = - sidebarSectionsContainerHeight > 0 - ? Math.max( - SIDEBAR_PROJECTS_RESIZE_MIN_HEIGHT, - sidebarSectionsContainerHeight - SIDEBAR_RECENT_RESIZE_MIN_HEIGHT, - ) - : SIDEBAR_PROJECTS_RESIZE_MIN_HEIGHT; - const effectiveProjectSectionHeight = - projectSectionHeight === null - ? null - : clampSidebarSectionHeight( + const sidebarSectionLayout = useMemo(() => { + const { + containerHeight, + projectsHeaderHeight, + recentHeaderHeight, + handleHeight, + projectsContentHeight, + } = sidebarSectionMetrics; + const measured = containerHeight > 0; + const available = Math.max( + 0, + containerHeight - projectsHeaderHeight - recentHeaderHeight - handleHeight, + ); + const resizeMaxHeight = Math.floor(available / 2); + const resizeMinHeight = Math.max(0, Math.min(projectsContentHeight, resizeMaxHeight)); + + let projectsBodyHeight = 0; + if (showProjects && !projectsCollapsed) { + if (recentCollapsed) { + projectsBodyHeight = available; + } else if (projectSectionHeight !== null) { + projectsBodyHeight = clampSidebarSectionHeight( projectSectionHeight, - SIDEBAR_PROJECTS_RESIZE_MIN_HEIGHT, - maxProjectSectionHeight, + resizeMinHeight, + resizeMaxHeight, ); - const activeProjectSectionHeight = isProjectSectionResizing - ? pendingProjectSectionHeightRef.current ?? effectiveProjectSectionHeight - : effectiveProjectSectionHeight; - const hasCustomProjectSectionHeight = - canResizeProjectSections && activeProjectSectionHeight !== null; - const shouldExpandProjectSectionForCollapsedRecent = - showProjects && !projectsCollapsed && recentCollapsed && isRecentCollapseLayoutSettled; - const shouldUseProjectSectionAvailableHeight = hasCustomProjectSectionHeight; - const shouldStretchProjectSection = - shouldUseProjectSectionAvailableHeight || shouldExpandProjectSectionForCollapsedRecent; - const projectsSectionStyle = hasCustomProjectSectionHeight - ? { flexBasis: `${activeProjectSectionHeight}px` } - : undefined; + } else { + projectsBodyHeight = resizeMinHeight; + } + } + const recentBodyHeight = recentCollapsed ? 0 : Math.max(0, available - projectsBodyHeight); + + const projectsBodyTrack = + !showProjects || projectsCollapsed + ? "0px" + : measured + ? `${projectsBodyHeight}px` + : "min-content"; + const recentBodyTrack = recentCollapsed + ? "0px" + : measured + ? `${recentBodyHeight}px` + : "minmax(0, 1fr)"; + const gridTemplateRows = showProjects + ? `auto ${projectsBodyTrack} auto auto ${recentBodyTrack}` + : `auto ${recentBodyTrack}`; + + return { projectsBodyHeight, resizeMinHeight, resizeMaxHeight, gridTemplateRows }; + }, [ + projectSectionHeight, + projectsCollapsed, + recentCollapsed, + showProjects, + sidebarSectionMetrics, + ]); + sidebarSectionLayoutRef.current = { + projectsBodyHeight: sidebarSectionLayout.projectsBodyHeight, + resizeMinHeight: sidebarSectionLayout.resizeMinHeight, + resizeMaxHeight: sidebarSectionLayout.resizeMaxHeight, + }; const handleMenuOpenChange = useCallback((id: string, open: boolean) => { setOpenMenuId((current) => { if (open) { @@ -1192,17 +1230,13 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi }, [isOpen]); useEffect(() => { - if (!recentCollapsed) { - setIsRecentCollapseLayoutSettled(false); + if (!pendingProjectRemoveId) { return; } - - const timeoutId = window.setTimeout(() => { - setIsRecentCollapseLayoutSettled(true); - }, SIDEBAR_SECTION_TRANSITION_MS); - - return () => window.clearTimeout(timeoutId); - }, [recentCollapsed]); + if (!projects.some((project) => project.id === pendingProjectRemoveId)) { + setPendingProjectRemoveId(null); + } + }, [pendingProjectRemoveId, projects]); useEffect(() => { if (pendingDeleteId !== null || renamingId !== null) { @@ -1255,41 +1289,68 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi } }, [pendingProjectRemoveId, projects]); + // biome-ignore lint/correctness/useExhaustiveDependencies: re-run to (re)observe section refs when sections mount/unmount or toggle useEffect(() => { - if (!isOpen || !showProjects) { - setSidebarSectionsContainerHeight(0); + if (!isOpen) { return; } - const container = sidebarSectionsRef.current; - if (!container) { + if (!container || typeof ResizeObserver === "undefined") { return; } let frameId = 0; - const updateHeight = () => { + const measure = () => { frameId = 0; - setSidebarSectionsContainerHeight(container.clientHeight); + setSidebarSectionMetrics((previous) => { + const next = { + containerHeight: container.clientHeight, + projectsHeaderHeight: projectsHeaderRef.current?.offsetHeight ?? 0, + recentHeaderHeight: recentHeaderRef.current?.offsetHeight ?? 0, + handleHeight: sectionResizeHandleRef.current?.offsetHeight ?? 0, + projectsContentHeight: projectsBodyRef.current?.offsetHeight ?? 0, + }; + if ( + previous.containerHeight === next.containerHeight && + previous.projectsHeaderHeight === next.projectsHeaderHeight && + previous.recentHeaderHeight === next.recentHeaderHeight && + previous.handleHeight === next.handleHeight && + previous.projectsContentHeight === next.projectsContentHeight + ) { + return previous; + } + return next; + }); }; - const scheduleUpdate = () => { + const scheduleMeasure = () => { if (frameId !== 0) { return; } - frameId = window.requestAnimationFrame(updateHeight); + frameId = window.requestAnimationFrame(measure); }; - scheduleUpdate(); - window.addEventListener("resize", scheduleUpdate); - const resizeObserver = - typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleUpdate); - resizeObserver?.observe(container); + scheduleMeasure(); + window.addEventListener("resize", scheduleMeasure); + const resizeObserver = new ResizeObserver(scheduleMeasure); + resizeObserver.observe(container); + const observedTargets = [ + projectsHeaderRef.current, + recentHeaderRef.current, + sectionResizeHandleRef.current, + projectsBodyRef.current, + ]; + for (const target of observedTargets) { + if (target) { + resizeObserver.observe(target); + } + } return () => { - window.removeEventListener("resize", scheduleUpdate); + window.removeEventListener("resize", scheduleMeasure); if (frameId !== 0) { window.cancelAnimationFrame(frameId); } - resizeObserver?.disconnect(); + resizeObserver.disconnect(); }; }, [isOpen, projectsCollapsed, recentCollapsed, showProjects]); @@ -1308,46 +1369,33 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi return; } - const projectsSection = projectsSectionRef.current; - const sidebarSections = sidebarSectionsRef.current; - if (!projectsSection || !sidebarSections) { - return; - } - event.preventDefault(); projectSectionResizeCleanupRef.current?.(); const pointerId = event.pointerId; const resizeTarget = event.currentTarget; const startY = event.clientY; - const containerHeight = sidebarSections.clientHeight; + const layout = sidebarSectionLayoutRef.current; const startHeight = clampSidebarSectionHeight( - projectsSection.getBoundingClientRect().height, - SIDEBAR_PROJECTS_RESIZE_MIN_HEIGHT, - Math.max( - SIDEBAR_PROJECTS_RESIZE_MIN_HEIGHT, - containerHeight - SIDEBAR_RECENT_RESIZE_MIN_HEIGHT, - ), + layout.projectsBodyHeight, + layout.resizeMinHeight, + layout.resizeMaxHeight, ); const previousCursor = document.body.style.cursor; const previousUserSelect = document.body.style.userSelect; - pendingProjectSectionHeightRef.current = startHeight; setIsProjectSectionResizing(true); document.body.style.cursor = "row-resize"; document.body.style.userSelect = "none"; - projectsSection.style.flexBasis = `${startHeight}px`; resizeTarget.setPointerCapture(pointerId); const scheduleProjectSectionHeight = (nextHeight: number) => { - pendingProjectSectionHeightRef.current = nextHeight; if (projectSectionResizeFrameRef.current !== null) { return; } projectSectionResizeFrameRef.current = window.requestAnimationFrame(() => { projectSectionResizeFrameRef.current = null; - const draftHeight = pendingProjectSectionHeightRef.current ?? startHeight; - projectsSection.style.flexBasis = `${draftHeight}px`; + setProjectSectionHeight(nextHeight); }); }; @@ -1370,11 +1418,7 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi window.cancelAnimationFrame(projectSectionResizeFrameRef.current); projectSectionResizeFrameRef.current = null; } - const finalHeight = pendingProjectSectionHeightRef.current ?? startHeight; - projectsSection.style.flexBasis = `${finalHeight}px`; - setProjectSectionHeight(finalHeight); setIsProjectSectionResizing(false); - pendingProjectSectionHeightRef.current = null; }; const handleMove = (moveEvent: globalThis.PointerEvent) => { @@ -1382,16 +1426,12 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi return; } moveEvent.preventDefault(); - const nextContainerHeight = sidebarSectionsRef.current?.clientHeight ?? containerHeight; - const nextMaxHeight = Math.max( - SIDEBAR_PROJECTS_RESIZE_MIN_HEIGHT, - nextContainerHeight - SIDEBAR_RECENT_RESIZE_MIN_HEIGHT, - ); + const liveLayout = sidebarSectionLayoutRef.current; scheduleProjectSectionHeight( clampSidebarSectionHeight( startHeight + moveEvent.clientY - startY, - SIDEBAR_PROJECTS_RESIZE_MIN_HEIGHT, - nextMaxHeight, + liveLayout.resizeMinHeight, + liveLayout.resizeMaxHeight, ), ); }; @@ -1565,20 +1605,20 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi -
+
{showProjects ? ( -
-
+ <> +
+ + ) : null} - {canResizeProjectSections ? ( +
- ) : null} +
+
+ {Math.max(totalItems, items.length)} +
+ {canShareConversations ? ( + + ) : null} +
+
-
- -
-
- {Math.max(totalItems, items.length)} -
- {canShareConversations ? ( - - ) : null}
-
- + ) : null}
-
- {errorMessage ? ( -
- + {isLoading ? ( + + ) : items.length === 0 ? ( +
+
+
- ) : null} -
- {isLoading ? ( - - ) : items.length === 0 ? ( -
-
- +

+ {t("chat.emptyChatHistory")} +

+

+ {t("chat.clickNewConversation")} +

+
+ ) : ( +
+ {virtualHistoryRows.map((virtualRow) => { + const item = items[virtualRow.index]; + if (!item) return null; + + return ( +
+ {renderHistoryRow(item)}
-

- {t("chat.emptyChatHistory")} -

-

- {t("chat.clickNewConversation")} -

-
- ) : ( -
- {virtualHistoryRows.map((virtualRow) => { - const item = items[virtualRow.index]; - if (!item) return null; - - return ( -
- {renderHistoryRow(item)} -
- ); - })} -
- )} - {!isLoading && items.length > 0 && (hasMore || isLoadingMore) ? ( -
- {isLoadingMore - ? t("sidebar.loadingMoreHistory") - : t("sidebar.continueLoadingHistory")} -
- ) : null} + ); + })}
-
+ )} + {!isLoading && items.length > 0 && (hasMore || isLoadingMore) ? ( +
+ {isLoadingMore + ? t("sidebar.loadingMoreHistory") + : t("sidebar.continueLoadingHistory")} +
+ ) : null}
diff --git a/crates/agent-gui/src/components/chat/ChatHistorySidebar.tsx b/crates/agent-gui/src/components/chat/ChatHistorySidebar.tsx index 24619b05..3a49f5f8 100644 --- a/crates/agent-gui/src/components/chat/ChatHistorySidebar.tsx +++ b/crates/agent-gui/src/components/chat/ChatHistorySidebar.tsx @@ -102,17 +102,12 @@ const PROJECT_HEADER_BUTTON_CLASS = "transition-colors hover:!bg-foreground/[0.06] hover:text-foreground active:!bg-foreground/[0.1] focus-visible:!bg-foreground/[0.08] focus-visible:ring-2 focus-visible:ring-ring"; const PROJECT_ICON_BUTTON_CLASS = "h-7 w-7 rounded-lg text-muted-foreground transition-colors hover:!bg-foreground/[0.08] hover:text-foreground active:!bg-foreground/[0.1] focus-visible:!bg-foreground/[0.08] data-[state=open]:!bg-foreground/[0.08] data-[state=open]:text-foreground"; -const SIDEBAR_SECTION_TRANSITION_MS = 200; -const SIDEBAR_SECTION_TRANSITION_CLASS = - "overflow-hidden transition-[flex-grow,flex-basis,opacity,border-color] duration-200 ease-out motion-reduce:transition-none"; -const SIDEBAR_COLLAPSIBLE_PANEL_CLASS = - "grid min-h-0 overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out motion-reduce:transition-none"; -const SIDEBAR_COLLAPSIBLE_CONTENT_CLASS = - "min-h-0 transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none"; +const SIDEBAR_SECTION_ROWS_TRANSITION_CLASS = + "transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none"; +const SIDEBAR_SECTION_BODY_CLASS = + "min-h-0 overflow-hidden transition-opacity duration-300 ease-out motion-reduce:transition-none"; const SIDEBAR_SECTION_CHEVRON_CLASS = - "h-3.5 w-3.5 shrink-0 transition-transform duration-200 ease-out motion-reduce:transition-none"; -const SIDEBAR_PROJECTS_RESIZE_MIN_HEIGHT = 92; -const SIDEBAR_RECENT_RESIZE_MIN_HEIGHT = 156; + "h-3.5 w-3.5 shrink-0 transition-transform duration-300 ease-out motion-reduce:transition-none"; const EMPTY_PROJECT_PATH_KEYS = new Set(); const EMPTY_PROJECT_ACTIVITY_UPDATED_ATS = new Map(); const HISTORY_LOADING_SKELETON_ROWS = [ @@ -156,8 +151,8 @@ const HistoryRow = memo(function HistoryRow(props: { onDeleteConversation: (id: string) => void; onSetPendingDelete: (id: string | null) => void; }) { - const { - item, + const { + item, isActive, isBusy, isRunning, @@ -173,12 +168,12 @@ const HistoryRow = memo(function HistoryRow(props: { onCancelRename, onSetPinned, onShareConversation, - onDeleteConversation, - onSetPendingDelete, - } = props; - const { t } = useLocale(); + onDeleteConversation, + onSetPendingDelete, + } = props; + const { t } = useLocale(); - const inputRef = useRef(null); + const inputRef = useRef(null); const [menuOpen, setMenuOpen] = useState(false); const handleSelect = useCallback(() => { @@ -216,15 +211,15 @@ const HistoryRow = memo(function HistoryRow(props: { inputRef.current?.select(); }, [isRenaming]); - if (isPendingDelete) { - return ( -
-

- {t("chat.conversationDeleteConfirm").replace("{title}", item.title)} -

-

- {t("chat.conversationDeleteWarning")} -

+ if (isPendingDelete) { + return ( +
+

+ {t("chat.conversationDeleteConfirm").replace("{title}", item.title)} +

+

+ {t("chat.conversationDeleteWarning")} +

@@ -302,8 +297,8 @@ const HistoryRow = memo(function HistoryRow(props: { @@ -313,8 +308,8 @@ const HistoryRow = memo(function HistoryRow(props: { @@ -324,8 +319,8 @@ const HistoryRow = memo(function HistoryRow(props: { @@ -339,8 +334,8 @@ const HistoryRow = memo(function HistoryRow(props: { type="button" variant="ghost" size="icon" - title={t("chat.conversationMore")} - aria-label={t("chat.conversationMore")} + title={t("chat.conversationMore")} + aria-label={t("chat.conversationMore")} onPointerDown={(e: React.PointerEvent) => e.stopPropagation() } @@ -371,18 +366,18 @@ const HistoryRow = memo(function HistoryRow(props: { ) : ( )} - {item.isPinned ? t("chat.conversationUnpin") : t("chat.conversationPin")} + {item.isPinned ? t("chat.conversationUnpin") : t("chat.conversationPin")} ) : null} {canShareConversation && !item.isPending ? ( - {t("chat.conversationShare")} + {t("chat.conversationShare")} ) : null} - {t("chat.conversationRename")} + {t("chat.conversationRename")} - {t("chat.conversationDelete")} + {t("chat.conversationDelete")} @@ -726,10 +721,7 @@ const ProjectRow = memo(function ProjectRow(props: { ) : null} {onBrowseProjectInSystemFileManager ? ( - + {t("chat.workspaceBrowseInSystemFileManager")} @@ -862,14 +854,25 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi const [pendingDeleteId, setPendingDeleteId] = useState(null); const [pendingProjectRemoveId, setPendingProjectRemoveId] = useState(null); const [projectSectionHeight, setProjectSectionHeight] = useState(null); - const [sidebarSectionsContainerHeight, setSidebarSectionsContainerHeight] = useState(0); const [isProjectSectionResizing, setIsProjectSectionResizing] = useState(false); - const [isRecentCollapseLayoutSettled, setIsRecentCollapseLayoutSettled] = - useState(recentCollapsed); + const [sidebarSectionMetrics, setSidebarSectionMetrics] = useState({ + containerHeight: 0, + projectsHeaderHeight: 0, + recentHeaderHeight: 0, + handleHeight: 0, + projectsContentHeight: 0, + }); const sidebarSectionsRef = useRef(null); - const projectsSectionRef = useRef(null); + const projectsHeaderRef = useRef(null); + const recentHeaderRef = useRef(null); + const sectionResizeHandleRef = useRef(null); + const projectsBodyRef = useRef(null); + const sidebarSectionLayoutRef = useRef({ + projectsBodyHeight: 0, + resizeMinHeight: 0, + resizeMaxHeight: 0, + }); const projectSectionResizeFrameRef = useRef(null); - const pendingProjectSectionHeightRef = useRef(null); const projectSectionResizeCleanupRef = useRef<(() => void) | null>(null); const handleSelectConversation = useStableEvent(onSelectConversation); const handleStartRenaming = useStableEvent(onStartRenaming); @@ -921,34 +924,68 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi }); }, [projectActivityUpdatedAts, projects, runningProjectPathKeys]); const canResizeProjectSections = showProjects && !projectsCollapsed && !recentCollapsed; - const maxProjectSectionHeight = - sidebarSectionsContainerHeight > 0 - ? Math.max( - SIDEBAR_PROJECTS_RESIZE_MIN_HEIGHT, - sidebarSectionsContainerHeight - SIDEBAR_RECENT_RESIZE_MIN_HEIGHT, - ) - : SIDEBAR_PROJECTS_RESIZE_MIN_HEIGHT; - const effectiveProjectSectionHeight = - projectSectionHeight === null - ? null - : clampSidebarSectionHeight( + const sidebarSectionLayout = useMemo(() => { + const { + containerHeight, + projectsHeaderHeight, + recentHeaderHeight, + handleHeight, + projectsContentHeight, + } = sidebarSectionMetrics; + const measured = containerHeight > 0; + const available = Math.max( + 0, + containerHeight - projectsHeaderHeight - recentHeaderHeight - handleHeight, + ); + // Upper bound: workspace and recent split the available space evenly. + const resizeMaxHeight = Math.floor(available / 2); + // Lower bound: hug the workspace content (but never beyond the even split). + const resizeMinHeight = Math.max(0, Math.min(projectsContentHeight, resizeMaxHeight)); + + let projectsBodyHeight = 0; + if (showProjects && !projectsCollapsed) { + if (recentCollapsed) { + projectsBodyHeight = available; + } else if (projectSectionHeight !== null) { + projectsBodyHeight = clampSidebarSectionHeight( projectSectionHeight, - SIDEBAR_PROJECTS_RESIZE_MIN_HEIGHT, - maxProjectSectionHeight, + resizeMinHeight, + resizeMaxHeight, ); - const activeProjectSectionHeight = isProjectSectionResizing - ? pendingProjectSectionHeightRef.current ?? effectiveProjectSectionHeight - : effectiveProjectSectionHeight; - const hasCustomProjectSectionHeight = - canResizeProjectSections && activeProjectSectionHeight !== null; - const shouldExpandProjectSectionForCollapsedRecent = - showProjects && !projectsCollapsed && recentCollapsed && isRecentCollapseLayoutSettled; - const shouldUseProjectSectionAvailableHeight = hasCustomProjectSectionHeight; - const shouldStretchProjectSection = - shouldUseProjectSectionAvailableHeight || shouldExpandProjectSectionForCollapsedRecent; - const projectsSectionStyle = hasCustomProjectSectionHeight - ? { flexBasis: `${activeProjectSectionHeight}px` } - : undefined; + } else { + projectsBodyHeight = resizeMinHeight; + } + } + const recentBodyHeight = recentCollapsed ? 0 : Math.max(0, available - projectsBodyHeight); + + const projectsBodyTrack = + !showProjects || projectsCollapsed + ? "0px" + : measured + ? `${projectsBodyHeight}px` + : "min-content"; + const recentBodyTrack = recentCollapsed + ? "0px" + : measured + ? `${recentBodyHeight}px` + : "minmax(0, 1fr)"; + const gridTemplateRows = showProjects + ? `auto ${projectsBodyTrack} auto auto ${recentBodyTrack}` + : `auto ${recentBodyTrack}`; + + return { projectsBodyHeight, resizeMinHeight, resizeMaxHeight, gridTemplateRows }; + }, [ + projectSectionHeight, + projectsCollapsed, + recentCollapsed, + showProjects, + sidebarSectionMetrics, + ]); + sidebarSectionLayoutRef.current = { + projectsBodyHeight: sidebarSectionLayout.projectsBodyHeight, + resizeMinHeight: sidebarSectionLayout.resizeMinHeight, + resizeMaxHeight: sidebarSectionLayout.resizeMaxHeight, + }; const historyScrollRef = useRef(null); const getHistoryItemKey = useCallback((index: number) => items[index]?.id ?? index, [items]); const historyVirtualizer = useVirtualizer({ @@ -984,19 +1021,6 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi recentCollapsed, ]); - useEffect(() => { - if (!recentCollapsed) { - setIsRecentCollapseLayoutSettled(false); - return; - } - - const timeoutId = window.setTimeout(() => { - setIsRecentCollapseLayoutSettled(true); - }, SIDEBAR_SECTION_TRANSITION_MS); - - return () => window.clearTimeout(timeoutId); - }, [recentCollapsed]); - useEffect(() => { if (!pendingProjectRemoveId) { return; @@ -1006,41 +1030,68 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi } }, [pendingProjectRemoveId, projects]); + // biome-ignore lint/correctness/useExhaustiveDependencies: re-run to (re)observe section refs when sections mount/unmount or toggle useEffect(() => { - if (!isOpen || !showProjects) { - setSidebarSectionsContainerHeight(0); + if (!isOpen) { return; } - const container = sidebarSectionsRef.current; - if (!container) { + if (!container || typeof ResizeObserver === "undefined") { return; } let frameId = 0; - const updateHeight = () => { + const measure = () => { frameId = 0; - setSidebarSectionsContainerHeight(container.clientHeight); + setSidebarSectionMetrics((previous) => { + const next = { + containerHeight: container.clientHeight, + projectsHeaderHeight: projectsHeaderRef.current?.offsetHeight ?? 0, + recentHeaderHeight: recentHeaderRef.current?.offsetHeight ?? 0, + handleHeight: sectionResizeHandleRef.current?.offsetHeight ?? 0, + projectsContentHeight: projectsBodyRef.current?.offsetHeight ?? 0, + }; + if ( + previous.containerHeight === next.containerHeight && + previous.projectsHeaderHeight === next.projectsHeaderHeight && + previous.recentHeaderHeight === next.recentHeaderHeight && + previous.handleHeight === next.handleHeight && + previous.projectsContentHeight === next.projectsContentHeight + ) { + return previous; + } + return next; + }); }; - const scheduleUpdate = () => { + const scheduleMeasure = () => { if (frameId !== 0) { return; } - frameId = window.requestAnimationFrame(updateHeight); + frameId = window.requestAnimationFrame(measure); }; - scheduleUpdate(); - window.addEventListener("resize", scheduleUpdate); - const resizeObserver = - typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleUpdate); - resizeObserver?.observe(container); + scheduleMeasure(); + window.addEventListener("resize", scheduleMeasure); + const resizeObserver = new ResizeObserver(scheduleMeasure); + resizeObserver.observe(container); + const observedTargets = [ + projectsHeaderRef.current, + recentHeaderRef.current, + sectionResizeHandleRef.current, + projectsBodyRef.current, + ]; + for (const target of observedTargets) { + if (target) { + resizeObserver.observe(target); + } + } return () => { - window.removeEventListener("resize", scheduleUpdate); + window.removeEventListener("resize", scheduleMeasure); if (frameId !== 0) { window.cancelAnimationFrame(frameId); } - resizeObserver?.disconnect(); + resizeObserver.disconnect(); }; }, [isOpen, projectsCollapsed, recentCollapsed, showProjects]); @@ -1059,46 +1110,33 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi return; } - const projectsSection = projectsSectionRef.current; - const sidebarSections = sidebarSectionsRef.current; - if (!projectsSection || !sidebarSections) { - return; - } - event.preventDefault(); projectSectionResizeCleanupRef.current?.(); const pointerId = event.pointerId; const resizeTarget = event.currentTarget; const startY = event.clientY; - const containerHeight = sidebarSections.clientHeight; + const layout = sidebarSectionLayoutRef.current; const startHeight = clampSidebarSectionHeight( - projectsSection.getBoundingClientRect().height, - SIDEBAR_PROJECTS_RESIZE_MIN_HEIGHT, - Math.max( - SIDEBAR_PROJECTS_RESIZE_MIN_HEIGHT, - containerHeight - SIDEBAR_RECENT_RESIZE_MIN_HEIGHT, - ), + layout.projectsBodyHeight, + layout.resizeMinHeight, + layout.resizeMaxHeight, ); const previousCursor = document.body.style.cursor; const previousUserSelect = document.body.style.userSelect; - pendingProjectSectionHeightRef.current = startHeight; setIsProjectSectionResizing(true); document.body.style.cursor = "row-resize"; document.body.style.userSelect = "none"; - projectsSection.style.flexBasis = `${startHeight}px`; resizeTarget.setPointerCapture(pointerId); const scheduleProjectSectionHeight = (nextHeight: number) => { - pendingProjectSectionHeightRef.current = nextHeight; if (projectSectionResizeFrameRef.current !== null) { return; } projectSectionResizeFrameRef.current = window.requestAnimationFrame(() => { projectSectionResizeFrameRef.current = null; - const draftHeight = pendingProjectSectionHeightRef.current ?? startHeight; - projectsSection.style.flexBasis = `${draftHeight}px`; + setProjectSectionHeight(nextHeight); }); }; @@ -1121,11 +1159,7 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi window.cancelAnimationFrame(projectSectionResizeFrameRef.current); projectSectionResizeFrameRef.current = null; } - const finalHeight = pendingProjectSectionHeightRef.current ?? startHeight; - projectsSection.style.flexBasis = `${finalHeight}px`; - setProjectSectionHeight(finalHeight); setIsProjectSectionResizing(false); - pendingProjectSectionHeightRef.current = null; }; const handleMove = (moveEvent: globalThis.PointerEvent) => { @@ -1133,16 +1167,12 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi return; } moveEvent.preventDefault(); - const nextContainerHeight = sidebarSectionsRef.current?.clientHeight ?? containerHeight; - const nextMaxHeight = Math.max( - SIDEBAR_PROJECTS_RESIZE_MIN_HEIGHT, - nextContainerHeight - SIDEBAR_RECENT_RESIZE_MIN_HEIGHT, - ); + const liveLayout = sidebarSectionLayoutRef.current; scheduleProjectSectionHeight( clampSidebarSectionHeight( startHeight + moveEvent.clientY - startY, - SIDEBAR_PROJECTS_RESIZE_MIN_HEIGHT, - nextMaxHeight, + liveLayout.resizeMinHeight, + liveLayout.resizeMaxHeight, ), ); }; @@ -1308,20 +1338,20 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi
-
+
{showProjects ? ( -
-
+ <> +
-
-
+
+
{renderedProjects.map((project) => { const pathKey = workspaceProjectPathKey(project.path); return ( @@ -1414,161 +1434,144 @@ export const ChatHistorySidebar = memo(function ChatHistorySidebar(props: ChatHi })}
-
+ + ) : null} - {canResizeProjectSections ? ( +
- ) : null} +
+
+ {Math.max(totalItems, items.length)} +
+ {canShareConversations ? ( + + ) : null} +
+
+ {errorMessage ? ( +
+ +
+ ) : null}
- -
-
- {Math.max(totalItems, items.length)} + {isLoading ? ( + + ) : items.length === 0 ? ( +
+ +

+ {t("chat.emptyChatHistory")} +

+

+ {t("chat.clickNewConversation")} +

- {canShareConversations ? ( - - ) : null} -
-
+ ) : ( +
+ {virtualHistoryRows.map((virtualRow) => { + const item = items[virtualRow.index]; + if (!item) return null; -
+ {renderHistoryRow(item)} +
+ ); + })} +
)} - > -
- {errorMessage ? ( -
- -
- ) : null} -
- {isLoading ? ( - - ) : items.length === 0 ? ( -
- -

- {t("chat.emptyChatHistory")} -

-

- {t("chat.clickNewConversation")} -

-
- ) : ( -
- {virtualHistoryRows.map((virtualRow) => { - const item = items[virtualRow.index]; - if (!item) return null; - - return ( -
- {renderHistoryRow(item)} -
- ); - })} -
- )} - {!isLoading && items.length > 0 && (hasMore || isLoadingMore) ? ( -
- {isLoadingMore - ? t("sidebar.loadingMoreHistory") - : t("sidebar.continueLoadingHistory")} -
- ) : null} + {!isLoading && items.length > 0 && (hasMore || isLoadingMore) ? ( +
+ {isLoadingMore + ? t("sidebar.loadingMoreHistory") + : t("sidebar.continueLoadingHistory")}
-
+ ) : null}