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)
This commit is contained in:
Nicolas Varrot
2026-02-13 02:44:33 +00:00
parent 00bf6d156f
commit f09482e6cb
5 changed files with 369 additions and 8 deletions

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react'; import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
import { useGateway } from './hooks/useGateway'; import { useGateway } from './hooks/useGateway';
import { useSecondarySession } from './hooks/useSecondarySession';
import { useNotifications, setBaseTitle } from './hooks/useNotifications'; import { useNotifications, setBaseTitle } from './hooks/useNotifications';
import { Header } from './components/Header'; import { Header } from './components/Header';
import { Sidebar } from './components/Sidebar'; import { Sidebar } from './components/Sidebar';
@@ -7,15 +8,64 @@ import { LoginScreen } from './components/LoginScreen';
import { ConnectionBanner } from './components/ConnectionBanner'; import { ConnectionBanner } from './components/ConnectionBanner';
import { KeyboardShortcuts } from './components/KeyboardShortcuts'; import { KeyboardShortcuts } from './components/KeyboardShortcuts';
import { ToolCollapseProvider } from './contexts/ToolCollapseContext'; 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 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() { export default function App() {
const { const {
status, messages, sessions, activeSession, isGenerating, isLoadingHistory, status, messages, sessions, activeSession, isGenerating, isLoadingHistory,
sendMessage, abort, switchSession, deleteSession, sendMessage, abort, switchSession, deleteSession,
authenticated, login, logout, connectError, isConnecting, agentIdentity, authenticated, login, logout, connectError, isConnecting, agentIdentity,
getClient, addEventListener,
} = useGateway(); } = useGateway();
const [splitSession, setSplitSession] = useState<string | null>(null);
const [splitRatio, setSplitRatio] = useState(getSavedSplitRatio);
const [splitDragging, setSplitDragging] = useState(false);
const splitContainerRef = useRef<HTMLDivElement>(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 [sidebarOpen, setSidebarOpen] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false); const [shortcutsOpen, setShortcutsOpen] = useState(false);
const { notify, soundEnabled, toggleSound } = useNotifications(); const { notify, soundEnabled, toggleSound } = useNotifications();
@@ -93,15 +143,50 @@ export default function App() {
activeSession={activeSession} activeSession={activeSession}
onSwitch={switchSession} onSwitch={switchSession}
onDelete={deleteSession} onDelete={deleteSession}
onSplit={handleSplit}
splitSession={splitSession}
open={sidebarOpen} open={sidebarOpen}
onClose={() => setSidebarOpen(false)} onClose={() => setSidebarOpen(false)}
/> />
<div className="flex-1 flex flex-col min-w-0" aria-hidden={sidebarOpen ? true : undefined}> <div ref={splitContainerRef} className="flex-1 flex min-w-0" aria-hidden={sidebarOpen ? true : undefined}>
<Header status={status} sessionKey={activeSession} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} soundEnabled={soundEnabled} onToggleSound={toggleSound} messages={messages} agentAvatarUrl={agentIdentity?.avatar} /> {/* Primary pane */}
<ConnectionBanner status={status} /> <div className="flex flex-col min-w-0" style={splitSession ? { width: `${splitRatio}%` } : { flex: 1 }}>
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-zinc-500"><div className="animate-pulse text-sm">Loading</div></div>}> <Header status={status} sessionKey={activeSession} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} soundEnabled={soundEnabled} onToggleSound={toggleSound} messages={messages} agentAvatarUrl={agentIdentity?.avatar} />
<Chat messages={messages} isGenerating={isGenerating} isLoadingHistory={isLoadingHistory} status={status} sessionKey={activeSession} onSend={sendMessage} onAbort={abort} agentAvatarUrl={agentIdentity?.avatar} /> <ConnectionBanner status={status} />
</Suspense> <Suspense fallback={<div className="flex-1 flex items-center justify-center text-zinc-500"><div className="animate-pulse text-sm">Loading</div></div>}>
<Chat messages={messages} isGenerating={isGenerating} isLoadingHistory={isLoadingHistory} status={status} sessionKey={activeSession} onSend={sendMessage} onAbort={abort} agentAvatarUrl={agentIdentity?.avatar} />
</Suspense>
</div>
{/* Split divider + secondary pane */}
{splitSession && (
<>
<div
className={`w-1 cursor-col-resize flex-shrink-0 transition-colors ${splitDragging ? 'bg-pc-accent/60' : 'bg-pc-border hover:bg-pc-accent/40'}`}
onMouseDown={() => setSplitDragging(true)}
role="separator"
aria-orientation="vertical"
/>
<div className="flex flex-col min-w-0" style={{ width: `${100 - splitRatio}%` }}>
{/* Secondary header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-pc-border bg-[var(--pc-bg-surface)]">
<span className="text-sm font-medium text-pc-text truncate flex-1">
{(() => { const s = sessions.find(s => s.key === splitSession); return s ? sessionDisplayName(s) : splitSession; })()}
</span>
<button
onClick={() => setSplitSession(null)}
className="p-1 rounded-lg text-pc-text-muted hover:text-pc-text hover:bg-[var(--pc-hover)] transition-colors"
title={t('split.close')}
aria-label={t('split.close')}
>
<X size={14} />
</button>
</div>
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-zinc-500"><div className="animate-pulse text-sm">Loading</div></div>}>
<Chat messages={secondary.messages} isGenerating={secondary.isGenerating} isLoadingHistory={secondary.isLoadingHistory} status={status} sessionKey={splitSession} onSend={secondary.sendMessage} onAbort={secondary.abort} agentAvatarUrl={agentIdentity?.avatar} />
</Suspense>
</div>
</>
)}
</div> </div>
<KeyboardShortcuts open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} /> <KeyboardShortcuts open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
</div> </div>

View File

@@ -1,5 +1,5 @@
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'; 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 type { Session } from '../types';
import { useT } from '../hooks/useLocale'; import { useT } from '../hooks/useLocale';
import { SessionIcon } from './SessionIcon'; import { SessionIcon } from './SessionIcon';
@@ -57,11 +57,13 @@ interface Props {
activeSession: string; activeSession: string;
onSwitch: (key: string) => void; onSwitch: (key: string) => void;
onDelete: (key: string) => void; onDelete: (key: string) => void;
onSplit?: (key: string) => void;
splitSession?: string | null;
open: boolean; open: boolean;
onClose: () => void; 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 t = useT();
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
const [focusIdx, setFocusIdx] = useState(-1); const [focusIdx, setFocusIdx] = useState(-1);
@@ -332,6 +334,20 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
> >
<Pin size={12} className={isPinned ? 'fill-current' : ''} /> <Pin size={12} className={isPinned ? 'fill-current' : ''} />
</button> </button>
{onSplit && (
<button
onClick={(e) => { e.stopPropagation(); onSplit(s.key); }}
className={`shrink-0 p-0.5 rounded-lg transition-all ${
splitSession === s.key
? 'text-pc-accent opacity-80 hover:opacity-100'
: 'text-pc-text-faint opacity-0 group-hover/item:opacity-60 hover:!opacity-100 hover:text-pc-text-secondary'
}`}
title={t('sidebar.openSplit')}
aria-label={t('sidebar.openSplit')}
>
<Columns2 size={12} />
</button>
)}
<button <button
onClick={(e) => { e.stopPropagation(); setConfirmDelete(s.key); }} onClick={(e) => { e.stopPropagation(); setConfirmDelete(s.key); }}
className="shrink-0 p-0.5 rounded-lg transition-all text-pc-text-faint opacity-0 group-hover/item:opacity-60 hover:!opacity-100 hover:text-red-400" className="shrink-0 p-0.5 rounded-lg transition-all text-pc-text-faint opacity-0 group-hover/item:opacity-60 hover:!opacity-100 hover:text-red-400"

View File

@@ -495,9 +495,18 @@ export function useGateway() {
hasUnread: unreadSessions.has(s.key), hasUnread: unreadSessions.has(s.key),
})); }));
const getClient = useCallback(() => clientRef.current, []);
const addEventListener = useCallback((fn: (event: string, payload: JsonPayload) => void) => {
const client = clientRef.current;
if (!client) return () => {};
return client.onEvent(fn);
}, []);
return { return {
status, messages, sessions: enrichedSessions, activeSession, isGenerating, isLoadingHistory, status, messages, sessions: enrichedSessions, activeSession, isGenerating, isLoadingHistory,
sendMessage, abort, switchSession, loadSessions, deleteSession, sendMessage, abort, switchSession, loadSessions, deleteSession,
authenticated, login, logout, connectError, isConnecting, agentIdentity, authenticated, login, logout, connectError, isConnecting, agentIdentity,
getClient, addEventListener,
}; };
} }

View File

@@ -0,0 +1,247 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import type { GatewayClient, JsonPayload } from '../lib/gateway';
import type { ChatMessage, MessageBlock } from '../types';
import { isSystemEvent } from '../lib/systemEvent';
interface ChatPayloadMessage {
content?: string | Array<{ type: string; text?: string; thinking?: string }>;
}
function extractText(message: ChatPayloadMessage | undefined): string {
if (!message) return '';
const content = message.content;
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.filter((b) => b.type === 'text' && typeof b.text === 'string')
.map((b) => b.text as string)
.join('\n');
}
return '';
}
function extractThinking(message: ChatPayloadMessage | undefined): string {
if (!message) return '';
const content = message.content;
if (!Array.isArray(content)) return '';
return content
.filter((b) => b.type === 'thinking')
.map((b) => b.thinking || b.text || '')
.join('\n');
}
/**
* Hook to manage a secondary session for split view.
* Loads history and listens for streaming events for a specific session.
*/
export function useSecondarySession(
getClient: () => GatewayClient | null,
addEventListener: (fn: (event: string, payload: JsonPayload) => void) => () => void,
sessionKey: string | null,
) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const sessionKeyRef = useRef(sessionKey);
const currentRunIdRef = useRef<string | null>(null);
useEffect(() => { sessionKeyRef.current = sessionKey; }, [sessionKey]);
const loadHistory = useCallback(async (key: string) => {
setIsLoadingHistory(true);
try {
const res = await getClient()?.send('chat.history', { sessionKey: key, limit: 100 });
const rawMsgs = res?.messages as Array<Record<string, unknown>> | undefined;
if (!rawMsgs) return;
/* eslint-disable @typescript-eslint/no-explicit-any */
const msgs: ChatMessage[] = rawMsgs.map((m: Record<string, any>, i: number) => {
const blocks: MessageBlock[] = [];
if (m.content) {
if (Array.isArray(m.content)) {
for (const block of m.content) {
if (block.type === 'text') blocks.push({ type: 'text', text: block.text });
else if (block.type === 'thinking') blocks.push({ type: 'thinking', text: block.thinking || block.text || '' });
else if (block.type === 'image') {
const src = block.source || {};
blocks.push({ type: 'image', mediaType: src.media_type || block.media_type || 'image/png', data: src.data || block.data, url: block.url || src.url });
}
else if (block.type === 'image_url') {
blocks.push({ type: 'image', mediaType: 'image/png', url: block.image_url?.url || block.url });
}
else if (block.type === 'tool_use') blocks.push({ type: 'tool_use', name: block.name, input: block.input, id: block.id });
else if (block.type === 'tool_result') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.tool_use_id });
else if (block.type === 'toolCall') blocks.push({ type: 'tool_use', name: block.name, input: block.arguments || block.input, id: block.id });
else if (block.type === 'toolResult') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.toolCallId || block.tool_use_id, name: block.name });
}
} else if (typeof m.content === 'string') {
blocks.push({ type: 'text', text: m.content });
}
}
const role: 'user' | 'assistant' = m.role === 'user' ? 'user' : 'assistant';
if (m.role === 'toolResult') {
const toolBlocks: MessageBlock[] = blocks.map(b => {
if (b.type === 'text') return { type: 'tool_result' as const, content: b.text, toolUseId: m.toolCallId };
return b;
});
return { id: m.id || `hist-${i}`, role: 'assistant' as const, content: '', timestamp: m.timestamp || Date.now(), blocks: toolBlocks, isToolResult: true };
}
const textContent = blocks.filter((b): b is Extract<MessageBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('');
const metadata: Record<string, unknown> = {};
for (const [k, v] of Object.entries(m)) {
if (['content', 'blocks'].includes(k)) continue;
metadata[k] = v;
}
return { id: m.id || `hist-${i}`, role, content: textContent, timestamp: m.timestamp || Date.now(), blocks, metadata, isSystemEvent: role === 'user' && isSystemEvent(textContent) };
});
/* eslint-enable @typescript-eslint/no-explicit-any */
const merged: ChatMessage[] = [];
for (const msg of msgs) {
const isToolResult = 'isToolResult' in msg && (msg as ChatMessage & { isToolResult?: boolean }).isToolResult;
if (isToolResult && merged.length > 0 && merged[merged.length - 1].role === 'assistant') {
merged[merged.length - 1] = { ...merged[merged.length - 1], blocks: [...merged[merged.length - 1].blocks, ...msg.blocks] };
} else if (!isToolResult) {
merged.push(msg);
}
}
setMessages(merged);
} catch {
// ignore
} finally {
setIsLoadingHistory(false);
}
}, [getClient]);
// Load history when session changes
useEffect(() => {
if (!sessionKey) {
setMessages([]);
return;
}
loadHistory(sessionKey);
}, [sessionKey, loadHistory]);
// Handle streaming events for this secondary session
const handleEvent = useCallback((event: string, payload: JsonPayload) => {
if (!sessionKeyRef.current) return;
const evtSession = payload.sessionKey as string | undefined;
if (evtSession !== sessionKeyRef.current) return;
if (event === 'agent') {
if (payload?.stream !== 'tool') return;
const data = (payload.data ?? {}) as Record<string, unknown>;
const phase = data.phase as string | undefined;
const toolCallId = data.toolCallId as string | undefined;
const name = (data.name as string) || 'tool';
if (!toolCallId) return;
setMessages(prev => {
const last = prev[prev.length - 1];
if (!last || last.role !== 'assistant' || !last.isStreaming) return prev;
const updated = { ...last, blocks: [...last.blocks] };
if (phase === 'start') {
updated.blocks.push({ type: 'tool_use' as const, name, input: (data.args as Record<string, unknown>) ?? {}, id: toolCallId });
} else if (phase === 'result') {
const rawResult = data.result;
const result = typeof rawResult === 'string' ? rawResult : JSON.stringify(rawResult, null, 2);
updated.blocks.push({ type: 'tool_result' as const, content: result?.slice(0, 500) || '', toolUseId: toolCallId, name });
}
return [...prev.slice(0, -1), updated];
});
return;
}
if (event !== 'chat') return;
const state = payload.state as string | undefined;
const runId = payload.runId as string;
const message = payload.message as ChatPayloadMessage | undefined;
const errorMessage = payload.errorMessage as string | undefined;
if (state === 'delta') {
const text = extractText(message);
const thinking = extractThinking(message);
currentRunIdRef.current = runId;
setIsGenerating(true);
setMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant' && last.isStreaming && last.runId === runId) {
const updated = { ...last };
updated.content = text;
const toolBlocks = updated.blocks.filter(b => b.type === 'tool_use' || b.type === 'tool_result');
const newBlocks: MessageBlock[] = [];
if (thinking) newBlocks.push({ type: 'thinking' as const, text: thinking });
newBlocks.push(...toolBlocks);
newBlocks.push({ type: 'text' as const, text });
updated.blocks = newBlocks;
return [...prev.slice(0, -1), updated];
}
const blocks: MessageBlock[] = [];
if (thinking) blocks.push({ type: 'thinking' as const, text: thinking });
blocks.push({ type: 'text' as const, text });
return [...prev, { id: runId + '-' + Date.now(), role: 'assistant' as const, content: text, timestamp: Date.now(), blocks, isStreaming: true, runId }];
});
} else if (state === 'final') {
currentRunIdRef.current = null;
setIsGenerating(false);
if (sessionKeyRef.current) loadHistory(sessionKeyRef.current);
} else if (state === 'error') {
currentRunIdRef.current = null;
setIsGenerating(false);
setMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant' && last.isStreaming && last.runId === runId) {
return [...prev.slice(0, -1), { ...last, isStreaming: false }];
}
return [...prev, { id: 'error-' + Date.now(), role: 'assistant' as const, content: `Error: ${errorMessage || 'unknown error'}`, timestamp: Date.now(), blocks: [{ type: 'text' as const, text: `Error: ${errorMessage || 'unknown error'}` }] }];
});
} else if (state === 'aborted') {
currentRunIdRef.current = null;
setIsGenerating(false);
setMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant' && last.isStreaming) {
return [...prev.slice(0, -1), { ...last, isStreaming: false }];
}
return prev;
});
}
}, [loadHistory]);
// Register event listener for streaming updates
useEffect(() => {
if (!sessionKey) return;
const unsub = addEventListener(handleEvent);
return unsub;
}, [sessionKey, addEventListener, handleEvent]);
const sendMessage = useCallback(async (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => {
if (!sessionKeyRef.current) return;
const userMsg: ChatMessage = {
id: 'user-' + Date.now(),
role: 'user',
content: text,
timestamp: Date.now(),
blocks: [{ type: 'text', text }],
};
setMessages(prev => [...prev, userMsg]);
setIsGenerating(true);
try {
await getClient()?.send('chat.send', {
sessionKey: sessionKeyRef.current,
message: text,
deliver: false,
...(attachments && attachments.length > 0 ? { attachments } : {}),
});
} catch {
setIsGenerating(false);
}
}, [getClient]);
const abort = useCallback(async () => {
if (!sessionKeyRef.current) return;
try {
await getClient()?.send('chat.abort', { sessionKey: sessionKeyRef.current });
} catch { /* ignore */ }
setIsGenerating(false);
}, [getClient]);
return { messages, isLoadingHistory, isGenerating, sendMessage, abort, handleEvent };
}

