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