Repository Public
1'use client';2import { useState } from 'react';3import { useQueryClient } from '@tanstack/react-query';4import { motion } from 'motion/react';5import type { TerminalSession } from '@cr/shared';6import { LIVE_SESSION_STATES } from '@cr/shared';7import { Archive, ArchiveRestore, Pin, PinOff, Plus, RotateCw, Search, Trash2 } from 'lucide-react';8import { useSessions } from '@/lib/queries';9import { useUi } from '@/stores/ui';10import { useNow } from '@/hooks/use-viewport';11import { useOpenSession } from '@/hooks/use-nav';12import { sortSessions } from '@/hooks/use-current';13import { patchSession, restartSession, terminateSession } from '@/lib/session-actions';14import { errorMessage } from '@/lib/api';15import { fmtRelative } from '@/lib/format';16import { recoverableExplanation } from '@/lib/states';17import { toast } from '@/stores/toasts';18import { PullToRefresh } from '@/components/ui/pull-to-refresh';19import { SwipeRow, type SwipeAction } from '@/components/ui/swipe-row';20import { FilterChips } from '@/components/ui/chips';21import { ConfirmDialog } from '@/components/ui/dialog';22import { EmptyState, ErrorState, Skeleton } from '@/components/ui/misc';23import { IllusTerminal } from '@/components/ui/illustrations';24import { KindIcon, StateChip, stagger } from '@/components/home/sections';25import { useBrand } from '@/stores/branding';2627type Filter = 'all' | 'needs' | 'live' | 'ended' | 'archived';28const isEnded = (s: TerminalSession) => s.state === 'STOPPED' || s.state === 'FAILED';2930/** Phone session list: sticky search + chips, sections, cards with swipe actions (pin / archive / terminate). */31export function MobileSessions() {32 const brand = useBrand();33 const qc = useQueryClient();34 const [filter, setFilter] = useState<Filter>('all');35 const [text, setText] = useState('');36 const active = useSessions(false);37 const archived = useSessions(true, filter === 'archived');38 const q = filter === 'archived' ? archived : active;39 const open = useOpenSession();40 const openNew = useUi((s) => s.openNewSession);41 const now = useNow(10_000);42 const [ending, setEnding] = useState<TerminalSession | null>(null);43 const [restarting, setRestarting] = useState<TerminalSession | null>(null);4445 const f = text.trim().toLowerCase();46 const all = sortSessions(q.data ?? []).filter((s) => !f || `${s.title} ${s.projectName ?? ''}`.toLowerCase().includes(f));47 const needs = all.filter((s) => s.state === 'WAITING_FOR_INPUT');48 const live = all.filter((s) => s.state !== 'WAITING_FOR_INPUT' && (LIVE_SESSION_STATES.includes(s.state) || s.state === 'RECOVERABLE' || s.state === 'CREATED'));49 const ended = all.filter(isEnded);50 const groups: [string, TerminalSession[]][] =51 filter === 'archived' ? [['Archived', all]] : filter === 'needs' ? [['Needs you', needs]] : filter === 'live' ? [['Active', live]] : filter === 'ended' ? [['Ended', ended]] : [['Needs you', needs], ['Active', live], ['Ended', ended]];52 const total = groups.reduce((n, g) => n + g[1].length, 0);53 const act = async (fn: () => Promise<unknown>) => {54 try {55 await fn();56 } catch (e) {57 toast.error('Action failed', errorMessage(e));58 }59 };60 const actionsFor = (s: TerminalSession): SwipeAction[] => [61 { label: s.pinned ? 'Unpin' : 'Pin', icon: s.pinned ? <PinOff className="size-5" /> : <Pin className="size-5" />, onAction: () => act(() => patchSession(qc, s.id, { pinned: !s.pinned })) },62 ...(isEnded(s) || s.archived ? [{ label: s.archived ? 'Restore' : 'Archive', icon: s.archived ? <ArchiveRestore className="size-5" /> : <Archive className="size-5" />, tone: 'accent' as const, onAction: () => act(() => patchSession(qc, s.id, { archived: !s.archived })) }] : []),63 ...(!isEnded(s) && !s.archived ? [{ label: 'End', icon: <Trash2 className="size-5" />, tone: 'danger' as const, onAction: () => setEnding(s) }] : []),64 ];65 let index = 0;66 return (67 <div className="flex h-full flex-col">68 <div className="sticky top-0 z-10 flex shrink-0 flex-col gap-3 bg-bg px-4 pb-3">69 <div className="flex gap-2">70 <div className="relative min-w-0 flex-1">71 <Search className="pointer-events-none absolute left-4 top-1/2 size-[18px] -translate-y-1/2 text-faint" aria-hidden />72 <input value={text} onChange={(e) => setText(e.target.value)} placeholder="Search sessions" aria-label="Search sessions" className="h-12 w-full rounded-full border border-card-line bg-surface pl-11 pr-4 text-text shadow-card placeholder:text-faint focus:border-accent focus:outline-none" />73 </div>74 <button aria-label="New session" onClick={() => openNew()} className="bg-brand pressable flex size-12 shrink-0 items-center justify-center rounded-full shadow-float"><Plus className="size-6" strokeWidth={2.4} /></button>75 </div>76 <FilterChips<Filter> label="Filter sessions" value={filter} onChange={setFilter} options={[{ id: 'all', label: 'All' }, { id: 'needs', label: 'Needs you', count: needs.length }, { id: 'live', label: 'Active' }, { id: 'ended', label: 'Ended' }, { id: 'archived', label: 'Archived' }]} />77 </div>78 <PullToRefresh onRefresh={() => q.refetch()} className="min-h-0 flex-1" testId="sessions-list">79 <div className="flex flex-col gap-5 px-4 pb-10 pt-1">80 {q.isPending ? (81 [0, 1, 2].map((i) => <Skeleton key={i} className="h-[76px] rounded-md" />)82 ) : q.isError ? (83 <ErrorState error={q.error} onRetry={() => q.refetch()} />84 ) : total === 0 ? (85 <EmptyState illustration={<IllusTerminal />} title={f ? 'Nothing matches' : filter === 'archived' ? 'No archived sessions' : filter === 'needs' ? 'Nothing needs you' : 'No sessions yet'} description={f ? 'Try another name or project.' : filter === 'all' ? `Start a ${brand.agentName} or shell session to get going.` : undefined} />86 ) : (87 groups.filter((g) => g[1].length > 0).map(([label, list]) => (88 <section key={label} aria-label={label}>89 <h2 className="mb-2 px-1 text-xs font-semibold uppercase tracking-wide text-muted">{label} · {list.length}</h2>90 <ul className="flex flex-col gap-2.5">91 {list.map((s) => (92 <motion.li key={s.id} {...stagger(index++)} data-testid="session-item" data-state={s.state}>93 <SwipeRow actions={actionsFor(s)} className="card">94 <div className="flex items-center gap-3 p-3.5">95 <button onClick={() => open(s.id)} className="flex min-w-0 flex-1 items-center gap-3 text-left">96 <KindIcon kind={s.kind} />97 <span className="min-w-0 flex-1">98 <span className="flex items-center gap-1.5">99 <span className="truncate text-[15px] font-semibold text-text">{s.title}</span>100 {s.pinned && <Pin className="size-3.5 shrink-0 text-accent" aria-label="Pinned" />}101 </span>102 <span className={s.state === 'WAITING_FOR_INPUT' ? 'block truncate text-[13px] text-coral-ink' : 'tnum block truncate text-[13px] text-muted'}>103 {s.state === 'WAITING_FOR_INPUT' ? (s.waitingReason ?? 'Waiting for your input') : `${s.projectName ?? 'No project'} · ${fmtRelative(s.lastActivityAt ?? s.createdAt, now)}${isEnded(s) && s.exitCode != null ? ` · exit ${s.exitCode}` : ''}`}104 </span>105 </span>106 </button>107 {s.state === 'RECOVERABLE' ? (108 <button onClick={() => setRestarting(s)} className="pressable flex h-9 shrink-0 items-center gap-1.5 rounded-full bg-accent px-3.5 text-[13px] font-semibold text-accent-fg"><RotateCw className="size-3.5" /> Restart</button>109 ) : (110 <StateChip session={s} />111 )}112 </div>113 </SwipeRow>114 </motion.li>115 ))}116 </ul>117 </section>118 ))119 )}120 {total > 0 && <p className="text-center text-xs text-faint">Swipe a session left to pin, archive or end it.</p>}121 </div>122 </PullToRefresh>123 <ConfirmDialog open={!!ending} onOpenChange={(o) => !o && setEnding(null)} title="Terminate session?" description={<>The process of “{ending?.title}” will be stopped on the server. Unsaved work inside it is lost.</>} confirmLabel="Terminate" danger onConfirm={() => (ending ? act(() => terminateSession(qc, ending.id)) : undefined)} />124 <ConfirmDialog open={!!restarting} onOpenChange={(o) => !o && setRestarting(null)} title="Restart session?" description={recoverableExplanation(brand)} confirmLabel="Restart" onConfirm={() => (restarting ? act(async () => { await restartSession(qc, restarting.id); open(restarting.id); }) : undefined)} />125 </div>126 );127}128