mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-06-29 05:39:31 +00:00
- Put Browse Files and Kill buttons on the same line in sidebar header - Remove "running" text from session status, keep only the colored dot - Apply home directory path filtering (~/...) for better readability - Import and use formatPathForDisplay from path-utils These changes maximize usable space in the vertical tabs sidebar.
372 lines
No EOL
16 KiB
TypeScript
372 lines
No EOL
16 KiB
TypeScript
/**
|
|
* Session List Component
|
|
*
|
|
* Displays a grid of session cards and manages the session creation modal.
|
|
* Handles session filtering (hide/show exited) and cleanup operations.
|
|
*
|
|
* @fires navigate-to-session - When a session is selected (detail: { sessionId: string })
|
|
* @fires refresh - When session list needs refreshing
|
|
* @fires error - When an error occurs (detail: string)
|
|
* @fires session-created - When a new session is created (detail: { sessionId: string, message?: string })
|
|
* @fires create-modal-close - When create modal should close
|
|
* @fires hide-exited-change - When hide exited state changes (detail: boolean)
|
|
* @fires kill-all-sessions - When all sessions should be killed
|
|
*
|
|
* @listens session-killed - From session-card when a session is killed
|
|
* @listens session-kill-error - From session-card when kill fails
|
|
* @listens clean-exited-sessions - To trigger cleanup of exited sessions
|
|
*/
|
|
import { html, LitElement } from 'lit';
|
|
import { customElement, property, state } from 'lit/decorators.js';
|
|
import { repeat } from 'lit/directives/repeat.js';
|
|
import type { Session } from '../../shared/types.js';
|
|
import type { AuthClient } from '../services/auth-client.js';
|
|
import './session-create-form.js';
|
|
import './session-card.js';
|
|
import { createLogger } from '../utils/logger.js';
|
|
import { formatPathForDisplay } from '../utils/path-utils.js';
|
|
|
|
const logger = createLogger('session-list');
|
|
|
|
// Re-export Session type for backward compatibility
|
|
export type { Session };
|
|
|
|
@customElement('session-list')
|
|
export class SessionList extends LitElement {
|
|
// Disable shadow DOM to use Tailwind
|
|
createRenderRoot() {
|
|
return this;
|
|
}
|
|
|
|
@property({ type: Array }) sessions: Session[] = [];
|
|
@property({ type: Boolean }) loading = false;
|
|
@property({ type: Boolean }) hideExited = true;
|
|
@property({ type: Boolean }) showCreateModal = false;
|
|
@property({ type: Object }) authClient!: AuthClient;
|
|
@property({ type: String }) selectedSessionId: string | null = null;
|
|
@property({ type: Boolean }) compactMode = false;
|
|
|
|
@state() private cleaningExited = false;
|
|
private previousRunningCount = 0;
|
|
|
|
private handleRefresh() {
|
|
this.dispatchEvent(new CustomEvent('refresh'));
|
|
}
|
|
|
|
private handleSessionSelect(e: CustomEvent) {
|
|
const session = e.detail as Session;
|
|
|
|
// Dispatch a custom event that the app can handle with view transitions
|
|
this.dispatchEvent(
|
|
new CustomEvent('navigate-to-session', {
|
|
detail: { sessionId: session.id },
|
|
bubbles: true,
|
|
composed: true,
|
|
})
|
|
);
|
|
}
|
|
|
|
private async handleSessionKilled(e: CustomEvent) {
|
|
const { sessionId } = e.detail;
|
|
logger.debug(`session ${sessionId} killed, updating session list`);
|
|
|
|
// Remove the session from the local state
|
|
this.sessions = this.sessions.filter((session) => session.id !== sessionId);
|
|
|
|
// Then trigger a refresh to get the latest server state
|
|
this.dispatchEvent(new CustomEvent('refresh'));
|
|
}
|
|
|
|
private handleSessionKillError(e: CustomEvent) {
|
|
const { sessionId, error } = e.detail;
|
|
logger.error(`failed to kill session ${sessionId}:`, error);
|
|
|
|
// Dispatch error event to parent for user notification
|
|
this.dispatchEvent(
|
|
new CustomEvent('error', {
|
|
detail: `Failed to kill session: ${error}`,
|
|
})
|
|
);
|
|
}
|
|
|
|
public async handleCleanupExited() {
|
|
if (this.cleaningExited) return;
|
|
|
|
this.cleaningExited = true;
|
|
this.requestUpdate();
|
|
|
|
try {
|
|
const response = await fetch('/api/cleanup-exited', {
|
|
method: 'POST',
|
|
headers: {
|
|
...this.authClient.getAuthHeader(),
|
|
},
|
|
});
|
|
|
|
if (response.ok) {
|
|
// Get the list of exited sessions before cleanup
|
|
const exitedSessions = this.sessions.filter((s) => s.status === 'exited');
|
|
|
|
// Apply black hole animation to all exited sessions
|
|
if (exitedSessions.length > 0) {
|
|
const sessionCards = this.querySelectorAll('session-card');
|
|
const exitedCards: HTMLElement[] = [];
|
|
|
|
sessionCards.forEach((card) => {
|
|
const sessionCard = card as HTMLElement & { session?: { id: string; status: string } };
|
|
if (sessionCard.session?.status === 'exited') {
|
|
exitedCards.push(sessionCard);
|
|
}
|
|
});
|
|
|
|
// Apply animation to all exited cards
|
|
exitedCards.forEach((card) => {
|
|
card.classList.add('black-hole-collapsing');
|
|
});
|
|
|
|
// Wait for animation to complete
|
|
if (exitedCards.length > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
}
|
|
|
|
// Remove all exited sessions at once
|
|
this.sessions = this.sessions.filter((session) => session.status !== 'exited');
|
|
}
|
|
|
|
this.dispatchEvent(new CustomEvent('refresh'));
|
|
} else {
|
|
this.dispatchEvent(
|
|
new CustomEvent('error', { detail: 'Failed to cleanup exited sessions' })
|
|
);
|
|
}
|
|
} catch (error) {
|
|
logger.error('error cleaning up exited sessions:', error);
|
|
this.dispatchEvent(new CustomEvent('error', { detail: 'Failed to cleanup exited sessions' }));
|
|
} finally {
|
|
this.cleaningExited = false;
|
|
this.requestUpdate();
|
|
}
|
|
}
|
|
|
|
private handleOpenFileBrowser() {
|
|
this.dispatchEvent(
|
|
new CustomEvent('open-file-browser', {
|
|
bubbles: true,
|
|
})
|
|
);
|
|
}
|
|
|
|
render() {
|
|
const filteredSessions = this.hideExited
|
|
? this.sessions.filter((session) => session.status !== 'exited')
|
|
: this.sessions;
|
|
|
|
return html`
|
|
<div class="font-mono text-sm p-4 bg-black">
|
|
${
|
|
filteredSessions.length === 0
|
|
? html`
|
|
<div class="text-dark-text-muted text-center py-8">
|
|
${
|
|
this.loading
|
|
? 'Loading sessions...'
|
|
: this.hideExited && this.sessions.length > 0
|
|
? html`
|
|
<div class="space-y-4 max-w-2xl mx-auto text-left">
|
|
<div class="text-lg font-semibold text-dark-text">
|
|
No running sessions
|
|
</div>
|
|
<div class="text-sm text-dark-text-muted">
|
|
There are exited sessions. Show them by toggling "Hide exited" above.
|
|
</div>
|
|
</div>
|
|
`
|
|
: html`
|
|
<div class="space-y-6 max-w-2xl mx-auto text-left">
|
|
<div class="text-lg font-semibold text-dark-text">
|
|
No terminal sessions yet!
|
|
</div>
|
|
|
|
<div class="space-y-3">
|
|
<div class="text-sm text-dark-text-muted">
|
|
Get started by using the
|
|
<code class="bg-dark-bg-secondary px-2 py-1 rounded">vt</code> command
|
|
in your terminal:
|
|
</div>
|
|
|
|
<div
|
|
class="bg-dark-bg-secondary p-4 rounded-lg font-mono text-xs space-y-2"
|
|
>
|
|
<div class="text-green-400">vt pnpm run dev</div>
|
|
<div class="text-dark-text-muted pl-4"># Monitor your dev server</div>
|
|
|
|
<div class="text-green-400">vt claude --dangerously...</div>
|
|
<div class="text-dark-text-muted pl-4">
|
|
# Keep an eye on AI agents
|
|
</div>
|
|
|
|
<div class="text-green-400">vt --shell</div>
|
|
<div class="text-dark-text-muted pl-4">
|
|
# Open an interactive shell
|
|
</div>
|
|
|
|
<div class="text-green-400">vt python train.py</div>
|
|
<div class="text-dark-text-muted pl-4">
|
|
# Watch long-running scripts
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-3 border-t border-dark-border pt-4">
|
|
<div class="text-sm font-semibold text-dark-text">
|
|
Haven't installed the CLI yet?
|
|
</div>
|
|
<div class="text-sm text-dark-text-muted space-y-1">
|
|
<div>→ Click the VibeTunnel menu bar icon</div>
|
|
<div>→ Go to Settings → Advanced → Install CLI Tools</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="text-xs text-dark-text-muted mt-4">
|
|
Once installed, any command prefixed with
|
|
<code class="bg-dark-bg-secondary px-1 rounded">vt</code> will appear
|
|
here, accessible from any browser at localhost:4020.
|
|
</div>
|
|
</div>
|
|
`
|
|
}
|
|
</div>
|
|
`
|
|
: html`
|
|
<div class="${this.compactMode ? 'space-y-2' : 'session-flex-responsive'}">
|
|
${this.compactMode
|
|
? html`
|
|
<!-- Browse Files button as special tab -->
|
|
<div
|
|
class="flex items-center gap-2 p-3 rounded-md cursor-pointer transition-all hover:bg-dark-bg-tertiary border border-dark-border bg-dark-bg-secondary"
|
|
@click=${this.handleOpenFileBrowser}
|
|
title="Browse Files (⌘O)"
|
|
>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-mono text-accent-green truncate">
|
|
📁 Browse Files
|
|
</div>
|
|
<div class="text-xs text-dark-text-muted truncate">Open file browser</div>
|
|
</div>
|
|
<div class="flex items-center gap-2 flex-shrink-0">
|
|
<span class="text-dark-text-muted text-xs">⌘O</span>
|
|
</div>
|
|
</div>
|
|
`
|
|
: ''}
|
|
${repeat(
|
|
filteredSessions,
|
|
(session) => session.id,
|
|
(session) => html`
|
|
${this.compactMode
|
|
? html`
|
|
<!-- Compact list item for sidebar -->
|
|
<div
|
|
class="flex items-center gap-2 p-3 rounded-md cursor-pointer transition-all hover:bg-dark-bg-tertiary ${session.id ===
|
|
this.selectedSessionId
|
|
? 'bg-dark-bg-tertiary border border-accent-green shadow-sm'
|
|
: 'border border-transparent'}"
|
|
@click=${() =>
|
|
this.handleSessionSelect({ detail: session } as CustomEvent)}
|
|
>
|
|
<div class="flex-1 min-w-0">
|
|
<div
|
|
class="text-sm font-mono text-accent-green truncate"
|
|
title="${session.name || session.command}"
|
|
>
|
|
${session.name || session.command}
|
|
</div>
|
|
<div class="text-xs text-dark-text-muted truncate">
|
|
${formatPathForDisplay(session.workingDir)}
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2 flex-shrink-0">
|
|
<div
|
|
class="w-2 h-2 rounded-full ${session.status === 'running'
|
|
? 'bg-status-success'
|
|
: 'bg-status-warning'}"
|
|
title="${session.status}"
|
|
></div>
|
|
${session.status === 'running' || session.status === 'exited'
|
|
? html`
|
|
<button
|
|
class="btn-ghost text-status-error p-1 rounded hover:bg-dark-bg"
|
|
@click=${async (e: Event) => {
|
|
e.stopPropagation();
|
|
// Kill the session
|
|
try {
|
|
const endpoint =
|
|
session.status === 'exited'
|
|
? `/api/sessions/${session.id}/cleanup`
|
|
: `/api/sessions/${session.id}`;
|
|
const response = await fetch(endpoint, {
|
|
method: 'DELETE',
|
|
headers: this.authClient.getAuthHeader(),
|
|
});
|
|
if (response.ok) {
|
|
this.handleSessionKilled({
|
|
detail: { sessionId: session.id },
|
|
} as CustomEvent);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to kill session', error);
|
|
}
|
|
}}
|
|
title="${session.status === 'running'
|
|
? 'Kill session'
|
|
: 'Clean up session'}"
|
|
>
|
|
<svg
|
|
class="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
`
|
|
: ''}
|
|
</div>
|
|
</div>
|
|
`
|
|
: html`
|
|
<!-- Full session card for main view -->
|
|
<session-card
|
|
.session=${session}
|
|
.authClient=${this.authClient}
|
|
@session-select=${this.handleSessionSelect}
|
|
@session-killed=${this.handleSessionKilled}
|
|
@session-kill-error=${this.handleSessionKillError}
|
|
>
|
|
</session-card>
|
|
`}
|
|
`
|
|
)}
|
|
</div>
|
|
`
|
|
}
|
|
|
|
<session-create-form
|
|
.visible=${this.showCreateModal}
|
|
.authClient=${this.authClient}
|
|
@session-created=${(e: CustomEvent) =>
|
|
this.dispatchEvent(new CustomEvent('session-created', { detail: e.detail }))}
|
|
@cancel=${() => this.dispatchEvent(new CustomEvent('create-modal-close'))}
|
|
@error=${(e: CustomEvent) =>
|
|
this.dispatchEvent(new CustomEvent('error', { detail: e.detail }))}
|
|
></session-create-form>
|
|
</div>
|
|
`;
|
|
}
|
|
} |