View File

@@ -59,6 +59,8 @@ const en = {
'sidebar.delete': 'Delete session', 'sidebar.delete': 'Delete session',
'sidebar.deleteConfirm': 'Delete this session? This cannot be undone.', 'sidebar.deleteConfirm': 'Delete this session? This cannot be undone.',
'sidebar.deleteCancel': 'Cancel', 'sidebar.deleteCancel': 'Cancel',
'sidebar.openSplit': 'Open in split view',
'split.close': 'Close split view',
// Thinking // Thinking
'thinking.label': 'Thinking', 'thinking.label': 'Thinking',
@@ -171,6 +173,8 @@ const fr: Record<keyof typeof en, string> = {
'sidebar.delete': 'Supprimer la session', 'sidebar.delete': 'Supprimer la session',
'sidebar.deleteConfirm': 'Supprimer cette session ? Cette action est irréversible.', 'sidebar.deleteConfirm': 'Supprimer cette session ? Cette action est irréversible.',
'sidebar.deleteCancel': 'Annuler', 'sidebar.deleteCancel': 'Annuler',
'sidebar.openSplit': 'Ouvrir en vue scindée',
'split.close': 'Fermer la vue scindée',
'thinking.label': 'Réflexion', 'thinking.label': 'Réflexion',
'thinking.reasoning': 'Réflexion…', 'thinking.reasoning': 'Réflexion…',