mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-10 12:05:53 +00:00
- Add optional session name field to create form - Update Session interface to include name property - Backend now accepts and stores custom session names - Session cards and views display name when available, fallback to command - Session names are passed to tty-fwd for better identification 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
318 lines
9.4 KiB
TypeScript
318 lines
9.4 KiB
TypeScript
import { LitElement, html, PropertyValues } from 'lit';
|
||
import { customElement, property, state } from 'lit/decorators.js';
|
||
import './file-browser.js';
|
||
|
||
export interface SessionCreateData {
|
||
command: string[];
|
||
workingDir: string;
|
||
name?: string;
|
||
}
|
||
|
||
@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;
|
||
|
||
@state() private isCreating = false;
|
||
@state() private showFileBrowser = false;
|
||
|
||
private readonly STORAGE_KEY_WORKING_DIR = 'vibetunnel_last_working_dir';
|
||
private readonly STORAGE_KEY_COMMAND = 'vibetunnel_last_command';
|
||
|
||
connectedCallback() {
|
||
super.connectedCallback();
|
||
// Load from localStorage when component is first created
|
||
this.loadFromLocalStorage();
|
||
}
|
||
|
||
private loadFromLocalStorage() {
|
||
try {
|
||
const savedWorkingDir = localStorage.getItem(this.STORAGE_KEY_WORKING_DIR);
|
||
const savedCommand = localStorage.getItem(this.STORAGE_KEY_COMMAND);
|
||
|
||
console.log('Loading from localStorage:', { savedWorkingDir, savedCommand });
|
||
|
||
if (savedWorkingDir) {
|
||
this.workingDir = savedWorkingDir;
|
||
}
|
||
if (savedCommand) {
|
||
this.command = savedCommand;
|
||
}
|
||
|
||
// Force re-render to update the input values
|
||
this.requestUpdate();
|
||
} catch (error) {
|
||
console.warn('Failed to load from localStorage:', error);
|
||
}
|
||
}
|
||
|
||
private saveToLocalStorage() {
|
||
try {
|
||
const workingDir = this.workingDir.trim();
|
||
const command = this.command.trim();
|
||
|
||
console.log('Saving to localStorage:', { workingDir, command });
|
||
|
||
// Only save non-empty values
|
||
if (workingDir) {
|
||
localStorage.setItem(this.STORAGE_KEY_WORKING_DIR, workingDir);
|
||
}
|
||
if (command) {
|
||
localStorage.setItem(this.STORAGE_KEY_COMMAND, command);
|
||
}
|
||
} catch (error) {
|
||
console.warn('Failed to save to localStorage:', error);
|
||
}
|
||
}
|
||
|
||
updated(changedProperties: PropertyValues) {
|
||
super.updated(changedProperties);
|
||
|
||
// Load from localStorage when form becomes visible
|
||
if (changedProperties.has('visible') && this.visible) {
|
||
this.loadFromLocalStorage();
|
||
}
|
||
}
|
||
|
||
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 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(),
|
||
};
|
||
|
||
// 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' },
|
||
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();
|
||
this.dispatchEvent(
|
||
new CustomEvent('error', {
|
||
detail: `Failed to create session: ${error.error}`,
|
||
})
|
||
);
|
||
}
|
||
} catch (error) {
|
||
console.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'));
|
||
}
|
||
|
||
render() {
|
||
if (!this.visible) {
|
||
return html``;
|
||
}
|
||
|
||
return html`
|
||
<div
|
||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center"
|
||
style="z-index: 9999;"
|
||
>
|
||
<div
|
||
class="bg-vs-bg-secondary border border-vs-border font-mono text-sm w-96 max-w-full mx-4"
|
||
>
|
||
<div class="p-4 border-b border-vs-border flex justify-between items-center">
|
||
<div class="text-vs-assistant text-sm">Create New Session</div>
|
||
<button
|
||
class="text-vs-muted hover:text-vs-text text-lg leading-none border-none bg-transparent cursor-pointer"
|
||
@click=${this.handleCancel}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div class="p-4">
|
||
<div class="mb-4">
|
||
<div class="text-vs-text mb-2">Session Name (optional):</div>
|
||
<input
|
||
type="text"
|
||
class="w-full bg-vs-bg text-vs-text border border-vs-border outline-none font-mono px-4 py-2"
|
||
.value=${this.sessionName}
|
||
@input=${this.handleSessionNameChange}
|
||
placeholder="My Session"
|
||
?disabled=${this.disabled || this.isCreating}
|
||
/>
|
||
</div>
|
||
|
||
<div class="mb-4">
|
||
<div class="text-vs-text mb-2">Working Directory:</div>
|
||
<div class="flex gap-4">
|
||
<input
|
||
type="text"
|
||
class="flex-1 bg-vs-bg text-vs-text border border-vs-border outline-none font-mono px-4 py-2"
|
||
.value=${this.workingDir}
|
||
@input=${this.handleWorkingDirChange}
|
||
placeholder="~/"
|
||
?disabled=${this.disabled || this.isCreating}
|
||
/>
|
||
<button
|
||
class="bg-vs-function text-vs-bg hover:bg-vs-highlight font-mono px-4 py-2 border-none"
|
||
@click=${this.handleBrowse}
|
||
?disabled=${this.disabled || this.isCreating}
|
||
>
|
||
browse
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mb-4">
|
||
<div class="text-vs-text mb-2">Command:</div>
|
||
<input
|
||
type="text"
|
||
class="w-full bg-vs-bg text-vs-text border border-vs-border outline-none font-mono px-4 py-2"
|
||
.value=${this.command}
|
||
@input=${this.handleCommandChange}
|
||
@keydown=${(e: KeyboardEvent) => e.key === 'Enter' && this.handleCreate()}
|
||
placeholder="zsh"
|
||
?disabled=${this.disabled || this.isCreating}
|
||
/>
|
||
</div>
|
||
|
||
<div class="flex gap-4 justify-end">
|
||
<button
|
||
class="bg-vs-muted text-vs-bg hover:bg-vs-text font-mono px-4 py-2 border-none"
|
||
@click=${this.handleCancel}
|
||
?disabled=${this.isCreating}
|
||
>
|
||
cancel
|
||
</button>
|
||
<button
|
||
class="bg-vs-user text-vs-text hover:bg-vs-accent font-mono px-4 py-2 border-none disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-vs-user"
|
||
@click=${this.handleCreate}
|
||
?disabled=${this.disabled ||
|
||
this.isCreating ||
|
||
!this.workingDir.trim() ||
|
||
!this.command.trim()}
|
||
>
|
||
${this.isCreating ? 'creating...' : 'create'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<file-browser
|
||
.visible=${this.showFileBrowser}
|
||
.currentPath=${this.workingDir}
|
||
@directory-selected=${this.handleDirectorySelected}
|
||
@browser-cancel=${this.handleBrowserCancel}
|
||
></file-browser>
|
||
`;
|
||
}
|
||
}
|