Mobile header is now two rows. Row 1: hamburger | project · branch indicator (live via GET /api/projects/:id/git, 30s poll) | ModelPicker | FolderTree. Row 2: pane-switcher pill (hand-rolled BottomSheet) + NewPaneMenu. Chat-within-pane navigation hidden on mobile; users switch panes via the sheet. Cross-tab status sync via chat_status frames published from inference.ts at working/idle/error transitions; StatusDot component renders amber-pulse/green/red/gray on each pane row and on desktop ChatTabBar tabs. Level 1 git awareness exposes a read-only git_status tool to the model, backed by services/git_meta.ts (execFile + 2s timeout + 30s cache). Workspace.tsx now receives panes/chats hooks as props (hoisted into Session.tsx) so the header pill shares state with the pane grid. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
93 lines
2.5 KiB
TypeScript
93 lines
2.5 KiB
TypeScript
import { useEffect, useRef, useState, type ReactNode, type TouchEvent } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
children: ReactNode;
|
|
title?: string;
|
|
}
|
|
|
|
// Past this drag distance, release dismisses the sheet.
|
|
const SWIPE_DISMISS_THRESHOLD_PX = 80;
|
|
|
|
export function BottomSheet({ open, onClose, children, title }: Props) {
|
|
const [dragY, setDragY] = useState(0);
|
|
const startYRef = useRef<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose();
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, [open, onClose]);
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setDragY(0);
|
|
startYRef.current = null;
|
|
}
|
|
}, [open]);
|
|
|
|
function onTouchStart(e: TouchEvent<HTMLDivElement>) {
|
|
const t = e.touches[0];
|
|
if (!t) return;
|
|
startYRef.current = t.clientY;
|
|
}
|
|
function onTouchMove(e: TouchEvent<HTMLDivElement>) {
|
|
const t = e.touches[0];
|
|
if (!t || startYRef.current === null) return;
|
|
const dy = t.clientY - startYRef.current;
|
|
// Clamp to downward drags so the sheet doesn't "rubber-band" up.
|
|
if (dy > 0) setDragY(dy);
|
|
}
|
|
function onTouchEnd() {
|
|
if (dragY > SWIPE_DISMISS_THRESHOLD_PX) {
|
|
onClose();
|
|
} else {
|
|
setDragY(0);
|
|
}
|
|
startYRef.current = null;
|
|
}
|
|
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className="fixed inset-0 z-40 bg-black/40"
|
|
onClick={onClose}
|
|
aria-hidden="true"
|
|
/>
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
className={cn(
|
|
'fixed inset-x-0 bottom-0 z-50 rounded-t-2xl border-t border-border bg-popover text-popover-foreground shadow-2xl',
|
|
'transition-transform duration-150 will-change-transform',
|
|
'max-h-[70vh] flex flex-col',
|
|
)}
|
|
style={{
|
|
transform: `translateY(${dragY}px)`,
|
|
paddingBottom: 'env(safe-area-inset-bottom)',
|
|
}}
|
|
>
|
|
<div
|
|
onTouchStart={onTouchStart}
|
|
onTouchMove={onTouchMove}
|
|
onTouchEnd={onTouchEnd}
|
|
className="flex flex-col items-center pt-2 pb-1 select-none touch-none"
|
|
>
|
|
<div className="w-10 h-1 bg-muted-foreground/40 rounded-full" />
|
|
{title && (
|
|
<div className="mt-1 text-sm font-medium text-muted-foreground">{title}</div>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto">{children}</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|