add tmux sessions

This commit is contained in:
2026-01-20 14:04:19 -05:00
parent 2b50c91267
commit 66b4acaf42
37 changed files with 2888 additions and 327 deletions
+13 -1
View File
@@ -2,7 +2,7 @@ const API_BASE = '/api';
export interface Session {
id: string;
provider: 'claude' | 'opencode';
provider: 'claude' | 'opencode' | 'tmux';
'external-id'?: string;
externalId?: string;
title?: string;
@@ -150,6 +150,18 @@ class ApiClient {
async health(): Promise<{ status: string; service: string }> {
return this.request<{ status: string; service: string }>('/health');
}
// Terminal (tmux)
async getTerminalContent(sessionId: string): Promise<{ content: string; alive: boolean; 'session-name': string }> {
return this.request<{ content: string; alive: boolean; 'session-name': string }>(`/sessions/${sessionId}/terminal`);
}
async sendTerminalInput(sessionId: string, input: string): Promise<{ status: string }> {
return this.request<{ status: string }>(`/sessions/${sessionId}/terminal/input`, {
method: 'POST',
body: JSON.stringify({ input })
});
}
}
export const api = new ApiClient();
+53 -2
View File
@@ -1,20 +1,29 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher, onMount } from 'svelte';
export let disabled = false;
export let placeholder = 'Type a message...';
export let autoFocus = false;
const dispatch = createEventDispatcher<{ send: string }>();
let message = '';
let textarea: HTMLTextAreaElement;
let expanded = false;
let isFocused = false;
const LINE_HEIGHT = 22; // approximate line height in pixels
const PADDING = 24; // vertical padding
const MIN_HEIGHT_COLLAPSED = LINE_HEIGHT + PADDING; // 1 line
const MIN_HEIGHT_EXPANDED = LINE_HEIGHT * 4 + PADDING; // 4 lines
onMount(() => {
if (autoFocus) {
// Small delay to ensure page is ready
setTimeout(() => textarea?.focus(), 100);
}
});
function handleSubmit() {
const trimmed = message.trim();
if (!trimmed || disabled) return;
@@ -49,9 +58,29 @@
export function focus() {
textarea?.focus();
}
export function blur() {
textarea?.blur();
}
function toggleKeyboard() {
if (isFocused) {
textarea?.blur();
} else {
textarea?.focus();
}
}
function handleFocus() {
isFocused = true;
}
function handleBlur() {
isFocused = false;
}
</script>
<form on:submit|preventDefault={handleSubmit} class="border-t border-zinc-700 bg-zinc-900 safe-bottom">
<form on:submit|preventDefault={handleSubmit} class="flex-shrink-0 border-t border-zinc-700 bg-zinc-900 safe-bottom">
<div class="flex items-end gap-2 p-3">
<button
type="button"
@@ -74,12 +103,34 @@
bind:value={message}
on:keydown={handleKeydown}
on:input={resizeTextarea}
on:focus={handleFocus}
on:blur={handleBlur}
{placeholder}
{disabled}
rows={expanded ? 4 : 1}
class="input resize-none py-3 {expanded ? 'min-h-[112px]' : 'min-h-[44px]'}"
></textarea>
<!-- Keyboard toggle button (useful for mobile PWA) -->
<button
type="button"
on:click={toggleKeyboard}
class="h-[44px] w-[44px] flex items-center justify-center text-zinc-500 hover:text-zinc-300 transition-colors flex-shrink-0"
aria-label={isFocused ? 'Hide keyboard' : 'Show keyboard'}
>
{#if isFocused}
<!-- Keyboard hide icon -->
<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="M19 9l-7 7-7-7" />
</svg>
{:else}
<!-- Keyboard show icon -->
<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="M5 15l7-7 7 7" />
</svg>
{/if}
</button>
<button
type="submit"
disabled={disabled || !message.trim()}
+32 -20
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import type { Message, PermissionDenial, ToolInput } from '$lib/api';
import type { Message, PermissionDenial, ToolInput, Session } from '$lib/api';
import { onMount, afterUpdate } from 'svelte';
import { marked } from 'marked';
import FileDiff from './FileDiff.svelte';
@@ -7,6 +7,8 @@
export let messages: Message[] = [];
export let streamingContent: string = '';
export let isThinking: boolean = false;
export let provider: Session['provider'] = 'claude';
export let autoScroll: boolean = true;
// Configure marked for safe rendering
marked.setOptions({
@@ -19,7 +21,6 @@
}
let container: HTMLDivElement;
let autoScroll = true;
let collapsedMessages: Set<string> = new Set();
const COLLAPSE_THRESHOLD = 5; // show toggle if more than this many lines
@@ -30,14 +31,6 @@
}
}
function handleScroll() {
if (!container) return;
const threshold = 100;
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
autoScroll = distanceFromBottom < threshold;
}
function toggleCollapse(id: string) {
if (collapsedMessages.has(id)) {
collapsedMessages.delete(id);
@@ -123,8 +116,7 @@
<div
bind:this={container}
on:scroll={handleScroll}
class="flex-1 overflow-y-auto px-4 py-4 space-y-4"
class="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-4"
>
{#if messages.length === 0 && !streamingContent && !isThinking}
<div class="h-full flex items-center justify-center">
@@ -259,11 +251,25 @@
role={isCollapsible ? 'button' : undefined}
tabindex={isCollapsible ? 0 : undefined}
>
<div
class="text-sm break-words font-mono leading-relaxed markdown-content {isCollapsed ? 'line-clamp-3' : ''} {isCollapsible ? 'pr-6' : ''}"
>
{@html renderMarkdown(message.content)}
</div>
{#if provider === 'tmux' && message.role === 'assistant'}
<!-- Terminal output styling for tmux -->
<pre
class="text-sm break-words font-mono leading-relaxed bg-black/40 rounded p-2 overflow-x-auto whitespace-pre-wrap text-green-300 {isCollapsed ? 'line-clamp-3' : ''} {isCollapsible ? 'pr-6' : ''}"
>{message.content}</pre>
{:else if provider === 'tmux' && message.role === 'user'}
<!-- Command input styling for tmux -->
<div
class="text-sm break-words font-mono leading-relaxed {isCollapsed ? 'line-clamp-3' : ''} {isCollapsible ? 'pr-6' : ''}"
>
<span class="text-cyan-400">$</span> {message.content}
</div>
{:else}
<div
class="text-sm break-words font-mono leading-relaxed markdown-content {isCollapsed ? 'line-clamp-3' : ''} {isCollapsible ? 'pr-6' : ''}"
>
{@html renderMarkdown(message.content)}
</div>
{/if}
{#if isCollapsible}
<span
class="absolute right-3 top-3 text-zinc-500 transition-transform {isCollapsed ? '' : 'rotate-90'}"
@@ -291,9 +297,15 @@
</div>
{:else if streamingContent}
<div class="rounded-lg border p-3 {roleStyles.assistant}">
<div class="text-sm break-words font-mono leading-relaxed markdown-content">
{@html renderMarkdown(streamingContent)}<span class="animate-pulse">|</span>
</div>
{#if provider === 'tmux'}
<pre
class="text-sm break-words font-mono leading-relaxed bg-black/40 rounded p-2 overflow-x-auto whitespace-pre-wrap text-green-300"
>{streamingContent}<span class="animate-pulse text-white">|</span></pre>
{:else}
<div class="text-sm break-words font-mono leading-relaxed markdown-content">
{@html renderMarkdown(streamingContent)}<span class="animate-pulse">|</span>
</div>
{/if}
</div>
{/if}
{/if}
@@ -48,7 +48,7 @@
}
</script>
<div class="border-t border-amber-500/30 bg-amber-500/10 px-4 py-3">
<div class="flex-shrink-0 border-t border-amber-500/30 bg-amber-500/10 px-4 py-3">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 mt-0.5">
<svg
+109
View File
@@ -0,0 +1,109 @@
<script lang="ts">
import { onMount } from 'svelte';
import { pushStore, pushState } from '$lib/stores/push';
let loading = false;
let errorMessage: string | null = null;
// Subscribe to store changes
$: storeLoading = $pushStore.loading;
$: storeError = $pushStore.error;
onMount(() => {
pushStore.init();
});
async function handleToggle() {
if (loading || storeLoading) return;
loading = true;
errorMessage = null;
try {
await pushStore.toggle();
} catch (err) {
errorMessage = err instanceof Error ? err.message : 'An error occurred';
} finally {
loading = false;
}
}
$: isSubscribed = $pushState === 'subscribed';
$: isDenied = $pushState === 'denied';
$: isUnsupported = $pushState === 'unsupported';
$: isDisabled = isDenied || isUnsupported || loading || storeLoading;
</script>
{#if !isUnsupported}
<button
on:click={handleToggle}
disabled={isDisabled}
class="p-1.5 hover:bg-zinc-700 rounded transition-colors relative"
class:opacity-50={isDisabled}
aria-label={isSubscribed ? 'Disable notifications' : 'Enable notifications'}
title={isDenied
? 'Notifications blocked in browser settings'
: isSubscribed
? 'Notifications enabled - click to disable'
: 'Enable push notifications'}
>
{#if loading || storeLoading}
<svg class="h-5 w-5 text-zinc-400 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{:else}
<svg
class="h-5 w-5"
class:text-spice-500={isSubscribed}
class:text-zinc-400={!isSubscribed}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
{#if isSubscribed}
<!-- Bell with active indicator -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
{:else if isDenied}
<!-- Bell with slash -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3l18 18" />
{:else}
<!-- Bell outline -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
{/if}
</svg>
{/if}
<!-- Active indicator dot -->
{#if isSubscribed && !loading && !storeLoading}
<span class="absolute top-0.5 right-0.5 h-2 w-2 rounded-full bg-spice-500" />
{/if}
</button>
{/if}
{#if errorMessage || storeError}
<div
class="absolute top-full right-0 mt-1 px-2 py-1 bg-red-900/90 text-red-200 text-xs rounded shadow-lg max-w-[200px] z-50"
>
{errorMessage || storeError}
</div>
{/if}
+6 -10
View File
@@ -13,7 +13,6 @@
}
$: externalId = session['external-id'] || session.externalId || '';
$: workingDir = session['working-dir'] || session.workingDir || '';
$: updatedAt = session['updated-at'] || session.updatedAt || session['created-at'] || session.createdAt || '';
$: shortId = externalId.slice(0, 8);
@@ -38,9 +37,12 @@
'awaiting-permission': 'bg-amber-500 animate-pulse'
};
const providerColors = {
$: statusColor = session.provider === 'tmux' ? 'bg-green-500' : statusColors[session.status];
const providerColors: Record<string, string> = {
claude: 'text-spice-400',
opencode: 'text-emerald-400'
opencode: 'text-emerald-400',
tmux: 'text-cyan-400'
};
</script>
@@ -51,7 +53,7 @@
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="w-2 h-2 rounded-full {statusColors[session.status]}"></span>
<span class="w-2 h-2 rounded-full {statusColor}"></span>
<span class="text-xs font-medium uppercase tracking-wide {providerColors[session.provider]}">
{session.provider}
</span>
@@ -60,12 +62,6 @@
<h3 class="font-medium text-zinc-100 truncate">
{session.title || `Session ${shortId}`}
</h3>
{#if workingDir}
<p class="text-sm text-zinc-400 mt-1 break-all">
{workingDir}
</p>
{/if}
</div>
<div class="text-right flex-shrink-0 flex items-center gap-2">
@@ -2,12 +2,19 @@
import { createEventDispatcher } from 'svelte';
export let autoAcceptEdits: boolean = false;
export let provider: 'claude' | 'opencode' = 'claude';
export let autoScroll: boolean = true;
export let provider: 'claude' | 'opencode' | 'tmux' = 'claude';
const dispatch = createEventDispatcher<{
toggleAutoAccept: boolean;
toggleAutoScroll: boolean;
condenseAll: void;
}>();
function handleToggleAutoScroll() {
dispatch('toggleAutoScroll', !autoScroll);
}
let open = false;
function handleToggle() {
@@ -15,6 +22,11 @@
dispatch('toggleAutoAccept', newValue);
}
function handleCondenseAll() {
dispatch('condenseAll');
open = false;
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.settings-dropdown')) {
@@ -56,6 +68,36 @@
<span class="text-sm font-medium text-zinc-200">Session Settings</span>
</div>
<!-- Auto-scroll toggle (all providers) -->
<label
class="flex items-start gap-3 px-3 py-2.5 hover:bg-zinc-700/50 cursor-pointer transition-colors"
>
<input
type="checkbox"
checked={autoScroll}
on:change={handleToggleAutoScroll}
class="mt-0.5 h-4 w-4 rounded border-zinc-600 bg-zinc-700 text-spice-500 focus:ring-spice-500 focus:ring-offset-0"
/>
<div class="flex-1 min-w-0">
<span class="text-sm text-zinc-200 block">Auto-scroll</span>
<span class="text-xs text-zinc-500 block mt-0.5"
>Scroll to bottom on new content</span
>
</div>
</label>
{#if provider !== 'tmux'}
<button
on:click={handleCondenseAll}
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 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
<span class="text-sm text-zinc-200">Condense all</span>
</button>
{/if}
{#if provider === 'claude'}
<label
class="flex items-start gap-3 px-3 py-2.5 hover:bg-zinc-700/50 cursor-pointer transition-colors"
@@ -73,10 +115,6 @@
>
</div>
</label>
{:else}
<div class="px-3 py-2.5 text-xs text-zinc-500">
No settings available for OpenCode sessions yet.
</div>
{/if}
</div>
{/if}
@@ -0,0 +1,262 @@
<script lang="ts">
import { onMount, onDestroy, tick, createEventDispatcher } from 'svelte';
import { api, wsClient, type StreamEvent } from '$lib/api';
export let sessionId: string;
export let autoScroll: boolean = true;
export let autoFocus: boolean = true;
const dispatch = createEventDispatcher<{ aliveChange: boolean }>();
let terminalContent = '';
let prevContentLength = 0;
let isAlive = false;
let loading = true;
let error = '';
let terminalElement: HTMLPreElement;
let terminalInput: HTMLInputElement;
let refreshInterval: ReturnType<typeof setInterval> | null = null;
let unsubscribe: (() => void) | null = null;
let ctrlMode = false;
async function fetchTerminalContent() {
try {
const result = await api.getTerminalContent(sessionId);
const contentGrew = result.content.length > prevContentLength;
prevContentLength = result.content.length;
terminalContent = result.content;
if (isAlive !== result.alive) {
isAlive = result.alive;
dispatch('aliveChange', isAlive);
}
error = '';
if (contentGrew) {
await tick();
scrollToBottom();
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch terminal content';
} finally {
loading = false;
}
}
function scrollToBottom() {
if (autoScroll && terminalElement) {
terminalElement.scrollTop = terminalElement.scrollHeight;
}
}
async function handleKeydown(event: KeyboardEvent) {
if (!isAlive) return;
// Prevent default for all keys we handle
event.preventDefault();
if (event.key === 'Enter' && event.shiftKey) {
// Shift+Enter sends literal newline
await sendInput('\n');
} else if (event.key === 'Enter') {
// Enter sends Enter key
await sendInput('\r');
} else if (event.key === 'Backspace') {
await sendInput('\x7f');
} else if (event.key === 'Tab' && event.shiftKey) {
// Shift+Tab escape sequence
await sendInput('\x1b[Z');
} else if (event.key === 'Tab') {
await sendInput('\t');
} else if (event.key === 'Escape') {
await sendInput('\x1b');
} else if (event.key === 'ArrowUp') {
await sendInput('\x1b[A');
} else if (event.key === 'ArrowDown') {
await sendInput('\x1b[B');
} else if (event.key === 'ArrowRight') {
await sendInput('\x1b[C');
} else if (event.key === 'ArrowLeft') {
await sendInput('\x1b[D');
} else if (event.ctrlKey && event.key.length === 1) {
// Ctrl+letter = control character
const code = event.key.toLowerCase().charCodeAt(0) - 96;
if (code > 0 && code < 27) {
await sendInput(String.fromCharCode(code));
}
} else if (ctrlMode && event.key.length === 1 && /[a-z]/i.test(event.key)) {
// Ctrl mode toggle + letter = control character
const code = event.key.toLowerCase().charCodeAt(0) - 96;
if (code > 0 && code < 27) {
await sendInput(String.fromCharCode(code));
ctrlMode = false;
}
} else if (event.key.length === 1) {
// Regular printable character
await sendInput(event.key);
}
}
async function sendInput(input: string) {
try {
await api.sendTerminalInput(sessionId, input);
setTimeout(fetchTerminalContent, 200);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to send input';
}
}
async function sendCtrlC() {
await sendInput('\x03');
}
async function sendKey(key: string) {
await sendInput(key);
}
function handleWebSocketEvent(event: StreamEvent) {
if (event.event === 'terminal-update' && event.content !== undefined) {
const newContent = event.content as string;
const contentGrew = newContent.length > prevContentLength;
prevContentLength = newContent.length;
terminalContent = newContent;
if (contentGrew) {
tick().then(scrollToBottom);
}
}
}
onMount(async () => {
// Initial fetch
await fetchTerminalContent();
// Subscribe to WebSocket updates
await wsClient.connect();
unsubscribe = wsClient.subscribe(sessionId, handleWebSocketEvent);
// Periodic refresh every 1 second
refreshInterval = setInterval(fetchTerminalContent, 1000);
// Auto-focus input after content loads
if (autoFocus) {
setTimeout(() => terminalInput?.focus(), 100);
}
});
onDestroy(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
if (unsubscribe) {
unsubscribe();
}
});
</script>
<div class="flex flex-col flex-1 min-h-0 bg-black">
{#if loading}
<div class="flex-1 flex items-center justify-center">
<svg class="animate-spin h-8 w-8 text-cyan-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 error}
<div class="flex-1 flex items-center justify-center p-4">
<div class="text-red-400">{error}</div>
</div>
{:else}
<!-- Terminal output area -->
<pre
bind:this={terminalElement}
class="flex-1 min-h-0 overflow-auto p-3 font-mono text-sm text-green-400 whitespace-pre-wrap break-words leading-relaxed"
>{terminalContent || 'Terminal ready. Type a command below.'}</pre>
<!-- Quick action buttons -->
<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-2.5 py-1 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 text-xs font-mono text-zinc-200 transition-colors {ctrlMode ? 'font-bold ring-2 ring-cyan-400 rounded-lg' : 'rounded'}"
>
Ctrl
</button>
<button
on:click={sendCtrlC}
disabled={!isAlive}
class="px-2.5 py-1 bg-red-600/80 hover:bg-red-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
>
Ctrl+C
</button>
<button
on:click={() => sendInput('\x04')}
disabled={!isAlive}
class="px-2.5 py-1 bg-amber-600/80 hover:bg-amber-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
>
Ctrl+D
</button>
<span class="w-px h-4 bg-zinc-700"></span>
<button
on:click={() => sendKey('y')}
disabled={!isAlive}
class="px-2.5 py-1 bg-green-600/80 hover:bg-green-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
>
y
</button>
<button
on:click={() => sendKey('n')}
disabled={!isAlive}
class="px-2.5 py-1 bg-red-600/80 hover:bg-red-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
>
n
</button>
<span class="w-px h-4 bg-zinc-700"></span>
{#each ['1', '2', '3', '4', '5'] as num}
<button
on:click={() => sendKey(num)}
disabled={!isAlive}
class="px-2.5 py-1 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 rounded text-xs font-mono text-zinc-200 transition-colors"
>
{num}
</button>
{/each}
<span class="w-px h-4 bg-zinc-700"></span>
<button
on:click={() => sendInput('\t')}
disabled={!isAlive}
class="px-2.5 py-1 bg-cyan-600/80 hover:bg-cyan-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
>
Tab
</button>
<button
on:click={() => sendInput('\x1b[Z')}
disabled={!isAlive}
class="px-2.5 py-1 bg-cyan-600/80 hover:bg-cyan-600 disabled:opacity-50 rounded text-xs font-mono font-medium text-white transition-colors"
>
S-Tab
</button>
<button
on:click={scrollToBottom}
class="ml-auto p-1 bg-zinc-700 hover:bg-zinc-600 rounded text-zinc-200 transition-colors"
aria-label="Scroll to bottom"
>
<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>
</div>
<!-- Input area -->
<div class="flex-shrink-0 border-t border-zinc-800 bg-zinc-900 p-2 safe-bottom">
<div class="flex items-center gap-2 bg-black rounded border border-zinc-700 px-3 py-2">
<span class="text-cyan-400 font-mono text-sm">$</span>
<input
bind:this={terminalInput}
type="text"
on:keydown={handleKeydown}
class="flex-1 bg-transparent border-none outline-none font-mono text-sm text-green-400 placeholder-zinc-600"
placeholder="Keys sent immediately..."
disabled={!isAlive}
/>
</div>
</div>
{/if}
</div>
+150
View File
@@ -0,0 +1,150 @@
/**
* Web Push notification utilities for Spiceflow PWA
*/
const API_BASE = '/api';
/**
* Check if push notifications are supported
*/
export function isPushSupported(): boolean {
return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
}
/**
* Get the current notification permission status
*/
export function getPermissionStatus(): NotificationPermission {
if (!isPushSupported()) {
return 'denied';
}
return Notification.permission;
}
/**
* Request notification permission from the user
*/
export async function requestPermission(): Promise<NotificationPermission> {
if (!isPushSupported()) {
throw new Error('Push notifications are not supported');
}
return await Notification.requestPermission();
}
/**
* Convert URL-safe base64 string to Uint8Array
* (for applicationServerKey)
*/
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
/**
* Get the VAPID public key from the server
*/
export async function getVapidKey(): Promise<string> {
const response = await fetch(`${API_BASE}/push/vapid-key`);
if (!response.ok) {
throw new Error('Failed to get VAPID key');
}
const data = await response.json();
return data.publicKey;
}
/**
* Get the current push subscription if one exists
*/
export async function getSubscription(): Promise<PushSubscription | null> {
if (!isPushSupported()) {
return null;
}
const registration = await navigator.serviceWorker.ready;
return await registration.pushManager.getSubscription();
}
/**
* Subscribe to push notifications
*/
export async function subscribe(): Promise<PushSubscription> {
if (!isPushSupported()) {
throw new Error('Push notifications are not supported');
}
// Request permission first
const permission = await requestPermission();
if (permission !== 'granted') {
throw new Error('Notification permission denied');
}
// Get VAPID public key
const vapidKey = await getVapidKey();
const applicationServerKey = urlBase64ToUint8Array(vapidKey);
// Get service worker registration
const registration = await navigator.serviceWorker.ready;
// Subscribe to push
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey as BufferSource
});
// Send subscription to server
const response = await fetch(`${API_BASE}/push/subscribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(subscription.toJSON())
});
if (!response.ok) {
// Unsubscribe if server save failed
await subscription.unsubscribe();
throw new Error('Failed to save subscription on server');
}
return subscription;
}
/**
* Unsubscribe from push notifications
*/
export async function unsubscribe(): Promise<void> {
const subscription = await getSubscription();
if (!subscription) {
return;
}
// Notify server first
try {
await fetch(`${API_BASE}/push/unsubscribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ endpoint: subscription.endpoint })
});
} catch (err) {
console.warn('Failed to notify server of unsubscribe:', err);
}
// Unsubscribe locally
await subscription.unsubscribe();
}
/**
* Check if currently subscribed to push notifications
*/
export async function isSubscribed(): Promise<boolean> {
const subscription = await getSubscription();
return subscription !== null;
}
+143
View File
@@ -0,0 +1,143 @@
import { writable, derived } from 'svelte/store';
import {
isPushSupported,
getPermissionStatus,
isSubscribed,
subscribe as pushSubscribe,
unsubscribe as pushUnsubscribe
} from '$lib/push';
export type PushState = 'unsupported' | 'default' | 'denied' | 'subscribed' | 'unsubscribed';
interface PushStore {
supported: boolean;
permission: NotificationPermission;
subscribed: boolean;
loading: boolean;
error: string | null;
}
function createPushStore() {
const store = writable<PushStore>({
supported: false,
permission: 'default',
subscribed: false,
loading: false,
error: null
});
const { subscribe: storeSubscribe, set, update } = store;
return {
subscribe: storeSubscribe,
/**
* Initialize the push store state
*/
async init() {
const supported = isPushSupported();
if (!supported) {
set({
supported: false,
permission: 'denied',
subscribed: false,
loading: false,
error: null
});
return;
}
const permission = getPermissionStatus();
const subscribed = await isSubscribed();
set({
supported,
permission,
subscribed,
loading: false,
error: null
});
},
/**
* Subscribe to push notifications
*/
async enablePush() {
update((s) => ({ ...s, loading: true, error: null }));
try {
await pushSubscribe();
update((s) => ({
...s,
subscribed: true,
permission: 'granted',
loading: false
}));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to subscribe';
update((s) => ({
...s,
loading: false,
error: message,
permission: getPermissionStatus()
}));
throw err;
}
},
/**
* Unsubscribe from push notifications
*/
async disablePush() {
update((s) => ({ ...s, loading: true, error: null }));
try {
await pushUnsubscribe();
update((s) => ({
...s,
subscribed: false,
loading: false
}));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to unsubscribe';
update((s) => ({
...s,
loading: false,
error: message
}));
throw err;
}
},
/**
* Toggle subscription state
*/
async toggle() {
const state = await new Promise<PushStore>((resolve) => {
const unsub = storeSubscribe((s) => {
resolve(s);
unsub();
});
});
if (state.subscribed) {
await this.disablePush();
} else {
await this.enablePush();
}
}
};
}
export const pushStore = createPushStore();
/**
* Derived store for the overall push state
*/
export const pushState = derived(pushStore, ($push): PushState => {
if (!$push.supported) return 'unsupported';
if ($push.permission === 'denied') return 'denied';
if ($push.subscribed) return 'subscribed';
if ($push.permission === 'granted') return 'unsubscribed';
return 'default';
});
+27 -8
View File
@@ -212,19 +212,29 @@ function createActiveSessionStore() {
update((s) => ({ ...s, error: (e as Error).message }));
}
},
async rename(title: string) {
async rename(title: string): Promise<{ updated: Session; idChanged: boolean }> {
const state = get();
if (!state.session) return;
if (!state.session) throw new Error('No active session');
try {
const oldId = state.session.id;
const updated = await api.updateSession(state.session.id, { title });
const idChanged = updated.id !== oldId;
update((s) => ({
...s,
session: s.session ? { ...s.session, ...updated } : null
}));
// Also update in the sessions list
sessions.updateSession(state.session.id, { title });
return updated;
if (idChanged) {
// For tmux sessions, ID changes on rename - remove old, add new in sessions list
sessions.updateSession(oldId, { ...updated });
} else {
// Regular session - just update title
sessions.updateSession(state.session.id, { title });
}
return { updated, idChanged };
} catch (e) {
update((s) => ({ ...s, error: (e as Error).message }));
throw e;
@@ -329,11 +339,20 @@ function createActiveSessionStore() {
const permReq = event['permission-request'] || event.permissionRequest;
const permMessage = (event as StreamEvent & { message?: Message }).message;
const messageId = (event as StreamEvent & { 'message-id'?: string })['message-id'];
console.log('[WS] Permission request received:', permReq, 'message:', permMessage);
const autoAccepted = (event as StreamEvent & { 'auto-accepted'?: boolean })['auto-accepted'];
console.log('[WS] Permission request received:', permReq, 'message:', permMessage, 'autoAccepted:', autoAccepted);
if (permReq) {
// Store the message-id in the permission request for later status update
const permReqWithMsgId = messageId ? { ...permReq, 'message-id': messageId } : permReq;
update((s) => {
// If auto-accepted, just add message to history (don't show permission UI)
if (autoAccepted && permMessage) {
return {
...s,
messages: [...s.messages, permMessage]
// Note: pendingPermission stays null, so no permission UI shown
};
}
// If we received the full message, add it to messages array
// Otherwise just update pendingPermission
if (permMessage) {
@@ -345,7 +364,7 @@ function createActiveSessionStore() {
}
return { ...s, pendingPermission: permReqWithMsgId };
});
console.log('[WS] pendingPermission state updated with message-id:', messageId);
console.log('[WS] pendingPermission state updated with message-id:', messageId, 'autoAccepted:', autoAccepted);
}
} else if (event.event === 'error') {
update((s) => ({ ...s, error: event.message || 'Stream error', isThinking: false }));
@@ -366,5 +385,5 @@ export const sortedSessions: Readable<Session[]> = derived(sessions, ($sessions)
);
export const processingSessions: Readable<Session[]> = derived(sessions, ($sessions) =>
$sessions.sessions.filter((s) => s.status === 'processing')
$sessions.sessions.filter((s) => s.status === 'processing' && s.provider !== 'tmux')
);
+12 -1
View File
@@ -2,12 +2,23 @@
import '../app.css';
import { onMount } from 'svelte';
import { sessions } from '$lib/stores/sessions';
import { pushStore } from '$lib/stores/push';
onMount(() => {
sessions.load();
pushStore.init();
// Listen for navigation messages from service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'NAVIGATE' && event.data?.url) {
window.location.href = event.data.url;
}
});
}
});
</script>
<div class="h-screen flex flex-col bg-zinc-900 text-zinc-100 safe-top">
<div class="h-dvh flex flex-col bg-zinc-900 text-zinc-100 safe-top">
<slot />
</div>
+10
View File
@@ -3,6 +3,7 @@
import { sessions, sortedSessions, processingSessions } from '$lib/stores/sessions';
import type { Session } from '$lib/api';
import SessionCard from '$lib/components/SessionCard.svelte';
import PushToggle from '$lib/components/PushToggle.svelte';
let showNewSessionMenu = false;
let creating = false;
@@ -46,6 +47,8 @@
</div>
<div class="flex items-center gap-2">
<PushToggle />
<div class="relative">
<button
on:click|stopPropagation={() => (showNewSessionMenu = !showNewSessionMenu)}
@@ -83,6 +86,13 @@
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
OpenCode
</button>
<button
on:click={() => createNewSession('tmux')}
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>
Terminal (tmux)
</button>
</div>
{/if}
</div>
+72 -42
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { browser } from '$app/environment';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount, onDestroy, tick } from 'svelte';
@@ -7,23 +8,41 @@
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';
}
}
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();
});
@@ -70,7 +89,11 @@
const newTitle = editedTitle.trim();
isEditingTitle = false;
if (newTitle !== (session.title || '')) {
await activeSession.rename(newTitle);
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 });
}
}
}
@@ -89,12 +112,17 @@
$: session = $activeSession.session;
$: externalId = session?.['external-id'] || session?.externalId || '';
$: workingDir = session?.['working-dir'] || session?.workingDir || '';
$: shortId = externalId ? externalId.slice(0, 8) : session?.id?.slice(0, 8) || '';
$: projectName = workingDir.split('/').pop() || '';
$: 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);
@@ -105,6 +133,15 @@
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>
@@ -134,7 +171,7 @@
{: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 {statusColors[session.status] || statusColors.idle}"></span>
<span class="w-2 h-2 rounded-full {statusIndicator}"></span>
{#if isEditingTitle}
<input
bind:this={titleInput}
@@ -154,23 +191,19 @@
</button>
{/if}
</div>
{#if projectName}
<p class="text-xs text-zinc-500 truncate">{projectName}</p>
{/if}
</div>
<SessionSettings
{autoAcceptEdits}
{autoScroll}
provider={session.provider}
on:toggleAutoAccept={handleToggleAutoAccept}
on:toggleAutoScroll={handleToggleAutoScroll}
on:condenseAll={() => messageList?.condenseAll()}
/>
<span
class="text-xs font-medium uppercase {session.provider === 'claude'
? 'text-spice-400'
: 'text-emerald-400'}"
>
{session.provider}
<span class="text-xs font-medium uppercase {providerColors[session.provider] || 'text-zinc-400'}">
{session.provider === 'tmux' ? 'terminal' : session.provider}
</span>
{/if}
</div>
@@ -194,12 +227,9 @@
{#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 {statusColors[session.status] || statusColors.idle}"></span>
<span class="w-2 h-2 rounded-full {statusIndicator}"></span>
<span class="font-semibold truncate">{session.title || `Session ${shortId}`}</span>
</div>
{#if projectName}
<p class="text-xs text-zinc-500 truncate mt-0.5">{projectName}</p>
{/if}
</div>
{/if}
<button
@@ -211,15 +241,26 @@
</svg>
Back to sessions
</button>
<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>
<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>
{#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
@@ -255,23 +296,11 @@
></path>
</svg>
</div>
{:else if isTmuxSession}
<!-- Terminal view for tmux sessions -->
<TerminalView bind:this={terminalView} sessionId={sessionId || ''} {autoScroll} on:aliveChange={handleTmuxAliveChange} />
{:else}
{#if workingDir}
<div class="flex-shrink-0 px-4 py-2 bg-zinc-900/50 border-b border-zinc-800 flex items-center gap-2 text-xs text-zinc-500 landscape-mobile:hidden">
<svg class="h-3.5 w-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span class="truncate font-mono">{workingDir}</span>
<span class="flex-1"></span>
<button
on:click={() => messageList?.condenseAll()}
class="text-zinc-400 hover:text-zinc-200 transition-colors whitespace-nowrap"
>
condense all
</button>
</div>
{/if}
<MessageList bind:this={messageList} messages={$activeSession.messages} streamingContent={$activeSession.streamingContent} isThinking={$activeSession.isThinking} />
<MessageList bind:this={messageList} messages={$activeSession.messages} streamingContent={$activeSession.streamingContent} isThinking={$activeSession.isThinking} provider={session?.provider} {autoScroll} />
{#if $activeSession.pendingPermission}
<PermissionRequest
@@ -287,6 +316,7 @@
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'
+92
View File
@@ -0,0 +1,92 @@
/// <reference lib="webworker" />
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching';
declare let self: ServiceWorkerGlobalScope;
// Clean up old caches
cleanupOutdatedCaches();
// Precache all assets generated by the build
precacheAndRoute(self.__WB_MANIFEST);
// Push notification payload type
interface PushPayload {
title: string;
body: string;
sessionId: string;
sessionTitle: string;
tools: string[];
}
// Handle push events
self.addEventListener('push', (event) => {
if (!event.data) {
console.log('[SW] Push event with no data');
return;
}
try {
const payload: PushPayload = event.data.json();
console.log('[SW] Push received:', payload);
const options = {
body: payload.body,
icon: '/pwa-192x192.png',
badge: '/pwa-192x192.png',
tag: `permission-${payload.sessionId}`,
renotify: true,
requireInteraction: true,
data: {
sessionId: payload.sessionId,
url: `/session/${payload.sessionId}`
},
actions: [
{
action: 'open',
title: 'Open Session'
}
]
} satisfies NotificationOptions & { renotify?: boolean; requireInteraction?: boolean; actions?: { action: string; title: string }[] };
event.waitUntil(self.registration.showNotification(payload.title, options));
} catch (err) {
console.error('[SW] Error processing push:', err);
}
});
// Handle notification click
self.addEventListener('notificationclick', (event) => {
console.log('[SW] Notification clicked:', event.action);
event.notification.close();
const url = event.notification.data?.url || '/';
// Focus existing window or open new one
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
// Try to find an existing window with the app
for (const client of clientList) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
client.focus();
// Navigate to the session
client.postMessage({
type: 'NAVIGATE',
url: url
});
return;
}
}
// No existing window, open a new one
if (self.clients.openWindow) {
return self.clients.openWindow(url);
}
})
);
});
// Handle messages from the main app
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
+7 -16
View File
@@ -8,6 +8,9 @@ export default defineConfig({
sveltekit(),
basicSsl(),
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
manifest: {
@@ -39,24 +42,12 @@ export default defineConfig({
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/.*\/api\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 5 // 5 minutes
}
}
}
]
injectManifest: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}']
},
devOptions: {
enabled: true
enabled: true,
type: 'module'
}
})
],