SPB Git

Repository Public

page.tsx 9.5 KB 158 lines tsx

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

Theme