session maangement updates

This commit is contained in:
2026-01-20 16:59:03 -05:00
parent b6f772f901
commit d1cf575064
7 changed files with 241 additions and 62 deletions
+25
View File
@@ -97,6 +97,19 @@ export interface TerminalContent {
layout?: 'desktop' | 'landscape' | 'portrait';
}
export interface ExternalTmuxSession {
name: string;
'working-dir': string;
workingDir?: string;
}
export interface ImportedSession {
id: string;
name: string;
'working-dir': string;
workingDir?: string;
}
class ApiClient {
private baseUrl: string;
@@ -197,6 +210,18 @@ class ApiClient {
body: JSON.stringify({ mode })
});
}
// External tmux sessions
async getExternalTmuxSessions(): Promise<ExternalTmuxSession[]> {
return this.request<ExternalTmuxSession[]>('/tmux/external');
}
async importTmuxSession(name: string): Promise<ImportedSession> {
return this.request<ImportedSession>('/tmux/import', {
method: 'POST',
body: JSON.stringify({ name })
});
}
}
export const api = new ApiClient();
+13 -11
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import type { Message, PermissionDenial, ToolInput, Session } from '$lib/api';
import { onMount, afterUpdate, tick } from 'svelte';
import { afterUpdate, tick } from 'svelte';
import { marked } from 'marked';
import FileDiff from './FileDiff.svelte';
@@ -22,6 +22,7 @@
let container: HTMLDivElement;
let collapsedMessages: Set<string> = new Set();
let hasScrolledInitially = false;
const COLLAPSE_THRESHOLD = 5; // show toggle if more than this many lines
@@ -31,6 +32,17 @@
}
}
// Scroll to bottom when messages first load
$: if (messages.length > 0 && container && !hasScrolledInitially) {
hasScrolledInitially = true;
// Use tick + setTimeout to ensure DOM is fully rendered
tick().then(() => {
setTimeout(() => {
scrollToBottom();
}, 50);
});
}
function toggleCollapse(id: string) {
if (collapsedMessages.has(id)) {
collapsedMessages.delete(id);
@@ -59,16 +71,6 @@
collapsedMessages = collapsedMessages; // trigger reactivity
}
onMount(async () => {
await tick();
// Use double requestAnimationFrame to ensure DOM is fully laid out before scrolling
requestAnimationFrame(() => {
requestAnimationFrame(() => {
scrollToBottom();
});
});
});
afterUpdate(() => {
// Use requestAnimationFrame for consistent scroll behavior
requestAnimationFrame(() => {
+43 -29
View File
@@ -30,7 +30,7 @@
let lastHash: number | null = null;
let lastFrameId: number | null = null; // Track frame ordering to prevent out-of-order updates
let initialLoadComplete = false; // Track whether initial load has happened
let screenMode: 'desktop' | 'landscape' | 'portrait' = 'landscape';
let screenMode: 'fullscreen' | 'desktop' | 'landscape' | 'portrait' = 'landscape';
let resizing = false;
let inputBuffer = '';
let batchTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -112,23 +112,25 @@
case 'diff':
// Partial update - apply changes to specific lines
if (diff.changes) {
// Create a working copy to avoid in-place mutation issues
let newLines = [...terminalLines];
// Ensure array is long enough
while (terminalLines.length < totalLines) {
terminalLines.push('');
while (newLines.length < totalLines) {
newLines.push('');
}
// Truncate if needed
if (terminalLines.length > totalLines) {
terminalLines = terminalLines.slice(0, totalLines);
if (newLines.length > totalLines) {
newLines = newLines.slice(0, totalLines);
}
// Apply changes
for (const [lineNumStr, content] of Object.entries(diff.changes)) {
const lineNum = parseInt(lineNumStr, 10);
if (lineNum >= 0 && lineNum < totalLines) {
terminalLines[lineNum] = content ?? '';
newLines[lineNum] = content ?? '';
}
}
// Trigger reactivity
terminalLines = terminalLines;
// Assign new array reference to trigger reactivity
terminalLines = newLines;
}
lastHash = diff.hash ?? null;
if (frameId !== undefined) lastFrameId = frameId;
@@ -423,23 +425,25 @@
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"
></button>
<!-- Text zoom -->
<!-- Screen size selector (pushed right on wider screens) -->
<span class="w-px h-3 bg-zinc-700 ml-auto"></span>
<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"
title="Zoom out"
>-</button>
<span class="text-[9px] text-zinc-400 font-mono w-6 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"
title="Zoom in"
>+</button>
<!-- Screen size selector -->
<span class="w-px h-3 bg-zinc-700"></span>
<!-- Text zoom (hidden on mobile portrait, visible on landscape/desktop) -->
<div class="hidden landscape:flex sm:flex items-center gap-0.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"
title="Zoom out"
>-</button>
<span class="text-[9px] text-zinc-400 font-mono w-6 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"
title="Zoom in"
>+</button>
<span class="w-px h-3 bg-zinc-700"></span>
</div>
<button
on:click={() => resizeScreen('portrait')}
disabled={resizing}
@@ -454,7 +458,7 @@
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 (100x30)"
title="Landscape (65x24)"
>
<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">
<rect x="1" y="1" width="14" height="8" rx="1" />
@@ -464,11 +468,21 @@
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="Desktop (180x50)"
title="Split screen (100x40)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3.5 inline-block" fill="none" viewBox="0 0 20 16" stroke="currentColor" stroke-width="1.5">
<rect x="1" y="1" width="18" height="11" rx="1" />
<path d="M6 14h8M10 12v2" />
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3.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>
</button>
<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)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-4 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>
+96 -3
View File
@@ -1,12 +1,16 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { sessions, sortedSessions, processingSessions } from '$lib/stores/sessions';
import type { Session } from '$lib/api';
import { api, type Session, type ExternalTmuxSession } from '$lib/api';
import SessionCard from '$lib/components/SessionCard.svelte';
import PushToggle from '$lib/components/PushToggle.svelte';
let showNewSessionMenu = false;
let showImportMenu = false;
let creating = false;
let importing = false;
let externalSessions: ExternalTmuxSession[] = [];
let loadingExternal = false;
async function refresh() {
await sessions.load();
@@ -30,13 +34,47 @@
await sessions.delete(id);
}
}
async function loadExternalSessions() {
loadingExternal = true;
try {
externalSessions = await api.getExternalTmuxSessions();
} catch (e) {
console.error('Failed to load external sessions:', e);
externalSessions = [];
} finally {
loadingExternal = false;
}
}
async function toggleImportMenu() {
showImportMenu = !showImportMenu;
showNewSessionMenu = false;
if (showImportMenu) {
await loadExternalSessions();
}
}
async function importSession(name: string) {
importing = true;
showImportMenu = false;
try {
const imported = await api.importTmuxSession(name);
await sessions.load();
await goto(`/session/${imported.id}`);
} catch (e) {
console.error('Failed to import session:', e);
} finally {
importing = false;
}
}
</script>
<svelte:head>
<title>Spiceflow</title>
</svelte:head>
<svelte:window on:click={() => (showNewSessionMenu = false)} />
<svelte:window on:click={() => { showNewSessionMenu = false; showImportMenu = false; }} />
<!-- Header -->
<header class="flex-shrink-0 border-b border-zinc-800 px-4 py-3">
@@ -49,9 +87,64 @@
<div class="flex items-center gap-2">
<PushToggle />
<!-- Import tmux session button -->
<div class="relative">
<button
on:click|stopPropagation={() => (showNewSessionMenu = !showNewSessionMenu)}
on:click|stopPropagation={toggleImportMenu}
disabled={importing}
class="btn btn-secondary p-2"
title="Import tmux session"
>
{#if importing}
<svg class="h-5 w-5 animate-spin" 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>
{:else}
<svg xmlns="http://www.w3.org/2000/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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
{/if}
</button>
{#if showImportMenu}
<div
class="absolute right-0 top-full mt-1 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl z-50 min-w-[200px] overflow-hidden"
>
<div class="px-4 py-2 border-b border-zinc-700">
<span class="text-xs text-zinc-400 font-medium">Import tmux session</span>
</div>
{#if loadingExternal}
<div class="px-4 py-3 text-sm text-zinc-500 flex items-center gap-2">
<svg class="h-4 w-4 animate-spin" 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>
Loading...
</div>
{:else if externalSessions.length === 0}
<div class="px-4 py-3 text-sm text-zinc-500">
No external tmux sessions
</div>
{:else}
{#each externalSessions as session}
<button
on:click={() => importSession(session.name)}
class="w-full px-4 py-2.5 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors"
>
<span class="w-2 h-2 rounded-full bg-cyan-500"></span>
<span class="truncate">{session.name}</span>
</button>
{/each}
{/if}
</div>
{/if}
</div>
<!-- New session button -->
<div class="relative">
<button
on:click|stopPropagation={() => { showNewSessionMenu = !showNewSessionMenu; showImportMenu = false; }}
disabled={creating}
class="btn btn-primary p-2"
title="New Session"