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

@@ -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">