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:
@@ -40,3 +40,6 @@ Thumbs.db
|
|||||||
|
|
||||||
# PWA
|
# PWA
|
||||||
client/dev-dist/
|
client/dev-dist/
|
||||||
|
|
||||||
|
# Test results
|
||||||
|
e2e/test-results/
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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))))
|
||||||
|
|||||||
@@ -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)}]
|
||||||
|
|||||||
Reference in New Issue
Block a user