diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index ad2974d7e0..966b586080 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -797,7 +797,6 @@ const AIViewInner: React.FC = ({ key={workspaceId} workspaceId={workspaceId} workspacePath={namedWorkspacePath} - chatAreaRef={chatAreaRef} width={sidebarWidth} onStartResize={startResize} isResizing={isResizing} diff --git a/src/browser/components/ChatMetaSidebar.tsx b/src/browser/components/ChatMetaSidebar.tsx deleted file mode 100644 index 462880e2ec..0000000000 --- a/src/browser/components/ChatMetaSidebar.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React from "react"; -import { cn } from "@/common/lib/utils"; -import { usePersistedState } from "@/browser/hooks/usePersistedState"; -import { useWorkspaceUsage } from "@/browser/stores/WorkspaceStore"; -import { useProviderOptions } from "@/browser/hooks/useProviderOptions"; -import { useResizeObserver } from "@/browser/hooks/useResizeObserver"; -import { CostsTab } from "./RightSidebar/CostsTab"; -import { VerticalTokenMeter } from "./RightSidebar/VerticalTokenMeter"; -import { calculateTokenMeterData } from "@/common/utils/tokens/tokenMeterUtils"; - -interface ChatMetaSidebarProps { - workspaceId: string; - chatAreaRef: React.RefObject; -} - -const ChatMetaSidebarComponent: React.FC = ({ workspaceId, chatAreaRef }) => { - const usage = useWorkspaceUsage(workspaceId); - const { options } = useProviderOptions(); - const use1M = options.anthropic?.use1MContext ?? false; - const chatAreaSize = useResizeObserver(chatAreaRef); - - // Use lastContextUsage for context window display (last step = actual context size) - const lastUsage = usage?.liveUsage ?? usage?.lastContextUsage; - - // Memoize vertical meter data calculation to prevent unnecessary re-renders - const verticalMeterData = React.useMemo(() => { - // Get model from last usage - const model = lastUsage?.model ?? "unknown"; - return lastUsage - ? calculateTokenMeterData(lastUsage, model, use1M, true) - : { segments: [], totalTokens: 0, totalPercentage: 0 }; - }, [lastUsage, use1M]); - - // Calculate if we should show collapsed view with hysteresis - // Strategy: Observe ChatArea width directly (independent of sidebar width) - // - ChatArea has min-width: 750px and flex: 1 - // - Use hysteresis to prevent oscillation: - // * Collapse when chatAreaWidth <= 800px (tight space) - // * Expand when chatAreaWidth >= 1100px (lots of space) - // * Between 800-1100: maintain current state (dead zone) - const COLLAPSE_THRESHOLD = 800; // Collapse below this - const EXPAND_THRESHOLD = 1100; // Expand above this - const chatAreaWidth = chatAreaSize?.width ?? 1000; // Default to large to avoid flash - - // Persist collapsed state globally (not per-workspace) since chat area width is shared - // This prevents animation flash when switching workspaces - sidebar maintains its state - const [showCollapsed, setShowCollapsed] = usePersistedState( - "chat-meta-sidebar:collapsed", - false - ); - - React.useEffect(() => { - if (chatAreaWidth <= COLLAPSE_THRESHOLD) { - setShowCollapsed(true); - } else if (chatAreaWidth >= EXPAND_THRESHOLD) { - setShowCollapsed(false); - } - // Between thresholds: maintain current state (no change) - }, [chatAreaWidth, setShowCollapsed]); - - return ( -
-
-
- -
-
-
- -
-
- ); -}; - -// Memoize to prevent re-renders when parent (AIView) re-renders during streaming -// Only re-renders when workspaceId or chatAreaRef changes, or internal state updates -export const ChatMetaSidebar = React.memo(ChatMetaSidebarComponent); diff --git a/src/browser/components/LeftSidebar.tsx b/src/browser/components/LeftSidebar.tsx index 7c34da73ab..d9490cb54c 100644 --- a/src/browser/components/LeftSidebar.tsx +++ b/src/browser/components/LeftSidebar.tsx @@ -51,7 +51,7 @@ export function LeftSidebar(props: LeftSidebarProps) { className={cn( "h-screen bg-sidebar border-r border-border flex flex-col shrink-0", "transition-all duration-200 overflow-hidden relative z-20", - collapsed ? "w-8" : "w-72", + collapsed ? "w-5" : "w-72", "mobile-sidebar", collapsed && "mobile-sidebar-collapsed" )} diff --git a/src/browser/components/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar.tsx index cd6cb8936b..bec475de7b 100644 --- a/src/browser/components/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar.tsx @@ -19,6 +19,7 @@ import { AGE_THRESHOLDS_DAYS, } from "@/browser/utils/ui/workspaceFiltering"; import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; +import { SidebarCollapseButton } from "./ui/SidebarCollapseButton"; import SecretsModal from "./SecretsModal"; import type { Secret } from "@/common/types/secrets"; import { ForceDeleteModal } from "./ForceDeleteModal"; @@ -714,21 +715,12 @@ const ProjectSidebarInner: React.FC = ({ )} - - - - - - {collapsed ? "Expand sidebar" : "Collapse sidebar"} ( - {formatKeybind(KEYBINDS.TOGGLE_SIDEBAR)}) - - + {secretsModalState && ( = ({ "aria-label": ariaLabel, }) => { const width = collapsed - ? "20px" + ? "20px" // Match left sidebar collapsed width (w-5 = 20px) : customWidth ? `${customWidth}px` : wide @@ -74,10 +70,8 @@ const SidebarContainer: React.FC = ({ return (
; /** Custom width in pixels (persisted per-tab, provided by AIView) */ width?: number; /** Drag start handler for resize */ @@ -113,7 +106,6 @@ interface RightSidebarProps { const RightSidebarComponent: React.FC = ({ workspaceId, workspacePath, - chatAreaRef, width, onStartResize, isResizing = false, @@ -123,6 +115,9 @@ const RightSidebarComponent: React.FC = ({ // Global tab preference (not per-workspace) const [selectedTab, setSelectedTab] = usePersistedState(RIGHT_SIDEBAR_TAB_KEY, "costs"); + // Manual collapse state (persisted globally) + const [collapsed, setCollapsed] = usePersistedState(RIGHT_SIDEBAR_COLLAPSED_KEY, false); + const { statsTabState } = useFeatureFlags(); const statsTabEnabled = Boolean(statsTabState?.enabled); @@ -138,32 +133,31 @@ const RightSidebarComponent: React.FC = ({ // Review stats reported by ReviewPanel const [reviewStats, setReviewStats] = React.useState(null); - // Keyboard shortcuts for tab switching + // Keyboard shortcuts for tab switching (auto-expands if collapsed) React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (matchesKeybind(e, KEYBINDS.COSTS_TAB)) { e.preventDefault(); setSelectedTab("costs"); + setCollapsed(false); } else if (matchesKeybind(e, KEYBINDS.REVIEW_TAB)) { e.preventDefault(); setSelectedTab("review"); + setCollapsed(false); setFocusTrigger((prev) => prev + 1); } else if (statsTabEnabled && matchesKeybind(e, KEYBINDS.STATS_TAB)) { e.preventDefault(); setSelectedTab("stats"); + setCollapsed(false); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [setSelectedTab, statsTabEnabled]); + }, [setSelectedTab, setCollapsed, statsTabEnabled]); const usage = useWorkspaceUsage(workspaceId); - const { options } = useProviderOptions(); - const use1M = options.anthropic?.use1MContext ?? false; - const chatAreaSize = useResizeObserver(chatAreaRef); - const baseId = `right-sidebar-${workspaceId}`; const costsTabId = `${baseId}-tab-costs`; const statsTabId = `${baseId}-tab-stats`; @@ -172,10 +166,6 @@ const RightSidebarComponent: React.FC = ({ const statsPanelId = `${baseId}-panel-stats`; const reviewPanelId = `${baseId}-panel-review`; - // Use lastContextUsage for context window display (last step = actual context size) - const lastUsage = usage?.liveUsage ?? usage?.lastContextUsage; - const model = lastUsage?.model ?? null; - // Calculate session cost for tab display const sessionCost = React.useMemo(() => { const parts: ChatUsageDisplay[] = []; @@ -206,109 +196,30 @@ const RightSidebarComponent: React.FC = ({ return total > 0 ? total : null; })(); - // Auto-compaction settings: threshold per-model - const { threshold: autoCompactThreshold, setThreshold: setAutoCompactThreshold } = - useAutoCompactionSettings(workspaceId, model); - - // Memoize vertical meter data calculation to prevent unnecessary re-renders - const verticalMeterData = React.useMemo(() => { - return lastUsage - ? calculateTokenMeterData(lastUsage, model ?? "unknown", use1M, true) - : { segments: [], totalTokens: 0, totalPercentage: 0 }; - }, [lastUsage, model, use1M]); - - // Calculate if we should show collapsed view with hysteresis - // Strategy: Observe ChatArea width directly (independent of sidebar width) - // - ChatArea has min-width: 750px and flex: 1 - // - Use hysteresis to prevent oscillation: - // * Collapse when chatAreaWidth <= 800px (tight space) - // * Expand when chatAreaWidth >= 1100px (lots of space) - // * Between 800-1100: maintain current state (dead zone) - const COLLAPSE_THRESHOLD = 800; // Collapse below this - const EXPAND_THRESHOLD = 1100; // Expand above this - const chatAreaWidth = chatAreaSize?.width ?? 1000; // Default to large to avoid flash - - // Persist collapsed state globally (not per-workspace) since chat area width is shared - // This prevents animation flash when switching workspaces - sidebar maintains its state - const [showCollapsed, setShowCollapsed] = usePersistedState( - RIGHT_SIDEBAR_COLLAPSED_KEY, - false - ); - - React.useEffect(() => { - // Never collapse when Review tab is active - code review needs space - if (selectedTab === "review") { - if (showCollapsed) { - setShowCollapsed(false); - } - return; - } - - // If the sidebar is custom-resized (wider than the default Costs width), - // auto-collapse based on chatAreaWidth can oscillate between expanded and - // collapsed states (because collapsed is 20px but expanded can be much wider), - // which looks like a constant flash. In that case, keep it expanded and let - // the user resize manually. - if (width !== undefined && width > 300) { - if (showCollapsed) { - setShowCollapsed(false); - } - return; - } - - // Normal hysteresis for Costs/Tools tabs - if (chatAreaWidth <= COLLAPSE_THRESHOLD) { - setShowCollapsed(true); - } else if (chatAreaWidth >= EXPAND_THRESHOLD) { - setShowCollapsed(false); - } - // Between thresholds: maintain current state (no change) - }, [chatAreaWidth, selectedTab, showCollapsed, setShowCollapsed, width]); - - // Single render point for VerticalTokenMeter - // Shows when: (1) collapsed, OR (2) Review tab is active - const showMeter = showCollapsed || selectedTab === "review"; - const autoCompactionProps = React.useMemo( - () => ({ - threshold: autoCompactThreshold, - setThreshold: setAutoCompactThreshold, - }), - [autoCompactThreshold, setAutoCompactThreshold] - ); - const verticalMeter = showMeter ? ( - - ) : null; - return ( - {/* Full view when not collapsed */} -
- {/* Resize handle (left edge) */} - {onStartResize && ( -
onStartResize(e as unknown as React.MouseEvent)} - /> - )} + {!collapsed && ( + <> + {/* Resize handle (left edge) */} + {onStartResize && ( +
onStartResize(e as unknown as React.MouseEvent)} + /> + )} - {/* Render meter when Review tab is active */} - {selectedTab === "review" && ( -
{verticalMeter}
- )} - -
@@ -440,10 +351,14 @@ const RightSidebarComponent: React.FC = ({
)}
-
-
- {/* Render meter in collapsed view when sidebar is collapsed */} -
{verticalMeter}
+ + )} + + setCollapsed(!collapsed)} + side="right" + /> ); }; diff --git a/src/browser/components/RightSidebar/VerticalTokenMeter.tsx b/src/browser/components/RightSidebar/VerticalTokenMeter.tsx deleted file mode 100644 index 85c9211392..0000000000 --- a/src/browser/components/RightSidebar/VerticalTokenMeter.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import React from "react"; -import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip"; -import { TokenMeter } from "./TokenMeter"; -import { VerticalThresholdSlider, type AutoCompactionConfig } from "./ThresholdSlider"; -import { - type TokenMeterData, - formatTokens, - getSegmentLabel, -} from "@/common/utils/tokens/tokenMeterUtils"; - -interface VerticalTokenMeterProps { - data: TokenMeterData; - /** Auto-compaction settings for threshold slider */ - autoCompaction?: AutoCompactionConfig; -} - -const VerticalTokenMeterComponent: React.FC = ({ - data, - autoCompaction, -}) => { - if (data.segments.length === 0) return null; - - // Scale the bar based on context window usage (0-100%) - const usagePercentage = data.maxTokens ? data.totalPercentage : 100; - - return ( -
- {/* Percentage label at top */} - {data.maxTokens && ( -
- {Math.round(data.totalPercentage)} -
- )} - - {/* Bar container - relative for slider positioning, flex for proportional scaling */} -
- {/* Used portion - grows based on usage percentage */} -
- {/* Tooltip wraps the meter for hover display */} -
- - -
- -
-
- -
-
Last Request
-
- {data.segments.map((seg, i) => ( -
-
-
- {getSegmentLabel(seg.type)} -
- - {formatTokens(seg.tokens)} - -
- ))} -
-
- Total: {formatTokens(data.totalTokens)} - {data.maxTokens && ` / ${formatTokens(data.maxTokens)}`} - {data.maxTokens && ` (${data.totalPercentage.toFixed(1)}%)`} -
-
- 💡 Expand your viewport to see full details -
-
- - -
-
- {/* Empty portion - takes remaining space */} -
- - {/* Threshold slider overlay - only when autoCompaction config provided and maxTokens known */} - {autoCompaction && data.maxTokens && } -
-
- ); -}; - -// Memoize to prevent re-renders when data hasn't changed -export const VerticalTokenMeter = React.memo(VerticalTokenMeterComponent); diff --git a/src/browser/components/ui/SidebarCollapseButton.tsx b/src/browser/components/ui/SidebarCollapseButton.tsx new file mode 100644 index 0000000000..5b4564e9ec --- /dev/null +++ b/src/browser/components/ui/SidebarCollapseButton.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { Tooltip, TooltipTrigger, TooltipContent } from "./tooltip"; + +interface SidebarCollapseButtonProps { + collapsed: boolean; + onToggle: () => void; + /** Direction the sidebar expands toward (left sidebar expands right, right sidebar expands left) */ + side: "left" | "right"; + /** Optional keyboard shortcut to show in tooltip */ + shortcut?: string; +} + +/** + * Collapse/expand toggle button for sidebars. + * Renders at the bottom of the sidebar with « » chevrons. + */ +export const SidebarCollapseButton: React.FC = ({ + collapsed, + onToggle, + side, + shortcut, +}) => { + // Left sidebar: collapsed shows », expanded shows « + // Right sidebar: collapsed shows «, expanded shows » + const chevron = side === "left" ? (collapsed ? "»" : "«") : collapsed ? "«" : "»"; + + const label = collapsed ? "Expand sidebar" : "Collapse sidebar"; + + return ( + + + + + + {label} + {shortcut && ` (${shortcut})`} + + + ); +}; diff --git a/src/browser/stories/App.rightsidebar.stories.tsx b/src/browser/stories/App.rightsidebar.stories.tsx index 9940774700..0f31086c8c 100644 --- a/src/browser/stories/App.rightsidebar.stories.tsx +++ b/src/browser/stories/App.rightsidebar.stories.tsx @@ -5,7 +5,7 @@ */ import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; -import { setupSimpleChatStory, setupStreamingChatStory } from "./storyHelpers"; +import { setupSimpleChatStory, setupStreamingChatStory, expandRightSidebar } from "./storyHelpers"; import { createUserMessage, createAssistantMessage } from "./mockFactory"; import { within, userEvent, waitFor } from "@storybook/test"; import { @@ -73,7 +73,7 @@ export const CostsTab: AppStory = { localStorage.setItem(RIGHT_SIDEBAR_COSTS_WIDTH_KEY, "350"); localStorage.setItem(RIGHT_SIDEBAR_REVIEW_WIDTH_KEY, "700"); - return setupSimpleChatStory({ + const client = setupSimpleChatStory({ workspaceId: "ws-costs", workspaceName: "feature/api", projectName: "my-app", @@ -85,6 +85,8 @@ export const CostsTab: AppStory = { ], sessionUsage: createSessionUsage(0.56), }); + expandRightSidebar(); + return client; }} /> ), @@ -113,7 +115,7 @@ export const CostsTabWithCacheCreate: AppStory = { localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("costs")); localStorage.setItem(RIGHT_SIDEBAR_COSTS_WIDTH_KEY, "350"); - return setupSimpleChatStory({ + const client = setupSimpleChatStory({ workspaceId: "ws-cache-create", workspaceName: "feature/caching", projectName: "my-app", @@ -138,6 +140,8 @@ export const CostsTabWithCacheCreate: AppStory = { version: 1, }, }); + expandRightSidebar(); + return client; }} /> ), @@ -168,7 +172,7 @@ export const ReviewTab: AppStory = { localStorage.setItem(RIGHT_SIDEBAR_COSTS_WIDTH_KEY, "350"); localStorage.setItem(RIGHT_SIDEBAR_REVIEW_WIDTH_KEY, "700"); - return setupSimpleChatStory({ + const client = setupSimpleChatStory({ workspaceId: "ws-review", workspaceName: "feature/review", projectName: "my-app", @@ -178,6 +182,8 @@ export const ReviewTab: AppStory = { ], sessionUsage: createSessionUsage(0.42), }); + expandRightSidebar(); + return client; }} /> ), @@ -210,7 +216,7 @@ export const StatsTabIdle: AppStory = { setup={() => { localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("stats")); - return setupSimpleChatStory({ + const client = setupSimpleChatStory({ workspaceId: "ws-stats-idle", workspaceName: "feature/stats", projectName: "my-app", @@ -220,6 +226,8 @@ export const StatsTabIdle: AppStory = { ], sessionUsage: createSessionUsage(0.25), }); + expandRightSidebar(); + return client; }} /> ), @@ -245,7 +253,7 @@ export const StatsTabStreaming: AppStory = { setup={() => { localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("stats")); - return setupStreamingChatStory({ + const client = setupStreamingChatStory({ workspaceId: "ws-stats-streaming", workspaceName: "feature/streaming", projectName: "my-app", @@ -257,6 +265,8 @@ export const StatsTabStreaming: AppStory = { historySequence: 2, streamText: "I'll create a test suite for you. Let me start by analyzing...", }); + expandRightSidebar(); + return client; }} /> ), diff --git a/src/browser/stories/storyHelpers.ts b/src/browser/stories/storyHelpers.ts index 2c4d60e90e..6dd1522741 100644 --- a/src/browser/stories/storyHelpers.ts +++ b/src/browser/stories/storyHelpers.ts @@ -16,6 +16,7 @@ import type { APIClient } from "@/browser/contexts/API"; import { SELECTED_WORKSPACE_KEY, EXPANDED_PROJECTS_KEY, + RIGHT_SIDEBAR_COLLAPSED_KEY, getInputKey, getModelKey, getReviewsKey, @@ -70,6 +71,16 @@ export function expandProjects(projectPaths: string[]): void { localStorage.setItem(EXPANDED_PROJECTS_KEY, JSON.stringify(projectPaths)); } +/** Collapse the right sidebar (default for most stories) */ +export function collapseRightSidebar(): void { + localStorage.setItem(RIGHT_SIDEBAR_COLLAPSED_KEY, JSON.stringify(true)); +} + +/** Expand the right sidebar (for stories testing it) */ +export function expandRightSidebar(): void { + localStorage.setItem(RIGHT_SIDEBAR_COLLAPSED_KEY, JSON.stringify(false)); +} + /** Set reviews for a workspace */ export function setReviews(workspaceId: string, reviews: Review[]): void { const state: ReviewsState = { @@ -191,8 +202,9 @@ export function setupSimpleChatStory(opts: SimpleChatSetupOptions): APIClient { ? new Map([[workspaceId, opts.gitStatus]]) : undefined; - // Set localStorage for workspace selection + // Set localStorage for workspace selection and collapse right sidebar by default selectWorkspace(workspaces[0]); + collapseRightSidebar(); // Set up background processes map const bgProcesses = opts.backgroundProcesses @@ -277,8 +289,9 @@ export function setupStreamingChatStory(opts: StreamingChatSetupOptions): APICli ? new Map([[workspaceId, opts.gitStatus]]) : undefined; - // Set localStorage for workspace selection + // Set localStorage for workspace selection and collapse right sidebar by default selectWorkspace(workspaces[0]); + collapseRightSidebar(); const workspaceStatsSnapshots = new Map(); if (opts.statsTabEnabled) { @@ -353,8 +366,9 @@ export function setupCustomChatStory(opts: CustomChatSetupOptions): APIClient { const chatHandlers = new Map([[workspaceId, opts.chatHandler]]); - // Set localStorage for workspace selection + // Set localStorage for workspace selection and collapse right sidebar by default selectWorkspace(workspaces[0]); + collapseRightSidebar(); // Return ORPC client return createMockORPCClient({ diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index c4de82c6ce..7a6e8a12c9 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -265,10 +265,10 @@ export function getSessionTimingKey(workspaceId: string): string { export const RIGHT_SIDEBAR_TAB_KEY = "right-sidebar-tab"; /** - * Right sidebar hidden state (global, auto-collapse on small screens) - * Format: "right-sidebar:hidden" + * Right sidebar collapsed state (global, manual toggle) + * Format: "right-sidebar:collapsed" */ -export const RIGHT_SIDEBAR_COLLAPSED_KEY = "right-sidebar:hidden"; +export const RIGHT_SIDEBAR_COLLAPSED_KEY = "right-sidebar:collapsed"; /** * Right sidebar width for Costs tab (global)