From 051e3dfcb47e84940e219b8a343004a9fe8a6db1 Mon Sep 17 00:00:00 2001 From: Adam Jeniski Date: Wed, 21 Jan 2026 14:26:30 -0500 Subject: [PATCH] fix last session --- client/CLAUDE.md | 2 - client/src/lib/CLAUDE.md | 2 - client/src/lib/api.ts | 6 + client/src/lib/components/PushToggle.svelte | 109 ------ client/src/lib/components/SessionCard.svelte | 19 +- .../src/lib/components/SessionSettings.svelte | 17 + client/src/lib/components/TerminalView.svelte | 76 +++- client/src/lib/push.ts | 150 -------- client/src/lib/stores/push.ts | 143 -------- client/src/lib/stores/sessions.ts | 4 + client/src/routes/+layout.svelte | 11 - client/src/routes/+page.svelte | 28 +- client/src/routes/session/[id]/+page.svelte | 83 ++++- client/src/sw.ts | 75 ---- e2e/tests/last-session.spec.ts | 246 +++++++++++++ e2e/tests/permissions-claude.spec.ts | 74 ++++ e2e/tests/tmux-terminal.spec.ts | 95 +++++ server/CLAUDE.md | 4 +- server/src/spiceflow/CLAUDE.md | 9 +- server/src/spiceflow/adapters/tmux.clj | 52 +-- server/src/spiceflow/api/routes.clj | 65 +--- server/src/spiceflow/core.clj | 12 +- server/src/spiceflow/push/protocol.clj | 33 -- server/src/spiceflow/push/sender.clj | 337 ------------------ server/src/spiceflow/push/store.clj | 148 -------- server/src/spiceflow/push/vapid.clj | 129 ------- server/src/spiceflow/session/manager.clj | 36 -- server/src/spiceflow/terminal/diff.clj | 29 +- 28 files changed, 699 insertions(+), 1295 deletions(-) delete mode 100644 client/src/lib/components/PushToggle.svelte delete mode 100644 client/src/lib/push.ts delete mode 100644 client/src/lib/stores/push.ts create mode 100644 e2e/tests/last-session.spec.ts delete mode 100644 server/src/spiceflow/push/protocol.clj delete mode 100644 server/src/spiceflow/push/sender.clj delete mode 100644 server/src/spiceflow/push/store.clj delete mode 100644 server/src/spiceflow/push/vapid.clj diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 41fad10..4608686 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -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 diff --git a/client/src/lib/CLAUDE.md b/client/src/lib/CLAUDE.md index 02abe47..297302f 100644 --- a/client/src/lib/CLAUDE.md +++ b/client/src/lib/CLAUDE.md @@ -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) diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 0dabcf4..402b8ed 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/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 { diff --git a/client/src/lib/components/PushToggle.svelte b/client/src/lib/components/PushToggle.svelte deleted file mode 100644 index d816290..0000000 --- a/client/src/lib/components/PushToggle.svelte +++ /dev/null @@ -1,109 +0,0 @@ - - -{#if !isUnsupported} - -{/if} - -{#if errorMessage || storeError} -
- {errorMessage || storeError} -
-{/if} diff --git a/client/src/lib/components/SessionCard.svelte b/client/src/lib/components/SessionCard.svelte index aac53dd..39b9a62 100644 --- a/client/src/lib/components/SessionCard.svelte +++ b/client/src/lib/components/SessionCard.svelte @@ -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 @@
{formatTime(updatedAt)} + {#if session.provider === 'tmux'} + + {/if}
{/if} + + + {/if} diff --git a/client/src/lib/components/TerminalView.svelte b/client/src/lib/components/TerminalView.svelte index af28c04..b3b3c2a 100644 --- a/client/src/lib/components/TerminalView.svelte +++ b/client/src/lib/components/TerminalView.svelte @@ -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" diff --git a/client/src/lib/push.ts b/client/src/lib/push.ts deleted file mode 100644 index 1225273..0000000 --- a/client/src/lib/push.ts +++ /dev/null @@ -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 { - 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 { - 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 { - if (!isPushSupported()) { - return null; - } - - const registration = await navigator.serviceWorker.ready; - return await registration.pushManager.getSubscription(); -} - -/** - * Subscribe to push notifications - */ -export async function subscribe(): Promise { - 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 { - 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 { - const subscription = await getSubscription(); - return subscription !== null; -} diff --git a/client/src/lib/stores/push.ts b/client/src/lib/stores/push.ts deleted file mode 100644 index 21f5d51..0000000 --- a/client/src/lib/stores/push.ts +++ /dev/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({ - 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((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'; -}); diff --git a/client/src/lib/stores/sessions.ts b/client/src/lib/stores/sessions.ts index 545d0e0..002377b 100644 --- a/client/src/lib/stores/sessions.ts +++ b/client/src/lib/stores/sessions.ts @@ -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) => { diff --git a/client/src/routes/+layout.svelte b/client/src/routes/+layout.svelte index ca72e1a..7b515dd 100644 --- a/client/src/routes/+layout.svelte +++ b/client/src/routes/+layout.svelte @@ -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) { diff --git a/client/src/routes/+page.svelte b/client/src/routes/+page.svelte index c5bcbd2..33c6129 100644 --- a/client/src/routes/+page.svelte +++ b/client/src/routes/+page.svelte @@ -1,9 +1,18 @@