196 lines
9.0 KiB
TypeScript
196 lines
9.0 KiB
TypeScript
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<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 [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 (
|
|
<div className="h-dvh flex items-center justify-center bg-[var(--pc-bg-base)] text-pc-text-muted">
|
|
<div className="animate-pulse text-sm">Connecting…</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Not authenticated — show login
|
|
if (!authenticated) {
|
|
return <LoginScreen onConnect={login} error={connectError} isConnecting={isConnecting} />;
|
|
}
|
|
|
|
return (
|
|
<ToolCollapseProvider>
|
|
<div className="h-dvh flex overflow-x-hidden bg-[var(--pc-bg-base)] text-pc-text bg-[radial-gradient(ellipse_at_top,rgba(255,255,255,0.02),transparent_50%),radial_gradient(ellipse_at_bottom_right,rgba(99,102,241,0.04),transparent_50%)]" role="application" aria-label="PinchChat">
|
|
<Sidebar
|
|
sessions={sessions}
|
|
activeSession={activeSession}
|
|
onSwitch={switchSession}
|
|
onDelete={deleteSession}
|
|
onSplit={handleSplit}
|
|
splitSession={splitSession}
|
|
open={sidebarOpen}
|
|
onClose={() => setSidebarOpen(false)}
|
|
/>
|
|
<div ref={splitContainerRef} className="flex-1 flex min-w-0" aria-hidden={sidebarOpen ? true : undefined}>
|
|
{/* Primary pane */}
|
|
<div className="flex flex-col min-w-0" style={splitSession ? { width: `${splitRatio}%` } : { flex: 1 }}>
|
|
<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} />
|
|
<ConnectionBanner status={status} />
|
|
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-pc-text-muted"><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-pc-text-muted"><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>
|
|
<KeyboardShortcuts open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
|
</div>
|
|
</ToolCollapseProvider>
|
|
);
|
|
}
|