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 # PWA
client/dev-dist/ 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 | | `CLAUDE_SESSIONS_DIR` | ~/.claude/projects | Claude sessions |
| `OPENCODE_CMD` | opencode | OpenCode binary | | `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 ## Subdirectory CLAUDE.md Files
Each directory has specific details: 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`, { return this.request<{ status: string; mode: string }>(`/sessions/${sessionId}/terminal/resize`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ mode }) 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 // External tmux sessions
async getExternalTmuxSessions(): Promise<ExternalTmuxSession[]> { async getExternalTmuxSessions(): Promise<ExternalTmuxSession[]> {
return this.request<ExternalTmuxSession[]>('/tmux/external'); return this.request<ExternalTmuxSession[]>('/tmux/external');
@@ -23,6 +23,7 @@
let container: HTMLDivElement; let container: HTMLDivElement;
let collapsedMessages: Set<string> = new Set(); let collapsedMessages: Set<string> = new Set();
let hasScrolledInitially = false; let hasScrolledInitially = false;
let lastMessageCount = 0;
const COLLAPSE_THRESHOLD = 5; // show toggle if more than this many lines 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 // Scroll to bottom when messages first load
$: if (messages.length > 0 && container && !hasScrolledInitially) { $: if (messages.length > 0 && container && !hasScrolledInitially) {
hasScrolledInitially = true; hasScrolledInitially = true;
@@ -9,6 +9,8 @@
toggleAutoAccept: boolean; toggleAutoAccept: boolean;
toggleAutoScroll: boolean; toggleAutoScroll: boolean;
condenseAll: void; condenseAll: void;
refresh: void;
eject: void;
}>(); }>();
function handleToggleAutoScroll() { function handleToggleAutoScroll() {
@@ -27,6 +29,16 @@
open = false; open = false;
} }
function handleRefresh() {
dispatch('refresh');
open = false;
}
function handleEject() {
dispatch('eject');
open = false;
}
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
if (!target.closest('.settings-dropdown')) { if (!target.closest('.settings-dropdown')) {
@@ -86,6 +98,17 @@
</div> </div>
</label> </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'} {#if provider !== 'tmux'}
<button <button
on:click={handleCondenseAll} on:click={handleCondenseAll}
@@ -116,6 +139,23 @@
</div> </div>
</label> </label>
{/if} {/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> </div>
{/if} {/if}
</div> </div>
+95 -41
View File
@@ -35,8 +35,17 @@
let inputBuffer = ''; let inputBuffer = '';
let batchTimeout: ReturnType<typeof setTimeout> | null = null; let batchTimeout: ReturnType<typeof setTimeout> | null = null;
let isSending = false; let isSending = false;
let fontScale = 1; // 0.75, 0.875, 1, 1.125, 1.25, 1.5 let fontScale = 1; // 50% to 150% in 5% increments
const fontScales = [0.75, 0.875, 1, 1.125, 1.25, 1.5]; 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() { function zoomIn() {
const idx = fontScales.indexOf(fontScale); const idx = fontScales.indexOf(fontScale);
@@ -191,17 +200,45 @@
} }
} }
function scrollToBottom() { function scrollToBottom(force = false) {
if (autoScroll && terminalElement) { if ((force || autoScroll) && terminalElement) {
terminalElement.scrollTop = terminalElement.scrollHeight; 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) { async function handleKeydown(event: KeyboardEvent) {
// Ctrl+Down scrolls to bottom (don't send to tmux) // Ctrl+Down scrolls to bottom (don't send to tmux)
if (event.ctrlKey && event.key === 'ArrowDown') { if (event.ctrlKey && event.key === 'ArrowDown') {
event.preventDefault(); 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; return;
} }
@@ -253,6 +290,9 @@
} }
async function sendInput(input: string) { async function sendInput(input: string) {
// Scroll to bottom when sending input (force regardless of autoScroll)
scrollToBottom(true);
// Buffer the input for batching // Buffer the input for batching
inputBuffer += input; inputBuffer += input;
@@ -296,7 +336,7 @@
await sendInput(key); await sendInput(key);
} }
async function resizeScreen(mode: 'desktop' | 'landscape' | 'portrait') { async function resizeScreen(mode: 'fullscreen' | 'desktop' | 'landscape' | 'portrait') {
if (resizing) return; if (resizing) return;
resizing = true; resizing = true;
try { try {
@@ -318,7 +358,7 @@
if (event.diff) { if (event.diff) {
const changed = applyDiff(event.diff); const changed = applyDiff(event.diff);
if (changed) { if (changed) {
tick().then(scrollToBottom); tick().then(() => scrollToBottom());
} }
} else if (event.content !== undefined) { } else if (event.content !== undefined) {
// Fallback: full content replacement (no frame ID available) // Fallback: full content replacement (no frame ID available)
@@ -326,7 +366,7 @@
const newLines = newContent ? newContent.split('\n') : []; const newLines = newContent ? newContent.split('\n') : [];
if (newLines.join('\n') !== terminalLines.join('\n')) { if (newLines.join('\n') !== terminalLines.join('\n')) {
terminalLines = newLines; terminalLines = newLines;
tick().then(scrollToBottom); tick().then(() => scrollToBottom());
} }
} }
} }
@@ -380,104 +420,117 @@
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<pre <pre
bind:this={terminalElement} bind:this={terminalElement}
on:mouseup={copySelection}
on:click={() => terminalInput?.focus()} 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" 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;" style="font-size: {fontScale * 0.875}rem;"
>{@html terminalHtml}</pre> >{@html terminalHtml}</pre>
<!-- Quick action buttons --> <!-- 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 <button
on:click={() => ctrlMode = !ctrlMode} on:click={() => ctrlMode = !ctrlMode}
disabled={!isAlive} 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>
<button <button
on:click={sendCtrlC} on:click={sendCtrlC}
disabled={!isAlive} 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> >^C</button>
<button <button
on:click={() => sendInput('\x04')} on:click={() => sendInput('\x04')}
disabled={!isAlive} 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> >^D</button>
<span class="w-px h-3 bg-zinc-700"></span> <span class="w-px h-6 bg-zinc-700"></span>
<button <button
on:click={() => sendKey('y')} on:click={() => sendKey('y')}
disabled={!isAlive} 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> >y</button>
<button <button
on:click={() => sendKey('n')} on:click={() => sendKey('n')}
disabled={!isAlive} 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> >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} {#each ['1', '2', '3', '4'] as num}
<button <button
on:click={() => sendKey(num)} on:click={() => sendKey(num)}
disabled={!isAlive} 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> >{num}</button>
{/each} {/each}
<span class="w-px h-3 bg-zinc-700"></span> <span class="w-px h-6 bg-zinc-700"></span>
<button <button
on:click={() => sendInput('\t')} on:click={() => sendInput('\t')}
disabled={!isAlive} 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>
<button <button
on:click={() => sendInput('\x1b[Z')} on:click={() => sendInput('\x1b[Z')}
disabled={!isAlive} 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>
<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) --> <!-- 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) --> <!-- 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 <button
on:click={zoomOut} on:click={zoomOut}
disabled={fontScale <= fontScales[0]} 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" title="Zoom out"
>-</button> >-</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 <button
on:click={zoomIn} on:click={zoomIn}
disabled={fontScale >= fontScales[fontScales.length - 1]} 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" title="Zoom in"
>+</button> >+</button>
<span class="w-px h-3 bg-zinc-700"></span> <span class="w-px h-6 bg-zinc-700"></span>
</div> </div>
<button <button
on:click={() => resizeScreen('portrait')} on:click={() => resizeScreen('portrait')}
disabled={resizing} 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)" 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" /> <rect x="1" y="1" width="8" height="14" rx="1" />
</svg> </svg>
</button> </button>
<button <button
on:click={() => resizeScreen('landscape')} on:click={() => resizeScreen('landscape')}
disabled={resizing} 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" class="{btnBase} {screenMode === 'landscape' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'}"
title="Landscape (65x24)" 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" /> <rect x="1" y="1" width="14" height="8" rx="1" />
</svg> </svg>
</button> </button>
<button <button
on:click={() => resizeScreen('desktop')} on:click={() => resizeScreen('desktop')}
disabled={resizing} 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" class="{btnBase} {screenMode === 'desktop' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'}"
title="Split screen (100x40)" 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="1" y="1" width="8" height="12" rx="1" />
<rect x="11" y="1" width="8" height="12" rx="1" /> <rect x="11" y="1" width="8" height="12" rx="1" />
</svg> </svg>
@@ -485,20 +538,20 @@
<button <button
on:click={() => resizeScreen('fullscreen')} on:click={() => resizeScreen('fullscreen')}
disabled={resizing} 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" class="{btnBase} {screenMode === 'fullscreen' ? 'bg-cyan-600 text-white' : 'bg-zinc-700 hover:bg-zinc-600 text-zinc-200'}"
title="Fullscreen (180x60)" 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" /> <rect x="1" y="1" width="20" height="12" rx="1" />
</svg> </svg>
</button> </button>
<span class="w-px h-3 bg-zinc-700"></span> <span class="w-px h-6 bg-zinc-700"></span>
<button <button
on:click={scrollToBottom} on:click={() => scrollToBottom(true)}
class="p-0.5 bg-zinc-700 hover:bg-zinc-600 rounded-sm text-zinc-200 transition-colors" class={btnDefault}
aria-label="Scroll to bottom" 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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg> </svg>
</button> </button>
@@ -509,6 +562,7 @@
bind:this={terminalInput} bind:this={terminalInput}
type="text" type="text"
on:keydown={handleKeydown} on:keydown={handleKeydown}
on:focus={() => setTimeout(() => scrollToBottom(true), 59)}
class="sr-only" class="sr-only"
disabled={!isAlive} disabled={!isAlive}
aria-label="Terminal input" aria-label="Terminal input"
+46 -3
View File
@@ -3,7 +3,8 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { onMount, onDestroy, tick } from 'svelte'; 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 MessageList from '$lib/components/MessageList.svelte';
import InputBar from '$lib/components/InputBar.svelte'; import InputBar from '$lib/components/InputBar.svelte';
import PermissionRequest from '$lib/components/PermissionRequest.svelte'; import PermissionRequest from '$lib/components/PermissionRequest.svelte';
@@ -31,10 +32,12 @@
autoScroll = stored === 'true'; autoScroll = stored === 'true';
} }
} }
if (sessionId) { });
// Load session when sessionId changes (handles client-side navigation)
$: if (sessionId) {
activeSession.load(sessionId); activeSession.load(sessionId);
} }
});
function handleToggleAutoScroll(event: CustomEvent<boolean>) { function handleToggleAutoScroll(event: CustomEvent<boolean>) {
autoScroll = event.detail; autoScroll = event.detail;
@@ -128,6 +131,24 @@
activeSession.setAutoAcceptEdits(event.detail); 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> = { const statusColors: Record<string, string> = {
idle: 'bg-zinc-600', idle: 'bg-zinc-600',
processing: 'bg-green-500 animate-pulse', processing: 'bg-green-500 animate-pulse',
@@ -200,6 +221,8 @@
on:toggleAutoAccept={handleToggleAutoAccept} on:toggleAutoAccept={handleToggleAutoAccept}
on:toggleAutoScroll={handleToggleAutoScroll} on:toggleAutoScroll={handleToggleAutoScroll}
on:condenseAll={() => messageList?.condenseAll()} on:condenseAll={() => messageList?.condenseAll()}
on:refresh={handleRefresh}
on:eject={handleEject}
/> />
<span class="text-xs font-medium uppercase {providerColors[session.provider] || 'text-zinc-400'}"> <span class="text-xs font-medium uppercase {providerColors[session.provider] || 'text-zinc-400'}">
@@ -250,6 +273,15 @@
/> />
<span>Auto-scroll</span> <span>Auto-scroll</span>
</label> </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} {#if !isTmuxSession}
<button <button
on:click={() => { menuOpen = false; messageList?.condenseAll(); }} on:click={() => { menuOpen = false; messageList?.condenseAll(); }}
@@ -272,6 +304,17 @@
<span>Auto-accept edits</span> <span>Auto-accept edits</span>
</label> </label>
{/if} {/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> </div>
{/if} {/if}
</div> </div>
+150 -82
View File
@@ -1,31 +1,20 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { E2E_BACKEND_URL } from '../playwright.config'; import { E2E_BACKEND_URL } from '../playwright.config';
// Helper to clean up any existing tmux sessions via UI // Helper to delete a specific session by ID via API
async function cleanupTmuxSessions(page: import('@playwright/test').Page) { async function deleteSession(request: import('@playwright/test').APIRequestContext, sessionId: string) {
await page.goto('/'); try {
await request.delete(`${E2E_BACKEND_URL}/api/sessions/${encodeURIComponent(sessionId)}`);
// Delete all tmux session cards console.log(`[Cleanup] Deleted session: ${sessionId}`);
while (true) { } catch (e) {
const tmuxCards = page.locator('a.card').filter({ has: page.locator('span:text-is("tmux")') }); console.log(`[Cleanup] Failed to delete session ${sessionId}:`, e);
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
} }
} }
test.describe('Tmux Terminal Session', () => { test.describe('Tmux Terminal Session', () => {
test('create tmux session and run shell commands', async ({ page }) => { test('create tmux session and run shell commands', async ({ page, request }) => {
// Clean up any stale tmux sessions first // Track session ID for cleanup
await cleanupTmuxSessions(page); let createdSessionId: string | null = null;
// Enable console logging for debugging // Enable console logging for debugging
page.on('console', (msg) => { page.on('console', (msg) => {
@@ -61,7 +50,7 @@ test.describe('Tmux Terminal Session', () => {
// 4. Wait for navigation to session page // 4. Wait for navigation to session page
await page.waitForURL(/\/session\/.+/); await page.waitForURL(/\/session\/.+/);
const sessionUrl = page.url(); 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); console.log('[Test] Navigated to session page:', sessionUrl);
// 5. Wait for terminal view to load (should see the terminal pre element) // 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'); const terminalBadge = page.locator('text=TERMINAL');
await expect(terminalBadge).toBeVisible(); await expect(terminalBadge).toBeVisible();
// 8. Find the command input // 8. Find the command input (hidden input for keyboard capture)
const commandInput = page.locator('input[placeholder*="Type command"]'); const commandInput = page.locator('input[aria-label="Terminal input"]');
await expect(commandInput).toBeVisible();
await expect(commandInput).toBeEnabled(); await expect(commandInput).toBeEnabled();
// 9. Run `pwd` command // 9. Run `pwd` command - use type() to trigger keydown events
await commandInput.fill('pwd'); await commandInput.focus();
await commandInput.press('Enter'); await page.keyboard.type('pwd');
await page.keyboard.press('Enter');
console.log('[Test] Sent pwd command'); console.log('[Test] Sent pwd command');
// 10. Wait for output and verify it contains a path (starts with /) // 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'); console.log('[Test] pwd output verified - contains path');
// 11. Run `ls -al` command // 11. Run `ls -al` command
await commandInput.fill('ls -al'); await commandInput.focus();
await commandInput.press('Enter'); await page.keyboard.type('ls -al');
await page.keyboard.press('Enter');
console.log('[Test] Sent ls -al command'); console.log('[Test] Sent ls -al command');
// 12. Wait for output and verify it contains typical ls -al output // 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'); console.log('[Test] Tmux terminal test completed successfully');
// 15. Cleanup: Delete the session via UI // 15. Cleanup: Delete only the session we created
await page.goto('/'); if (createdSessionId) {
await expect(page).toHaveTitle(/Spiceflow/i); await deleteSession(request, createdSessionId);
}
// Find the tmux session card and delete it console.log('[Test] Cleanup: Test session deleted');
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');
}); });
test('deleting tmux session kills the tmux process', async ({ page, request }) => { test('deleting tmux session kills the tmux process', async ({ page, request }) => {
// Clean up any stale tmux sessions first // Track session ID for cleanup
await cleanupTmuxSessions(page); let createdSessionId: string | null = null;
// 1. Navigate to homepage and create a tmux session // 1. Navigate to homepage and create a tmux session
await page.goto('/'); await page.goto('/');
@@ -170,55 +146,147 @@ test.describe('Tmux Terminal Session', () => {
await page.waitForURL(/\/session\/.+/); await page.waitForURL(/\/session\/.+/);
const sessionUrl = page.url(); const sessionUrl = page.url();
// URL decode the session ID since it contains special characters // URL decode the session ID since it contains special characters
const sessionId = decodeURIComponent(sessionUrl.split('/session/')[1]); createdSessionId = decodeURIComponent(sessionUrl.split('/session/')[1]);
console.log('[Test] Created session:', sessionId); console.log('[Test] Created session:', createdSessionId);
// 2. Wait for terminal to load and session to be active // 2. Wait for terminal to load and session to be active
const terminalOutput = page.locator('pre.text-green-400'); const terminalOutput = page.locator('pre.text-green-400');
await expect(terminalOutput).toBeVisible({ timeout: 10000 }); await expect(terminalOutput).toBeVisible({ timeout: 10000 });
// 3. For ephemeral tmux sessions, the session ID IS the tmux session name // 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); console.log('[Test] Tmux session name:', tmuxSessionName);
// 4. Verify the tmux session exists by running a command // 4. Verify the tmux session exists by running a command
const commandInput = page.locator('input[placeholder*="Type command"]'); const commandInput = page.locator('input[aria-label="Terminal input"]');
await commandInput.fill('echo "session-alive"'); await expect(commandInput).toBeEnabled({ timeout: 5000 });
await commandInput.press('Enter'); await commandInput.focus();
await page.keyboard.type('echo "session-alive"');
await page.keyboard.press('Enter');
await page.waitForTimeout(500); await page.waitForTimeout(500);
// 5. Go back to home page // 5. Delete the session we created via API
await page.goto('/'); await deleteSession(request, createdSessionId!);
await expect(page).toHaveTitle(/Spiceflow/i); console.log('[Test] Test session deleted from API');
// 6. Find and delete the session by provider badge (more generic than session ID) // 6. Verify the tmux session was killed by checking the API
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
// The session should no longer exist // 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); expect(sessionResponse.status()).toBe(404);
console.log('[Test] Session no longer exists in API'); 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 // 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); expect(terminalCheckResponse.status()).toBe(404);
console.log('[Test] Tmux session properly cleaned up'); 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 ;; Screen size presets for different device orientations
(def ^:private screen-sizes (def ^:private screen-sizes
{:fullscreen {:width 180 :height 24} {:fullscreen {:width 260 :height 48}
:desktop {:width 100 :height 24} :desktop {:width 120 :height 48}
:landscape {:width 65 :height 24} :landscape {:width 80 :height 24}
:portrait {:width 40 :height 24}}) :portrait {:width 40 :height 24}})
(defn resize-session (defn resize-session
@@ -420,3 +420,24 @@
:name new-name :name new-name
:working-dir (or (run-tmux "display-message" "-t" new-name "-p" "#{pane_current_path}") :working-dir (or (run-tmux "display-message" "-t" new-name "-p" "#{pane_current_path}")
(System/getProperty "user.home"))}))))) (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 "Failed to import session. It may not exist or is already managed by spiceflow."))
(error-response 400 "Session name is required")))) (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 ;; Health check
(defn health-handler (defn health-handler
[_request] [_request]
@@ -367,6 +386,7 @@
["/sessions/:id/terminal" {:get (terminal-capture-handler store)}] ["/sessions/:id/terminal" {:get (terminal-capture-handler store)}]
["/sessions/:id/terminal/input" {:post (terminal-input-handler store broadcast-fn)}] ["/sessions/:id/terminal/input" {:post (terminal-input-handler store broadcast-fn)}]
["/sessions/:id/terminal/resize" {:post (terminal-resize-handler store)}] ["/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/external" {:get list-external-tmux-handler}]
["/tmux/import" {:post import-tmux-handler}] ["/tmux/import" {:post import-tmux-handler}]
["/push/vapid-key" {:get (vapid-key-handler push-store)}] ["/push/vapid-key" {:get (vapid-key-handler push-store)}]