diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 56445821..3d905694 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 @@ -736,6 +737,9 @@ function ContentHeader({ } function App() { + const { chatPanePlacement } = useTheme() + const isChatPaneInMiddle = chatPanePlacement === 'middle' + type ShortcutPane = 'left' | 'right' type MarkdownHistoryHandlers = { undo: () => boolean; redo: () => boolean } @@ -765,7 +769,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) @@ -5323,6 +5327,7 @@ function App() { , label: 'Open chat' } : (viewOpen && isChatSidebarOpen && !isRightPaneMaximized) - ? { onClick: () => setIsChatSidebarOpen(false), icon: , label: 'Expand pane' } + ? { + onClick: () => setIsChatSidebarOpen(false), + icon: isChatPaneInMiddle ? : , + label: 'Expand pane' + } : null return ( @@ -5989,9 +5998,11 @@ function App() { )} - {/* Chat sidebar - shown when viewing files/graph */} + {/* Chat pane - shown when viewing files/graph */} {isRightPaneContext && ( string @@ -183,6 +185,8 @@ export function ChatSidebar({ defaultWidth = DEFAULT_WIDTH, isOpen = true, isMaximized = false, + placement = 'right', + className, chatTabs, activeChatTabId, getChatTabTitle, @@ -246,6 +250,7 @@ export function ChatSidebar({ const startWidthRef = useRef(0) const prevIsMaximizedRef = useRef(isMaximized) const justToggledMaximize = prevIsMaximizedRef.current !== isMaximized + const isMiddlePlacement = placement === 'middle' const getMaxAllowedWidth = useCallback(() => { if (typeof window === 'undefined') return MAX_WIDTH @@ -306,7 +311,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 +326,7 @@ export function ChatSidebar({ document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mouseup', handleMouseUp) - }, [width, getMaxAllowedWidth]) + }, [width, getMaxAllowedWidth, isMiddlePlacement]) const activeTabState = useMemo(() => ({ runId: runId ?? null, @@ -511,8 +518,10 @@ export function ChatSidebar({ onMouseDownCapture={onActivate} onFocusCapture={onActivate} className={cn( - 'relative flex min-w-0 flex-col overflow-hidden border-l border-border bg-background', - !isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear' + 'relative flex min-w-0 flex-col overflow-hidden bg-background', + isMiddlePlacement ? 'border-r border-border' : 'border-l border-border', + !isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear', + className )} style={paneStyle} > @@ -520,7 +529,8 @@ export function ChatSidebar({
- {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..e810ad93 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 } = useTheme() return (
@@ -240,6 +240,26 @@ function AppearanceSettings() { />
+
+

Chat

+

+ Choose where chat sits when another pane is open +

+
+ setChatPanePlacement("right")} + /> + setChatPanePlacement("middle")} + /> +
+
) } diff --git a/apps/x/apps/renderer/src/contexts/theme-context.tsx b/apps/x/apps/renderer/src/contexts/theme-context.tsx index 1149cb42..03e7bc93 100644 --- a/apps/x/apps/renderer/src/contexts/theme-context.tsx +++ b/apps/x/apps/renderer/src/contexts/theme-context.tsx @@ -3,16 +3,24 @@ import * as React from "react" export type Theme = "light" | "dark" | "system" +export type ChatPanePlacement = "right" | "middle" type ThemeContextProps = { theme: Theme resolvedTheme: "light" | "dark" setTheme: (theme: Theme) => void + chatPanePlacement: ChatPanePlacement + setChatPanePlacement: (placement: ChatPanePlacement) => void } const ThemeContext = React.createContext(null) const STORAGE_KEY = "rowboat-theme" +const CHAT_PANE_PLACEMENT_STORAGE_KEY = "rowboat-chat-pane-placement" + +function isChatPanePlacement(value: string | null): value is ChatPanePlacement { + return value === "right" || value === "middle" +} function getSystemTheme(): "light" | "dark" { if (typeof window === "undefined") return "light" @@ -39,6 +47,11 @@ 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 [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => { if (theme === "system") return getSystemTheme() @@ -76,13 +89,20 @@ export function ThemeProvider({ setThemeState(newTheme) }, []) + const setChatPanePlacement = React.useCallback((placement: ChatPanePlacement) => { + localStorage.setItem(CHAT_PANE_PLACEMENT_STORAGE_KEY, placement) + setChatPanePlacementState(placement) + }, []) + const contextValue = React.useMemo( () => ({ theme, resolvedTheme, setTheme, + chatPanePlacement, + setChatPanePlacement, }), - [theme, resolvedTheme, setTheme] + [theme, resolvedTheme, setTheme, chatPanePlacement, setChatPanePlacement] ) return (