Repository Public
1'use client';2import { useRef, useState } from 'react';3import { motion } from 'motion/react';4import { Ellipsis, Plus } from 'lucide-react';5import { useSessions, useSettings } from '@/lib/queries';6import { useTerminals } from '@/stores/terminals';7import { useUi } from '@/stores/ui';8import { useActiveSession, useCurrentProject, sortSessions } from '@/hooks/use-current';9import { useNow } from '@/hooks/use-viewport';10import { SESSION_STATE } from '@/lib/states';11import { haptic } from '@/lib/haptic';12import { LIVE_SESSION_STATES } from '@cr/shared';13import { IconButton, Button } from '@/components/ui/button';14import { BottomSheet } from '@/components/ui/bottom-sheet';15import { SessionDot } from '@/components/sessions/state-badge';16import { SessionCard } from '@/components/home/sections';17import { ConnectionIndicator } from '@/components/shell/connection-indicator';18import { NotificationsBell } from '@/components/shell/notifications';19import { cn } from '@/lib/cn';20import { MobileToolbar } from './mobile-toolbar';21import { QuickActions } from './quick-actions';22import { NoSession, TerminalView, useSessionUrlSync } from './workspace';2324/** Phone terminal: header, session strip, edge-to-edge terminal card, key toolbar above the keyboard. */25export function MobileTerminal() {26 useSessionUrlSync();27 const settings = useSettings();28 const activeId = useTerminals((s) => s.activeId);29 const openIds = useTerminals((s) => s.openIds);30 const session = useActiveSession();31 const project = useCurrentProject();32 const all = useSessions().data ?? [];33 const sessions = sortSessions(all);34 const sheetOpen = useUi((s) => s.sessionsSheetOpen);35 const setUi = useUi((s) => s.set);36 const now = useNow(15_000);37 const [actions, setActions] = useState(false);38 const swipe = useRef<{ x: number; y: number } | null>(null);39 // strip: open tabs first, then the other live sessions40 const strip = [...openIds.map((id) => all.find((s) => s.id === id)).filter((s) => !!s), ...sessions.filter((s) => !openIds.includes(s.id) && LIVE_SESSION_STATES.includes(s.state))];4142 const select = (id: string) => {43 haptic(6);44 useTerminals.getState().open(id, true);45 };46 const cycle = (dir: 1 | -1) => {47 if (strip.length < 2) return;48 const i = strip.findIndex((s) => s.id === activeId);49 const next = strip[(i + dir + strip.length) % strip.length];50 if (next) select(next.id);51 };52 const waiting = session?.state === 'WAITING_FOR_INPUT';5354 return (55 <div className="flex h-full min-h-0 flex-col bg-bg">56 <header57 className="pt-safe shrink-0"58 data-testid="mobile-topbar"59 onTouchStart={(e) => { const t = e.touches[0]; swipe.current = t ? { x: t.clientX, y: t.clientY } : null; }}60 onTouchEnd={(e) => {61 const t = e.changedTouches[0];62 const s = swipe.current;63 swipe.current = null;64 if (!t || !s) return;65 const dx = t.clientX - s.x;66 if (Math.abs(dx) > 60 && Math.abs(dx) > 2 * Math.abs(t.clientY - s.y)) cycle(dx < 0 ? 1 : -1);67 }}68 >69 <div className="px-safe flex h-[54px] items-center gap-0.5 pl-4 pr-1.5">70 <button onClick={() => setUi({ sessionsSheetOpen: true })} className="flex h-11 min-w-0 flex-1 flex-col justify-center text-left" aria-label="Open session list" data-testid="mobile-switcher">71 <span className="block truncate text-[17px] font-bold leading-tight tracking-tight">{session?.projectName ?? project?.name ?? 'Terminal'}</span>72 <span className={cn('block truncate text-[12.5px] leading-tight', waiting ? 'font-medium text-coral-ink' : 'text-muted')}>73 {session ? `${session.title} · ${waiting ? (session.waitingReason ?? 'Needs you') : SESSION_STATE[session.state].label}` : 'No session open'}74 </span>75 </button>76 <ConnectionIndicator compact className="px-2" />77 <NotificationsBell />78 <IconButton label="Terminal actions" noTooltip onClick={() => { haptic(6); setActions(true); }} data-testid="mobile-overflow">79 <Ellipsis className="size-5" />80 </IconButton>81 </div>82 <div className="no-scrollbar flex gap-2 overflow-x-auto px-4 pb-2.5 pt-1" role="tablist" aria-label="Sessions" data-testid="session-strip">83 {strip.map((s) => {84 const active = s.id === activeId;85 return (86 <button key={s.id} role="tab" aria-selected={active} data-session-id={s.id} onClick={() => select(s.id)} className={cn('relative flex h-9 max-w-[180px] shrink-0 items-center gap-1.5 rounded-full px-3.5 text-[13px] font-medium transition-colors', active ? 'text-accent-fg' : 'bg-surface text-text-2 shadow-card', s.state === 'WAITING_FOR_INPUT' && !active && 'ring-2 ring-coral/70')}>87 {active && <motion.span layoutId="strip-active" className="absolute inset-0 rounded-full bg-accent" transition={{ type: 'spring', stiffness: 480, damping: 38 }} />}88 <span className="relative"><SessionDot state={s.state} className={active ? '!border-white/80' : undefined} /></span>89 <span className="relative truncate">{s.title}</span>90 </button>91 );92 })}93 <button aria-label="New session" onClick={() => useUi.getState().openNewSession(project?.id ?? null)} className="pressable flex size-9 shrink-0 items-center justify-center rounded-full bg-surface text-accent shadow-card">94 <Plus className="size-[18px]" strokeWidth={2.4} />95 </button>96 </div>97 </header>98 <div className="relative min-h-0 flex-1">99 {activeId ? <TerminalView key={activeId} sessionId={activeId} focused mobile /> : <NoSession mobile />}100 {/* left-edge swipe opens the session list */}101 <div102 aria-hidden103 className="absolute inset-y-0 left-0 z-20 w-3"104 onTouchStart={(e) => { const t = e.touches[0]; swipe.current = t ? { x: t.clientX, y: t.clientY } : null; }}105 onTouchMove={(e) => { const t = e.touches[0]; if (t && swipe.current && t.clientX - swipe.current.x > 36) { swipe.current = null; setUi({ sessionsSheetOpen: true }); } }}106 />107 </div>108 {activeId && <MobileToolbar sessionId={activeId} keys={settings.mobileToolbar} />}109 <BottomSheet open={sheetOpen} onOpenChange={(o) => setUi({ sessionsSheetOpen: o })} title="Sessions" testId="sessions-sheet">110 <div className="flex flex-col gap-2.5 px-4">111 <Button variant="brand" size="lg" className="w-full" onClick={() => { setUi({ sessionsSheetOpen: false }); useUi.getState().openNewSession(project?.id ?? null); }}>112 <Plus className="size-5" /> New session113 </Button>114 {sessions.length === 0 ? (115 <p className="py-8 text-center text-[15px] text-muted">No sessions yet</p>116 ) : (117 sessions.map((s) => <SessionCard key={s.id} session={s} now={now} className={cn(s.id === activeId && 'ring-2 ring-accent')} onOpen={() => { select(s.id); setUi({ sessionsSheetOpen: false }); }} />)118 )}119 </div>120 </BottomSheet>121 <QuickActions open={actions} onOpenChange={setActions} session={session} sessionId={activeId} />122 </div>123 );124}125