Skip to content

Commit 97685d4

Browse files
cto-new[bot]LoneRifle
authored andcommitted
feat(offline): impl service worker, connectivity, UX
Implements offline-mode support, with service worker, connectivity store, and comprehensive UX guards. **1. Service Worker (`src/service-worker.ts`)** - Precaches app shell using SvelteKit's `$service-worker` module - Runtime caching for attachment blobs (GET `/output/[sha256]` - cache-first) - Cache-first for assets, Network-first for API calls - `message` listener for `SKIP_WAITING` support - Old cache cleanup on activation **2. Connectivity Store (`src/lib/stores/isOnline.svelte.ts`)** - Svelte 5 runes store tracking `navigator.onLine` - Listens to `online`/`offline` events **3. Frontend Update Handling (`src/routes/+layout.svelte`)** - New SW waiting detection with "Update Now" notification bar - Posts `SKIP_WAITING` to activate new version - Listens for `controllerchange` to reload **4. Offline UX Enhancements** - Offline banner in layout - ChatInput disabled (textarea, uploads, MCP) with tooltips - Voice recording and send button disabled in ChatWindow - NavMenu "New Chat" visually disabled - NavConversationItem Rename/Delete disabled - UploadedFile triggers fetch for SW caching
1 parent 873fa83 commit 97685d4

10 files changed

Lines changed: 443 additions & 68 deletions

File tree

