Add session eject feature and terminal UX improvements

- Add eject button for tmux sessions (keeps tmux running, removes from Spiceflow)
- Add refresh button to session settings for all providers
- Improve terminal controls: larger buttons, more zoom levels (50-150%), copy selection, paste clipboard, enter key
- Fix session navigation: properly reload when switching between sessions
- Update tmux screen presets to larger dimensions (fullscreen 260x48, desktop 120x48, landscape 80x24)
- Add testing documentation to CLAUDE.md
- Refactor E2E tests to use API-based cleanup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-20 23:37:06 -05:00
parent 3d121c2e08
commit 5171059692
10 changed files with 448 additions and 131 deletions
+3
View File
@@ -40,3 +40,6 @@ Thumbs.db
# PWA
client/dev-dist/
# Test results
e2e/test-results/
+53
View File
@@ -123,6 +123,59 @@ Environment variables or `server/resources/config.edn`:
| `CLAUDE_SESSIONS_DIR` | ~/.claude/projects | Claude sessions |
| `OPENCODE_CMD` | opencode | OpenCode binary |
## Testing Changes
Always test changes before considering them complete. Choose the appropriate testing method:
### 1. E2E Tests (Playwright)
For UI changes, user flows, and integration between frontend/backend:
```bash
cd e2e && npm test # Run all E2E tests
cd e2e && npm run test:headed # Visible browser for debugging
cd e2e && npm test -- -g "test name" # Run specific test
```
### 2. Unit Tests (Clojure)
For backend logic, pure functions, and isolated components:
```bash
cd server && clj -M:test # Run all unit tests
```
### 3. REPL Testing
For quick one-off validation of Clojure code during development:
```clojure
;; In REPL (clj -M:dev, then (go))
(require '[spiceflow.some-ns :as ns])
(ns/some-function arg1 arg2) ; Test function directly
```
### 4. Manual API Testing
For testing HTTP endpoints directly:
```bash
# Health check
curl http://localhost:3000/api/health
# List sessions
curl http://localhost:3000/api/sessions
# Create session
curl -X POST http://localhost:3000/api/sessions \
-H "Content-Type: application/json" \
-d '{"provider": "claude"}'
# Get session
curl http://localhost:3000/api/sessions/:id
```
### When to Use Each
| Change Type | Recommended Testing |
|-------------|---------------------|
| UI component | E2E tests |
| API endpoint | Manual API + unit tests |
| Business logic | Unit tests + REPL |
| User flow | E2E tests |
| Bug fix | Add regression test + manual verify |
## Subdirectory CLAUDE.md Files
Each directory has specific details:
+7 -1
View File
@@ -204,13 +204,19 @@ class ApiClient {
});
}
async resizeTerminal(sessionId: string, mode: 'desktop' | 'landscape' | 'portrait'): Promise<{ status: string; mode: string }> {
async resizeTerminal(sessionId: string, mode: 'fullscreen' | 'desktop' | 'landscape' | 'portrait'): Promise<{ status: string; mode: string }> {
return this.request<{ status: string; mode: string }>(`/sessions/${sessionId}/terminal/resize`, {
method: 'POST',
body: JSON.stringify({ mode })
});
}
async ejectSession(sessionId: string): Promise<{ status: string; oldName: string; newName: string; message: string }> {
return this.request<{ status: string; oldName: string; newName: string; message: string }>(`/sessions/${sessionId}/eject`, {
method: 'POST'
});
}
// External tmux sessions
async getExternalTmuxSessions(): Promise<ExternalTmuxSession[]> {
return this.request<ExternalTmuxSession[]>('/tmux/external');
@@ -23,6 +23,7 @@
let container: HTMLDivElement;
let collapsedMessages: Set<string> = new Set();
let hasScrolledInitially = false;
let lastMessageCount = 0;
const COLLAPSE_THRESHOLD = 5; // show toggle if more than this many lines
@@ -32,6 +33,14 @@
}
}
// Reset scroll flag when messages are cleared (navigating to new session)
$: if (messages.length === 0 && lastMessageCount > 0) {
hasScrolledInitially = false;
}
// Track message count for detecting session changes
$: lastMessageCount = messages.length;
// Scroll to bottom when messages first load
$: if (messages.length > 0 && container && !hasScrolledInitially) {
hasScrolledInitially = true;
@@ -9,6 +9,8 @@
toggleAutoAccept: boolean;
toggleAutoScroll: boolean;
condenseAll: void;
refresh: void;
eject: void;
}>();
function handleToggleAutoScroll() {
@@ -27,6 +29,16 @@
open = false;
}
function handleRefresh() {
dispatch('refresh');
open = false;
}
function handleEject() {
dispatch('eject');
open = false;
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.settings-dropdown')) {
@@ -86,6 +98,17 @@
</div>
</label>
<!-- Refresh button (all providers) -->
<button
on:click={handleRefresh}
class="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-zinc-700/50 transition-colors text-left"
>
<svg class="h-4 w-4 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span class="text-sm text-zinc-200">Refresh</span>
</button>
{#if provider !== 'tmux'}
<button
on:click={handleCondenseAll}
@@ -116,6 +139,23 @@
</div>
</label>
{/if}
{#if provider === 'tmux'}
<button
on:click={handleEject}
class="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-zinc-700/50 transition-colors text-left border-t border-zinc-700"
>
<svg class="h-4 w-4 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<div class="flex-1 min-w-0">
<span class="text-sm text-amber-400 block">Eject session</span>
<span class="text-xs text-zinc-500 block mt-0.5"
>Keep tmux running, remove from Spiceflow</span
>
</div>
</button>
{/if}
</div>
{/if}
</div>
+95 -41
View File
@@ -35,8 +35,17 @@
let inputBuffer = '';
let batchTimeout: ReturnType<typeof setTimeout> | null = null;
let isSending = false;
let fontScale = 1; // 0.75, 0.875, 1, 1.125, 1.25, 1.5
const fontScales = [0.75, 0.875, 1, 1.125, 1.25, 1.5];
let fontScale = 1; // 50% to 150% in 5% increments
const fontScales = [0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 1, 1.05, 1.1, 1.15, 1.2, 1.25, 1.3, 1.35, 1.4, 1.45, 1.5];
// Consistent button styling for quick action bar
const btnBase = 'px-2.5 py-1.5 rounded-sm text-sm font-mono transition-colors disabled:opacity-50';
const btnDefault = `${btnBase} bg-zinc-700 hover:bg-zinc-600 text-zinc-200`;
const btnRed = `${btnBase} bg-red-600/80 hover:bg-red-600 text-white`;
const btnGreen = `${btnBase} bg-green-600/80 hover:bg-green-600 text-white`;
const btnAmber = `${btnBase} bg-amber-600/80 hover:bg-amber-600 text-white`;
const btnCyan = `${btnBase} bg-cyan-600/80 hover:bg-cyan-600 text-white`;
const btnViolet = `${btnBase} bg-violet-600/80 hover:bg-violet-600 text-white`;
function zoomIn() {
const idx = fontScales.indexOf(fontScale);
@@ -191,17 +200,45 @@
}
}
function scrollToBottom() {
if (autoScroll && terminalElement) {
function scrollToBottom(force = false) {
if ((force || autoScroll) && terminalElement) {
terminalElement.scrollTop = terminalElement.scrollHeight;
}
}
async function pasteFromClipboard() {
try {
const text = await navigator.clipboard.readText();
if (text) {
await sendInput(text);
}
} catch (e) {
console.error('Failed to read clipboard:', e);
}
}
function copySelection() {
const selection = window.getSelection();
const text = selection?.toString();
if (text) {
navigator.clipboard.writeText(text).catch(e => {
console.error('Failed to copy:', e);
});
}
}
async function handleKeydown(event: KeyboardEvent) {
// Ctrl+Down scrolls to bottom (don't send to tmux)
if (event.ctrlKey && event.key === 'ArrowDown') {
event.preventDefault();
scrollToBottom();
scrollToBottom(true);
return;
}
// Ctrl+Shift+V pastes from clipboard
if (event.ctrlKey && event.shiftKey && event.key === 'V') {
event.preventDefault();
await pasteFromClipboard();
return;
}
@@ -253,6 +290,9 @@
}
async function sendInput(input: string) {
// Scroll to bottom when sending input (force regardless of autoScroll)
scrollToBottom(true);
// Buffer the input for batching
inputBuffer += input;
@@ -296,7 +336,7 @@
await sendInput(key);
}
async function resizeScreen(mode: 'desktop' | 'landscape' | 'portrait') {
async function resizeScreen(mode: 'fullscreen' | 'desktop' | 'landscape' | 'portrait') {
if (resizing) return;
resizing = true;
try {
@@ -318,7 +358,7 @@
if (event.diff) {
const changed = applyDiff(event.diff);
if (changed) {
tick().then(scrollToBottom);
tick().then(() => scrollToBottom());
}
} else if (event.content !== undefined) {
// Fallback: full content replacement (no frame ID available)
@@ -326,7 +366,7 @@
const newLines = newContent ? newContent.split('\n') : [];
if (newLines.join('\n') !== terminalLines.join('\n')) {
terminalLines = newLines;
tick().then(scrollToBottom);
tick().then(() => scrollToBottom());
}
}
}
@@ -380,104 +420,117 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<pre
bind:this={terminalElement}
on:mouseup={copySelection}
on:click={() => terminalInput?.focus()}
class="flex-1 min-h-0 overflow-auto p-3 font-mono text-green-400 whitespace-pre-wrap break-words leading-relaxed terminal-content cursor-text"
style="font-size: {fontScale * 0.875}rem;"
>{@html terminalHtml}</pre>
<!-- Quick action buttons -->
<div class="flex-shrink-0 px-1 py-0.5 bg-zinc-900 border-t border-zinc-800 flex items-center gap-0.5 overflow-x-auto">
<div class="flex-shrink-0 px-2 py-1.5 bg-zinc-900 border-t border-zinc-800 flex items-center gap-1.5 overflow-x-auto">
<button
on:click={() => ctrlMode = !ctrlMode}
disabled={!isAlive}
class="px-1.5 py-0.5 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 text-[10px] font-mono text-zinc-200 transition-colors {ctrlMode ? 'font-bold ring-1 ring-cyan-400 rounded' : 'rounded-sm'}"
class="{btnBase} bg-zinc-700 hover:bg-zinc-600 text-zinc-200 {ctrlMode ? 'font-bold ring-1 ring-cyan-400 rounded' : 'rounded-sm'}"
>^</button>
<button
on:click={sendCtrlC}
disabled={!isAlive}
class="px-1.5 py-0.5 bg-red-600/80 hover:bg-red-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors"
class={btnRed}
>^C</button>
<button
on:click={() => sendInput('\x04')}
disabled={!isAlive}
class="px-1.5 py-0.5 bg-amber-600/80 hover:bg-amber-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors"
class={btnAmber}
>^D</button>
<span class="w-px h-3 bg-zinc-700"></span>
<span class="w-px h-6 bg-zinc-700"></span>
<button
on:click={() => sendKey('y')}
disabled={!isAlive}
class="px-1.5 py-0.5 bg-green-600/80 hover:bg-green-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors"
class={btnGreen}
>y</button>
<button
on:click={() => sendKey('n')}
disabled={!isAlive}
class="px-1.5 py-0.5 bg-red-600/80 hover:bg-red-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors"
class={btnRed}
>n</button>
<span class="w-px h-3 bg-zinc-700"></span>
<span class="w-px h-6 bg-zinc-700"></span>
{#each ['1', '2', '3', '4'] as num}
<button
on:click={() => sendKey(num)}
disabled={!isAlive}
class="px-1.5 py-0.5 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-zinc-200 transition-colors"
class={btnDefault}
>{num}</button>
{/each}
<span class="w-px h-3 bg-zinc-700"></span>
<span class="w-px h-6 bg-zinc-700"></span>
<button
on:click={() => sendInput('\t')}
disabled={!isAlive}
class="px-1.5 py-0.5 bg-cyan-600/80 hover:bg-cyan-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors"
class={btnCyan}
>⇥</button>
<button
on:click={() => sendInput('\x1b[Z')}
disabled={!isAlive}
class="px-1.5 py-0.5 bg-cyan-600/80 hover:bg-cyan-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-white transition-colors"
class={btnCyan}
>⇤</button>
<button
on:click={() => sendInput('\r')}
disabled={!isAlive}
class={btnGreen}
>↵</button>
<span class="w-px h-6 bg-zinc-700"></span>
<button
on:click={pasteFromClipboard}
disabled={!isAlive}
class={btnViolet}
title="Paste (Ctrl+Shift+V)"
>📋</button>
<!-- Screen size selector (pushed right on wider screens) -->
<span class="w-px h-3 bg-zinc-700 ml-auto"></span>
<span class="w-px h-6 bg-zinc-700 ml-auto"></span>
<!-- Text zoom (hidden on mobile portrait, visible on landscape/desktop) -->
<div class="hidden landscape:flex sm:flex items-center gap-0.5">
<div class="hidden landscape:flex sm:flex items-center gap-1.5">
<button
on:click={zoomOut}
disabled={fontScale <= fontScales[0]}
class="px-1 py-0.5 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-zinc-200 transition-colors"
class={btnDefault}
title="Zoom out"
>-</button>
<span class="text-[9px] text-zinc-400 font-mono w-6 text-center">{Math.round(fontScale * 100)}%</span>
<span class="text-xs text-zinc-400 font-mono w-8 text-center">{Math.round(fontScale * 100)}%</span>
<button
on:click={zoomIn}
disabled={fontScale >= fontScales[fontScales.length - 1]}
class="px-1 py-0.5 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 rounded-sm text-[10px] font-mono text-zinc-200 transition-colors"
class={btnDefault}
title="Zoom in"
>+</button>
<span class="w-px h-3 bg-zinc-700"></span>
<span class="w-px h-6 bg-zinc-700"></span>
</div>
<button
on:click={() => resizeScreen('portrait')}
disabled={resizing}
class="px-1 py-0.5 rounded-sm text-[10px] font-mono transition-colors {screenMode === 'portrait' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'} disabled:opacity-50"
class="{btnBase} {screenMode === 'portrait' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'}"
title="Portrait (50x60)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-2.5 w-2 inline-block" fill="none" viewBox="0 0 10 16" stroke="currentColor" stroke-width="1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-3 inline-block" fill="none" viewBox="0 0 10 16" stroke="currentColor" stroke-width="1.5">
<rect x="1" y="1" width="8" height="14" rx="1" />
</svg>
</button>
<button
on:click={() => resizeScreen('landscape')}
disabled={resizing}
class="px-1 py-0.5 rounded-sm text-[10px] font-mono transition-colors {screenMode === 'landscape' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'} disabled:opacity-50"
title="Landscape (65x24)"
class="{btnBase} {screenMode === 'landscape' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'}"
title="Landscape (80x24)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-2.5 w-3 inline-block" fill="none" viewBox="0 0 16 10" stroke="currentColor" stroke-width="1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-5 inline-block" fill="none" viewBox="0 0 16 10" stroke="currentColor" stroke-width="1.5">
<rect x="1" y="1" width="14" height="8" rx="1" />
</svg>
</button>
<button
on:click={() => resizeScreen('desktop')}
disabled={resizing}
class="px-1 py-0.5 rounded-sm text-[10px] font-mono transition-colors {screenMode === 'desktop' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'} disabled:opacity-50"
title="Split screen (100x40)"
class="{btnBase} {screenMode === 'desktop' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'}"
title="Split screen (120x48)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-2.5 w-3.5 inline-block" fill="none" viewBox="0 0 20 14" stroke="currentColor" stroke-width="1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-5 inline-block" fill="none" viewBox="0 0 20 14" stroke="currentColor" stroke-width="1.5">
<rect x="1" y="1" width="8" height="12" rx="1" />
<rect x="11" y="1" width="8" height="12" rx="1" />
</svg>
@@ -485,20 +538,20 @@
<button
on:click={() => resizeScreen('fullscreen')}
disabled={resizing}
class="px-1 py-0.5 rounded-sm text-[10px] font-mono transition-colors {screenMode === 'fullscreen' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'} disabled:opacity-50"
title="Fullscreen (180x60)"
class="{btnBase} {screenMode === 'fullscreen' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'}"
title="Fullscreen (260x48)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-2.5 w-4 inline-block" fill="none" viewBox="0 0 22 14" stroke="currentColor" stroke-width="1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-6 inline-block" fill="none" viewBox="0 0 22 14" stroke="currentColor" stroke-width="1.5">
<rect x="1" y="1" width="20" height="12" rx="1" />
</svg>
</button>
<span class="w-px h-3 bg-zinc-700"></span>
<span class="w-px h-6 bg-zinc-700"></span>
<button
on:click={scrollToBottom}
class="p-0.5 bg-zinc-700 hover:bg-zinc-600 rounded-sm text-zinc-200 transition-colors"
on:click={() => scrollToBottom(true)}
class={btnDefault}
aria-label="Scroll to bottom"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</button>
@@ -509,6 +562,7 @@
bind:this={terminalInput}
type="text"
on:keydown={handleKeydown}
on:focus={() => setTimeout(() => scrollToBottom(true), 59)}
class="sr-only"
disabled={!isAlive}
aria-label="Terminal input"
+47 -4
View File
@@ -3,7 +3,8 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount, onDestroy, tick } from 'svelte';
import { activeSession } from '$lib/stores/sessions';
import { activeSession, sessions } from '$lib/stores/sessions';
import { api } from '$lib/api';
import MessageList from '$lib/components/MessageList.svelte';
import InputBar from '$lib/components/InputBar.svelte';
import PermissionRequest from '$lib/components/PermissionRequest.svelte';
@@ -31,11 +32,13 @@
autoScroll = stored === 'true';
}
}
if (sessionId) {
activeSession.load(sessionId);
}
});
// Load session when sessionId changes (handles client-side navigation)
$: if (sessionId) {
activeSession.load(sessionId);
}
function handleToggleAutoScroll(event: CustomEvent<boolean>) {
autoScroll = event.detail;
if (browser) {
@@ -128,6 +131,24 @@
activeSession.setAutoAcceptEdits(event.detail);
}
function handleRefresh() {
if (sessionId) {
activeSession.load(sessionId);
}
}
async function handleEject() {
if (!sessionId || !isTmuxSession) return;
try {
const result = await api.ejectSession(sessionId);
alert(result.message);
await sessions.load(); // Refresh sessions list so ejected session is removed
goto('/');
} catch (e) {
alert('Failed to eject session: ' + (e instanceof Error ? e.message : 'Unknown error'));
}
}
const statusColors: Record<string, string> = {
idle: 'bg-zinc-600',
processing: 'bg-green-500 animate-pulse',
@@ -200,6 +221,8 @@
on:toggleAutoAccept={handleToggleAutoAccept}
on:toggleAutoScroll={handleToggleAutoScroll}
on:condenseAll={() => messageList?.condenseAll()}
on:refresh={handleRefresh}
on:eject={handleEject}
/>
<span class="text-xs font-medium uppercase {providerColors[session.provider] || 'text-zinc-400'}">
@@ -250,6 +273,15 @@
/>
<span>Auto-scroll</span>
</label>
<button
on:click={() => { menuOpen = false; handleRefresh(); }}
class="w-full px-3 py-2 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</button>
{#if !isTmuxSession}
<button
on:click={() => { menuOpen = false; messageList?.condenseAll(); }}
@@ -272,6 +304,17 @@
<span>Auto-accept edits</span>
</label>
{/if}
{#if isTmuxSession}
<button
on:click={() => { menuOpen = false; handleEject(); }}
class="w-full px-3 py-2 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors border-t border-zinc-700 text-amber-400"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Eject session
</button>
{/if}
</div>
{/if}
</div>
+150 -82
View File
@@ -1,31 +1,20 @@
import { test, expect } from '@playwright/test';
import { E2E_BACKEND_URL } from '../playwright.config';
// Helper to clean up any existing tmux sessions via UI
async function cleanupTmuxSessions(page: import('@playwright/test').Page) {
await page.goto('/');
// Delete all tmux session cards
while (true) {
const tmuxCards = page.locator('a.card').filter({ has: page.locator('span:text-is("tmux")') });
const count = await tmuxCards.count();
if (count === 0) break;
// Set up one-time dialog handler for this deletion
page.once('dialog', async (dialog) => {
await dialog.accept();
});
const deleteButton = tmuxCards.first().locator('button[title="Delete session"]');
await deleteButton.click();
await page.waitForTimeout(500); // Wait for deletion to process
// Helper to delete a specific session by ID via API
async function deleteSession(request: import('@playwright/test').APIRequestContext, sessionId: string) {
try {
await request.delete(`${E2E_BACKEND_URL}/api/sessions/${encodeURIComponent(sessionId)}`);
console.log(`[Cleanup] Deleted session: ${sessionId}`);
} catch (e) {
console.log(`[Cleanup] Failed to delete session ${sessionId}:`, e);
}
}
test.describe('Tmux Terminal Session', () => {
test('create tmux session and run shell commands', async ({ page }) => {
// Clean up any stale tmux sessions first
await cleanupTmuxSessions(page);
test('create tmux session and run shell commands', async ({ page, request }) => {
// Track session ID for cleanup
let createdSessionId: string | null = null;
// Enable console logging for debugging
page.on('console', (msg) => {
@@ -61,7 +50,7 @@ test.describe('Tmux Terminal Session', () => {
// 4. Wait for navigation to session page
await page.waitForURL(/\/session\/.+/);
const sessionUrl = page.url();
const sessionId = decodeURIComponent(sessionUrl.split('/session/')[1]);
createdSessionId = decodeURIComponent(sessionUrl.split('/session/')[1]);
console.log('[Test] Navigated to session page:', sessionUrl);
// 5. Wait for terminal view to load (should see the terminal pre element)
@@ -78,14 +67,14 @@ test.describe('Tmux Terminal Session', () => {
const terminalBadge = page.locator('text=TERMINAL');
await expect(terminalBadge).toBeVisible();
// 8. Find the command input
const commandInput = page.locator('input[placeholder*="Type command"]');
await expect(commandInput).toBeVisible();
// 8. Find the command input (hidden input for keyboard capture)
const commandInput = page.locator('input[aria-label="Terminal input"]');
await expect(commandInput).toBeEnabled();
// 9. Run `pwd` command
await commandInput.fill('pwd');
await commandInput.press('Enter');
// 9. Run `pwd` command - use type() to trigger keydown events
await commandInput.focus();
await page.keyboard.type('pwd');
await page.keyboard.press('Enter');
console.log('[Test] Sent pwd command');
// 10. Wait for output and verify it contains a path (starts with /)
@@ -101,8 +90,9 @@ test.describe('Tmux Terminal Session', () => {
console.log('[Test] pwd output verified - contains path');
// 11. Run `ls -al` command
await commandInput.fill('ls -al');
await commandInput.press('Enter');
await commandInput.focus();
await page.keyboard.type('ls -al');
await page.keyboard.press('Enter');
console.log('[Test] Sent ls -al command');
// 12. Wait for output and verify it contains typical ls -al output
@@ -131,30 +121,16 @@ test.describe('Tmux Terminal Session', () => {
console.log('[Test] Tmux terminal test completed successfully');
// 15. Cleanup: Delete the session via UI
await page.goto('/');
await expect(page).toHaveTitle(/Spiceflow/i);
// Find the tmux session card and delete it
const tmuxCards = page.locator('a.card').filter({ has: page.locator('span:text-is("tmux")') });
await expect(tmuxCards).toHaveCount(1);
// Set up one-time dialog handler
page.once('dialog', async (dialog) => {
await dialog.accept();
});
const deleteButton = tmuxCards.first().locator('button[title="Delete session"]');
await deleteButton.click();
// Wait for no tmux cards to remain
await expect(tmuxCards).toHaveCount(0, { timeout: 5000 });
console.log('[Test] Cleanup: Session deleted');
// 15. Cleanup: Delete only the session we created
if (createdSessionId) {
await deleteSession(request, createdSessionId);
}
console.log('[Test] Cleanup: Test session deleted');
});
test('deleting tmux session kills the tmux process', async ({ page, request }) => {
// Clean up any stale tmux sessions first
await cleanupTmuxSessions(page);
// Track session ID for cleanup
let createdSessionId: string | null = null;
// 1. Navigate to homepage and create a tmux session
await page.goto('/');
@@ -170,55 +146,147 @@ test.describe('Tmux Terminal Session', () => {
await page.waitForURL(/\/session\/.+/);
const sessionUrl = page.url();
// URL decode the session ID since it contains special characters
const sessionId = decodeURIComponent(sessionUrl.split('/session/')[1]);
console.log('[Test] Created session:', sessionId);
createdSessionId = decodeURIComponent(sessionUrl.split('/session/')[1]);
console.log('[Test] Created session:', createdSessionId);
// 2. Wait for terminal to load and session to be active
const terminalOutput = page.locator('pre.text-green-400');
await expect(terminalOutput).toBeVisible({ timeout: 10000 });
// 3. For ephemeral tmux sessions, the session ID IS the tmux session name
const tmuxSessionName = sessionId;
const tmuxSessionName = createdSessionId;
console.log('[Test] Tmux session name:', tmuxSessionName);
// 4. Verify the tmux session exists by running a command
const commandInput = page.locator('input[placeholder*="Type command"]');
await commandInput.fill('echo "session-alive"');
await commandInput.press('Enter');
const commandInput = page.locator('input[aria-label="Terminal input"]');
await expect(commandInput).toBeEnabled({ timeout: 5000 });
await commandInput.focus();
await page.keyboard.type('echo "session-alive"');
await page.keyboard.press('Enter');
await page.waitForTimeout(500);
// 5. Go back to home page
await page.goto('/');
await expect(page).toHaveTitle(/Spiceflow/i);
// 5. Delete the session we created via API
await deleteSession(request, createdSessionId!);
console.log('[Test] Test session deleted from API');
// 6. Find and delete the session by provider badge (more generic than session ID)
const tmuxCards = page.locator('a.card').filter({ has: page.locator('span:text-is("tmux")') });
await expect(tmuxCards).toHaveCount(1);
// Set up one-time dialog handler to accept the confirmation
page.once('dialog', async (dialog) => {
console.log('[Test] Dialog appeared:', dialog.message());
await dialog.accept();
});
// Click the delete button (X icon) on the session card
const deleteButton = tmuxCards.first().locator('button[title="Delete session"]');
await deleteButton.click();
// 7. Wait for session to be deleted (no more tmux cards)
await expect(tmuxCards).toHaveCount(0, { timeout: 5000 });
console.log('[Test] Session deleted from UI');
// 8. Verify the tmux session was killed by checking the API
// 6. Verify the tmux session was killed by checking the API
// The session should no longer exist
const sessionResponse = await request.get(`${E2E_BACKEND_URL}/api/sessions/${sessionId}`);
const sessionResponse = await request.get(`${E2E_BACKEND_URL}/api/sessions/${encodeURIComponent(createdSessionId!)}`);
expect(sessionResponse.status()).toBe(404);
console.log('[Test] Session no longer exists in API');
// 9. Verify the tmux session is no longer alive
// 7. Verify the tmux session is no longer alive
// We can check this by trying to get terminal content - it should fail
const terminalCheckResponse = await request.get(`${E2E_BACKEND_URL}/api/sessions/${sessionId}/terminal`);
const terminalCheckResponse = await request.get(`${E2E_BACKEND_URL}/api/sessions/${encodeURIComponent(createdSessionId!)}/terminal`);
expect(terminalCheckResponse.status()).toBe(404);
console.log('[Test] Tmux session properly cleaned up');
});
test('eject tmux session removes from spiceflow but keeps tmux running', async ({ page, request }) => {
// Track session ID for cleanup
let createdSessionId: string | null = null;
let ejectedSessionName: string | null = null;
// 1. Navigate to homepage and create a tmux session
await page.goto('/');
await expect(page).toHaveTitle(/Spiceflow/i);
const createButton = page.locator('button[title="New Session"]');
await createButton.click();
const tmuxOption = page.locator('button:has-text("Terminal (tmux)")');
await tmuxOption.click();
// Wait for navigation to session page
await page.waitForURL(/\/session\/.+/);
const sessionUrl = page.url();
createdSessionId = decodeURIComponent(sessionUrl.split('/session/')[1]);
console.log('[Test] Created session:', createdSessionId);
// 2. Wait for terminal to load and session to be active
const terminalOutput = page.locator('pre.text-green-400');
await expect(terminalOutput).toBeVisible({ timeout: 10000 });
// 3. Run a command to verify terminal is working
const commandInput = page.locator('input[aria-label="Terminal input"]');
await expect(commandInput).toBeEnabled({ timeout: 5000 });
await commandInput.focus();
await page.keyboard.type('echo "eject-test-marker"');
await page.keyboard.press('Enter');
await page.waitForTimeout(500);
// Verify command output appeared
await expect(async () => {
const content = await terminalOutput.textContent();
expect(content).toContain('eject-test-marker');
}).toPass({ timeout: 5000 });
console.log('[Test] Terminal is working');
// 4. Handle the alert that will appear after eject
page.on('dialog', async (dialog) => {
console.log('[Test] Alert message:', dialog.message());
// Extract the new session name from the message
// Message format: "Session ejected. You can attach to it with: tmux attach -t <name>"
const match = dialog.message().match(/tmux attach -t (\S+)/);
if (match) {
ejectedSessionName = match[1];
}
await dialog.accept();
});
// 5. Open settings menu and click eject
const settingsButton = page.locator('button[title="Session settings"]');
await settingsButton.click();
const ejectButton = page.locator('text=Eject session');
await expect(ejectButton).toBeVisible();
await ejectButton.click();
console.log('[Test] Clicked eject button');
// 6. Wait for navigation back to home page
await page.waitForURL('/');
console.log('[Test] Navigated back to home');
// 7. Verify the session is no longer in the list
await page.waitForTimeout(500); // Give time for UI to update
const sessionCards = page.locator(`a[href="/session/${encodeURIComponent(createdSessionId!)}"]`);
await expect(sessionCards).toHaveCount(0);
console.log('[Test] Session removed from list');
// 8. Verify the ejected session still exists as an external tmux session
// Open the import menu to check external sessions
const importButton = page.locator('button[title="Import tmux session"]');
await importButton.click();
// Wait for the dropdown to load
await page.waitForTimeout(1000);
// The ejected session should now appear in the import list
// The name should be without the spiceflow- prefix
const externalSessionItem = page.locator('.min-w-\\[200px\\] button').filter({ hasText: /.+/ });
const externalSessionTexts = await externalSessionItem.allTextContents();
console.log('[Test] External sessions:', externalSessionTexts);
// Verify at least one external session exists (our ejected one)
expect(externalSessionTexts.length).toBeGreaterThan(0);
console.log('[Test] Ejected session is available for import');
// 9. Cleanup: Import the ejected session back and delete it via UI
if (ejectedSessionName) {
// Click on the ejected session in the import dropdown to import it
const ejectedSessionButton = page.locator(`button:has-text("${ejectedSessionName}")`);
await ejectedSessionButton.click();
console.log('[Test] Clicked to import ejected session');
// Wait for navigation to the imported session
await page.waitForURL(/\/session\/.+/);
const importedSessionUrl = page.url();
const importedSessionId = decodeURIComponent(importedSessionUrl.split('/session/')[1]);
console.log('[Test] Imported session:', importedSessionId);
// Delete the imported session via API (which will kill the tmux session)
await deleteSession(request, importedSessionId);
console.log('[Test] Cleaned up imported session');
}
});
});
+24 -3
View File
@@ -339,9 +339,9 @@
;; Screen size presets for different device orientations
(def ^:private screen-sizes
{:fullscreen {:width 180 :height 24}
:desktop {:width 100 :height 24}
:landscape {:width 65 :height 24}
{:fullscreen {:width 260 :height 48}
:desktop {:width 120 :height 48}
:landscape {:width 80 :height 24}
:portrait {:width 40 :height 24}})
(defn resize-session
@@ -420,3 +420,24 @@
:name new-name
:working-dir (or (run-tmux "display-message" "-t" new-name "-p" "#{pane_current_path}")
(System/getProperty "user.home"))})))))
(defn eject-session
"Eject a spiceflow-managed tmux session by removing the spiceflow prefix.
The tmux session continues running but is no longer managed by spiceflow.
Returns the new session name on success, nil on failure."
[session-name]
(when (and session-name (str/starts-with? session-name session-prefix))
(let [;; Remove the spiceflow- prefix to get the base name
base-name (subs session-name (count session-prefix))
output-file (output-file-path session-name)]
;; Stop pipe-pane first
(run-tmux "pipe-pane" "-t" session-name)
;; Clean up output file
(when output-file
(let [f (File. ^String output-file)]
(when (.exists f)
(.delete f))))
;; Rename the session to remove spiceflow prefix
(when (rename-session session-name base-name)
(log/info "[Tmux] Ejected session" session-name "-> " base-name)
base-name))))
+20
View File
@@ -310,6 +310,25 @@
(error-response 400 "Failed to import session. It may not exist or is already managed by spiceflow."))
(error-response 400 "Session name is required"))))
(defn eject-tmux-handler
"Eject a spiceflow-managed tmux session. Removes it from spiceflow but keeps the tmux session running."
[store]
(fn [request]
(let [id (get-in request [:path-params :id])]
(if (tmux-session-id? id)
(if (tmux/session-alive? id)
(if-let [new-name (tmux/eject-session id)]
(do
;; Remove the session from the database (tmux sessions aren't in DB, but call anyway for safety)
(db/delete-session store id)
(json-response {:status "ejected"
:old-name id
:new-name new-name
:message (str "Session ejected. You can attach to it with: tmux attach -t " new-name)}))
(error-response 500 "Failed to eject tmux session"))
(error-response 400 "Tmux session not alive"))
(error-response 400 "Not a tmux session")))))
;; Health check
(defn health-handler
[_request]
@@ -367,6 +386,7 @@
["/sessions/:id/terminal" {:get (terminal-capture-handler store)}]
["/sessions/:id/terminal/input" {:post (terminal-input-handler store broadcast-fn)}]
["/sessions/:id/terminal/resize" {:post (terminal-resize-handler store)}]
["/sessions/:id/eject" {:post (eject-tmux-handler store)}]
["/tmux/external" {:get list-external-tmux-handler}]
["/tmux/import" {:post import-tmux-handler}]
["/push/vapid-key" {:get (vapid-key-handler push-store)}]