From 7f3c16cc332569f0e7fbbd94c5701bc4072e88c2 Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:49:49 +0530 Subject: [PATCH] Pane placement (#598) * allow user to change pane placement * allow user to change starting pane size --- apps/x/apps/renderer/src/App.tsx | 36 +++++++++++--- .../renderer/src/components/chat-sidebar.tsx | 35 ++++++++++---- .../src/components/settings-dialog.tsx | 48 ++++++++++++++++++- .../renderer/src/contexts/theme-context.tsx | 42 +++++++++++++++- 4 files changed, 144 insertions(+), 17 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 56445821..df85e06b 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowLeft, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; @@ -117,6 +117,7 @@ import { useVoiceTTS } from '@/hooks/useVoiceTTS' import { useMeetingTranscription, type CalendarEventMeta } from '@/hooks/useMeetingTranscription' import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity' import * as analytics from '@/lib/analytics' +import { useTheme } from '@/contexts/theme-context' type DirEntry = z.infer type RunEventType = z.infer @@ -165,6 +166,7 @@ function AutoScrollPre({ className, children }: { className?: string; children: } const DEFAULT_SIDEBAR_WIDTH = 256 +const DEFAULT_CHAT_PANE_WIDTH = 460 const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g const graphPalette = [ { hue: 210, sat: 72, light: 52 }, @@ -736,6 +738,9 @@ function ContentHeader({ } function App() { + const { chatPanePlacement, chatPaneSize } = useTheme() + const isChatPaneInMiddle = chatPanePlacement === 'middle' + type ShortcutPane = 'left' | 'right' type MarkdownHistoryHandlers = { undo: () => boolean; redo: () => boolean } @@ -765,7 +770,7 @@ function App() { // Lives in ViewState so folder drill-down participates in back/forward history. const [knowledgeViewFolderPath, setKnowledgeViewFolderPath] = useState(null) const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false) - // Default landing view: Home in the middle with the chat docked on the right. + // Default landing view: Home with the chat docked according to appearance settings. const [isHomeOpen, setIsHomeOpen] = useState(true) const [emailInitialThreadId, setEmailInitialThreadId] = useState(null) const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0) @@ -5246,6 +5251,17 @@ function App() { const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isBrowserOpen) const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const shouldCollapseLeftPane = isRightPaneOnlyMode + const nonChatPaneStyle = React.useMemo(() => { + const style: React.CSSProperties = { maxWidth: insetMaxWidth } + if (!isRightPaneContext || !isChatSidebarOpen || isRightPaneMaximized) return style + if (chatPaneSize === 'chat-equal') { + return { ...style, width: 0, flex: '1 1 0' } + } + if (chatPaneSize === 'chat-bigger') { + return { ...style, width: DEFAULT_CHAT_PANE_WIDTH, flex: '0 0 auto' } + } + return style + }, [chatPaneSize, insetMaxWidth, isChatSidebarOpen, isRightPaneContext, isRightPaneMaximized]) // Collapsing: pin max-width to the snapshot px (no transition) for one frame so it's // binding immediately (no flex jump), then animate to 0. Expanding goes back to 100% // — its non-binding range lands at the end of the range, where it isn't visible. @@ -5323,10 +5339,11 @@ function App() { setActiveShortcutPane('left')} onFocusCapture={() => setActiveShortcutPane('left')} @@ -5438,7 +5455,11 @@ function App() { : (viewOpen && !isChatSidebarOpen) ? { onClick: openChatSidePane, icon: , label: 'Open chat' } : (viewOpen && isChatSidebarOpen && !isRightPaneMaximized) - ? { onClick: () => setIsChatSidebarOpen(false), icon: , label: 'Expand pane' } + ? { + onClick: () => setIsChatSidebarOpen(false), + icon: isChatPaneInMiddle ? : , + label: 'Expand pane' + } : null return ( @@ -5989,10 +6010,13 @@ function App() { )} - {/* Chat sidebar - shown when viewing files/graph */} + {/* Chat pane - shown when viewing files/graph */} {isRightPaneContext && ( string @@ -183,6 +187,9 @@ export function ChatSidebar({ defaultWidth = DEFAULT_WIDTH, isOpen = true, isMaximized = false, + placement = 'right', + paneSize = 'chat-smaller', + className, chatTabs, activeChatTabId, getChatTabTitle, @@ -246,6 +253,8 @@ export function ChatSidebar({ const startWidthRef = useRef(0) const prevIsMaximizedRef = useRef(isMaximized) const justToggledMaximize = prevIsMaximizedRef.current !== isMaximized + const isMiddlePlacement = placement === 'middle' + const isResizable = paneSize === 'chat-smaller' const getMaxAllowedWidth = useCallback(() => { if (typeof window === 'undefined') return MAX_WIDTH @@ -306,7 +315,9 @@ export function ChatSidebar({ setIsResizing(true) const handleMouseMove = (event: MouseEvent) => { - const delta = startXRef.current - event.clientX + const delta = isMiddlePlacement + ? event.clientX - startXRef.current + : startXRef.current - event.clientX const maxAllowedWidth = getMaxAllowedWidth() setWidth(clampPaneWidth(startWidthRef.current + delta, maxAllowedWidth)) } @@ -319,7 +330,7 @@ export function ChatSidebar({ document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mouseup', handleMouseUp) - }, [width, getMaxAllowedWidth]) + }, [width, getMaxAllowedWidth, isMiddlePlacement]) const activeTabState = useMemo(() => ({ runId: runId ?? null, @@ -501,8 +512,11 @@ export function ChatSidebar({ // not add extra width to the right and overflow the app viewport. return { width: 0, flex: '1 1 auto' } } + if (paneSize === 'chat-equal' || paneSize === 'chat-bigger') { + return { width: 0, flex: '1 1 0' } + } return { width, flex: '0 0 auto' } - }, [isOpen, isMaximized, width]) + }, [isOpen, isMaximized, paneSize, width]) return (
- {!isMaximized && ( + {!isMaximized && isResizable && (
- {isMaximized ? : } + {isMaximized + ? (isMiddlePlacement ? : ) + : (isMiddlePlacement ? : )} {isMaximized ? 'Dock to side pane' : 'Expand chat'} diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 74082664..bf85d99b 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback, useMemo } from "react" -import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw } from "lucide-react" +import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight } from "lucide-react" import { Dialog, @@ -210,7 +210,7 @@ function ThemeOption({ } function AppearanceSettings() { - const { theme, setTheme } = useTheme() + const { theme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize } = useTheme() return (
@@ -240,6 +240,50 @@ function AppearanceSettings() { />
+
+

Chat

+

+ Choose where chat sits when another pane is open +

+
+ setChatPanePlacement("right")} + /> + setChatPanePlacement("middle")} + /> +
+

Chat size

+

+ Choose how much width chat gets when another pane is open +

+
+ setChatPaneSize("chat-smaller")} + /> + setChatPaneSize("chat-equal")} + /> + setChatPaneSize("chat-bigger")} + /> +
+
) } diff --git a/apps/x/apps/renderer/src/contexts/theme-context.tsx b/apps/x/apps/renderer/src/contexts/theme-context.tsx index 1149cb42..04df59e7 100644 --- a/apps/x/apps/renderer/src/contexts/theme-context.tsx +++ b/apps/x/apps/renderer/src/contexts/theme-context.tsx @@ -3,16 +3,32 @@ import * as React from "react" export type Theme = "light" | "dark" | "system" +export type ChatPanePlacement = "right" | "middle" +export type ChatPaneSize = "chat-smaller" | "chat-equal" | "chat-bigger" type ThemeContextProps = { theme: Theme resolvedTheme: "light" | "dark" setTheme: (theme: Theme) => void + chatPanePlacement: ChatPanePlacement + setChatPanePlacement: (placement: ChatPanePlacement) => void + chatPaneSize: ChatPaneSize + setChatPaneSize: (size: ChatPaneSize) => void } const ThemeContext = React.createContext(null) const STORAGE_KEY = "rowboat-theme" +const CHAT_PANE_PLACEMENT_STORAGE_KEY = "rowboat-chat-pane-placement" +const CHAT_PANE_SIZE_STORAGE_KEY = "rowboat-chat-pane-size" + +function isChatPanePlacement(value: string | null): value is ChatPanePlacement { + return value === "right" || value === "middle" +} + +function isChatPaneSize(value: string | null): value is ChatPaneSize { + return value === "chat-smaller" || value === "chat-equal" || value === "chat-bigger" +} function getSystemTheme(): "light" | "dark" { if (typeof window === "undefined") return "light" @@ -39,6 +55,16 @@ export function ThemeProvider({ const stored = localStorage.getItem(STORAGE_KEY) as Theme | null return stored || defaultTheme }) + const [chatPanePlacement, setChatPanePlacementState] = React.useState(() => { + if (typeof window === "undefined") return "right" + const stored = localStorage.getItem(CHAT_PANE_PLACEMENT_STORAGE_KEY) + return isChatPanePlacement(stored) ? stored : "right" + }) + const [chatPaneSize, setChatPaneSizeState] = React.useState(() => { + if (typeof window === "undefined") return "chat-smaller" + const stored = localStorage.getItem(CHAT_PANE_SIZE_STORAGE_KEY) + return isChatPaneSize(stored) ? stored : "chat-smaller" + }) const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => { if (theme === "system") return getSystemTheme() @@ -76,13 +102,27 @@ export function ThemeProvider({ setThemeState(newTheme) }, []) + const setChatPanePlacement = React.useCallback((placement: ChatPanePlacement) => { + localStorage.setItem(CHAT_PANE_PLACEMENT_STORAGE_KEY, placement) + setChatPanePlacementState(placement) + }, []) + + const setChatPaneSize = React.useCallback((size: ChatPaneSize) => { + localStorage.setItem(CHAT_PANE_SIZE_STORAGE_KEY, size) + setChatPaneSizeState(size) + }, []) + const contextValue = React.useMemo( () => ({ theme, resolvedTheme, setTheme, + chatPanePlacement, + setChatPanePlacement, + chatPaneSize, + setChatPaneSize, }), - [theme, resolvedTheme, setTheme] + [theme, resolvedTheme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize] ) return (