add tmux sessions
This commit is contained in:
+13
-1
@@ -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();
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
});
|
||||
@@ -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')
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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'
|
||||
}
|
||||
})
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user