- 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>
370 lines
12 KiB
Svelte
370 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { browser } from '$app/environment';
|
|
import { page } from '$app/stores';
|
|
import { goto } from '$app/navigation';
|
|
import { onMount, onDestroy, tick } from 'svelte';
|
|
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';
|
|
import SessionSettings from '$lib/components/SessionSettings.svelte';
|
|
import TerminalView from '$lib/components/TerminalView.svelte';
|
|
|
|
$: sessionId = $page.params.id;
|
|
|
|
let inputBar: InputBar;
|
|
let messageList: MessageList;
|
|
let terminalView: TerminalView;
|
|
let steerMode = false;
|
|
let isEditingTitle = false;
|
|
let editedTitle = '';
|
|
let titleInput: HTMLInputElement;
|
|
let menuOpen = false;
|
|
let autoScroll = true;
|
|
let tmuxAlive = false;
|
|
|
|
// Load auto-scroll preference from localStorage
|
|
onMount(() => {
|
|
if (browser) {
|
|
const stored = localStorage.getItem('spiceflow-auto-scroll');
|
|
if (stored !== null) {
|
|
autoScroll = stored === 'true';
|
|
}
|
|
}
|
|
});
|
|
|
|
// Load session when sessionId changes (handles client-side navigation)
|
|
$: if (sessionId) {
|
|
activeSession.load(sessionId);
|
|
}
|
|
|
|
function handleToggleAutoScroll(event: CustomEvent<boolean>) {
|
|
autoScroll = event.detail;
|
|
if (browser) {
|
|
localStorage.setItem('spiceflow-auto-scroll', String(autoScroll));
|
|
}
|
|
}
|
|
|
|
onDestroy(() => {
|
|
activeSession.clear();
|
|
});
|
|
|
|
function handleSend(event: CustomEvent<string>) {
|
|
if (steerMode && $activeSession.pendingPermission) {
|
|
// Send as steer response
|
|
activeSession.respondToPermission('steer', event.detail);
|
|
steerMode = false;
|
|
} else {
|
|
activeSession.sendMessage(event.detail);
|
|
}
|
|
}
|
|
|
|
function handlePermissionAccept() {
|
|
activeSession.respondToPermission('accept');
|
|
}
|
|
|
|
function handlePermissionDeny() {
|
|
activeSession.respondToPermission('deny');
|
|
}
|
|
|
|
function handlePermissionSteer() {
|
|
steerMode = true;
|
|
// Focus the input bar
|
|
inputBar?.focus();
|
|
}
|
|
|
|
function goBack() {
|
|
goto('/');
|
|
}
|
|
|
|
async function startEditingTitle() {
|
|
if (!session) return;
|
|
editedTitle = session.title || '';
|
|
isEditingTitle = true;
|
|
await tick();
|
|
titleInput?.focus();
|
|
titleInput?.select();
|
|
}
|
|
|
|
async function saveTitle() {
|
|
if (!session || !isEditingTitle) return;
|
|
const newTitle = editedTitle.trim();
|
|
isEditingTitle = false;
|
|
if (newTitle !== (session.title || '')) {
|
|
const result = await activeSession.rename(newTitle);
|
|
// For tmux sessions, the ID changes on rename - navigate to new URL
|
|
if (result?.idChanged && result.updated?.id) {
|
|
goto(`/session/${result.updated.id}`, { replaceState: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
function cancelEditTitle() {
|
|
isEditingTitle = false;
|
|
editedTitle = '';
|
|
}
|
|
|
|
function handleTitleKeydown(event: KeyboardEvent) {
|
|
if (event.key === 'Enter') {
|
|
saveTitle();
|
|
} else if (event.key === 'Escape') {
|
|
cancelEditTitle();
|
|
}
|
|
}
|
|
|
|
$: session = $activeSession.session;
|
|
$: externalId = session?.['external-id'] || session?.externalId || '';
|
|
$: shortId = externalId ? externalId.slice(0, 8) : session?.id?.slice(0, 8) || '';
|
|
$: isNewSession = !externalId && $activeSession.messages.length === 0;
|
|
$: assistantName = session?.provider === 'opencode' ? 'OpenCode' : 'Claude';
|
|
$: autoAcceptEdits = session?.['auto-accept-edits'] || session?.autoAcceptEdits || false;
|
|
$: isTmuxSession = session?.provider === 'tmux';
|
|
|
|
const providerColors: Record<string, string> = {
|
|
claude: 'text-spice-400',
|
|
opencode: 'text-emerald-400',
|
|
tmux: 'text-cyan-400'
|
|
};
|
|
|
|
function handleToggleAutoAccept(event: CustomEvent<boolean>) {
|
|
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',
|
|
'awaiting-permission': 'bg-amber-500 animate-pulse'
|
|
};
|
|
|
|
function handleTmuxAliveChange(event: CustomEvent<boolean>) {
|
|
tmuxAlive = event.detail;
|
|
}
|
|
|
|
// For tmux sessions, use tmuxAlive; for others, use session status
|
|
$: statusIndicator = isTmuxSession
|
|
? (tmuxAlive ? 'bg-green-500' : 'bg-zinc-600')
|
|
: (statusColors[session?.status || 'idle'] || statusColors.idle);
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>{session?.title || `Session ${shortId}`} - Spiceflow</title>
|
|
</svelte:head>
|
|
|
|
<svelte:window on:click={() => (menuOpen = false)} />
|
|
|
|
<!-- Header - Full (portrait) -->
|
|
<header class="flex-shrink-0 border-b border-zinc-800 px-4 py-3 landscape-mobile:hidden">
|
|
<div class="flex items-center gap-3">
|
|
<button
|
|
on:click={goBack}
|
|
class="p-1 -ml-1 hover:bg-zinc-700 rounded transition-colors"
|
|
aria-label="Go back"
|
|
>
|
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
</button>
|
|
|
|
{#if $activeSession.loading}
|
|
<div class="flex-1">
|
|
<div class="h-5 w-32 bg-zinc-700 rounded animate-pulse"></div>
|
|
<div class="h-4 w-24 bg-zinc-800 rounded animate-pulse mt-1"></div>
|
|
</div>
|
|
{:else if session}
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2">
|
|
<span class="w-2 h-2 rounded-full {statusIndicator}"></span>
|
|
{#if isEditingTitle}
|
|
<input
|
|
bind:this={titleInput}
|
|
bind:value={editedTitle}
|
|
on:blur={saveTitle}
|
|
on:keydown={handleTitleKeydown}
|
|
class="font-semibold bg-zinc-800 border border-zinc-600 rounded px-2 py-0.5 text-zinc-100 focus:outline-none focus:border-spice-500 w-full max-w-[200px]"
|
|
placeholder="Session name"
|
|
/>
|
|
{:else}
|
|
<button
|
|
on:click={startEditingTitle}
|
|
class="font-semibold truncate text-left hover:text-spice-400 transition-colors"
|
|
title="Click to rename"
|
|
>
|
|
{session.title || `Session ${shortId}`}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<SessionSettings
|
|
{autoAcceptEdits}
|
|
{autoScroll}
|
|
provider={session.provider}
|
|
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'}">
|
|
{session.provider === 'tmux' ? 'terminal' : session.provider}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Header - Collapsed (landscape mobile) -->
|
|
<div class="landscape-menu fixed top-2 right-2 z-50">
|
|
<button
|
|
on:click|stopPropagation={() => (menuOpen = !menuOpen)}
|
|
class="p-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg border border-zinc-700 transition-colors"
|
|
aria-label="Menu"
|
|
>
|
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
|
</svg>
|
|
</button>
|
|
|
|
{#if menuOpen}
|
|
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
|
<div on:click|stopPropagation class="absolute right-0 top-full mt-1 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl min-w-[200px] overflow-hidden">
|
|
{#if session}
|
|
<div class="px-3 py-2 border-b border-zinc-700">
|
|
<div class="flex items-center gap-2">
|
|
<span class="w-2 h-2 rounded-full {statusIndicator}"></span>
|
|
<span class="font-semibold truncate">{session.title || `Session ${shortId}`}</span>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
<button
|
|
on:click={() => { menuOpen = false; goBack(); }}
|
|
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="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
Back to sessions
|
|
</button>
|
|
<label class="w-full px-3 py-2 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={autoScroll}
|
|
on:change={() => { autoScroll = !autoScroll; localStorage.setItem('spiceflow-auto-scroll', String(autoScroll)); }}
|
|
class="h-4 w-4 rounded border-zinc-600 bg-zinc-700 text-spice-500 focus:ring-spice-500 focus:ring-offset-0"
|
|
/>
|
|
<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(); }}
|
|
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 6h16M4 10h16M4 14h16M4 18h16" />
|
|
</svg>
|
|
Condense all
|
|
</button>
|
|
{/if}
|
|
{#if session?.provider === 'claude'}
|
|
<label class="w-full px-3 py-2 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors cursor-pointer border-t border-zinc-700">
|
|
<input
|
|
type="checkbox"
|
|
checked={autoAcceptEdits}
|
|
on:change={() => activeSession.setAutoAcceptEdits(!autoAcceptEdits)}
|
|
class="h-4 w-4 rounded border-zinc-600 bg-zinc-700 text-spice-500 focus:ring-spice-500 focus:ring-offset-0"
|
|
/>
|
|
<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>
|
|
|
|
<!-- Content -->
|
|
{#if $activeSession.error}
|
|
<div class="flex-1 flex items-center justify-center p-4">
|
|
<div class="text-center">
|
|
<div class="text-red-400 mb-4">{$activeSession.error}</div>
|
|
<button on:click={goBack} class="btn btn-secondary">Go Back</button>
|
|
</div>
|
|
</div>
|
|
{:else if $activeSession.loading}
|
|
<div class="flex-1 flex items-center justify-center">
|
|
<svg class="animate-spin h-8 w-8 text-spice-500" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
|
></circle>
|
|
<path
|
|
class="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
></path>
|
|
</svg>
|
|
</div>
|
|
{:else if isTmuxSession}
|
|
<!-- Terminal view for tmux sessions -->
|
|
<TerminalView bind:this={terminalView} sessionId={sessionId || ''} {autoScroll} on:aliveChange={handleTmuxAliveChange} />
|
|
{:else}
|
|
<MessageList bind:this={messageList} messages={$activeSession.messages} streamingContent={$activeSession.streamingContent} isThinking={$activeSession.isThinking} provider={session?.provider} {autoScroll} />
|
|
|
|
{#if $activeSession.pendingPermission}
|
|
<PermissionRequest
|
|
permission={$activeSession.pendingPermission}
|
|
{assistantName}
|
|
on:accept={handlePermissionAccept}
|
|
on:deny={handlePermissionDeny}
|
|
on:steer={handlePermissionSteer}
|
|
/>
|
|
{/if}
|
|
|
|
<InputBar
|
|
bind:this={inputBar}
|
|
on:send={handleSend}
|
|
disabled={session?.status === 'processing' && $activeSession.streamingContent !== ''}
|
|
autoFocus={true}
|
|
placeholder={steerMode
|
|
? `Tell ${assistantName} what to do instead...`
|
|
: session?.status === 'processing'
|
|
? 'Waiting for response...'
|
|
: 'Type a message...'}
|
|
/>
|
|
{/if}
|