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'; 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(); const prevMessageCountRef = useRef(messages.length); // Notify on new assistant messages when tab is not focused useEffect(() => { const prevCount = prevMessageCountRef.current; prevMessageCountRef.current = messages.length; if (messages.length > prevCount) { const last = messages[messages.length - 1]; if (last && last.role === 'assistant' && !last.isStreaming) { const preview = last.content?.slice(0, 100) || 'New message'; notify('PinchChat', preview); } } }, [messages, notify]); // Update document title with active session label useEffect(() => { const session = sessions.find(s => s.key === activeSession); setBaseTitle(session?.label || session?.key); return () => setBaseTitle(undefined); }, [activeSession, sessions]); // Keyboard shortcuts: Escape, ?, Alt+↑/↓ for session navigation const handleKeyDown = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape' && sidebarOpen) { setSidebarOpen(false); } // Open shortcuts help with ? (only when not typing in an input) if (e.key === '?' && !shortcutsOpen) { const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return; e.preventDefault(); setShortcutsOpen(true); } // Alt+↑ / Alt+↓ — switch to previous/next session if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { e.preventDefault(); if (sessions.length < 2) return; const idx = sessions.findIndex(s => s.key === activeSession); if (idx === -1) return; const next = e.key === 'ArrowUp' ? (idx - 1 + sessions.length) % sessions.length : (idx + 1) % sessions.length; switchSession(sessions[next].key); } }, [sidebarOpen, shortcutsOpen, sessions, activeSession, switchSession]); useEffect(() => { document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [handleKeyDown]); // Still checking stored credentials if (authenticated === null) { return (
Connecting…
); } // Not authenticated — show login if (!authenticated) { return ; } return (
setSidebarOpen(false)} />
{/* 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)} />
); }