* feat: add chat virtualization and turn grouping infrastructure Implement VirtualMessageList with virtualization and overscan for smooth scrolling Refactor MessageList to render only visible messages and pass scroll refs Introduce TurnGroupingContext and provider to stabilize per-turn rendering and reduce re-renders * feat: add web server session activity endpoint for visibility restore Add /api/session-activity endpoint to expose tracked session activity Use web server activity first when restoring visibility, with fallback to global status Update server SSE to set session phase on activity events * feat(chat): split TurnGroupingContext into UI/Streaming contexts Add separate UI state and streaming contexts to reduce re-renders. Skip animations for items visible in collapsed view when expanding. Track shown parts in collapsed mode to fade in progressively * feat(ui): cache turn groups with expand state and guard web activity Add isExpanded to the turn cache key to trigger updates correctly Expose isGroupExpanded from UI state so groups reflect expand/collapse Limit web server session activity fetch to web runtime only
420 lines
18 KiB
TypeScript
420 lines
18 KiB
TypeScript
import React from 'react';
|
|
import { Header } from './Header';
|
|
import { Sidebar } from './Sidebar';
|
|
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
|
import { CommandPalette } from '../ui/CommandPalette';
|
|
import { HelpDialog } from '../ui/HelpDialog';
|
|
import { SessionSidebar } from '@/components/session/SessionSidebar';
|
|
import { SessionDialogs } from '@/components/session/SessionDialogs';
|
|
import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
|
|
import { MultiRunLauncher } from '@/components/multirun';
|
|
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { useUpdateStore } from '@/stores/useUpdateStore';
|
|
import { useDeviceInfo } from '@/lib/device';
|
|
import { useEdgeSwipe } from '@/hooks/useEdgeSwipe';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
import { ChatView, PlanView, GitView, DiffView, TerminalView, FilesView, SettingsView } from '@/components/views';
|
|
|
|
export const MainLayout: React.FC = () => {
|
|
const {
|
|
isSidebarOpen,
|
|
activeMainTab,
|
|
setIsMobile,
|
|
isSessionSwitcherOpen,
|
|
isSettingsDialogOpen,
|
|
setSettingsDialogOpen,
|
|
isMultiRunLauncherOpen,
|
|
setMultiRunLauncherOpen,
|
|
multiRunLauncherPrefillPrompt,
|
|
} = useUIStore();
|
|
|
|
const { isMobile } = useDeviceInfo();
|
|
|
|
useEdgeSwipe({ enabled: true });
|
|
|
|
// Trigger update check 3 seconds after mount (for both mobile and desktop)
|
|
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
|
|
React.useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
checkForUpdates();
|
|
}, 3000);
|
|
return () => clearTimeout(timer);
|
|
}, [checkForUpdates]);
|
|
|
|
React.useEffect(() => {
|
|
const previous = useUIStore.getState().isMobile;
|
|
if (previous !== isMobile) {
|
|
setIsMobile(isMobile);
|
|
}
|
|
}, [isMobile, setIsMobile]);
|
|
|
|
React.useEffect(() => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
let timeoutId: number | undefined;
|
|
|
|
const handleResize = () => {
|
|
if (timeoutId !== undefined) {
|
|
window.clearTimeout(timeoutId);
|
|
}
|
|
|
|
timeoutId = window.setTimeout(() => {
|
|
useUIStore.getState().updateProportionalSidebarWidths();
|
|
}, 150);
|
|
};
|
|
|
|
window.addEventListener('resize', handleResize);
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', handleResize);
|
|
if (timeoutId !== undefined) {
|
|
window.clearTimeout(timeoutId);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
const root = document.documentElement;
|
|
|
|
let stickyKeyboardInset = 0;
|
|
let ignoreOpenUntilZero = false;
|
|
let previousHeight = 0;
|
|
let keyboardAvoidTarget: HTMLElement | null = null;
|
|
|
|
const setKeyboardOpen = useUIStore.getState().setKeyboardOpen;
|
|
|
|
const clearKeyboardAvoidTarget = () => {
|
|
if (!keyboardAvoidTarget) {
|
|
return;
|
|
}
|
|
keyboardAvoidTarget.style.setProperty('--oc-keyboard-avoid-offset', '0px');
|
|
keyboardAvoidTarget.removeAttribute('data-keyboard-avoid-active');
|
|
keyboardAvoidTarget = null;
|
|
};
|
|
|
|
const resolveKeyboardAvoidTarget = (active: HTMLElement | null) => {
|
|
if (!active) {
|
|
return null;
|
|
}
|
|
const markedTarget = active.closest('[data-keyboard-avoid]') as HTMLElement | null;
|
|
if (markedTarget) {
|
|
return markedTarget;
|
|
}
|
|
if (active.classList.contains('overlay-scrollbar-container')) {
|
|
const parent = active.parentElement;
|
|
if (parent instanceof HTMLElement) {
|
|
return parent;
|
|
}
|
|
}
|
|
return active;
|
|
};
|
|
|
|
const forceKeyboardClosed = () => {
|
|
stickyKeyboardInset = 0;
|
|
ignoreOpenUntilZero = true;
|
|
root.style.setProperty('--oc-keyboard-inset', '0px');
|
|
setKeyboardOpen(false);
|
|
};
|
|
|
|
const updateVisualViewport = () => {
|
|
const viewport = window.visualViewport;
|
|
|
|
const height = viewport ? Math.round(viewport.height) : window.innerHeight;
|
|
const offsetTop = viewport ? Math.max(0, Math.round(viewport.offsetTop)) : 0;
|
|
|
|
root.style.setProperty('--oc-visual-viewport-offset-top', `${offsetTop}px`);
|
|
|
|
const active = document.activeElement as HTMLElement | null;
|
|
const tagName = active?.tagName;
|
|
const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
|
|
const isTextTarget = isInput || Boolean(active?.isContentEditable);
|
|
|
|
const layoutHeight = Math.round(root.clientHeight || window.innerHeight);
|
|
const viewportSum = height + offsetTop;
|
|
const rawInset = Math.max(0, layoutHeight - viewportSum);
|
|
|
|
// Keyboard heuristic:
|
|
// - when an input is focused, smaller deltas can still be keyboard
|
|
// - when not focused, treat only big deltas as keyboard (ignore toolbars)
|
|
const openThreshold = isTextTarget ? 120 : 180;
|
|
const measuredInset = rawInset >= openThreshold ? rawInset : 0;
|
|
|
|
// Make the UI stable: treat keyboard inset as a step function.
|
|
// - When opening: take the first big inset and hold it.
|
|
// - When closing starts: immediately drop to 0 (even if keyboard animation continues).
|
|
// Closing start signals:
|
|
// - focus lost (handled via focusout)
|
|
// - visual viewport height starts increasing while inset is non-zero
|
|
if (ignoreOpenUntilZero) {
|
|
if (measuredInset === 0) {
|
|
ignoreOpenUntilZero = false;
|
|
}
|
|
stickyKeyboardInset = 0;
|
|
} else if (stickyKeyboardInset === 0) {
|
|
if (measuredInset > 0 && isTextTarget) {
|
|
stickyKeyboardInset = measuredInset;
|
|
}
|
|
} else {
|
|
// Only detect closing-by-height when focus is NOT on text input
|
|
// (prevents false positives during Android keyboard animation)
|
|
const closingByHeight = !isTextTarget && height > previousHeight + 6;
|
|
|
|
if (measuredInset === 0) {
|
|
stickyKeyboardInset = 0;
|
|
setKeyboardOpen(false);
|
|
} else if (closingByHeight) {
|
|
forceKeyboardClosed();
|
|
} else if (measuredInset > 0 && isTextTarget) {
|
|
// When focus is on text input, track actual inset (allows settling
|
|
// to correct value after Android animation fluctuations)
|
|
stickyKeyboardInset = measuredInset;
|
|
setKeyboardOpen(true);
|
|
} else if (measuredInset > stickyKeyboardInset) {
|
|
stickyKeyboardInset = measuredInset;
|
|
setKeyboardOpen(true);
|
|
}
|
|
}
|
|
|
|
root.style.setProperty('--oc-keyboard-inset', `${stickyKeyboardInset}px`);
|
|
previousHeight = height;
|
|
|
|
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
|
const keyboardHomeIndicator = isIOS && stickyKeyboardInset > 0 ? 34 : 0;
|
|
root.style.setProperty('--oc-keyboard-home-indicator', `${keyboardHomeIndicator}px`);
|
|
|
|
const avoidTarget = isTextTarget ? resolveKeyboardAvoidTarget(active) : null;
|
|
|
|
if (!isMobile || !avoidTarget || !active) {
|
|
clearKeyboardAvoidTarget();
|
|
} else {
|
|
if (avoidTarget !== keyboardAvoidTarget) {
|
|
clearKeyboardAvoidTarget();
|
|
keyboardAvoidTarget = avoidTarget;
|
|
}
|
|
const viewportBottom = offsetTop + height;
|
|
const rect = active.getBoundingClientRect();
|
|
const overlap = rect.bottom - viewportBottom;
|
|
const clearance = 8;
|
|
const keyboardInset = Math.max(stickyKeyboardInset, measuredInset);
|
|
const avoidOffset = overlap > clearance && keyboardInset > 0
|
|
? Math.min(overlap, keyboardInset)
|
|
: 0;
|
|
const target = keyboardAvoidTarget;
|
|
if (target) {
|
|
target.style.setProperty('--oc-keyboard-avoid-offset', `${avoidOffset}px`);
|
|
target.setAttribute('data-keyboard-avoid-active', 'true');
|
|
}
|
|
}
|
|
|
|
// Only force-scroll lock while an input is focused.
|
|
if (isMobile && isTextTarget) {
|
|
const scroller = document.scrollingElement;
|
|
if (scroller && scroller.scrollTop !== 0) {
|
|
scroller.scrollTop = 0;
|
|
}
|
|
if (window.scrollY !== 0) {
|
|
window.scrollTo(0, 0);
|
|
}
|
|
}
|
|
};
|
|
|
|
updateVisualViewport();
|
|
|
|
const viewport = window.visualViewport;
|
|
viewport?.addEventListener('resize', updateVisualViewport);
|
|
viewport?.addEventListener('scroll', updateVisualViewport);
|
|
window.addEventListener('resize', updateVisualViewport);
|
|
window.addEventListener('orientationchange', updateVisualViewport);
|
|
// Reset ignoreOpenUntilZero when focus moves to a text input.
|
|
// This allows keyboard detection to work when user taps input quickly
|
|
// while keyboard is still closing (common on Android).
|
|
const handleFocusIn = (event: FocusEvent) => {
|
|
const target = event.target as HTMLElement | null;
|
|
if (!target) {
|
|
return;
|
|
}
|
|
const tagName = target.tagName;
|
|
const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
|
|
if (isInput || target.isContentEditable) {
|
|
ignoreOpenUntilZero = false;
|
|
}
|
|
updateVisualViewport();
|
|
};
|
|
document.addEventListener('focusin', handleFocusIn, true);
|
|
|
|
const handleFocusOut = (event: FocusEvent) => {
|
|
const target = event.target as HTMLElement | null;
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
const tagName = target.tagName;
|
|
const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
|
|
if (isInput || target.isContentEditable) {
|
|
// Check if focus is moving to another input - if so, don't close keyboard
|
|
const related = event.relatedTarget as HTMLElement | null;
|
|
const relatedTag = related?.tagName;
|
|
const relatedIsInput = relatedTag === 'INPUT' || relatedTag === 'TEXTAREA' || relatedTag === 'SELECT' || related?.isContentEditable;
|
|
if (relatedIsInput) {
|
|
return;
|
|
}
|
|
forceKeyboardClosed();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('focusout', handleFocusOut, true);
|
|
|
|
return () => {
|
|
viewport?.removeEventListener('resize', updateVisualViewport);
|
|
viewport?.removeEventListener('scroll', updateVisualViewport);
|
|
window.removeEventListener('resize', updateVisualViewport);
|
|
window.removeEventListener('orientationchange', updateVisualViewport);
|
|
document.removeEventListener('focusin', handleFocusIn, true);
|
|
document.removeEventListener('focusout', handleFocusOut, true);
|
|
clearKeyboardAvoidTarget();
|
|
};
|
|
}, [isMobile]);
|
|
|
|
const secondaryView = React.useMemo(() => {
|
|
switch (activeMainTab) {
|
|
case 'plan':
|
|
return <PlanView />;
|
|
case 'git':
|
|
return <GitView />;
|
|
case 'diff':
|
|
return <DiffView />;
|
|
case 'terminal':
|
|
return <TerminalView />;
|
|
case 'files':
|
|
return <FilesView />;
|
|
default:
|
|
return null;
|
|
}
|
|
}, [activeMainTab]);
|
|
|
|
const isChatActive = activeMainTab === 'chat';
|
|
const isSettingsActive = isSettingsDialogOpen && !isMobile;
|
|
|
|
return (
|
|
<DiffWorkerProvider>
|
|
<div
|
|
className={cn(
|
|
'main-content-safe-area h-[100dvh]',
|
|
isMobile ? 'flex flex-col' : 'flex',
|
|
'bg-background'
|
|
)}
|
|
>
|
|
<CommandPalette />
|
|
<HelpDialog />
|
|
<SessionDialogs />
|
|
|
|
{isMobile ? (
|
|
<>
|
|
{/* Mobile: Header + content with drill-down pattern */}
|
|
{!(isSettingsDialogOpen || isMultiRunLauncherOpen) && <Header />}
|
|
<div
|
|
className={cn(
|
|
'flex flex-1 overflow-hidden',
|
|
(isSettingsDialogOpen || isMultiRunLauncherOpen) && 'hidden'
|
|
)}
|
|
style={{ paddingTop: 'var(--oc-header-height, 56px)' }}
|
|
>
|
|
{/* Mobile drill-down: show sessions sidebar OR main content */}
|
|
{isSessionSwitcherOpen ? (
|
|
<div className="flex-1 overflow-hidden bg-sidebar">
|
|
<ErrorBoundary><SessionSidebar mobileVariant /></ErrorBoundary>
|
|
</div>
|
|
) : (
|
|
<main className="flex-1 overflow-hidden bg-background relative">
|
|
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
|
<ErrorBoundary><ChatView /></ErrorBoundary>
|
|
</div>
|
|
{secondaryView && (
|
|
<div className="absolute inset-0">
|
|
<ErrorBoundary>{secondaryView}</ErrorBoundary>
|
|
</div>
|
|
)}
|
|
</main>
|
|
)}
|
|
</div>
|
|
|
|
{/* Mobile multi-run launcher: full screen */}
|
|
{isMultiRunLauncherOpen && (
|
|
<div className="absolute inset-0 z-10 bg-background header-safe-area">
|
|
<ErrorBoundary>
|
|
<MultiRunLauncher
|
|
initialPrompt={multiRunLauncherPrefillPrompt}
|
|
onCreated={() => setMultiRunLauncherOpen(false)}
|
|
onCancel={() => setMultiRunLauncherOpen(false)}
|
|
/>
|
|
</ErrorBoundary>
|
|
</div>
|
|
)}
|
|
|
|
{/* Mobile settings: full screen */}
|
|
{isSettingsDialogOpen && (
|
|
<div className="absolute inset-0 z-10 bg-background header-safe-area">
|
|
<ErrorBoundary><SettingsView onClose={() => setSettingsDialogOpen(false)} /></ErrorBoundary>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* Desktop: Header always on top, then Sidebar + Content below */}
|
|
<div className="flex flex-1 flex-col overflow-hidden relative">
|
|
{/* Normal view: Header above Sidebar + content (like SettingsView) */}
|
|
<div className={cn('absolute inset-0 flex flex-col', (isSettingsActive || isMultiRunLauncherOpen) && 'invisible')}>
|
|
<Header />
|
|
<div className="flex flex-1 overflow-hidden">
|
|
<Sidebar isOpen={isSidebarOpen} isMobile={isMobile}>
|
|
<SessionSidebar />
|
|
</Sidebar>
|
|
<main className="flex-1 overflow-hidden bg-background relative">
|
|
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
|
<ErrorBoundary><ChatView /></ErrorBoundary>
|
|
</div>
|
|
{secondaryView && (
|
|
<div className="absolute inset-0">
|
|
<ErrorBoundary>{secondaryView}</ErrorBoundary>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Multi-Run Launcher: replaces tabs content only */}
|
|
{isMultiRunLauncherOpen && (
|
|
<div className={cn('absolute inset-0 z-10 bg-background')}>
|
|
<ErrorBoundary>
|
|
<MultiRunLauncher
|
|
initialPrompt={multiRunLauncherPrefillPrompt}
|
|
onCreated={() => setMultiRunLauncherOpen(false)}
|
|
onCancel={() => setMultiRunLauncherOpen(false)}
|
|
/>
|
|
</ErrorBoundary>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Settings view: full screen overlay */}
|
|
{isSettingsActive && (
|
|
<div className={cn('absolute inset-0 z-10 bg-background')}>
|
|
<ErrorBoundary><SettingsView onClose={() => setSettingsDialogOpen(false)} /></ErrorBoundary>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
</div>
|
|
</DiffWorkerProvider>
|
|
);
|
|
};
|