Files
boocode/apps/web/src/components/MobileTabSwitcher.tsx
indifferentketchup 990a615b87 web(coder UI): ChatInput migration + Thinking render + DiffPanel route fix
Bundles in-progress working-tree UI work not authored this session (CoderPane ChatInput migration, AgentComposerBar/CoderMessageList/tab-bar/sidebar/pane refinements, provider icons) with this session's changes to the same files: MessageBubble renders a collapsible 'Thinking' block from reasoning_text/reasoning_parts (surfacing ACP agent_thought_chunk + native reasoning), and the DiffPanel approve/reject calls are repointed to the real /api/coder/pending/:id/apply and /reject routes (the old /sessions/:id/pending/:id/approve|reject paths did not exist).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 03:12:06 +00:00

300 lines
11 KiB
TypeScript

import { useRef, useState } from 'react';
import {
Code,
ChevronDown,
Edit2,
MessageSquare,
MoreHorizontal,
Settings as SettingsIcon,
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 { ChatThroughput } from '@/components/ChatThroughput';
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>;
}
// v1.10.4: swipe-left-to-close on the pane pill. Threshold matches the spec
// (80px). Vertical bail-out at 30px because the pill sits inside a vertical
// scrollable header — diagonal-ish swipes shouldn't accidentally close panes.
const SWIPE_CLOSE_PX = 80;
const SWIPE_VERTICAL_BAIL_PX = 30;
// Visual cap: pill translates left up to this much. Past this, dragX stays
// pinned so the user has a clear "release to close" indicator.
const SWIPE_VISUAL_CAP = 120;
function paneIcon(kind: WorkspacePane['kind']) {
if (kind === 'terminal') return <Terminal size={14} />;
if (kind === 'coder') return <Code size={14} />;
if (kind === 'settings') return <SettingsIcon 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 === 'coder') return 'Coder';
if (pane.kind === 'settings') return 'Settings';
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('');
// v1.10.4: swipe-left state. dragX is the (clamped, negative) drag offset
// in px. suppressClick latches when a swipe completes so the trailing click
// doesn't pop open the BottomSheet on the just-closed pane.
const [dragX, setDragX] = useState(0);
const swipeStart = useRef<{ x: number; y: number } | null>(null);
const swipeBailed = useRef(false);
const suppressClick = useRef(false);
const active = panes[activePaneIdx];
const activeLabel = active ? paneLabel(active, chats) : 'Empty';
const activeChatId = paneActiveChatId(active);
function onPillTouchStart(e: React.TouchEvent<HTMLDivElement>): void {
if (e.touches.length !== 1) return;
const t = e.touches[0]!;
swipeStart.current = { x: t.clientX, y: t.clientY };
swipeBailed.current = false;
setDragX(0);
}
function onPillTouchMove(e: React.TouchEvent<HTMLDivElement>): void {
if (!swipeStart.current || swipeBailed.current) return;
if (e.touches.length !== 1) return;
const t = e.touches[0]!;
const dx = t.clientX - swipeStart.current.x;
const dy = t.clientY - swipeStart.current.y;
// Bail to scroll if vertical motion dominates before horizontal.
if (Math.abs(dy) > SWIPE_VERTICAL_BAIL_PX && Math.abs(dy) > Math.abs(dx)) {
swipeBailed.current = true;
setDragX(0);
return;
}
// Only allow leftward drag (negative). Cap visual displacement.
const clamped = Math.max(-SWIPE_VISUAL_CAP, Math.min(0, dx));
setDragX(clamped);
}
function onPillTouchEnd(): void {
const finalDx = dragX;
swipeStart.current = null;
if (swipeBailed.current) {
setDragX(0);
return;
}
if (finalDx <= -SWIPE_CLOSE_PX && panes.length > 1) {
suppressClick.current = true;
// Reset dragX after the close so subsequent re-renders look right.
setDragX(0);
onRemovePane(activePaneIdx);
return;
}
setDragX(0);
}
function onPillClick(): void {
if (suppressClick.current) {
suppressClick.current = false;
return;
}
setOpen(true);
}
const swipeProgress = Math.min(1, Math.abs(dragX) / SWIPE_CLOSE_PX);
// 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 (
<>
<div
className="flex-1 relative min-w-0"
onTouchStart={onPillTouchStart}
onTouchMove={onPillTouchMove}
onTouchEnd={onPillTouchEnd}
onTouchCancel={onPillTouchEnd}
>
{/* v1.10.4: red "Close" hint behind the pill. Opacity tracks the
swipe progress (0 at rest, 1 at the close threshold). aria-hidden
because the actionable affordance is the swipe, not this label. */}
<div
aria-hidden="true"
className="absolute inset-0 flex items-center justify-end pr-4 rounded-full bg-destructive/80 text-destructive-foreground text-xs font-medium"
style={{ opacity: swipeProgress, pointerEvents: 'none' }}
>
Close
</div>
<button
type="button"
onClick={onPillClick}
className="flex-1 w-full 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 relative"
aria-label="Switch pane"
style={{
transform: `translateX(${dragX}px)`,
transition: dragX === 0 ? 'transform 180ms ease-out' : 'none',
}}
>
<span className="shrink-0 text-muted-foreground">{paneIcon(active?.kind ?? 'chat')}</span>
<StatusDot chatId={activeChatId} />
<ChatThroughput chatId={activeChatId} />
<span className="truncate flex-1 text-left">{activeLabel}</span>
<ChevronDown size={14} className="opacity-60 shrink-0" />
</button>
</div>
<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} />
<ChatThroughput 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" className="min-w-44">
{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>
</>
);
}