('open-settings', (event) => {
+ handler(event.payload);
+ });
+}
+
+
diff --git a/src/app/generated/tauri/index.ts b/src/app/generated/tauri/index.ts
index 5b327d1d8e..1552c71e1f 100644
--- a/src/app/generated/tauri/index.ts
+++ b/src/app/generated/tauri/index.ts
@@ -1,7 +1,7 @@
/**
* Auto-generated TypeScript bindings for Tauri commands
* Generated by tauri-typegen v0.5.0
- * Generated at: 2026-07-20T19:29:46.118855830+00:00
+ * Generated at: 2026-07-20T21:19:31.191156200+00:00
* Generator: none
*
* Do not edit manually - regenerate using: cargo tauri-typegen generate
@@ -9,3 +9,4 @@
export * from './types';
export * from './commands';
+export * from './events';
diff --git a/src/app/generated/tauri/types.ts b/src/app/generated/tauri/types.ts
index 36d349b021..8589d12658 100644
--- a/src/app/generated/tauri/types.ts
+++ b/src/app/generated/tauri/types.ts
@@ -1,7 +1,7 @@
/**
* Auto-generated TypeScript bindings for Tauri commands
* Generated by tauri-typegen v0.5.0
- * Generated at: 2026-07-20T19:29:46.117694182+00:00
+ * Generated at: 2026-07-20T21:19:31.188681500+00:00
* Generator: none
*
* Do not edit manually - regenerate using: cargo tauri-typegen generate
@@ -60,6 +60,11 @@ export interface AbortNativeUploadParams {
[key: string]: unknown;
}
+export interface HapticFeedbackParams {
+ style: string;
+ [key: string]: unknown;
+}
+
export interface LoopbackFetchParams {
request: LoopbackFetchRequest;
[key: string]: unknown;
diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx
index d1ffe04883..35fffbdace 100644
--- a/src/app/pages/Router.tsx
+++ b/src/app/pages/Router.tsx
@@ -27,6 +27,7 @@ import { getLocalStorageItem } from '$state/utils/atomWithLocalStorage';
import { NotificationJumper } from '$hooks/useNotificationJumper';
import { SearchModalRenderer } from '$features/navigate';
import { GlobalKeyboardShortcuts } from '$components/GlobalKeyboardShortcuts';
+import { DesktopShortcuts } from '$components/tauri/DesktopShortcuts';
import { CallEmbedProvider } from '$components/CallEmbedProvider';
import { SplashScreen } from '$components/splash-screen';
import {
@@ -257,6 +258,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
+
{/* Screen reader live region — populated by announce() in utils/announce.ts */}
({
current: null,
@@ -80,53 +83,25 @@ export function SyncStatus({ mx }: SyncStatusProps) {
}, [])
);
- if (stateData.showConnecting) {
- return (
-
-
- Connecting...
-
-
-
- );
- }
+ const view = useMemo(() => {
+ if (stateData.showConnecting) return { text: 'Connecting...', variant: 'Success' };
+ if (showDisconnected && stateData.current === SyncState.Reconnecting) {
+ return { text: 'Connection Lost! Reconnecting...', variant: 'Warning' };
+ }
+ if (showDisconnected && stateData.current === SyncState.Error) {
+ return { text: 'Connection Lost!', variant: 'Critical' };
+ }
+ return null;
+ }, [stateData, showDisconnected]);
- if (showDisconnected && stateData.current === SyncState.Reconnecting) {
- return (
-
-
- Connection Lost! Reconnecting...
-
-
-
- );
- }
+ // Publish to the atom that feeds the custom desktop titlebar's status pill.
+ useEffect(() => {
+ setTitlebarStatus(view);
+ return () => setTitlebarStatus(null);
+ }, [setTitlebarStatus, view]);
- if (showDisconnected && stateData.current === SyncState.Error) {
- return (
-
-
- Connection Lost!
-
-
-
- );
- }
+ // Where a custom titlebar renders the pill, skip the inline banner.
+ if (hasCustomDesktopTitlebar()) return null;
- return null;
+ return ;
}
diff --git a/src/app/styles/edgeToEdgeInsets.test.ts b/src/app/styles/edgeToEdgeInsets.test.ts
index a5ef0e2df6..2afcca8452 100644
--- a/src/app/styles/edgeToEdgeInsets.test.ts
+++ b/src/app/styles/edgeToEdgeInsets.test.ts
@@ -47,7 +47,7 @@ describe('android edge-to-edge inset contract', () => {
const systemBarShell = readWorkspaceFile('src/app/components/app-shell/SystemBarShell.tsx');
const mobileCapability = readWorkspaceFile('src-tauri/capabilities/mobile.json');
- expect(appShell).toContain('const contentHeight = useCustomWindowsTitleBar');
+ expect(appShell).toContain('const contentHeight = hasCustomTitleBar');
expect(appShell).toContain("height: '100%'");
expect(appShell).toContain('height: contentHeight');
expect(appShell).toContain('');
diff --git a/src/app/styles/overrides/TauriDesktop.css b/src/app/styles/overrides/TauriDesktop.css
index f09f856e0b..9d4a9a4090 100644
--- a/src/app/styles/overrides/TauriDesktop.css
+++ b/src/app/styles/overrides/TauriDesktop.css
@@ -20,6 +20,16 @@
-webkit-app-region: drag;
}
+/* macOS keeps native traffic lights (transparent titlebar); this is just a
+ draggable strip that reserves space for them and hosts the status pill. */
+.tauri-titlebar--mac {
+ display: block;
+ grid-template-columns: none;
+ padding-left: 72px;
+ app-region: drag;
+ -webkit-app-region: drag;
+}
+
.tauri-titlebar__drag {
display: flex;
align-items: center;
diff --git a/src/app/utils/androidBack.ts b/src/app/utils/androidBack.ts
new file mode 100644
index 0000000000..7f6b96d771
--- /dev/null
+++ b/src/app/utils/androidBack.ts
@@ -0,0 +1,53 @@
+import { useEffect, useRef } from 'react';
+import { isTauri } from '@tauri-apps/api/core';
+import { type as osType } from '@tauri-apps/plugin-os';
+
+// Returns true if it consumed the back event (e.g. closed an overlay).
+export type AndroidBackHandler = () => boolean;
+
+const handlers: AndroidBackHandler[] = [];
+
+export function pushAndroidBackHandler(handler: AndroidBackHandler): () => void {
+ handlers.push(handler);
+ return () => {
+ const index = handlers.lastIndexOf(handler);
+ if (index !== -1) handlers.splice(index, 1);
+ };
+}
+
+function routerBack(): boolean {
+ const idx = (window.history.state as { idx?: number } | null)?.idx ?? 0;
+ if (idx > 0) {
+ window.history.back();
+ return true;
+ }
+ return false;
+}
+
+function runAndroidBack(): boolean {
+ for (let i = handlers.length - 1; i >= 0; i -= 1) {
+ if (handlers[i]?.()) return true;
+ }
+ return routerBack();
+}
+
+declare global {
+ interface Window {
+ __sableAndroidBack?: () => boolean;
+ }
+}
+
+export function installAndroidBackBridge(): void {
+ if (!isTauri() || osType() !== 'android') return;
+ window.__sableAndroidBack = runAndroidBack;
+}
+
+export function useAndroidBackHandler(handler: AndroidBackHandler, enabled = true): void {
+ const handlerRef = useRef(handler);
+ handlerRef.current = handler;
+
+ useEffect(() => {
+ if (!enabled) return undefined;
+ return pushAndroidBackHandler(() => handlerRef.current());
+ }, [enabled]);
+}
diff --git a/src/app/utils/haptics.ts b/src/app/utils/haptics.ts
new file mode 100644
index 0000000000..460b3172e3
--- /dev/null
+++ b/src/app/utils/haptics.ts
@@ -0,0 +1,21 @@
+import { invoke, isTauri } from '@tauri-apps/api/core';
+import { type as osType } from '@tauri-apps/plugin-os';
+
+export type HapticKind = 'light' | 'medium' | 'heavy' | 'selection';
+
+const ANDROID_MS: Record = {
+ light: 8,
+ medium: 12,
+ heavy: 18,
+ selection: 5,
+};
+
+export function haptic(kind: HapticKind = 'light'): void {
+ if (!isTauri()) return;
+ const os = osType();
+ if (os === 'android') {
+ navigator.vibrate?.(ANDROID_MS[kind]);
+ } else if (os === 'ios') {
+ invoke('haptic_feedback', { style: kind }).catch(() => {});
+ }
+}
diff --git a/src/app/utils/tauriNative.ts b/src/app/utils/tauriNative.ts
new file mode 100644
index 0000000000..94e962256b
--- /dev/null
+++ b/src/app/utils/tauriNative.ts
@@ -0,0 +1,58 @@
+import { isTauri } from '@tauri-apps/api/core';
+import { type as osType } from '@tauri-apps/plugin-os';
+
+const KEYBOARD_HEIGHT = '--keyboard-height';
+const DESKTOP_OS = new Set(['linux', 'macos', 'windows']);
+const EDITABLE_SELECTOR = 'input, textarea, [contenteditable="true"], [contenteditable=""]';
+
+function installIosKeyboardInset(): () => void {
+ let frame = 0;
+
+ const update = () => {
+ frame = 0;
+ const viewport = window.visualViewport;
+ const height = viewport ? window.innerHeight - viewport.height - viewport.offsetTop : 0;
+ document.documentElement.style.setProperty(
+ KEYBOARD_HEIGHT,
+ `${Math.max(0, Math.round(height))}px`
+ );
+ };
+
+ const schedule = () => {
+ if (frame) cancelAnimationFrame(frame);
+ frame = requestAnimationFrame(update);
+ };
+
+ update();
+ window.visualViewport?.addEventListener('resize', schedule);
+ window.visualViewport?.addEventListener('scroll', schedule);
+ window.addEventListener('orientationchange', schedule);
+
+ return () => {
+ if (frame) cancelAnimationFrame(frame);
+ window.visualViewport?.removeEventListener('resize', schedule);
+ window.visualViewport?.removeEventListener('scroll', schedule);
+ window.removeEventListener('orientationchange', schedule);
+ };
+}
+
+// Suppress the webview's own context menu except on editable fields, where the
+// native paste/spellcheck menu is expected.
+function installDesktopContextMenuSuppression(): () => void {
+ const onContextMenu = (event: MouseEvent) => {
+ const target = event.target as HTMLElement | null;
+ if (target?.closest(EDITABLE_SELECTOR)) return;
+ event.preventDefault();
+ };
+
+ document.addEventListener('contextmenu', onContextMenu);
+ return () => document.removeEventListener('contextmenu', onContextMenu);
+}
+
+export function installTauriNativeBehaviors(): void {
+ if (!isTauri()) return;
+
+ const os = osType();
+ if (os === 'ios') installIosKeyboardInset();
+ if (DESKTOP_OS.has(os)) installDesktopContextMenuSuppression();
+}
diff --git a/src/app/utils/tauriTitlebar.ts b/src/app/utils/tauriTitlebar.ts
new file mode 100644
index 0000000000..7167df47aa
--- /dev/null
+++ b/src/app/utils/tauriTitlebar.ts
@@ -0,0 +1,10 @@
+import { isTauri } from '@tauri-apps/api/core';
+import { type as osType } from '@tauri-apps/plugin-os';
+
+// Windows and Linux draw their own titlebar with a slot for the sync pill; macOS
+// keeps native decorations and uses the inline banner like web/mobile.
+export function hasCustomDesktopTitlebar(): boolean {
+ if (!isTauri()) return false;
+ const os = osType();
+ return os === 'windows' || os === 'linux';
+}
diff --git a/src/index.tsx b/src/index.tsx
index 036d41cbbe..cc5c5ec8df 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -24,6 +24,8 @@ import { initTauriMediaSession } from './app/utils/tauriMediaAuth';
import { registerAppServiceWorker } from './serviceWorkerBootstrap';
import { hasServiceWorker } from './app/utils/platform';
import { installIosPwaViewportHeight } from './app/utils/iosPwaViewport';
+import { installTauriNativeBehaviors } from './app/utils/tauriNative';
+import { installAndroidBackBridge } from './app/utils/androidBack';
enableMapSet();
installConsolePasteScamWarning();
@@ -32,6 +34,8 @@ const log = createLogger('index');
document.body.classList.add(configClass, varsClass);
installIosPwaViewportHeight();
+installTauriNativeBehaviors();
+installAndroidBackBridge();
registerAppServiceWorker();