fix last session
This commit is contained in:
@@ -23,7 +23,6 @@ client/
|
||||
│ │ └── session/[id]/+page.svelte
|
||||
│ ├── lib/
|
||||
│ │ ├── api.ts # HTTP + WebSocket
|
||||
│ │ ├── push.ts # Push notifications
|
||||
│ │ ├── stores/sessions.ts # State management
|
||||
│ │ └── components/ # UI components
|
||||
│ ├── app.css # Tailwind
|
||||
@@ -90,7 +89,6 @@ wsClient.subscribe(id, (event) => { ... });
|
||||
| `SessionCard` | Session list item |
|
||||
| `SessionSettings` | Gear menu |
|
||||
| `TerminalView` | Tmux display |
|
||||
| `PushToggle` | Push notification toggle |
|
||||
|
||||
## Theme
|
||||
|
||||
|
||||
@@ -7,9 +7,7 @@ Core library: API clients, stores, components.
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `api.ts` | HTTP client, WebSocket, types |
|
||||
| `push.ts` | Push notification utilities |
|
||||
| `stores/sessions.ts` | Session state management |
|
||||
| `stores/push.ts` | Push notification state |
|
||||
| `components/` | Svelte components |
|
||||
|
||||
## Types (api.ts)
|
||||
|
||||
@@ -86,6 +86,8 @@ export interface TerminalDiff {
|
||||
hash?: number;
|
||||
'frame-id'?: number; // Auto-incrementing ID for ordering frames (prevents out-of-order issues)
|
||||
frameId?: number;
|
||||
'clear-indices'?: number[]; // Line indices where clear screen sequences occurred
|
||||
clearIndices?: number[];
|
||||
}
|
||||
|
||||
export interface TerminalContent {
|
||||
@@ -95,6 +97,10 @@ export interface TerminalContent {
|
||||
sessionName?: string;
|
||||
diff?: TerminalDiff;
|
||||
layout?: 'desktop' | 'landscape' | 'portrait';
|
||||
'clear-indices'?: number[]; // Line indices where clear screen sequences occurred
|
||||
clearIndices?: number[];
|
||||
'scroll-to-line'?: number; // Suggested scroll position (line after last clear)
|
||||
scrollToLine?: number;
|
||||
}
|
||||
|
||||
export interface ExternalTmuxSession {
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
<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}
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
export let session: Session;
|
||||
|
||||
const dispatch = createEventDispatcher<{ delete: void }>();
|
||||
const dispatch = createEventDispatcher<{ delete: void; eject: void }>();
|
||||
|
||||
function handleDelete(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
@@ -12,6 +12,12 @@
|
||||
dispatch('delete');
|
||||
}
|
||||
|
||||
function handleEject(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dispatch('eject');
|
||||
}
|
||||
|
||||
$: externalId = session['external-id'] || session.externalId || '';
|
||||
$: updatedAt = session['updated-at'] || session.updatedAt || session['created-at'] || session.createdAt || '';
|
||||
$: shortId = externalId.slice(0, 8);
|
||||
@@ -66,6 +72,17 @@
|
||||
|
||||
<div class="text-right flex-shrink-0 flex items-center gap-2">
|
||||
<span class="text-xs text-zinc-500">{formatTime(updatedAt)}</span>
|
||||
{#if session.provider === 'tmux'}
|
||||
<button
|
||||
on:click={handleEject}
|
||||
class="p-1 text-zinc-500 hover:text-amber-400 hover:bg-amber-500/10 rounded transition-colors"
|
||||
title="Eject session (keep tmux running)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
on:click={handleDelete}
|
||||
class="p-1 text-zinc-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
eject: void;
|
||||
bigMode: void;
|
||||
goToLastSession: void;
|
||||
delete: void;
|
||||
}>();
|
||||
|
||||
function handleToggleAutoScroll() {
|
||||
@@ -61,6 +62,11 @@
|
||||
open = false;
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
dispatch('delete');
|
||||
open = false;
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.settings-dropdown')) {
|
||||
@@ -244,6 +250,17 @@
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Delete session -->
|
||||
<button
|
||||
on:click={handleDelete}
|
||||
class="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-zinc-700/50 transition-colors text-left border-t border-zinc-700"
|
||||
>
|
||||
<svg class="h-4 w-4 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
<span class="text-sm text-red-400">Delete session</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
let lastHash: number | null = null;
|
||||
let lastFrameId: number | null = null; // Track frame ordering to prevent out-of-order updates
|
||||
let initialLoadComplete = false; // Track whether initial load has happened
|
||||
let refreshCount = 0; // Counter for periodic full refresh
|
||||
let scrolledToClearLine: number | null = null; // Track which clear line we've already scrolled to (prevents jitter)
|
||||
let screenMode: 'fullscreen' | 'desktop' | 'landscape' | 'portrait' | null = null;
|
||||
let resizing = false;
|
||||
let inputBuffer = '';
|
||||
@@ -164,17 +166,23 @@
|
||||
const result = await api.getTerminalContent(sessionId, fresh);
|
||||
|
||||
let changed = false;
|
||||
const scrollToLine = result['scroll-to-line'] ?? result.scrollToLine ?? null;
|
||||
|
||||
// On initial load, always use full content directly for reliability
|
||||
// On initial load or fresh fetch, always use full content directly for reliability
|
||||
// Use diffs only for incremental updates after initial load
|
||||
if (!initialLoadComplete) {
|
||||
// First load: use raw content, ignore diff
|
||||
if (!initialLoadComplete || fresh) {
|
||||
// Full refresh: use raw content, reset frame tracking
|
||||
const newLines = result.content ? result.content.split('\n') : [];
|
||||
terminalLines = newLines;
|
||||
// Only update if content actually changed to prevent jitter
|
||||
const newContent = newLines.join('\n');
|
||||
const currentContent = terminalLines.join('\n');
|
||||
if (newContent !== currentContent) {
|
||||
terminalLines = newLines;
|
||||
changed = true;
|
||||
}
|
||||
lastHash = result.diff?.hash ?? null;
|
||||
// Initialize frame ID tracking from first response
|
||||
// Reset frame ID tracking on fresh fetch to accept new frames
|
||||
lastFrameId = result.diff?.['frame-id'] ?? result.diff?.frameId ?? null;
|
||||
changed = newLines.length > 0;
|
||||
initialLoadComplete = true;
|
||||
// Set screen mode from server-detected layout
|
||||
if (result.layout) {
|
||||
@@ -200,7 +208,14 @@
|
||||
|
||||
if (changed) {
|
||||
await tick();
|
||||
scrollToBottom();
|
||||
// If a new clear was detected that we haven't scrolled to yet, scroll once
|
||||
// Otherwise scroll to bottom as usual
|
||||
if (scrollToLine !== null && scrollToLine !== scrolledToClearLine) {
|
||||
scrollToClearLine(scrollToLine);
|
||||
scrolledToClearLine = scrollToLine;
|
||||
} else {
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch terminal content';
|
||||
@@ -215,6 +230,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to position a specific line at the top of the viewport.
|
||||
* Used after clear to show the post-clear content at the top.
|
||||
*/
|
||||
function scrollToClearLine(lineIndex: number) {
|
||||
if (terminalElement && lineIndex >= 0) {
|
||||
// Calculate approximate scroll position based on line height
|
||||
const computedStyle = getComputedStyle(terminalElement);
|
||||
const lineHeight = parseFloat(computedStyle.lineHeight) || 24;
|
||||
terminalElement.scrollTop = lineIndex * lineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
async function pasteFromClipboard() {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
@@ -363,11 +391,23 @@
|
||||
|
||||
function handleWebSocketEvent(event: StreamEvent) {
|
||||
if (event.event === 'terminal-update') {
|
||||
// Extract scroll-to-line from WebSocket event
|
||||
const eventScrollToLine = (event as StreamEvent & { 'scroll-to-line'?: number; scrollToLine?: number })['scroll-to-line'] ??
|
||||
(event as StreamEvent & { scrollToLine?: number }).scrollToLine ?? null;
|
||||
|
||||
// Apply diff if provided (includes frame ID ordering check)
|
||||
if (event.diff) {
|
||||
const changed = applyDiff(event.diff);
|
||||
if (changed) {
|
||||
tick().then(() => scrollToBottom());
|
||||
tick().then(() => {
|
||||
// If a new clear was detected that we haven't scrolled to yet, scroll once
|
||||
if (eventScrollToLine !== null && eventScrollToLine !== scrolledToClearLine) {
|
||||
scrollToClearLine(eventScrollToLine);
|
||||
scrolledToClearLine = eventScrollToLine;
|
||||
} else {
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (event.content !== undefined) {
|
||||
// Fallback: full content replacement (no frame ID available)
|
||||
@@ -375,7 +415,14 @@
|
||||
const newLines = newContent ? newContent.split('\n') : [];
|
||||
if (newLines.join('\n') !== terminalLines.join('\n')) {
|
||||
terminalLines = newLines;
|
||||
tick().then(() => scrollToBottom());
|
||||
tick().then(() => {
|
||||
if (eventScrollToLine !== null && eventScrollToLine !== scrolledToClearLine) {
|
||||
scrollToClearLine(eventScrollToLine);
|
||||
scrolledToClearLine = eventScrollToLine;
|
||||
} else {
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -402,8 +449,13 @@
|
||||
await wsClient.connect();
|
||||
unsubscribe = wsClient.subscribe(sessionId, handleWebSocketEvent);
|
||||
|
||||
// Periodic refresh every 1 second (no fresh flag for incremental diffs)
|
||||
refreshInterval = setInterval(() => fetchTerminalContent(false), 1000);
|
||||
// Periodic refresh every 1 second
|
||||
// Every 5th refresh (every 5 seconds), do a full fetch to ensure sync
|
||||
refreshInterval = setInterval(() => {
|
||||
refreshCount++;
|
||||
const doFullRefresh = refreshCount % 5 === 0;
|
||||
fetchTerminalContent(doFullRefresh);
|
||||
}, 1000);
|
||||
|
||||
// Auto-focus input after content loads
|
||||
if (autoFocus) {
|
||||
@@ -609,7 +661,7 @@
|
||||
bind:this={terminalInput}
|
||||
type="text"
|
||||
on:keydown={handleKeydown}
|
||||
on:focus={() => setTimeout(() => scrollToBottom(true), 59)}
|
||||
on:focus={() => setTimeout(() => { if (terminalLines.length > 0) scrollToBottom(true); }, 59)}
|
||||
class="sr-only"
|
||||
disabled={!isAlive}
|
||||
aria-label="Terminal input"
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
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';
|
||||
});
|
||||
@@ -342,6 +342,10 @@ function createActiveSessionStore() {
|
||||
const autoAccepted = (event as StreamEvent & { 'auto-accepted'?: boolean })['auto-accepted'];
|
||||
console.log('[WS] Permission request received:', permReq, 'message:', permMessage, 'autoAccepted:', autoAccepted);
|
||||
if (permReq) {
|
||||
// Vibrate on mobile when permission is requested (and not auto-accepted)
|
||||
if (!autoAccepted && typeof navigator !== 'undefined' && navigator.vibrate) {
|
||||
navigator.vibrate([200, 100, 200]);
|
||||
}
|
||||
// Store the message-id in the permission request for later status update
|
||||
const permReqWithMsgId = messageId ? { ...permReq, 'message-id': messageId } : permReq;
|
||||
update((s) => {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import '../app.css';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { sessions } from '$lib/stores/sessions';
|
||||
import { pushStore } from '$lib/stores/push';
|
||||
|
||||
let containerHeight = '100dvh';
|
||||
|
||||
@@ -15,16 +14,6 @@
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Track visual viewport for keyboard handling
|
||||
if (typeof window !== 'undefined' && window.visualViewport) {
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { sessions, sortedSessions, processingSessions } from '$lib/stores/sessions';
|
||||
import { api, type Session, type ExternalTmuxSession } from '$lib/api';
|
||||
import SessionCard from '$lib/components/SessionCard.svelte';
|
||||
import PushToggle from '$lib/components/PushToggle.svelte';
|
||||
|
||||
function clearLastSessionIfMatch(id: string) {
|
||||
if (browser) {
|
||||
const lastSession = localStorage.getItem('spiceflow-last-session');
|
||||
if (lastSession === id) {
|
||||
localStorage.removeItem('spiceflow-last-session');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let showNewSessionMenu = false;
|
||||
let showImportMenu = false;
|
||||
@@ -31,10 +40,23 @@
|
||||
|
||||
async function deleteSession(id: string) {
|
||||
if (confirm('Delete this session?')) {
|
||||
clearLastSessionIfMatch(id);
|
||||
await sessions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
async function ejectSession(id: string) {
|
||||
if (confirm('Eject this session? The tmux session will keep running but be removed from Spiceflow.')) {
|
||||
try {
|
||||
clearLastSessionIfMatch(id);
|
||||
await api.ejectSession(id);
|
||||
await sessions.load();
|
||||
} catch (e) {
|
||||
console.error('Failed to eject session:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExternalSessions() {
|
||||
loadingExternal = true;
|
||||
try {
|
||||
@@ -85,8 +107,6 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<PushToggle />
|
||||
|
||||
<!-- Import tmux session button -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@@ -278,7 +298,7 @@
|
||||
{:else}
|
||||
<div class="p-4 space-y-3">
|
||||
{#each $sortedSessions as session (session.id)}
|
||||
<SessionCard {session} on:delete={() => deleteSession(session.id)} />
|
||||
<SessionCard {session} on:delete={() => deleteSession(session.id)} on:eject={() => ejectSession(session.id)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { goto, beforeNavigate } from '$app/navigation';
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import { activeSession, sessions } from '$lib/stores/sessions';
|
||||
import { api } from '$lib/api';
|
||||
@@ -33,8 +33,11 @@
|
||||
if (stored !== null) {
|
||||
autoScroll = stored === 'true';
|
||||
}
|
||||
// Load last session from localStorage
|
||||
lastSessionId = localStorage.getItem('spiceflow-last-session');
|
||||
// Load last session from localStorage (skip if it's the current session)
|
||||
const storedLastSession = localStorage.getItem('spiceflow-last-session');
|
||||
if (storedLastSession && storedLastSession !== sessionId) {
|
||||
lastSessionId = storedLastSession;
|
||||
}
|
||||
// Detect mobile (screen width < 640px or height < 450px)
|
||||
const checkMobile = () => {
|
||||
isMobile = window.innerWidth < 640 || window.innerHeight < 450;
|
||||
@@ -45,6 +48,24 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Validate lastSessionId: clear if deleted/ejected or if it matches current session
|
||||
// Track $sessions.sessions explicitly so this reactive runs when sessions list updates
|
||||
$: {
|
||||
const sessionsList = $sessions.sessions;
|
||||
if (browser && lastSessionId) {
|
||||
if (lastSessionId === sessionId) {
|
||||
// Don't show "last session" if it's the current session
|
||||
lastSessionId = null;
|
||||
} else if (sessionsList.length > 0) {
|
||||
const exists = sessionsList.some(s => s.id === lastSessionId);
|
||||
if (!exists) {
|
||||
localStorage.removeItem('spiceflow-last-session');
|
||||
lastSessionId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track session history when navigating to a new session
|
||||
let previousSessionId: string | null = null;
|
||||
$: if (browser && sessionId && sessionId !== previousSessionId) {
|
||||
@@ -78,6 +99,29 @@
|
||||
activeSession.clear();
|
||||
});
|
||||
|
||||
// Save current session as "last session" when navigating away
|
||||
beforeNavigate(({ to }) => {
|
||||
if (!browser || !sessionId) return;
|
||||
|
||||
const toSessionId = to?.route?.id === '/session/[id]' ? to.params?.id : null;
|
||||
|
||||
// Don't save if navigating to the same session
|
||||
if (toSessionId === sessionId) return;
|
||||
|
||||
// Don't overwrite if going to home and then back to the stored session
|
||||
// Only save if navigating to a DIFFERENT session OR if localStorage is empty/matches current
|
||||
const currentStored = localStorage.getItem('spiceflow-last-session');
|
||||
|
||||
if (toSessionId) {
|
||||
// Going to a different session - always save current
|
||||
localStorage.setItem('spiceflow-last-session', sessionId);
|
||||
} else if (!currentStored || currentStored === sessionId) {
|
||||
// Going to home and localStorage is empty or has current session - save current
|
||||
localStorage.setItem('spiceflow-last-session', sessionId);
|
||||
}
|
||||
// If going to home but localStorage has a different session, preserve it
|
||||
});
|
||||
|
||||
function handleSend(event: CustomEvent<string>) {
|
||||
if (steerMode && $activeSession.pendingPermission) {
|
||||
// Send as steer response
|
||||
@@ -165,9 +209,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
function clearLastSessionIfMatch(id: string) {
|
||||
if (browser) {
|
||||
const stored = localStorage.getItem('spiceflow-last-session');
|
||||
if (stored === id) {
|
||||
localStorage.removeItem('spiceflow-last-session');
|
||||
lastSessionId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEject() {
|
||||
if (!sessionId || !isTmuxSession) return;
|
||||
try {
|
||||
clearLastSessionIfMatch(sessionId);
|
||||
const result = await api.ejectSession(sessionId);
|
||||
alert(result.message);
|
||||
await sessions.load(); // Refresh sessions list so ejected session is removed
|
||||
@@ -181,6 +236,18 @@
|
||||
terminalView?.setBigMode(true);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!sessionId) return;
|
||||
if (!confirm('Are you sure you want to delete this session?')) return;
|
||||
try {
|
||||
clearLastSessionIfMatch(sessionId);
|
||||
await sessions.delete(sessionId);
|
||||
goto('/');
|
||||
} catch (e) {
|
||||
alert('Failed to delete session: ' + (e instanceof Error ? e.message : 'Unknown error'));
|
||||
}
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
idle: 'bg-zinc-600',
|
||||
processing: 'bg-green-500 animate-pulse',
|
||||
@@ -266,6 +333,7 @@
|
||||
on:eject={handleEject}
|
||||
on:bigMode={handleBigMode}
|
||||
on:goToLastSession={goToLastSession}
|
||||
on:delete={handleDelete}
|
||||
/>
|
||||
|
||||
<!-- Refresh button - desktop only -->
|
||||
@@ -392,6 +460,15 @@
|
||||
Eject session
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
on:click={() => { menuOpen = false; handleDelete(); }}
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-zinc-700 flex items-center gap-2 transition-colors border-t border-zinc-700 text-red-400"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Delete session
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -9,81 +9,6 @@ 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') {
|
||||
|
||||
Reference in New Issue
Block a user