src/lib/components/NavConversationItem.svelte

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
import EditConversationModal from "$lib/components/EditConversationModal.svelte";
1313
import DeleteConversationModal from "$lib/components/DeleteConversationModal.svelte";
1414
import { requireAuthUser } from "$lib/utils/auth";
15+
import { useIsOnline } from "$lib/stores/isOnline.svelte";
16+
17+
const isOnline = useIsOnline();
1518
1619
interface Props {
1720
conv: ConvSidebar;
@@ -57,7 +60,7 @@
5760

5861
<div
5962
class="group flex h-8 flex-none items-center gap-1.5 rounded-lg pr-1.5 pl-2 text-base text-gray-600 hover:bg-gray-100 max-sm:h-10 sm:text-sm dark:text-gray-300 dark:hover:bg-gray-700
60-
{conv.id === page.params.id ? 'bg-gray-100 dark:bg-gray-700' : ''}"
63+
{conv.id === page.params.id ? 'bg-gray-100 dark:bg-gray-700' : ''}"
6164
>
6265
{#if inlineEditing}
6366
<input
@@ -123,14 +126,26 @@
123126
>
124127
<DropdownMenu.Item
125128
class="flex h-9 items-center gap-2 rounded-md px-2 text-sm text-gray-700 select-none focus-visible:outline-hidden data-highlighted:bg-gray-100 sm:h-8 dark:text-gray-200 dark:data-highlighted:bg-white/10"
126-
onSelect={() => (renameOpen = true)}
129+
disabled={!isOnline.value}
130+
onSelect={() => {
131+
if (!isOnline.value) return;
132+
renameOpen = true;
133+
}}
134+
data-offline={!isOnline.value || undefined}
135+
title={!isOnline.value ? "Rename requires an internet connection" : undefined}
127136
>
128137
<CarbonEdit class="size-4 opacity-90 dark:opacity-80" />
129138
Rename
130139
</DropdownMenu.Item>
131140
<DropdownMenu.Item
132141
class="flex h-9 items-center gap-2 rounded-md px-2 text-sm text-red-500 select-none focus-visible:outline-hidden data-highlighted:bg-red-50 data-highlighted:text-red-600 sm:h-8 dark:text-red-400 dark:data-highlighted:bg-red-500/10 dark:data-highlighted:text-red-400"
133-
onSelect={() => (deleteOpen = true)}
142+
disabled={!isOnline.value}
143+
onSelect={() => {
144+
if (!isOnline.value) return;
145+
deleteOpen = true;
146+
}}
147+
data-offline={!isOnline.value || undefined}
148+
title={!isOnline.value ? "Delete requires an internet connection" : undefined}
134149
>
135150
<CarbonTrashCan class="size-4 opacity-90 dark:opacity-80" />
136151
Delete

src/lib/components/NavMenu.svelte

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@
3232
import { isPro } from "$lib/stores/isPro";
3333
import IconPro from "$lib/components/icons/IconPro.svelte";
3434
import MCPServerManager from "./mcp/MCPServerManager.svelte";
35+
import { useIsOnline } from "$lib/stores/isOnline.svelte";
3536
3637
const publicConfig = usePublicConfig();
3738
const client = useAPIClient();
39+
const isOnline = useIsOnline();
3840
3941
interface Props {
4042
conversations: ConvSidebar[];
@@ -143,7 +145,9 @@
143145
href={`${base}/`}
144146
onclick={handleNewChatClick}
145147
class="flex rounded-lg border bg-white px-2 py-0.5 text-center whitespace-nowrap shadow-xs hover:shadow-none sm:text-smd dark:border-gray-600 dark:bg-gray-700"
146-
title="Ctrl/Cmd + Shift + O"
148+
class:pointer-events-none={!isOnline.value}
149+
class:opacity-50={!isOnline.value}
150+
title={!isOnline.value ? "New chat requires an internet connection" : "Ctrl/Cmd + Shift + O"}
147151
>
148152
New Chat
149153
</a>

src/lib/components/chat/ChatInput.svelte

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
} from "$lib/stores/mcpServers";
2828
import { getMcpServerFaviconUrl } from "$lib/utils/favicon";
2929
import { page } from "$app/state";
30+
import { useIsOnline } from "$lib/stores/isOnline.svelte";
3031
3132
interface Props {
3233
files?: File[];
@@ -61,6 +62,9 @@
6162
onsubmit,
6263
}: Props = $props();
6364
65+
const isOnline = useIsOnline();
66+
let offline = $derived(!isOnline.value);
67+
6468
const onFileChange = async (e: Event) => {
6569
if (!e.target) return;
6670
const target = e.target as HTMLInputElement;
@@ -230,6 +234,9 @@
230234
let selectedServers = $derived(
231235
$allMcpServers.filter((server) => $selectedServerIds.has(server.id))
232236
);
237+
238+
// Effective disabled: combine the prop with offline state
239+
let effectiveDisabled = $derived(disabled || offline);
233240
</script>
234241

235242
<div class="flex min-h-full flex-1 flex-col" onpaste={onPaste}>
@@ -238,14 +245,15 @@
238245
tabindex="0"
239246
inputmode="text"
240247
class="scrollbar-custom max-h-[4lh] w-full resize-none overflow-x-hidden overflow-y-auto border-0 bg-transparent px-2.5 py-2.5 outline-hidden focus:ring-0 focus-visible:ring-0 sm:px-3 md:max-h-[8lh]"
241-
class:text-gray-400={disabled}
248+
class:text-gray-400={effectiveDisabled}
242249
bind:value
243250
bind:this={textareaElement}
244251
onkeydown={handleKeydown}
245252
oncompositionstart={() => (isCompositionOn = true)}
246253
oncompositionend={() => (isCompositionOn = false)}
247254
{placeholder}
248-
{disabled}
255+
disabled={effectiveDisabled}
256+
title={offline ? "You need to be online to send messages" : placeholder}
249257
onfocus={handleFocus}
250258
onblur={handleBlur}
251259
onbeforeinput={requireAuthUser}
@@ -261,7 +269,7 @@
261269
<div class="flex items-center">
262270
<input
263271
bind:this={fileInputEl}
264-
disabled={loading}
272+
disabled={loading || offline}
265273
class="absolute hidden size-0"
266274
aria-label="Upload file"
267275
type="file"
@@ -282,13 +290,18 @@
282290
isDropdownOpen = false;
283291
return;
284292
}
293+
if (open && offline) {
294+
isDropdownOpen = false;
295+
return;
296+
}
285297
isDropdownOpen = open;
286298
}}
287299
>
288300
<DropdownMenu.Trigger
289301
class="btn size-8 rounded-full border bg-white text-black shadow-sm transition-none enabled:hover:bg-white enabled:hover:shadow-inner sm:size-7 dark:border-transparent dark:bg-gray-600/50 dark:text-white dark:hover:enabled:bg-gray-600"
290-
disabled={loading}
302+
disabled={loading || offline}
291303
aria-label="Add attachment"
304+
title={offline ? "Attachments require an internet connection" : "Add attachment"}
292305
>
293306
<IconPlus class="text-base sm:text-sm" />
294307
</DropdownMenu.Trigger>
@@ -425,7 +438,7 @@
425438
class:cursor-help={!modelSupportsTools}
426439
title={modelSupportsTools
427440
? "MCP servers enabled"
428-
: "Current model doesnt support tools"}
441+
: "Current model doesn't support tools"}
429442
>
430443
<button
431444
class="inline-flex cursor-pointer items-center gap-1 bg-transparent p-0 leading-none whitespace-nowrap text-current select-none focus:outline-hidden"
@@ -480,8 +493,6 @@
480493
</div>
481494

