ChatThroughput renders inline beside StatusDot while streaming or tool_running. Subscribes to existing usage frames via sessionEvents. Hides when status drops to idle/error or data is older than 10s. Addresses the 2026-05-21 spike's UX gap where slow streams looked identical to dead streams — now there's a live token velocity readout that immediately distinguishes the two. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
300 lines
11 KiB
TypeScript
300 lines
11 KiB
TypeScript
import { useRef, useState } from 'react';
|
|
import {
|
|
Bot,
|
|
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 === 'agent') return <Bot 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 === 'agent') return 'Agent';
|
|
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">
|
|
{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>
|
|
</>
|
|
);
|
|
}
|