/** * Session Create Form Component * * Modal dialog for creating new terminal sessions. Provides command input, * working directory selection, and options for spawning in native terminal. * * @fires session-created - When session is successfully created (detail: { sessionId: string, message?: string }) * @fires cancel - When form is cancelled * @fires error - When creation fails (detail: string) * * @listens file-selected - From file browser when directory is selected * @listens browser-cancel - From file browser when cancelled */ import { html, LitElement, type PropertyValues } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import './file-browser.js'; import type { AuthClient } from '../services/auth-client.js'; import { createLogger } from '../utils/logger.js'; import type { Session } from './session-list.js'; const logger = createLogger('session-create-form'); export interface SessionCreateData { command: string[]; workingDir: string; name?: string; spawn_terminal?: boolean; cols?: number; rows?: number; } @customElement('session-create-form') export class SessionCreateForm extends LitElement { // Disable shadow DOM to use Tailwind createRenderRoot() { return this; } @property({ type: String }) workingDir = '~/'; @property({ type: String }) command = 'zsh'; @property({ type: String }) sessionName = ''; @property({ type: Boolean }) disabled = false; @property({ type: Boolean }) visible = false; @property({ type: Object }) authClient!: AuthClient; @property({ type: Boolean }) spawnWindow = true; @state() private isCreating = false; @state() private showFileBrowser = false; @state() private selectedQuickStart = 'zsh'; private quickStartCommands = [ { label: 'claude', command: 'claude' }, { label: 'zsh', command: 'zsh' }, { label: 'bash', command: 'bash' }, { label: 'python3', command: 'python3' }, { label: 'node', command: 'node' }, { label: 'pnpm run dev', command: 'pnpm run dev' }, ]; private readonly STORAGE_KEY_WORKING_DIR = 'vibetunnel_last_working_dir'; private readonly STORAGE_KEY_COMMAND = 'vibetunnel_last_command'; private readonly STORAGE_KEY_SPAWN_WINDOW = 'vibetunnel_spawn_window'; connectedCallback() { super.connectedCallback(); // Load from localStorage when component is first created this.loadFromLocalStorage(); } disconnectedCallback() { super.disconnectedCallback(); // Clean up document event listener if modal is still visible if (this.visible) { document.removeEventListener('keydown', this.handleGlobalKeyDown); } } private handleGlobalKeyDown = (e: KeyboardEvent) => { // Only handle events when modal is visible if (!this.visible) return; if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); this.handleCancel(); } else if (e.key === 'Enter') { // Don't interfere with Enter in textarea elements if (e.target instanceof HTMLTextAreaElement) return; // Check if form is valid (same conditions as Create button) const canCreate = !this.disabled && !this.isCreating && this.workingDir.trim() && this.command.trim(); if (canCreate) { e.preventDefault(); e.stopPropagation(); this.handleCreate(); } } }; private loadFromLocalStorage() { try { const savedWorkingDir = localStorage.getItem(this.STORAGE_KEY_WORKING_DIR); const savedCommand = localStorage.getItem(this.STORAGE_KEY_COMMAND); const savedSpawnWindow = localStorage.getItem(this.STORAGE_KEY_SPAWN_WINDOW); logger.debug( `loading from localStorage: workingDir=${savedWorkingDir}, command=${savedCommand}, spawnWindow=${savedSpawnWindow}` ); if (savedWorkingDir) { this.workingDir = savedWorkingDir; } if (savedCommand) { this.command = savedCommand; } if (savedSpawnWindow !== null) { this.spawnWindow = savedSpawnWindow === 'true'; } // Force re-render to update the input values this.requestUpdate(); } catch (_error) { logger.warn('failed to load from localStorage'); } } private saveToLocalStorage() { try { const workingDir = this.workingDir.trim(); const command = this.command.trim(); logger.debug( `saving to localStorage: workingDir=${workingDir}, command=${command}, spawnWindow=${this.spawnWindow}` ); // Only save non-empty values if (workingDir) { localStorage.setItem(this.STORAGE_KEY_WORKING_DIR, workingDir); } if (command) { localStorage.setItem(this.STORAGE_KEY_COMMAND, command); } localStorage.setItem(this.STORAGE_KEY_SPAWN_WINDOW, String(this.spawnWindow)); } catch (_error) { logger.warn('failed to save to localStorage'); } } updated(changedProperties: PropertyValues) { super.updated(changedProperties); // Handle visibility changes if (changedProperties.has('visible')) { if (this.visible) { // Load from localStorage when form becomes visible this.loadFromLocalStorage(); // Add global keyboard listener document.addEventListener('keydown', this.handleGlobalKeyDown); } else { // Remove global keyboard listener when hidden document.removeEventListener('keydown', this.handleGlobalKeyDown); } } } private handleWorkingDirChange(e: Event) { const input = e.target as HTMLInputElement; this.workingDir = input.value; this.dispatchEvent( new CustomEvent('working-dir-change', { detail: this.workingDir, }) ); } private handleCommandChange(e: Event) { const input = e.target as HTMLInputElement; this.command = input.value; } private handleSessionNameChange(e: Event) { const input = e.target as HTMLInputElement; this.sessionName = input.value; } private handleSpawnWindowChange() { this.spawnWindow = !this.spawnWindow; } private handleBrowse() { this.showFileBrowser = true; } private handleDirectorySelected(e: CustomEvent) { this.workingDir = e.detail; this.showFileBrowser = false; } private handleBrowserCancel() { this.showFileBrowser = false; } private async handleCreate() { if (!this.workingDir.trim() || !this.command.trim()) { this.dispatchEvent( new CustomEvent('error', { detail: 'Please fill in both working directory and command', }) ); return; } this.isCreating = true; // Use conservative defaults that work well across devices // The terminal will auto-resize to fit the actual container after creation const terminalCols = 120; const terminalRows = 30; const sessionData: SessionCreateData = { command: this.parseCommand(this.command.trim()), workingDir: this.workingDir.trim(), spawn_terminal: this.spawnWindow, cols: terminalCols, rows: terminalRows, }; // Add session name if provided if (this.sessionName.trim()) { sessionData.name = this.sessionName.trim(); } try { const response = await fetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.authClient.getAuthHeader(), }, body: JSON.stringify(sessionData), }); if (response.ok) { const result = await response.json(); // Save to localStorage before clearing the fields this.saveToLocalStorage(); this.command = ''; // Clear command on success this.sessionName = ''; // Clear session name on success this.dispatchEvent( new CustomEvent('session-created', { detail: result, }) ); } else { const error = await response.json(); // Use the detailed error message if available, otherwise fall back to the error field const errorMessage = error.details || error.error || 'Unknown error'; this.dispatchEvent( new CustomEvent('error', { detail: errorMessage, }) ); } } catch (error) { logger.error('error creating session:', error); this.dispatchEvent( new CustomEvent('error', { detail: 'Failed to create session', }) ); } finally { this.isCreating = false; } } private parseCommand(commandStr: string): string[] { // Simple command parsing - split by spaces but respect quotes const args: string[] = []; let current = ''; let inQuotes = false; let quoteChar = ''; for (let i = 0; i < commandStr.length; i++) { const char = commandStr[i]; if ((char === '"' || char === "'") && !inQuotes) { inQuotes = true; quoteChar = char; } else if (char === quoteChar && inQuotes) { inQuotes = false; quoteChar = ''; } else if (char === ' ' && !inQuotes) { if (current) { args.push(current); current = ''; } } else { current += char; } } if (current) { args.push(current); } return args; } private handleCancel() { this.dispatchEvent(new CustomEvent('cancel')); } private handleQuickStart(command: string) { this.command = command; this.selectedQuickStart = command; } render() { if (!this.visible) { return html``; } return html`