SPB Git

Repository Public

mobile-terminal.tsx 7.1 KB 125 lines tsx

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
Files main
Menu

Theme