From f09482e6cb35f73fa559fd856bd34528f7c0be00 Mon Sep 17 00:00:00 2001 From: Nicolas Varrot Date: Fri, 13 Feb 2026 02:44:33 +0000 Subject: [PATCH] feat: multi-tab split view for 2 sessions side by side - Add split view button (columns icon) in sidebar session actions - Click to open any session in a secondary pane alongside the primary - Resizable divider between panes (drag to resize, persisted in localStorage) - Secondary pane supports full chat: history, streaming, send, abort - Close split view via X button or clicking the split icon again - Each pane has independent scroll, search, and tool collapse - Keyboard shortcut and i18n support (EN/FR) --- src/App.tsx | 97 +++++++++++- src/components/Sidebar.tsx | 20 ++- src/hooks/useGateway.ts | 9 ++ src/hooks/useSecondarySession.ts | 247 +++++++++++++++++++++++++++++++ src/lib/i18n.ts | 4 + 5 files changed, 369 insertions(+), 8 deletions(-) create mode 100644 src/hooks/useSecondarySession.ts diff --git a/src/App.tsx b/src/App.tsx index e3b363f..287410a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react'; import { useGateway } from './hooks/useGateway'; +import { useSecondarySession } from './hooks/useSecondarySession'; import { useNotifications, setBaseTitle } from './hooks/useNotifications'; import { Header } from './components/Header'; import { Sidebar } from './components/Sidebar'; @@ -7,15 +8,64 @@ import { LoginScreen } from './components/LoginScreen'; import { ConnectionBanner } from './components/ConnectionBanner'; import { KeyboardShortcuts } from './components/KeyboardShortcuts'; import { ToolCollapseProvider } from './contexts/ToolCollapseContext'; +import { sessionDisplayName } from './lib/sessionName'; +import { X } from 'lucide-react'; +import { useT } from './hooks/useLocale'; const Chat = lazy(() => import('./components/Chat').then(m => ({ default: m.Chat }))); +const SPLIT_WIDTH_KEY = 'pinchchat-split-width'; +const MIN_SPLIT = 250; + +function getSavedSplitRatio(): number { + try { + const v = localStorage.getItem(SPLIT_WIDTH_KEY); + if (v) { const n = Number(v); if (n >= 20 && n <= 80) return n; } + } catch { /* noop */ } + return 50; +} + export default function App() { const { status, messages, sessions, activeSession, isGenerating, isLoadingHistory, sendMessage, abort, switchSession, deleteSession, authenticated, login, logout, connectError, isConnecting, agentIdentity, + getClient, addEventListener, } = useGateway(); + const [splitSession, setSplitSession] = useState(null); + const [splitRatio, setSplitRatio] = useState(getSavedSplitRatio); + const [splitDragging, setSplitDragging] = useState(false); + const splitContainerRef = useRef(null); + const splitRatioRef = useRef(splitRatio); + const secondary = useSecondarySession(getClient, addEventListener, splitSession); + const t = useT(); + const handleSplit = useCallback((key: string) => { + setSplitSession(prev => prev === key ? null : key); + }, []); + + // Split pane drag + useEffect(() => { + if (!splitDragging) return; + const onMove = (e: MouseEvent) => { + const container = splitContainerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const total = rect.width; + if (total < MIN_SPLIT * 2) return; + const x = e.clientX - rect.left; + const pct = Math.max(20, Math.min(80, (x / total) * 100)); + setSplitRatio(pct); + splitRatioRef.current = pct; + }; + const onUp = () => { + setSplitDragging(false); + localStorage.setItem(SPLIT_WIDTH_KEY, String(Math.round(splitRatioRef.current))); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + return () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; + }, [splitDragging, splitRatio]); + const [sidebarOpen, setSidebarOpen] = useState(false); const [shortcutsOpen, setShortcutsOpen] = useState(false); const { notify, soundEnabled, toggleSound } = useNotifications(); @@ -93,15 +143,50 @@ export default function App() { activeSession={activeSession} onSwitch={switchSession} onDelete={deleteSession} + onSplit={handleSplit} + splitSession={splitSession} open={sidebarOpen} onClose={() => setSidebarOpen(false)} /> -
-
setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} soundEnabled={soundEnabled} onToggleSound={toggleSound} messages={messages} agentAvatarUrl={agentIdentity?.avatar} /> - -
Loading…
}> - - +
+ {/* Primary pane */} +
+
setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} soundEnabled={soundEnabled} onToggleSound={toggleSound} messages={messages} agentAvatarUrl={agentIdentity?.avatar} /> + +
Loading…
}> + + +
+ {/* Split divider + secondary pane */} + {splitSession && ( + <> +
setSplitDragging(true)} + role="separator" + aria-orientation="vertical" + /> +
+ {/* Secondary header */} +
+ + {(() => { const s = sessions.find(s => s.key === splitSession); return s ? sessionDisplayName(s) : splitSession; })()} + + +
+
Loading…
}> + + +
+ + )} setShortcutsOpen(false)} /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 82cb327..659503a 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useRef, useEffect, useCallback } from 'react'; -import { X, Sparkles, Search, Pin, Trash2 } from 'lucide-react'; +import { X, Sparkles, Search, Pin, Trash2, Columns2 } from 'lucide-react'; import type { Session } from '../types'; import { useT } from '../hooks/useLocale'; import { SessionIcon } from './SessionIcon'; @@ -57,11 +57,13 @@ interface Props { activeSession: string; onSwitch: (key: string) => void; onDelete: (key: string) => void; + onSplit?: (key: string) => void; + splitSession?: string | null; open: boolean; onClose: () => void; } -export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onClose }: Props) { +export function Sidebar({ sessions, activeSession, onSwitch, onDelete, onSplit, splitSession, open, onClose }: Props) { const t = useT(); const [filter, setFilter] = useState(''); const [focusIdx, setFocusIdx] = useState(-1); @@ -332,6 +334,20 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC > + {onSplit && ( + + )}