Repository Public
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