mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-08 03:07:53 +00:00
feat: make sidebar width adjustable in task view
This commit is contained in:
parent
632b86cfbe
commit
8b37ea7ac3
2 changed files with 293 additions and 131 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
134
webview-ui/src/components/chat/ResizablePanels.tsx
Normal file
134
webview-ui/src/components/chat/ResizablePanels.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue