feat(mobile): v1.8 tab switcher + branch indicator + git_status tool

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>
This commit is contained in:
2026-05-16 20:07:53 +00:00
parent 92bd3b1cdf
commit 2bce4d85fa
19 changed files with 1217 additions and 622 deletions

View File

@@ -0,0 +1,92 @@
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>
</>
);
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { History, MessageSquare, Plus, X } from 'lucide-react';
import type { Chat, WorkspacePane } from '@/api/types';
import { StatusDot } from '@/components/StatusDot';
import {
ContextMenu,
ContextMenuContent,
@@ -66,7 +67,7 @@ export function ChatTabBar({
}
return (
<div className="flex items-center border-b border-border bg-muted/20 h-8 shrink-0 overflow-x-auto">
<div className="flex items-center border-b border-border bg-muted/20 h-8 shrink-0 overflow-x-auto max-md:hidden">
{tabs.map((chat, tabIdx) => {
const isActive = tabIdx === pane.activeChatIdx;
const isLast = tabIdx === tabs.length - 1;
@@ -91,6 +92,7 @@ export function ChatTabBar({
)}
>
<MessageSquare size={12} className="shrink-0" />
<StatusDot chatId={chat.id} />
{renamingId === chat.id ? (
<input
autoFocus

View File

@@ -0,0 +1,207 @@
import { useState } from 'react';
import {
Bot,
ChevronDown,
Edit2,
MessageSquare,
MoreHorizontal,
Terminal,
X,
} from 'lucide-react';
import { toast } from 'sonner';
import type { Chat, WorkspacePane } from '@/api/types';
import { BottomSheet } from '@/components/BottomSheet';
import { StatusDot } from '@/components/StatusDot';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useLongPress } from '@/hooks/useLongPress';
import { cn } from '@/lib/utils';
interface Props {
panes: WorkspacePane[];
activePaneIdx: number;
chats: Chat[];
onSwitchPane: (idx: number) => void;
onRemovePane: (idx: number) => void;
onRenameChat: (chatId: string, name: string) => Promise<void>;
}
function paneIcon(kind: WorkspacePane['kind']) {
if (kind === 'terminal') return <Terminal size={14} />;
if (kind === 'agent') return <Bot size={14} />;
return <MessageSquare size={14} />;
}
function paneActiveChatId(pane: WorkspacePane | undefined): string | null {
if (!pane) return null;
if (pane.chatId) return pane.chatId;
const idx = pane.activeChatIdx;
if (idx < 0 || idx >= pane.chatIds.length) return null;
return pane.chatIds[idx] ?? null;
}
function paneLabel(pane: WorkspacePane, chats: Chat[]): string {
const cid = paneActiveChatId(pane);
if (cid) {
const c = chats.find((x) => x.id === cid);
if (c) return c.name ?? 'New chat';
}
if (pane.kind === 'chat') return 'Chat';
if (pane.kind === 'terminal') return 'Terminal';
if (pane.kind === 'agent') return 'Agent';
return 'Empty';
}
export function MobileTabSwitcher({
panes,
activePaneIdx,
chats,
onSwitchPane,
onRemovePane,
onRenameChat,
}: Props) {
const [open, setOpen] = useState(false);
const [renamingChatId, setRenamingChatId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const active = panes[activePaneIdx];
const activeLabel = active ? paneLabel(active, chats) : 'Empty';
const activeChatId = paneActiveChatId(active);
// Long-press mirrors ChatTabBar: synthesize a contextmenu event on the row
// so the trailing kebab's Radix DropdownMenu opens at the touch point.
const longPress = useLongPress(({ clientX, clientY, target }) => {
if (!target || !(target instanceof Element)) return;
const row = target.closest('[data-pane-id]') as HTMLElement | null;
if (!row) return;
const trigger = row.querySelector('[data-pane-kebab]') as HTMLElement | null;
if (trigger) {
trigger.click();
return;
}
row.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX, clientY }),
);
});
function startRename(chatId: string, currentName: string | null) {
setRenamingChatId(chatId);
setRenameValue(currentName ?? '');
}
async function finishRename() {
if (renamingChatId && renameValue.trim()) {
try {
await onRenameChat(renamingChatId, renameValue.trim());
} catch (err) {
toast.error(err instanceof Error ? err.message : 'rename failed');
}
}
setRenamingChatId(null);
}
function handleSwitchPane(idx: number) {
onSwitchPane(idx);
setOpen(false);
}
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="flex-1 inline-flex items-center gap-1.5 min-h-[44px] px-3 text-sm rounded-full bg-muted/40 hover:bg-muted/70 text-foreground min-w-0"
aria-label="Switch pane"
>
<span className="shrink-0 text-muted-foreground">{paneIcon(active?.kind ?? 'chat')}</span>
<StatusDot chatId={activeChatId} />
<span className="truncate flex-1 text-left">{activeLabel}</span>
<ChevronDown size={14} className="opacity-60 shrink-0" />
</button>
<BottomSheet open={open} onClose={() => setOpen(false)} title="Panes">
<ul className="px-2 py-2 space-y-1">
{panes.map((pane, idx) => {
const isActive = idx === activePaneIdx;
const cid = paneActiveChatId(pane);
const chat = cid ? chats.find((c) => c.id === cid) ?? null : null;
const label = paneLabel(pane, chats);
return (
<li
key={pane.id}
data-pane-id={pane.id}
onTouchStart={longPress.onTouchStart}
onTouchMove={longPress.onTouchMove}
onTouchEnd={longPress.onTouchEnd}
onTouchCancel={longPress.onTouchCancel}
onClick={() => handleSwitchPane(idx)}
style={{ WebkitTouchCallout: 'none' }}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded min-h-[48px] cursor-default select-none',
isActive
? 'bg-accent/40 border-l-2 border-primary'
: 'hover:bg-muted/50',
)}
>
<span className="shrink-0 text-muted-foreground">{paneIcon(pane.kind)}</span>
<StatusDot chatId={cid ?? null} />
{renamingChatId === cid && cid ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void finishRename()}
onKeyDown={(e) => {
if (e.key === 'Enter') void finishRename();
if (e.key === 'Escape') setRenamingChatId(null);
}}
onClick={(e) => e.stopPropagation()}
className="bg-transparent border-b border-border text-sm outline-none flex-1 min-w-0"
/>
) : (
<span className="truncate flex-1 text-sm">{label}</span>
)}
{isActive && (
<span aria-hidden="true" className="text-primary text-xs shrink-0">
</span>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
data-pane-kebab
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground min-h-[44px] min-w-[44px]"
aria-label="Pane options"
>
<MoreHorizontal size={14} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{chat && (
<DropdownMenuItem onSelect={() => startRename(chat.id, chat.name)}>
<Edit2 size={14} /> Rename chat
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={panes.length <= 1}
onSelect={() => onRemovePane(idx)}
>
<X size={14} /> Close pane
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</li>
);
})}
</ul>
{/* v1.8: New-pane button moved out of the sheet to the header row 2
(see NewPaneMenu). Sheet is for switching only. */}
</BottomSheet>
</>
);
}

View File

@@ -0,0 +1,44 @@
import { Bot, MessageSquare, Plus, Terminal } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface Props {
onAddPane: (kind: 'chat' | 'terminal' | 'agent') => void;
disabled?: boolean;
}
// v1.8 row-2 right cluster: mirrors the desktop Workspace.tsx Split dropdown.
// Terminal and Agent items pass through to addSplitPane which already shows
// "coming soon" toasts; rendering them here matches the Batch 3 workspace
// model so the UI is forward-compatible with BooTerm/BooCoder.
export function NewPaneMenu({ onAddPane, disabled }: Props) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled}
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-full bg-muted/40 hover:bg-muted/70 text-foreground disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
aria-label="New pane"
>
<Plus size={16} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New chat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
<Terminal size={14} /> New terminal
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('agent')}>
<Bot size={14} /> New agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,36 @@
import { useChatStatus, type DerivedStatus } from '@/hooks/useChatStatus';
import { cn } from '@/lib/utils';
interface Props {
chatId: string | null | undefined;
className?: string;
}
const STATUS_CLASS: Record<DerivedStatus, string> = {
working: 'bg-amber-500 animate-pulse',
idle_warm: 'bg-emerald-500',
idle_cold: 'bg-muted-foreground/40',
error: 'bg-destructive',
};
const STATUS_LABEL: Record<DerivedStatus, string> = {
working: 'working',
idle_warm: 'idle',
idle_cold: 'idle',
error: 'error',
};
export function StatusDot({ chatId, className }: Props) {
const status = useChatStatus(chatId);
return (
<span
aria-label={`Status: ${STATUS_LABEL[status]}`}
title={STATUS_LABEL[status]}
className={cn(
'inline-block w-1.5 h-1.5 rounded-full shrink-0',
STATUS_CLASS[status],
className,
)}
/>
);
}

View File

@@ -1,14 +1,11 @@
import { useCallback, useEffect } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
import type { Chat, WorkspacePane } from '@/api/types';
import { useWorkspacePanes, MAX_PANES } from '@/hooks/useWorkspacePanes';
import { useSessionChats } from '@/hooks/useSessionChats';
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
import { useViewport } from '@/hooks/useViewport';
import { ChatPane } from '@/components/panes/ChatPane';
import { ChatTabBar } from '@/components/ChatTabBar';
import { SessionLandingPage } from '@/components/SessionLandingPage';
import { SwipeablePaneTab } from '@/components/SwipeablePaneTab';
import {
DropdownMenu,
DropdownMenuContent,
@@ -23,14 +20,24 @@ interface Props {
// Batch 9: threaded down to ChatPane → ChatInput → AgentPicker.
agentId?: string | null;
onAgentChange?: (agentId: string | null) => void | Promise<void>;
// v1.8: panes + chats hoisted into Session.tsx so the mobile header pill
// (MobileTabSwitcher) can share state with the pane grid.
panesHook: UseWorkspacePanesResult;
chatsHook: UseSessionChatsResult;
}
export function Workspace({ sessionId, projectId, agentId, onAgentChange }: Props) {
export function Workspace({
sessionId,
projectId,
agentId,
onAgentChange,
panesHook,
chatsHook,
}: Props) {
const {
panes,
activePaneIdx,
setActivePaneIdx,
activePaneIdxRef,
openChatInPane,
switchTab,
removeTab,
@@ -40,8 +47,6 @@ export function Workspace({ sessionId, projectId, agentId, onAgentChange }: Prop
showLandingPage,
addSplitPane,
removePane,
removeChatFromPanes,
initializeFirstChatIfEmpty,
handlePaneDragStart,
handlePaneDragOver,
handlePaneDragLeave,
@@ -49,15 +54,7 @@ export function Workspace({ sessionId, projectId, agentId, onAgentChange }: Prop
handlePaneDragEnd,
dragOverIdx,
draggingIdxRef,
} = useWorkspacePanes(sessionId);
// Thin wrapper so useSessionChats can route open_chat_in_active_pane events
// without knowing about pane indexing.
const openChatInActivePane = useCallback(
(chatId: string) => openChatInPane(activePaneIdxRef.current, chatId),
[openChatInPane, activePaneIdxRef],
);
} = panesHook;
const {
chats,
createChat,
@@ -66,47 +63,9 @@ export function Workspace({ sessionId, projectId, agentId, onAgentChange }: Prop
deleteChat,
renameChat,
handleLandingSend,
} = useSessionChats(sessionId, {
removeChatFromPanes,
openChatInPane,
openChatInActivePane,
initializeFirstChatIfEmpty,
});
} = chatsHook;
const { isMobile } = useViewport();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
// URL -> state (mobile only). Handles deep-link arrival and Back button
// history pops. On a bare URL (no ?pane), reset to first pane so Back
// from a ?pane URL returns the user to a sensible default.
useEffect(() => {
if (!isMobile || panes.length === 0) return;
const paneId = searchParams.get('pane');
if (!paneId) {
if (activePaneIdx !== 0) setActivePaneIdx(0);
return;
}
const idx = panes.findIndex((p) => p.id === paneId);
if (idx >= 0 && idx !== activePaneIdx) setActivePaneIdx(idx);
}, [isMobile, searchParams, panes, activePaneIdx, setActivePaneIdx]);
// Switch active pane and push URL (mobile only). User-initiated only;
// never called from URL-sync effect.
const switchActivePane = useCallback(
(idx: number) => {
setActivePaneIdx(idx);
if (isMobile) {
const pane = panes[idx];
if (!pane) return;
const params = new URLSearchParams(location.search);
params.set('pane', pane.id);
navigate(`${location.pathname}?${params.toString()}`);
}
},
[setActivePaneIdx, isMobile, panes, navigate, location.pathname, location.search],
);
function chatsForPane(pane: WorkspacePane): Chat[] {
return pane.chatIds
@@ -114,18 +73,6 @@ export function Workspace({ sessionId, projectId, agentId, onAgentChange }: Prop
.filter((c): c is Chat => c !== undefined);
}
function paneLabel(pane: WorkspacePane): string {
const activeChatId = pane.chatId;
if (activeChatId) {
const chat = chats.find((c) => c.id === activeChatId);
if (chat) return chat.name ?? 'New chat';
}
if (pane.kind === 'chat') return 'Chat';
if (pane.kind === 'terminal') return 'Terminal';
if (pane.kind === 'agent') return 'Agent';
return 'Empty';
}
return (
<div className="flex flex-col h-full min-h-0">
{!isMobile && (
@@ -159,20 +106,8 @@ export function Workspace({ sessionId, projectId, agentId, onAgentChange }: Prop
</div>
)}
{isMobile && panes.length > 1 && (
<div className="flex items-center gap-1 overflow-x-auto border-b border-border bg-muted/10 px-2 py-1 shrink-0">
{panes.map((pane, idx) => (
<SwipeablePaneTab
key={pane.id}
label={paneLabel(pane)}
isActive={idx === activePaneIdx}
onTap={() => switchActivePane(idx)}
onClose={() => removePane(idx)}
canClose={panes.length > 1}
/>
))}
</div>
)}
{/* v1.8: mobile multi-pane SwipeablePaneTab strip removed; the header
pill (MobileTabSwitcher) is the mobile pane switcher. */}
<div
className={cn('flex-1 min-h-0', isMobile ? 'flex' : 'grid')}
@@ -205,19 +140,24 @@ export function Workspace({ sessionId, projectId, agentId, onAgentChange }: Prop
onDragStart={!isMobile && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={!isMobile && panes.length > 1 ? handlePaneDragEnd : undefined}
>
<ChatTabBar
pane={pane}
tabs={chatsForPane(pane)}
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
onRemoveTab={(chatId) => removeTab(idx, chatId)}
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
onCloseAll={() => closeAllTabs(idx)}
onNewChat={() => void createChat(idx)}
onShowHistory={() => showLandingPage(idx)}
onRename={renameChat}
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/>
{/* Hidden on mobile per v1.8: chat-within-pane navigation
is not exposed on small screens; users switch panes via
the header pill instead. */}
{!isMobile && (
<ChatTabBar
pane={pane}
tabs={chatsForPane(pane)}
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
onRemoveTab={(chatId) => removeTab(idx, chatId)}
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
onCloseAll={() => closeAllTabs(idx)}
onNewChat={() => void createChat(idx)}
onShowHistory={() => showLandingPage(idx)}
onRename={renameChat}
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/>
)}
</div>
<div className="flex-1 min-h-0 overflow-hidden">