From 8b37ea7ac3125db61ac51041f071467655ce2f86 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 12 Jan 2026 11:58:42 +0000 Subject: [PATCH] feat: make sidebar width adjustable in task view --- webview-ui/src/components/chat/ChatView.tsx | 290 ++++++++++-------- .../src/components/chat/ResizablePanels.tsx | 134 ++++++++ 2 files changed, 293 insertions(+), 131 deletions(-) create mode 100644 webview-ui/src/components/chat/ResizablePanels.tsx diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 709218c935f..c3c040ea2fa 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -47,6 +47,8 @@ import { QueuedMessages } from "./QueuedMessages" import DismissibleUpsell from "../common/DismissibleUpsell" import { useCloudUpsell } from "@src/hooks/useCloudUpsell" import { Cloud } from "lucide-react" +import { ResizablePanels } from "./ResizablePanels" +import { TodoListDisplay } from "./TodoListDisplay" export interface ChatViewProps { isHidden: boolean @@ -1406,32 +1408,166 @@ const ChatViewComponent: React.ForwardRefRenderFunction )} {task ? ( - <> - + + + + {hasSystemPromptOverride && ( +
+ +
+ )} - {hasSystemPromptOverride && ( -
- -
- )} + {checkpointWarning && ( +
+ +
+ )} - {checkpointWarning && ( -
- + {/* Add todo list to sidebar if available */} + {latestTodos && latestTodos.length > 0 && ( +
+ +
+ )}
- )} - + } + rightPanel={ + <> +
+ isAtBottom || stickyFollowRef.current} + atBottomStateChange={(isAtBottom: boolean) => { + setIsAtBottom(isAtBottom) + // Only show the scroll-to-bottom button if not at bottom + setShowScrollToBottom(!isAtBottom) + }} + atBottomThreshold={10} + initialTopMostItemIndex={groupedMessages.length - 1} + /> +
+ {areButtonsVisible && ( +
+ {showScrollToBottom ? ( + + + + ) : ( + <> + {primaryButtonText && !isStreaming && ( + + + + )} + {(secondaryButtonText || isStreaming) && ( + + + + )} + + )} +
+ )} + + } + className="grow" + /> ) : (
@@ -1466,114 +1602,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction )} - {task && ( - <> -
- isAtBottom || stickyFollowRef.current} - atBottomStateChange={(isAtBottom: boolean) => { - setIsAtBottom(isAtBottom) - // Only show the scroll-to-bottom button if not at bottom - setShowScrollToBottom(!isAtBottom) - }} - atBottomThreshold={10} - initialTopMostItemIndex={groupedMessages.length - 1} - /> -
- {areButtonsVisible && ( -
- {showScrollToBottom ? ( - - - - ) : ( - <> - {primaryButtonText && !isStreaming && ( - - - - )} - {(secondaryButtonText || isStreaming) && ( - - - - )} - - )} -
- )} - - )} - { diff --git a/webview-ui/src/components/chat/ResizablePanels.tsx b/webview-ui/src/components/chat/ResizablePanels.tsx new file mode 100644 index 00000000000..42a6333215f --- /dev/null +++ b/webview-ui/src/components/chat/ResizablePanels.tsx @@ -0,0 +1,134 @@ +import React, { useState, useCallback, useEffect, useRef } from "react" +import { GripVertical } from "lucide-react" +import { cn } from "@src/lib/utils" + +interface ResizablePanelsProps { + leftPanel: React.ReactNode + rightPanel: React.ReactNode + minLeftWidth?: number // as percentage (default 20) + minRightWidth?: number // as percentage (default 35) + storageKey?: string // localStorage key for persisting widths + className?: string +} + +const DEFAULT_LEFT_WIDTH = 25 // Default 25% for sidebar +const DEFAULT_MIN_LEFT = 20 // Minimum 20% for sidebar +const DEFAULT_MIN_RIGHT = 35 // Minimum 35% for conversation + +export const ResizablePanels: React.FC = ({ + leftPanel, + rightPanel, + minLeftWidth = DEFAULT_MIN_LEFT, + minRightWidth = DEFAULT_MIN_RIGHT, + storageKey = "resizable-panels-width", + className, +}) => { + const containerRef = useRef(null) + const [isDragging, setIsDragging] = useState(false) + const [leftWidth, setLeftWidth] = useState(() => { + // Load from localStorage if available + if (storageKey) { + const stored = localStorage.getItem(storageKey) + if (stored) { + const parsed = parseFloat(stored) + if (!isNaN(parsed) && parsed >= minLeftWidth && parsed <= 100 - minRightWidth) { + return parsed + } + } + } + return DEFAULT_LEFT_WIDTH + }) + + // Persist to localStorage whenever leftWidth changes + useEffect(() => { + if (storageKey) { + localStorage.setItem(storageKey, leftWidth.toString()) + } + }, [leftWidth, storageKey]) + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault() + setIsDragging(true) + }, []) + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isDragging || !containerRef.current) return + + const container = containerRef.current + const rect = container.getBoundingClientRect() + const offsetX = e.clientX - rect.left + const newLeftWidth = (offsetX / rect.width) * 100 + + // Clamp to min/max values + const clampedWidth = Math.max(minLeftWidth, Math.min(100 - minRightWidth, newLeftWidth)) + setLeftWidth(clampedWidth) + }, + [isDragging, minLeftWidth, minRightWidth], + ) + + const handleMouseUp = useCallback(() => { + setIsDragging(false) + }, []) + + useEffect(() => { + if (isDragging) { + window.addEventListener("mousemove", handleMouseMove) + window.addEventListener("mouseup", handleMouseUp) + // Prevent text selection while dragging + document.body.style.userSelect = "none" + document.body.style.cursor = "ew-resize" + + return () => { + window.removeEventListener("mousemove", handleMouseMove) + window.removeEventListener("mouseup", handleMouseUp) + document.body.style.userSelect = "" + document.body.style.cursor = "" + } + } + }, [isDragging, handleMouseMove, handleMouseUp]) + + const rightWidth = 100 - leftWidth + + return ( +
+ {/* Left Panel */} +
+ {leftPanel} +
+ + {/* Divider with Drag Handle */} +
+ {/* Visual divider line */} +
+ {/* Drag handle icon */} +
+ +
+
+ + {/* Right Panel */} +
+ {rightPanel} +
+
+ ) +}