SPB Git

Repository Public

new-session-dialog.tsx 7.5 KB 157 lines tsx

1'use client';2import { useEffect, useState } from 'react';3import { useRouter } from 'next/navigation';4import { useQueryClient } from '@tanstack/react-query';5import { Sparkles, SquareTerminal } from 'lucide-react';6import { useConversations, useProjects, useSettings } from '@/lib/queries';7import { useUi } from '@/stores/ui';8import { createSession } from '@/lib/session-actions';9import { errorMessage } from '@/lib/api';10import { fmtDateTime } from '@/lib/format';11import { Dialog } from '@/components/ui/dialog';12import { Button } from '@/components/ui/button';13import { Field, Input } from '@/components/ui/input';14import { NONE, Select } from '@/components/ui/select';15import { Spinner } from '@/components/ui/misc';16import { cn } from '@/lib/cn';17import { useBrand } from '@/stores/branding';18import { continueCommand } from '@/lib/branding';1920type Mode = 'new' | 'continue' | 'resume';2122export function NewSessionDialog() {23  const brand = useBrand();24  const { open, projectId: presetProject } = useUi((s) => s.newSession);25  const setUi = useUi((s) => s.set);26  const qc = useQueryClient();27  const router = useRouter();28  const projects = useProjects().data ?? [];29  const settings = useSettings();30  const [projectId, setProjectId] = useState<string>(NONE);31  const [kind, setKind] = useState<'claude' | 'shell'>('claude');32  const [title, setTitle] = useState('');33  const [mode, setMode] = useState<Mode>('new');34  const [conversationId, setConversationId] = useState<string | null>(null);35  const [busy, setBusy] = useState(false);36  const [error, setError] = useState<string | null>(null);37  const realProject = projectId === NONE ? null : projectId;38  const conversations = useConversations(realProject, open && kind === 'claude' && mode === 'resume');3940  useEffect(() => {41    if (!open) return;42    setProjectId(presetProject ?? settings.defaultProjectId ?? projects[0]?.id ?? NONE);43    setKind('claude');44    setTitle('');45    setMode('new');46    setConversationId(null);47    setError(null);48    // eslint-disable-next-line react-hooks/exhaustive-deps -- reset only when the dialog opens49  }, [open]);5051  const close = () => setUi({ newSession: { open: false, projectId: null } });52  const submit = async () => {53    setBusy(true);54    setError(null);55    try {56      const s = await createSession(qc, {57        projectId: realProject,58        kind,59        ...(title.trim() ? { title: title.trim() } : {}),60        ...(kind === 'claude' && mode === 'continue' ? { continueLast: true } : {}),61        ...(kind === 'claude' && mode === 'resume' && conversationId ? { resumeConversationId: conversationId } : {}),62      });63      close();64      router.push(`/terminal/?s=${s.id}`);65    } catch (e) {66      setError(errorMessage(e));67    } finally {68      setBusy(false);69    }70  };71  const canSubmit = !(kind === 'claude' && mode === 'resume' && !conversationId);7273  return (74    <Dialog75      open={open}76      onOpenChange={(o) => !o && close()}77      title="New session"78      description="Starts a real process on the server. It keeps running when you close this browser."79      size="lg"80      footer={81        <>82          {error && <p role="alert" className="mr-auto text-xs text-error">{error}</p>}83          <Button variant="ghost" onClick={close}>Cancel</Button>84          <Button variant="primary" loading={busy} disabled={!canSubmit} onClick={submit} data-testid="create-session">Start session</Button>85        </>86      }87    >88      <div className="flex flex-col gap-4">89        <div role="radiogroup" aria-label="Session kind" className="grid grid-cols-2 gap-2">90          {([['claude', brand.agentName, `The ${brand.agentName} CLI`, Sparkles] as const, ['shell', 'Shell', 'Your login shell', SquareTerminal] as const]).map(([k, label, hint, Icon]) => (91            <button92              key={k}93              role="radio"94              aria-checked={kind === k}95              data-testid={`kind-${k}`}96              onClick={() => setKind(k)}97              className={cn('flex min-h-14 items-center gap-3 rounded-md border px-3 py-2 text-left transition-colors', kind === k ? 'border-accent/60 bg-accent/10' : 'border-line-2 hover:border-line-3')}98            >99              <Icon className={cn('size-4 shrink-0', kind === k ? 'text-accent' : 'text-muted')} />100              <span>101                <span className="block text-[13px] font-medium">{label}</span>102                <span className="block text-[11px] text-muted">{hint}</span>103              </span>104            </button>105          ))}106        </div>107        <Field label="Project">108          <Select109            value={projectId}110            onValueChange={(v) => { setProjectId(v); setConversationId(null); }}111            options={[...projects.map((p) => ({ value: p.id, label: p.name, hint: `${p.rootLabel}/${p.path}` })), { value: NONE, label: 'No project (workspace root)' }]}112          />113        </Field>114        <Field label="Title (optional)">115          <Input value={title} onChange={(e) => setTitle(e.target.value)} maxLength={120} placeholder="Defaults to the project name" />116        </Field>117        {kind === 'claude' && (118          <fieldset className="flex flex-col gap-1.5">119            <legend className="mb-1.5 text-xs font-medium text-text-2">Conversation</legend>120            {([['new', 'New conversation', null] as const, ['continue', 'Continue last', `${continueCommand(brand)} in this directory`] as const, ['resume', `Resume a previous ${brand.short} conversation`, realProject ? null : 'Select a project first'] as const]).map(([m, label, hint]) => (121              <label key={m} className={cn('flex min-h-9 cursor-pointer items-center gap-2.5 rounded-sm px-2 text-[13px] hover:bg-surface-2 pointer-coarse:min-h-11', m === 'resume' && !realProject && 'pointer-events-none opacity-50')}>122                <input type="radio" name="conv-mode" className="size-3.5 accent-accent" checked={mode === m} onChange={() => setMode(m)} disabled={m === 'resume' && !realProject} />123                <span className="flex-1">{label}</span>124                {hint && <span className="text-[11px] text-muted">{hint}</span>}125              </label>126            ))}127            {mode === 'resume' && realProject && (128              <div className="mt-1 max-h-52 overflow-y-auto rounded-md border border-line-2" data-testid="conversation-list">129                {conversations.isPending ? (130                  <div className="flex justify-center p-4"><Spinner /></div>131                ) : conversations.isError ? (132                  <p className="p-3 text-xs text-error">{errorMessage(conversations.error)}</p>133                ) : conversations.data.length === 0 ? (134                  <p className="p-3 text-xs text-muted">No previous conversations found for this project.</p>135                ) : (136                  conversations.data.map((c) => (137                    <button138                      key={c.id}139                      role="option"140                      aria-selected={conversationId === c.id}141                      onClick={() => setConversationId(c.id)}142                      className={cn('flex w-full flex-col gap-0.5 border-b border-line px-3 py-2 text-left last:border-b-0 hover:bg-surface-2', conversationId === c.id && 'bg-accent/10')}143                    >144                      <span className="line-clamp-2 text-[13px] text-text">{c.summary || 'Untitled conversation'}</span>145                      <span className="tnum text-[11px] text-muted">{fmtDateTime(c.updatedAt)} · {c.messageCount} messages</span>146                    </button>147                  ))148                )}149              </div>150            )}151          </fieldset>152        )}153      </div>154    </Dialog>155  );156}157
Files main
Menu

Theme