/** * 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 { TitleMode } from '../../shared/types.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; titleMode?: TitleMode; } @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 = false; @property({ type: String }) titleMode = TitleMode.DYNAMIC; @state() private isCreating = false; @state() private showFileBrowser = false; @state() private selectedQuickStart = 'zsh'; quickStartCommands = [ { label: 'claude', command: 'claude' }, { label: 'gemini', command: 'gemini' }, { label: 'zsh', command: 'zsh' }, { 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'; private readonly STORAGE_KEY_TITLE_MODE = 'vibetunnel_title_mode'; 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); const savedTitleMode = localStorage.getItem(this.STORAGE_KEY_TITLE_MODE); logger.debug( `loading from localStorage: workingDir=${savedWorkingDir}, command=${savedCommand}, spawnWindow=${savedSpawnWindow}, titleMode=${savedTitleMode}` ); if (savedWorkingDir) { this.workingDir = savedWorkingDir; } if (savedCommand) { this.command = savedCommand; } if (savedSpawnWindow !== null) { this.spawnWindow = savedSpawnWindow === 'true'; } if (savedTitleMode !== null) { // Validate the saved mode is a valid enum value if (Object.values(TitleMode).includes(savedTitleMode as TitleMode)) { this.titleMode = savedTitleMode as TitleMode; } else { // If invalid value in localStorage, default to DYNAMIC this.titleMode = TitleMode.DYNAMIC; } } else { // If no value in localStorage, ensure DYNAMIC is set this.titleMode = TitleMode.DYNAMIC; } // 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}, titleMode=${this.titleMode}` ); // 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)); localStorage.setItem(this.STORAGE_KEY_TITLE_MODE, this.titleMode); } 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; // Auto-select dynamic mode for Claude if (this.command.toLowerCase().includes('claude')) { this.titleMode = TitleMode.DYNAMIC; } } private handleSessionNameChange(e: Event) { const input = e.target as HTMLInputElement; this.sessionName = input.value; } private handleSpawnWindowChange() { this.spawnWindow = !this.spawnWindow; } private handleTitleModeChange(e: Event) { const select = e.target as HTMLSelectElement; this.titleMode = select.value as TitleMode; } private getTitleModeDescription(): string { switch (this.titleMode) { case TitleMode.NONE: return 'Apps control their own titles'; case TitleMode.FILTER: return 'Blocks all title changes'; case TitleMode.STATIC: return 'Shows path and command'; case TitleMode.DYNAMIC: return '○ idle ● active ▶ running'; default: return ''; } } 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; const sessionData: SessionCreateData = { command: this.parseCommand(this.command?.trim() || ''), workingDir: this.workingDir?.trim() || '', spawn_terminal: this.spawnWindow, titleMode: this.titleMode, }; // Only add dimensions for web sessions (not external terminal spawns) if (!this.spawnWindow) { // Use conservative defaults that work well across devices // The terminal will auto-resize to fit the actual container after creation sessionData.cols = 120; sessionData.rows = 30; } // 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; // Auto-select dynamic mode for Claude if (command.toLowerCase().includes('claude')) { this.titleMode = TitleMode.DYNAMIC; } } render() { if (!this.visible) { return html``; } return html`