feat: add sync status indicator and warning banner

- Add SyncStatusIndicator component showing connection status in TopNav
- Add SyncWarningBanner that appears after 30s of backend disconnection
- Green dot when synced, amber pulsing when syncing, red when error/offline
- Warning banner is dismissible but reappears on next failure

Closes #11
This commit is contained in:
2026-01-22 09:06:23 +01:00
parent c97bd572f2
commit d48cf7ce72
4 changed files with 236 additions and 1 deletions

View File

@@ -0,0 +1,71 @@
<script lang="ts">
/**
* SyncStatusIndicator.svelte - Compact sync status indicator for TopNav
* Shows connection status with backend: synced, syncing, error, or offline
*/
import { syncState } from '$lib/backend';
/** Computed status for display */
let displayStatus = $derived.by(() => {
if (syncState.status === 'offline' || !syncState.isOnline) {
return 'offline';
}
if (syncState.status === 'error') {
return 'error';
}
if (syncState.status === 'syncing') {
return 'syncing';
}
return 'synced';
});
/** Tooltip text based on status */
let tooltipText = $derived.by(() => {
switch (displayStatus) {
case 'offline':
return 'Backend offline - data stored locally only';
case 'error':
return syncState.lastError
? `Sync error: ${syncState.lastError}`
: 'Sync error - check backend connection';
case 'syncing':
return 'Syncing...';
case 'synced':
if (syncState.lastSyncTime) {
const ago = getTimeAgo(syncState.lastSyncTime);
return `Synced ${ago}`;
}
return 'Synced';
}
});
/** Format relative time */
function getTimeAgo(date: Date): string {
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}
</script>
<div class="relative flex items-center" title={tooltipText}>
<!-- Status dot -->
<span
class="inline-block h-2 w-2 rounded-full {displayStatus === 'synced'
? 'bg-emerald-500'
: displayStatus === 'syncing'
? 'animate-pulse bg-amber-500'
: 'bg-red-500'}"
aria-hidden="true"
></span>
<!-- Pending count badge (only when error/offline with pending items) -->
{#if (displayStatus === 'error' || displayStatus === 'offline') && syncState.pendingCount > 0}
<span
class="ml-1 rounded-full bg-red-500/20 px-1.5 py-0.5 text-[10px] font-medium text-red-500"
>
{syncState.pendingCount}
</span>
{/if}
</div>

View File

@@ -9,6 +9,7 @@
import ExportDialog from '$lib/components/shared/ExportDialog.svelte';
import ConfirmDialog from '$lib/components/shared/ConfirmDialog.svelte';
import ContextUsageBar from '$lib/components/chat/ContextUsageBar.svelte';
import SyncStatusIndicator from './SyncStatusIndicator.svelte';
interface Props {
/** Slot for the model select dropdown */
@@ -167,8 +168,13 @@
</div>
{/if}
<!-- Right section: Theme toggle + Chat actions -->
<!-- Right section: Sync status + Theme toggle + Chat actions -->
<div class="flex items-center gap-1">
<!-- Sync status indicator (always visible) -->
<div class="mr-1 px-2">
<SyncStatusIndicator />
</div>
<!-- Theme toggle (always visible) -->
<button
type="button"

View File

@@ -0,0 +1,154 @@
<script lang="ts">
/**
* SyncWarningBanner.svelte - Warning banner for sync failures
* Shows when backend is disconnected for >30 seconds continuously
*/
import { syncState } from '$lib/backend';
import { onMount } from 'svelte';
/** Threshold before showing banner (30 seconds) */
const FAILURE_THRESHOLD_MS = 30_000;
/** Track when failure started */
let failureStartTime = $state<number | null>(null);
/** Whether banner has been dismissed for this failure period */
let isDismissed = $state(false);
/** Whether enough time has passed to show banner */
let thresholdReached = $state(false);
/** Interval for checking threshold */
let checkInterval: ReturnType<typeof setInterval> | null = null;
/** Check if we're in a failure state */
let isInFailureState = $derived(
syncState.status === 'error' || syncState.status === 'offline' || !syncState.isOnline
);
/** Should show the banner */
let shouldShow = $derived(isInFailureState && thresholdReached && !isDismissed);
/** Watch for failure state changes */
$effect(() => {
if (isInFailureState) {
// Start tracking failure time if not already
if (failureStartTime === null) {
failureStartTime = Date.now();
isDismissed = false;
thresholdReached = false;
// Start interval to check threshold
if (checkInterval) clearInterval(checkInterval);
checkInterval = setInterval(() => {
if (failureStartTime && Date.now() - failureStartTime >= FAILURE_THRESHOLD_MS) {
thresholdReached = true;
if (checkInterval) {
clearInterval(checkInterval);
checkInterval = null;
}
}
}, 1000);
}
} else {
// Reset on recovery
failureStartTime = null;
isDismissed = false;
thresholdReached = false;
if (checkInterval) {
clearInterval(checkInterval);
checkInterval = null;
}
}
});
onMount(() => {
return () => {
if (checkInterval) {
clearInterval(checkInterval);
}
};
});
/** Dismiss the banner */
function handleDismiss() {
isDismissed = true;
}
</script>
{#if shouldShow}
<div
class="fixed left-0 right-0 top-12 z-50 flex items-center justify-center px-4 animate-in"
role="alert"
>
<div
class="flex items-center gap-3 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-2 text-red-400 shadow-lg backdrop-blur-sm"
>
<!-- Warning icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
/>
</svg>
<!-- Message -->
<span class="text-sm font-medium">
Backend not connected. Your data is only stored in this browser.
</span>
<!-- Pending count if any -->
{#if syncState.pendingCount > 0}
<span
class="rounded-full bg-red-500/20 px-2 py-0.5 text-xs font-medium"
>
{syncState.pendingCount} pending
</span>
{/if}
<!-- Dismiss button -->
<button
type="button"
onclick={handleDismiss}
class="ml-1 flex-shrink-0 rounded p-0.5 opacity-70 transition-opacity hover:opacity-100"
aria-label="Dismiss sync warning"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/if}
<style>
@keyframes slide-in-from-top {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-in {
animation: slide-in-from-top 0.3s ease-out;
}
</style>

View File

@@ -17,6 +17,7 @@
import ModelSelect from '$lib/components/layout/ModelSelect.svelte';
import { ToastContainer, ShortcutsModal } from '$lib/components/shared';
import UpdateBanner from '$lib/components/shared/UpdateBanner.svelte';
import SyncWarningBanner from '$lib/components/shared/SyncWarningBanner.svelte';
import type { LayoutData } from './$types';
import type { Snippet } from 'svelte';
@@ -184,5 +185,8 @@
<!-- Update notification banner -->
<UpdateBanner />
<!-- Sync warning banner (shows when backend disconnected) -->
<SyncWarningBanner />
<!-- Keyboard shortcuts help -->
<ShortcutsModal isOpen={showShortcutsModal} onClose={() => (showShortcutsModal = false)} />