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:
207
apps/web/src/components/MobileTabSwitcher.tsx
Normal file
207
apps/web/src/components/MobileTabSwitcher.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user