From 01e0d1c529151b34b8f3eee174e0898dab041a82 Mon Sep 17 00:00:00 2001 From: busya Date: Tue, 2 Jun 2026 15:22:30 +0300 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20persist=20active=20run=20in=20?= =?UTF-8?q?sessionStorage=20=E2=80=94=20survive=20tab=20close/reopen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: closing and reopening the translate job page (or tab close+reopen) lost the progress bar because the in-memory store starts fresh. The backend keeps translating, but the UI shows no progress. Fix: - Store: save {runId, jobId, isFullRun} to sessionStorage on startTranslationRun() via _saveToSessionStorage() - Store: add reconnectToRun() — re-establishes WS without resetting store state, preserving any progress data already received - Store: add getStoredActiveRun() — public reader for sessionStorage - Store: clear sessionStorage via _clearSessionStorage() on terminal states (added to _cleanup() called by WS terminal, max-reconnects, stop, reset) - Page: add that checks getStoredActiveRun() after job loads and calls reconnectToRun() to pick up the live progress Edge cases handled: - Store already connected to same run → skip (no duplicate WS) - sessionStorage unavailable → silent catch - SPA navigation → store already has the run, WS still connected → skip - Page reload → sessionStorage has the run, reconnects - Tab close + reopen → same as reload - Run completes while away → sessionStorage cleared by _cleanup() - Run already finished → getStoredActiveRun() returns null → skip Build ✅ 698 tests ✅ --- .../src/lib/stores/translationRun.svelte.ts | 68 +++++++++++++++++++ .../src/routes/translate/[id]/+page.svelte | 24 +++++++ 2 files changed, 92 insertions(+) diff --git a/frontend/src/lib/stores/translationRun.svelte.ts b/frontend/src/lib/stores/translationRun.svelte.ts index c778e42d..03cd82ec 100644 --- a/frontend/src/lib/stores/translationRun.svelte.ts +++ b/frontend/src/lib/stores/translationRun.svelte.ts @@ -110,6 +110,28 @@ let _timeoutTimer: ReturnType | null = null; const MAX_RECONNECT = 3; const RECONNECT_DELAY_MS = 10_000; const RUN_TIMEOUT_MS = 600_000; // 10 minutes +const SESSION_KEY = 'ss:translate:activeRun'; + +function _saveToSessionStorage(): void { + try { + sessionStorage.setItem(SESSION_KEY, JSON.stringify({ + runId: _state.runId, + jobId: _state.jobId, + isFullRun: _state.isFullRun, + timestamp: Date.now(), + })); + } catch { + // sessionStorage may be unavailable + } +} + +function _clearSessionStorage(): void { + try { + sessionStorage.removeItem(SESSION_KEY); + } catch { + // sessionStorage may be unavailable + } +} export function clearOnCompleteCallback(): void { _onCompleteCallback = null; @@ -132,6 +154,37 @@ export function startTranslationRun(runId: string, options: StartRunOptions = {} _onCompleteCallback = options.onComplete || null; _reconnectCount = 0; + _saveToSessionStorage(); + _connectWebSocket(runId); + _startTimeout(); +} + +/** + * Reconnect to a running translation run (e.g. after page reload). + * Unlike startTranslationRun, this does NOT clear the store first — + * it re-establishes the WS to receive live progress. + */ +export function reconnectToRun(runId: string, options: StartRunOptions = {}): void { + if (!runId) return; + + // If already connected to this run, skip + if (_ws && _state.runId === runId) return; + + stopTranslationRun(); + + _state = { + ..._state, + runId, + uxState: 'running', + jobId: options.jobId || _state.jobId || null, + isFullRun: options.isFullRun || _state.isFullRun || false, + }; + _notify(); + + _onCompleteCallback = options.onComplete || _onCompleteCallback; + _reconnectCount = 0; + + _saveToSessionStorage(); _connectWebSocket(runId); _startTimeout(); } @@ -164,6 +217,7 @@ function _clearReconnect(): void { function _cleanup(): void { _clearTimeout(); _clearReconnect(); + _clearSessionStorage(); if (_ws) { _ws.onclose = null; _ws.onerror = null; @@ -272,11 +326,25 @@ export function stopTranslationRun(): void { export function resetTranslationRun(): void { stopTranslationRun(); _reconnectCount = 0; + _clearSessionStorage(); _state = initialState; _notify(); _onCompleteCallback = null; } +/** Read persisted active run info from sessionStorage (survives page reload). */ +export function getStoredActiveRun(): { runId: string; jobId?: string; isFullRun?: boolean } | null { + try { + const raw = sessionStorage.getItem(SESSION_KEY); + if (!raw) return null; + const data = JSON.parse(raw); + if (!data?.runId) return null; + return data; + } catch { + return null; + } +} + export function updateTranslationRunState(state: Partial): void { _state = { ..._state, ...state }; _notify(); diff --git a/frontend/src/routes/translate/[id]/+page.svelte b/frontend/src/routes/translate/[id]/+page.svelte index 2084776c..b9531a54 100644 --- a/frontend/src/routes/translate/[id]/+page.svelte +++ b/frontend/src/routes/translate/[id]/+page.svelte @@ -89,8 +89,10 @@ import { translationRunStore, startTranslationRun, + reconnectToRun, resetTranslationRun, clearOnCompleteCallback, + getStoredActiveRun, } from '$lib/stores/translationRun.svelte.js'; @@ -293,6 +295,7 @@ // Watch for jobId changes — handles: // 1. Direct navigation to existing job page (environments loaded in onMount) // 2. Navigation after createJob (goto → param change, component reused) + // 3. Page reload — reconnect to any active run via sessionStorage // Guards: only runs when environments are ready and it's an existing job $effect(() => { if (jobId && !isNewJob && environments.length > 0) { @@ -300,6 +303,27 @@ } }); + // Pick up active run after job loads (survives page reload / new tab) + $effect(() => { + if (!existingJob || !existingJob.id) return; + // Only run once when job first loads — avoid re-triggering on state changes + if (!environments.length) return; + + const stored = getStoredActiveRun(); + if (!stored || stored.jobId !== existingJob.id) return; + // Don't reconnect if store is already connected to this run + if (translationRunStore.value?.runId === stored.runId) return; + + console.debug('[translate] picking up active run from sessionStorage', { runId: stored.runId }); + isRunning = true; + isFullRun = stored.isFullRun || false; + runComplete = false; + reconnectToRun(stored.runId, { + jobId: existingJob.id, + isFullRun: stored.isFullRun || false, + }); + }); + // Clean up store callback when page unmounts — the store keeps // polling so the global indicator in the layout still works. onDestroy(() => {