Repository Public
1'use client';2import { useRef, useState } from 'react';3import { useQueryClient } from '@tanstack/react-query';4import type { TerminalSession } from '@cr/shared';5import { Archive, Ellipsis, Pin, Plus, Search, Sparkles, SquareTerminal, Users } from 'lucide-react';6import { useSessions } from '@/lib/queries';7import { useUi } from '@/stores/ui';8import { useTerminals } from '@/stores/terminals';9import { useBreakpoint, useNow } from '@/hooks/use-viewport';10import { MobileSessions } from '@/components/sessions/mobile-sessions';11import { IllusTerminal } from '@/components/ui/illustrations';12import { useOpenSession } from '@/hooks/use-nav';13import { sortSessions } from '@/hooks/use-current';14import { patchSession, restartSession, resyncSession, terminateSession } from '@/lib/session-actions';15import { errorMessage } from '@/lib/api';16import { fmtRelative } from '@/lib/format';17import { recoverableExplanation } from '@/lib/states';18import { toast } from '@/stores/toasts';19import { Button } from '@/components/ui/button';20import { Input } from '@/components/ui/input';21import { ConfirmDialog } from '@/components/ui/dialog';22import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown';23import { EmptyState, ErrorState, PageHeader, Skeleton } from '@/components/ui/misc';24import { SessionStateBadge } from '@/components/sessions/state-badge';25import { cn } from '@/lib/cn';26import { useBrand } from '@/stores/branding';2728export default function SessionsPage() {29 const brand = useBrand();30 const bp = useBreakpoint();31 const [archived, setArchived] = useState(false);32 const [filter, setFilter] = useState('');33 const q = useSessions(archived);34 const openNew = useUi((s) => s.openNewSession);35 const now = useNow(5000);36 const f = filter.trim().toLowerCase();37 const list = sortSessions(q.data ?? []).filter((s) => !f || `${s.title} ${s.projectName ?? ''} ${s.state} ${s.kind}`.toLowerCase().includes(f));38 if (bp === 'mobile') return <MobileSessions />;39 return (40 <div className="flex h-full flex-col overflow-hidden">41 <PageHeader42 title="Sessions"43 description="Terminal sessions run on the server inside tmux and keep running when no browser is attached."44 actions={<Button variant="primary" onClick={() => openNew()}><Plus className="size-3.5" /> New session</Button>}45 />46 <div className="flex flex-wrap items-center gap-2 border-b border-line px-4 py-2 md:px-6">47 <div className="relative min-w-40 flex-1 sm:max-w-xs">48 <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted" aria-hidden />49 <Input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Filter sessions" aria-label="Filter sessions" className="pl-8" />50 </div>51 <div role="group" aria-label="View" className="flex overflow-hidden rounded-sm border border-line-2">52 {[false, true].map((a) => (53 <button key={String(a)} aria-pressed={archived === a} onClick={() => setArchived(a)} className={cn('h-8 px-3 text-xs pointer-coarse:h-11', archived === a ? 'bg-surface-2 text-text' : 'text-muted hover:text-text-2')}>54 {a ? 'Archived' : 'Active'}55 </button>56 ))}57 </div>58 </div>59 <div className="min-h-0 flex-1 overflow-y-auto" data-testid="sessions-list">60 {q.isPending ? (61 <div className="flex flex-col gap-2 p-4 md:p-6">{[0, 1, 2].map((i) => <Skeleton key={i} className="h-14" />)}</div>62 ) : q.isError ? (63 <ErrorState error={q.error} onRetry={() => q.refetch()} />64 ) : list.length === 0 ? (65 <EmptyState illustration={<IllusTerminal />} title={f ? 'No sessions match the filter' : archived ? 'No archived sessions' : 'No sessions yet'} description={!f && !archived ? `Start a ${brand.agentName} or shell session to get going.` : undefined} />66 ) : (67 <ul className="divide-y divide-line">{list.map((s) => <SessionItem key={s.id} session={s} now={now} />)}</ul>68 )}69 </div>70 </div>71 );72}7374function SessionItem({ session: s, now }: { session: TerminalSession; now: number }) {75 const brand = useBrand();76 const qc = useQueryClient();77 const open = useOpenSession();78 const isOpen = useTerminals((t) => t.openIds.includes(s.id));79 const [renaming, setRenaming] = useState(false);80 const [title, setTitle] = useState(s.title);81 const [confirm, setConfirm] = useState<'terminate' | 'restart' | null>(null);82 const keepFocus = useRef(false);83 const act = async (fn: () => Promise<unknown>, okMsg?: string) => {84 try {85 await fn();86 if (okMsg) toast.success(okMsg);87 } catch (e) {88 toast.error('Action failed', errorMessage(e));89 }90 };91 const commit = () => {92 setRenaming(false);93 const t = title.trim();94 if (t && t !== s.title) void act(() => patchSession(qc, s.id, { title: t }));95 else setTitle(s.title);96 };97 const waiting = s.state === 'WAITING_FOR_INPUT';98 const ended = s.state === 'STOPPED' || s.state === 'FAILED';99 const Icon = s.kind === 'claude' ? Sparkles : SquareTerminal;100 return (101 <li className={cn('flex flex-wrap items-center gap-x-4 gap-y-1.5 px-4 py-3 md:px-6', waiting && 'bg-coral/8')} data-testid="session-item" data-state={s.state}>102 <Icon className="size-4 shrink-0 text-muted" aria-label={s.kind === 'claude' ? brand.agentName : 'Shell'} />103 <div className="min-w-0 flex-1 basis-[calc(100%-2.5rem)] sm:basis-48">104 {renaming ? (105 <Input autoFocus value={title} maxLength={120} aria-label="Session title" onChange={(e) => setTitle(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') { setTitle(s.title); setRenaming(false); } }} className="h-7" />106 ) : (107 <button onClick={() => open(s.id)} onDoubleClick={() => setRenaming(true)} className="flex max-w-full items-center gap-1.5 text-left text-[13px] font-medium text-text hover:text-accent" title="Open (double-click to rename)">108 <span className="truncate">{s.title}</span>109 {s.pinned && <Pin className="size-3 shrink-0 text-muted" aria-label="Pinned" />}110 {isOpen && <span className="shrink-0 rounded-[4px] border border-line-2 px-1 text-[10px] font-normal text-muted">open</span>}111 </button>112 )}113 <p className={cn('mt-0.5 truncate text-xs', waiting ? 'text-coral-ink' : 'text-muted')}>114 {waiting ? (s.waitingReason ?? 'Waiting for input') : s.state === 'RECOVERABLE' ? recoverableExplanation(brand) : `${s.projectName ?? 'No project'}${s.cwd ? ` · ${s.cwd}` : ''}${ended && s.exitCode != null ? ` · exit ${s.exitCode}` : ''}`}115 </p>116 </div>117 <SessionStateBadge state={s.state} />118 <span className="tnum flex w-10 items-center gap-1 text-xs text-muted" title="Attached clients"><Users className="size-3" aria-hidden />{s.attachedClients}</span>119 <span className="tnum text-xs text-muted sm:w-20 sm:text-right" title="Last activity">{fmtRelative(s.lastActivityAt ?? s.createdAt, now)}</span>120 <div className="ml-auto flex items-center gap-1">121 {s.state === 'RECOVERABLE' ? <Button size="sm" variant="primary" onClick={() => setConfirm('restart')}>Restart</Button> : !ended && <Button size="sm" onClick={() => open(s.id)}>Open</Button>}122 <DropdownMenu>123 <DropdownMenuTrigger asChild>124 <button aria-label={`Actions for ${s.title}`} className="inline-flex size-7 items-center justify-center rounded-sm text-muted hover:bg-surface-2 hover:text-text pointer-coarse:size-11"><Ellipsis className="size-4" /></button>125 </DropdownMenuTrigger>126 {/* after "Rename" the inline input must keep focus: do not hand it back to the trigger */}127 <DropdownMenuContent onCloseAutoFocus={(e) => { if (keepFocus.current) { e.preventDefault(); keepFocus.current = false; } }}>128 <DropdownMenuItem onSelect={() => open(s.id)}>Open in terminal</DropdownMenuItem>129 <DropdownMenuItem onSelect={() => act(() => resyncSession(qc, s.id), 'Session state re-synchronised')}>Reconnect (re-sync state)</DropdownMenuItem>130 <DropdownMenuItem onSelect={() => { keepFocus.current = true; setRenaming(true); }}>Rename</DropdownMenuItem>131 <DropdownMenuItem onSelect={() => act(() => patchSession(qc, s.id, { pinned: !s.pinned }))}>{s.pinned ? 'Unpin' : 'Pin'}</DropdownMenuItem>132 <DropdownMenuItem onSelect={() => act(() => patchSession(qc, s.id, { archived: !s.archived }))}><Archive className="size-3.5" /> {s.archived ? 'Unarchive' : 'Archive'}</DropdownMenuItem>133 <DropdownMenuSeparator />134 <DropdownMenuItem danger disabled={ended} onSelect={() => setConfirm('terminate')}>Terminate…</DropdownMenuItem>135 </DropdownMenuContent>136 </DropdownMenu>137 </div>138 <ConfirmDialog139 open={confirm === 'terminate'}140 onOpenChange={(o) => !o && setConfirm(null)}141 title="Terminate session?"142 description={<>The process of “{s.title}” will be stopped on the server. Unsaved work inside it is lost. The {brand.short} conversation history stays on disk.</>}143 confirmLabel="Terminate"144 danger145 onConfirm={() => act(() => terminateSession(qc, s.id))}146 />147 <ConfirmDialog148 open={confirm === 'restart'}149 onOpenChange={(o) => !o && setConfirm(null)}150 title="Restart session?"151 description={recoverableExplanation(brand)}152 confirmLabel="Restart"153 onConfirm={() => act(async () => { await restartSession(qc, s.id); open(s.id); })}154 />155 </li>156 );157}158