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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user