482495
<style>
483-
/* In the base layer so utility classes (font-mono, text-xs, prose) keep
484-
winning over these element selectors, as they did before Tailwind v4 */
485496
@layer base {
486497
:global(pre),
487498
:global(textarea) {

src/lib/components/chat/ChatWindow.svelte

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@
6363
isMessageToolResultUpdate,
6464
} from "$lib/utils/messageUpdates";
6565
import type { ToolFront } from "$lib/types/Tool";
66+
import { useIsOnline } from "$lib/stores/isOnline.svelte";
67+
68+
const isOnline = useIsOnline();
6669
6770
interface Props {
6871
messages?: Message[];
@@ -630,11 +633,11 @@
630633
{/if}
631634
{#if canShare}
632635
<!-- Lives in the chat column (not the layout) so it stays visible when
633-
the artifact panel is open -->
636+
the artifact panel is open -->
634637
<button
635638
type="button"
636639
class="hidden size-8 items-center justify-center gap-2 rounded-xl border border-gray-200 bg-white/90 text-sm font-medium text-gray-700 shadow-xs hover:bg-white/60 hover:text-gray-500 md:absolute md:top-5 md:right-6 md:z-10 md:flex dark:border-gray-700 dark:bg-gray-800/80 dark:text-gray-200 dark:hover:bg-gray-700
637-
{loading ? 'cursor-not-allowed opacity-40' : ''}"
640+
{loading ? 'cursor-not-allowed opacity-40' : ''}"
638641
onclick={() => shareModal.open()}
639642
aria-label="Share conversation"
640643
disabled={loading}
@@ -651,7 +654,7 @@
651654
bind:this={chatContainer}
652655
>
653656
<!-- @container: descendants (e.g. the per-message router-metadata row) adapt
654-
to the actual column width, which shrinks when the artifact panel is open -->
657+
to the actual column width, which shrinks when the artifact panel is open -->
655658
<div
656659
class="@container mx-auto flex h-full max-w-3xl flex-col gap-6 px-5 pt-6 sm:gap-8 xl:max-w-4xl xl:pt-10"
657660
>
@@ -722,10 +725,10 @@
722725

723726
<div
724727
class="pointer-events-none absolute inset-x-0 bottom-0 z-0 mx-auto flex w-full
725-
max-w-3xl flex-col items-center justify-center bg-linear-to-t from-white
726-
via-white to-white/0 px-3.5 pt-2 *:pointer-events-auto
727-
max-sm:py-0 sm:px-5
728-
md:pb-4 xl:max-w-4xl dark:border-gray-800 dark:from-gray-900 dark:via-gray-900 dark:to-gray-900/0"
728+
max-w-3xl flex-col items-center justify-center bg-linear-to-t from-white
729+
via-white to-white/0 px-3.5 pt-2 *:pointer-events-auto
730+
max-sm:py-0 sm:px-5
731+
md:pb-4 xl:max-w-4xl dark:border-gray-800 dark:from-gray-900 dark:via-gray-900 dark:to-gray-900/0"
729732
>
730733
{#if !draft.length && !messages.length && !sources.length && !loading && (currentModel.isRouter || (modelSupportsTools && $allBaseServersEnabled)) && activeExamples.length && !hideRouterExamples && !lastIsError && $mcpServersLoaded}
731734
<div
@@ -856,7 +859,10 @@
856859
<button
857860
type="button"
858861
class="absolute right-10 bottom-2 mr-1.5 btn size-8 self-end rounded-full border bg-white/50 text-gray-500 transition-none hover:bg-gray-50 hover:text-gray-700 sm:right-9 sm:size-7 dark:border-transparent dark:bg-gray-600/50 dark:text-gray-300 dark:hover:bg-gray-500 dark:hover:text-white"
859-
disabled={isReadOnly}
862+
disabled={isReadOnly || !isOnline.value}
863+
title={!isOnline.value
864+
? "Voice recording requires an internet connection"
865+
: undefined}
860866
onclick={() => {
861867
isRecording = true;
862868
}}
@@ -867,10 +873,12 @@
867873
{/if}
868874
<button
869875
class="absolute right-2 bottom-2 btn size-8 self-end rounded-full border bg-white text-black shadow transition-none enabled:hover:bg-white enabled:hover:shadow-inner sm:size-7 dark:border-transparent dark:bg-gray-600 dark:text-white dark:hover:enabled:bg-black {!draft ||
870-
isReadOnly
876+
isReadOnly ||
877+
!isOnline.value
871878
? ''
872879
: 'bg-black! text-white! dark:bg-white! dark:text-black!'}"
873-
disabled={!draft || isReadOnly}
880+
disabled={!draft || isReadOnly || !isOnline.value}
881+
title={!isOnline.value ? "You need to be online to send messages" : undefined}
874882
type="submit"
875883
aria-label="Send message"
876884
name="submit"

src/lib/components/chat/UploadedFile.svelte

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@
2424
// Capture URL once at component creation to prevent reactive updates during navigation
2525
let urlNotTrailing = page.url.pathname.replace(/\/$/, "");
2626
27+
// Trigger a fetch to cache attachment blobs in the service worker
28+
$effect(() => {
29+
if (
30+
file.type === "hash" &&
31+
"serviceWorker" in navigator &&
32+
navigator.serviceWorker.controller
33+
) {
34+
const url = urlNotTrailing + "/output/" + file.value;
35+
fetch(url).catch(() => {
36+
// Silent - the SW will cache on success, offline fallback is fine
37+
});
38+
}
39+
});
40+
2741
function truncateMiddle(text: string, maxLength: number): string {
2842
if (text.length <= maxLength) {
2943
return text;

src/lib/stores/isOnline.svelte.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* Reactive connectivity store using Svelte 5 runes.
3+
*
4+
* `navigator.onLine` is only reliable when it reports `false` (definitely
5+
* offline); a `true` value merely means a network interface exists, not that
6+
* requests actually reach the server. It also fails to flip under DevTools'
7+
* Network "Offline" emulation once a service worker is controlling the page.
8+
*
9+
* So we probe once at startup to establish ground truth past that unreliable
10+
* initial value, then drive off the `online`/`offline` events (which fire
11+
* reliably on real transitions). The probe is a lightweight fetch to the
12+
* same-origin `/healthcheck` endpoint; the service worker serves that route
13+
* network-first, so it reflects real reachability (200 online, 503/throw
14+
* offline).
15+
*/
16+
import { browser } from "$app/environment";
17+
import { base } from "$app/paths";
18+
19+
const PROBE_TIMEOUT_MS = 5_000;
20+
21+
class IsOnlineStore {
22+
#online = $state<boolean>(browser ? navigator.onLine : true);
23+
#probing = false;
24+
25+
constructor() {
26+
if (!browser) return;
27+
28+
// `offline` is trustworthy on its own; react immediately.
29+
window.addEventListener("offline", () => {
30+
this.#online = false;
31+
});
32+
// `online` only hints that connectivity *might* be back — verify it.
33+
window.addEventListener("online", () => void this.probe());
34+
35+
// Re-check when the tab regains focus, in case state changed while hidden.
36+
document.addEventListener("visibilitychange", () => {
37+
if (document.visibilityState === "visible") void this.probe();
38+
});
39+
40+
// One probe at startup to correct navigator.onLine's unreliable initial
41+
// value; transitions after that come from the events above.
42+
void this.probe();
43+
}
44+
45+
/** Actively verify reachability by hitting the same-origin healthcheck. */
46+
async probe(): Promise<void> {
47+
if (!browser || this.#probing) return;
48+
49+
// `navigator.onLine === false` is a reliable offline signal — skip the
50+
// round-trip and avoid a doomed fetch.
51+
if (!navigator.onLine) {
52+
this.#online = false;
53+
return;
54+
}
55+
56+
this.#probing = true;
57+
const controller = new AbortController();
58+
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
59+
try {
60+
const res = await fetch(`${base}/healthcheck`, {
61+
method: "GET",
62+
cache: "no-store",
63+
signal: controller.signal,
64+
});
65+
this.#online = res.ok;
66+
} catch {
67+
this.#online = false;
68+
} finally {
69+
clearTimeout(timeout);
70+
this.#probing = false;
71+
}
72+
}
73+
74+
get value(): boolean {
75+
return this.#online;
76+
}
77+
}
78+
79+
let store: IsOnlineStore | undefined;
80+
81+
export function createIsOnlineStore(): IsOnlineStore {
82+
if (!store) {
83+
store = new IsOnlineStore();
84+
}
85+
return store;
86+
}
87+
88+
export function useIsOnline(): IsOnlineStore {
89+
if (!store) {
90+
store = new IsOnlineStore();
91+
}
92+
return store;
93+
}

0 commit comments

Comments
 (0)