feat: make sidebar width adjustable in task view

This commit is contained in:
Roo Code 2026-01-12 11:58:42 +00:00
parent 632b86cfbe
commit 8b37ea7ac3
2 changed files with 293 additions and 131 deletions

View file

@ -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<ChatViewRef, ChatViewPro
/>
)}
{task ? (
<>
<TaskHeader
task={task}
tokensIn={apiMetrics.totalTokensIn}
tokensOut={apiMetrics.totalTokensOut}
cacheWrites={apiMetrics.totalCacheWrites}
cacheReads={apiMetrics.totalCacheReads}
totalCost={apiMetrics.totalCost}
contextTokens={apiMetrics.contextTokens}
buttonsDisabled={sendingDisabled}
handleCondenseContext={handleCondenseContext}
todos={latestTodos}
/>
<ResizablePanels
storageKey="task-view-sidebar-width"
minLeftWidth={20}
minRightWidth={35}
leftPanel={
<div className="flex flex-col h-full overflow-y-auto">
<TaskHeader
task={task}
tokensIn={apiMetrics.totalTokensIn}
tokensOut={apiMetrics.totalTokensOut}
cacheWrites={apiMetrics.totalCacheWrites}
cacheReads={apiMetrics.totalCacheReads}
totalCost={apiMetrics.totalCost}
contextTokens={apiMetrics.contextTokens}
buttonsDisabled={sendingDisabled}
handleCondenseContext={handleCondenseContext}
todos={latestTodos}
/>
{hasSystemPromptOverride && (
<div className="px-3">
<SystemPromptWarning />
</div>
)}
{hasSystemPromptOverride && (
<div className="px-3">
<SystemPromptWarning />
</div>
)}
{checkpointWarning && (
<div className="px-3">
<CheckpointWarning warning={checkpointWarning} />
{checkpointWarning && (
<div className="px-3">
<CheckpointWarning warning={checkpointWarning} />
</div>
)}
{/* Add todo list to sidebar if available */}
{latestTodos && latestTodos.length > 0 && (
<div className="px-3 mt-3">
<TodoListDisplay todos={latestTodos} />
</div>
)}
</div>
)}
</>
}
rightPanel={
<>
<div className="grow flex" ref={scrollContainerRef}>
<Virtuoso
ref={virtuosoRef}
key={task.ts}
className="scrollable grow overflow-y-scroll mb-1"
increaseViewportBy={{ top: 3_000, bottom: 1000 }}
data={groupedMessages}
itemContent={itemContent}
followOutput={(isAtBottom: boolean) => 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}
/>
</div>
{areButtonsVisible && (
<div
className={`flex h-9 items-center mb-1 px-[15px] ${
showScrollToBottom
? "opacity-100"
: enableButtons || (isStreaming && !didClickCancel)
? "opacity-100"
: "opacity-50"
}`}>
{showScrollToBottom ? (
<StandardTooltip content={t("chat:scrollToBottom")}>
<Button
variant="secondary"
className="flex-[2]"
onClick={() => {
// Engage sticky follow until user scrolls up
stickyFollowRef.current = true
// Pin immediately to avoid lag during fast streaming
scrollToBottomAuto()
// Hide button immediately to prevent flash
setShowScrollToBottom(false)
}}>
<span className="codicon codicon-chevron-down"></span>
</Button>
</StandardTooltip>
) : (
<>
{primaryButtonText && !isStreaming && (
<StandardTooltip
content={
primaryButtonText === t("chat:retry.title")
? t("chat:retry.tooltip")
: primaryButtonText === t("chat:save.title")
? t("chat:save.tooltip")
: primaryButtonText === t("chat:approve.title")
? t("chat:approve.tooltip")
: primaryButtonText === t("chat:runCommand.title")
? t("chat:runCommand.tooltip")
: primaryButtonText ===
t("chat:startNewTask.title")
? t("chat:startNewTask.tooltip")
: primaryButtonText ===
t("chat:resumeTask.title")
? t("chat:resumeTask.tooltip")
: primaryButtonText ===
t("chat:proceedAnyways.title")
? t("chat:proceedAnyways.tooltip")
: primaryButtonText ===
t(
"chat:proceedWhileRunning.title",
)
? t(
"chat:proceedWhileRunning.tooltip",
)
: undefined
}>
<Button
variant="primary"
disabled={!enableButtons}
className={
secondaryButtonText ? "flex-1 mr-[6px]" : "flex-[2] mr-0"
}
onClick={() =>
handlePrimaryButtonClick(inputValue, selectedImages)
}>
{primaryButtonText}
</Button>
</StandardTooltip>
)}
{(secondaryButtonText || isStreaming) && (
<StandardTooltip
content={
isStreaming
? t("chat:cancel.tooltip")
: secondaryButtonText === t("chat:startNewTask.title")
? t("chat:startNewTask.tooltip")
: secondaryButtonText === t("chat:reject.title")
? t("chat:reject.tooltip")
: secondaryButtonText === t("chat:terminate.title")
? t("chat:terminate.tooltip")
: undefined
}>
<Button
variant="secondary"
disabled={!enableButtons && !(isStreaming && !didClickCancel)}
className={isStreaming ? "flex-[2] ml-0" : "flex-1 ml-[6px]"}
onClick={() =>
handleSecondaryButtonClick(inputValue, selectedImages)
}>
{isStreaming ? t("chat:cancel.title") : secondaryButtonText}
</Button>
</StandardTooltip>
)}
</>
)}
</div>
)}
</>
}
className="grow"
/>
) : (
<div className="flex flex-col h-full justify-center p-6 min-h-0 overflow-y-auto gap-4 relative">
<div className="flex flex-col items-start gap-2 justify-center h-full min-[400px]:px-6">
@ -1466,114 +1602,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
</div>
)}
{task && (
<>
<div className="grow flex" ref={scrollContainerRef}>
<Virtuoso
ref={virtuosoRef}
key={task.ts}
className="scrollable grow overflow-y-scroll mb-1"
increaseViewportBy={{ top: 3_000, bottom: 1000 }}
data={groupedMessages}
itemContent={itemContent}
followOutput={(isAtBottom: boolean) => 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}
/>
</div>
{areButtonsVisible && (
<div
className={`flex h-9 items-center mb-1 px-[15px] ${
showScrollToBottom
? "opacity-100"
: enableButtons || (isStreaming && !didClickCancel)
? "opacity-100"
: "opacity-50"
}`}>
{showScrollToBottom ? (
<StandardTooltip content={t("chat:scrollToBottom")}>
<Button
variant="secondary"
className="flex-[2]"
onClick={() => {
// Engage sticky follow until user scrolls up
stickyFollowRef.current = true
// Pin immediately to avoid lag during fast streaming
scrollToBottomAuto()
// Hide button immediately to prevent flash
setShowScrollToBottom(false)
}}>
<span className="codicon codicon-chevron-down"></span>
</Button>
</StandardTooltip>
) : (
<>
{primaryButtonText && !isStreaming && (
<StandardTooltip
content={
primaryButtonText === t("chat:retry.title")
? t("chat:retry.tooltip")
: primaryButtonText === t("chat:save.title")
? t("chat:save.tooltip")
: primaryButtonText === t("chat:approve.title")
? t("chat:approve.tooltip")
: primaryButtonText === t("chat:runCommand.title")
? t("chat:runCommand.tooltip")
: primaryButtonText === t("chat:startNewTask.title")
? t("chat:startNewTask.tooltip")
: primaryButtonText === t("chat:resumeTask.title")
? t("chat:resumeTask.tooltip")
: primaryButtonText ===
t("chat:proceedAnyways.title")
? t("chat:proceedAnyways.tooltip")
: primaryButtonText ===
t("chat:proceedWhileRunning.title")
? t("chat:proceedWhileRunning.tooltip")
: undefined
}>
<Button
variant="primary"
disabled={!enableButtons}
className={secondaryButtonText ? "flex-1 mr-[6px]" : "flex-[2] mr-0"}
onClick={() => handlePrimaryButtonClick(inputValue, selectedImages)}>
{primaryButtonText}
</Button>
</StandardTooltip>
)}
{(secondaryButtonText || isStreaming) && (
<StandardTooltip
content={
isStreaming
? t("chat:cancel.tooltip")
: secondaryButtonText === t("chat:startNewTask.title")
? t("chat:startNewTask.tooltip")
: secondaryButtonText === t("chat:reject.title")
? t("chat:reject.tooltip")
: secondaryButtonText === t("chat:terminate.title")
? t("chat:terminate.tooltip")
: undefined
}>
<Button
variant="secondary"
disabled={!enableButtons && !(isStreaming && !didClickCancel)}
className={isStreaming ? "flex-[2] ml-0" : "flex-1 ml-[6px]"}
onClick={() => handleSecondaryButtonClick(inputValue, selectedImages)}>
{isStreaming ? t("chat:cancel.title") : secondaryButtonText}
</Button>
</StandardTooltip>
)}
</>
)}
</div>
)}
</>
)}
<QueuedMessages
queue={messageQueue}
onRemove={(index) => {

View file

@ -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<ResizablePanelsProps> = ({
leftPanel,
rightPanel,
minLeftWidth = DEFAULT_MIN_LEFT,
minRightWidth = DEFAULT_MIN_RIGHT,
storageKey = "resizable-panels-width",
className,
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [leftWidth, setLeftWidth] = useState<number>(() => {
// 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 (
<div ref={containerRef} className={cn("flex w-full h-full relative", className)}>
{/* Left Panel */}
<div style={{ width: `${leftWidth}%` }} className="overflow-hidden">
{leftPanel}
</div>
{/* Divider with Drag Handle */}
<div
className="relative flex items-center justify-center"
style={{
width: "8px",
cursor: "ew-resize",
flexShrink: 0,
}}
onMouseDown={handleMouseDown}>
{/* Visual divider line */}
<div
className={cn(
"absolute inset-y-0 left-1/2 -translate-x-1/2 w-px",
"bg-vscode-panel-border transition-colors",
isDragging && "bg-vscode-focusBorder",
)}
/>
{/* Drag handle icon */}
<div
className={cn(
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
"bg-vscode-editor-background rounded px-0.5 py-1",
"transition-opacity",
)}>
<GripVertical className="cursor-ew-resize size-3 opacity-50 hover:opacity-100" />
</div>
</div>
{/* Right Panel */}
<div style={{ width: `${rightWidth}%` }} className="overflow-hidden">
{rightPanel}
</div>
</div>
)
}