Skip to content

Commit 0e50d68

Browse files
wesmclaude
andauthored
Add zoom controls and fix Tauri desktop dev navigation (#135)
## Summary - Add frontend zoom controls for the Tauri desktop build (Cmd+/Cmd-/Cmd+0 hotkeys and status bar buttons) - Fix Tauri navigation guard race that caused the app to open in Chrome instead of the webview ## Changes **Zoom controls** (`b8eded7`): - Add `zoomLevel` state to UIStore with localStorage persistence and CSS `zoom` property application - Handle Cmd+=, Cmd+-, Cmd+0 in the keyboard shortcut handler - Add `[-] 100% [+]` controls to the status bar (click percentage to reset) - Zoom steps: 67%, 75%, 80%, 90%, 100%, 110%, 125%, 150%, 175%, 200% **Tauri navigation fix** (`a6d1329`): - Switch the backend redirect from `window.eval("window.location.replace(...)")` to Tauri's native `window.navigate()` API **Review fixes** (`9ad264f`): - Gate zoom controls, keyboard shortcuts, and CSS zoom effect behind desktop detection (`?desktop` query param) so browser builds keep native Cmd+/- zoom - Tighten navigation guard to only allow the known sidecar port on `127.0.0.1` instead of any localhost port — reads `backend_port` from `SidecarState` at navigation time ## Test plan - [ ] Run `make desktop-dev` and verify the app loads inside the Tauri window (not Chrome) - [ ] Press Cmd+= / Cmd+- to zoom in/out; verify the UI scales - [ ] Press Cmd+0 to reset zoom to 100% - [ ] Click the `[-]` / `[+]` / percentage buttons in the status bar - [ ] Verify zoom level persists across app restarts - [ ] Verify the `?` shortcuts modal lists the zoom shortcuts - [ ] Open the web build (not desktop) and verify Cmd+/- uses native browser zoom, no zoom controls in status bar - [ ] Run `make test` to verify Go tests pass - [ ] Run `cargo test` in `desktop/src-tauri/` to verify Rust tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5a68ede commit 0e50d68

File tree

9 files changed

+230
-22
lines changed

9 files changed

+230
-22
lines changed

desktop/package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
55
"type": "module",
66
"scripts": {
77
"prepare-sidecar": "bash ./scripts/prepare-sidecar.sh",
8-
"tauri:dev": "npm run prepare-sidecar && tauri dev",
9-
"tauri:build": "npm run prepare-sidecar && tauri build",
10-
"tauri:build:macos-app": "npm run prepare-sidecar && tauri build --bundles app",
11-
"tauri:build:macos-dmg": "npm run prepare-sidecar && tauri build --bundles dmg",
12-
"tauri:build:windows": "npm run prepare-sidecar && tauri build --bundles nsis",
8+
"tauri:dev": "npm run prepare-sidecar && bash ./scripts/run-tauri.sh dev",
9+
"tauri:build": "npm run prepare-sidecar && bash ./scripts/run-tauri.sh build",
10+
"tauri:build:macos-app": "npm run prepare-sidecar && bash ./scripts/run-tauri.sh build --bundles app",
11+
"tauri:build:macos-dmg": "npm run prepare-sidecar && bash ./scripts/run-tauri.sh build --bundles dmg",
12+
"tauri:build:windows": "npm run prepare-sidecar && bash ./scripts/run-tauri.sh build --bundles nsis",
1313
"tauri": "tauri"
1414
},
1515
"devDependencies": {

desktop/scripts/prepare-sidecar.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ patch_tauri_version() {
108108
return 0
109109
fi
110110
local conf="$TAURI_DIR/src-tauri/tauri.conf.json"
111+
# Save original only if not already saved (handles re-run
112+
# after a previous failure left the backup behind).
113+
if [ ! -f "$conf.orig" ]; then
114+
cp "$conf" "$conf.orig"
115+
fi
111116
sed -i.bak \
112117
"s/\"version\": \"[^\"]*\"/\"version\": \"$semver\"/" \
113118
"$conf"

desktop/scripts/run-tauri.sh

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# Wrapper that restores tauri.conf.json after `tauri` exits,
5+
# undoing the version patch applied by prepare-sidecar.sh.
6+
# Uses the .orig backup instead of git checkout to preserve
7+
# any pre-existing uncommitted edits.
8+
9+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10+
CONF="$SCRIPT_DIR/../src-tauri/tauri.conf.json"
11+
12+
cleanup() {
13+
if [ -f "$CONF.orig" ]; then
14+
mv "$CONF.orig" "$CONF"
15+
fi
16+
}
17+
trap cleanup EXIT INT TERM
18+
19+
tauri "$@"

desktop/src-tauri/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,6 @@
77
# Generated by Tauri
88
# will have schema files for capabilities auto-completion
99
/gen/schemas
10+
11+
# Backup created by prepare-sidecar.sh version patching
12+
tauri.conf.json.orig

desktop/src-tauri/src/lib.rs

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,10 @@ fn init_navigation_guard_plugin<R: tauri::Runtime>() -> tauri::plugin::TauriPlug
120120
.on_navigation(|webview, url| {
121121
let backend_port = webview
122122
.app_handle()
123-
.state::<SidecarState>()
124-
.backend_port
125-
.lock()
126-
.ok()
127-
.and_then(|guard| *guard);
123+
.try_state::<SidecarState>()
124+
.and_then(|state| {
125+
state.backend_port.lock().ok().and_then(|g| *g)
126+
});
128127
if is_allowed_navigation_url(url, backend_port) {
129128
return true;
130129
}
@@ -151,13 +150,15 @@ fn is_allowed_navigation_url(url: &Url, backend_port: Option<u16>) -> bool {
151150
if url.scheme() == "tauri" && url.host_str() == Some("localhost") {
152151
return true;
153152
}
154-
if url.scheme() != "http" || url.host_str() != Some(HOST) {
155-
return false;
153+
// Only allow navigation to the known sidecar port on
154+
// localhost. Rejects all localhost URLs when the sidecar
155+
// port is not yet known.
156+
if let Some(port) = backend_port {
157+
return url.scheme() == "http"
158+
&& url.host_str() == Some(HOST)
159+
&& url.port() == Some(port);
156160
}
157-
matches!(
158-
(url.port(), backend_port),
159-
(Some(navigated_port), Some(sidecar_port)) if navigated_port == sidecar_port
160-
)
161+
false
161162
}
162163

163164
fn is_allowed_external_open_url(url: &Url) -> bool {
@@ -531,13 +532,22 @@ fn redirect_when_ready(window: WebviewWindow, port: u16) {
531532

532533
thread::spawn(move || {
533534
if wait_for_server(port, READY_TIMEOUT) {
534-
let script = format!("window.location.replace({target_url:?});");
535-
let _ = window.eval(&script);
535+
match Url::parse(target_url.as_str()) {
536+
Ok(url) => {
537+
if let Err(err) = window.navigate(url) {
538+
eprintln!("[agentsview] navigate failed: {err}");
539+
}
540+
}
541+
Err(err) => {
542+
eprintln!("[agentsview] invalid redirect URL: {err}");
543+
}
544+
}
536545
return;
537546
}
538547

539548
let _ = window.eval(
540-
"document.getElementById('status').textContent = 'AgentsView backend did not start within 30 seconds.';",
549+
"document.getElementById('status').textContent = \
550+
'AgentsView backend did not start within 30 seconds.';",
541551
);
542552
});
543553
}
@@ -903,12 +913,17 @@ mod tests {
903913
fn is_allowed_navigation_url_allows_local_only() {
904914
let tauri_url = Url::parse("tauri://localhost/index.html").expect("valid tauri url");
905915
assert!(is_allowed_navigation_url(&tauri_url, None));
916+
assert!(is_allowed_navigation_url(&tauri_url, Some(18080)));
906917

907918
let local_backend = Url::parse("http://127.0.0.1:18080/").expect("valid localhost url");
908919
assert!(is_allowed_navigation_url(&local_backend, Some(18080)));
909-
assert!(!is_allowed_navigation_url(&local_backend, Some(19090)));
920+
921+
// Reject when port is unknown
910922
assert!(!is_allowed_navigation_url(&local_backend, None));
911923

924+
// Reject when port doesn't match
925+
assert!(!is_allowed_navigation_url(&local_backend, Some(9999)));
926+
912927
let remote = Url::parse("https://example.com/").expect("valid remote url");
913928
assert!(!is_allowed_navigation_url(&remote, Some(18080)));
914929

frontend/src/lib/components/layout/StatusBar.svelte

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import { ui } from "../../stores/ui.svelte.js";
44
import { formatNumber, formatRelativeTime } from "../../utils/format.js";
55
6+
const isMac = navigator.platform.toUpperCase().includes("MAC");
7+
const mod = isMac ? "Cmd" : "Ctrl";
8+
69
let progressText = $derived.by(() => {
710
if (!sync.syncing || !sync.progress) return null;
811
const p = sync.progress;
@@ -31,6 +34,34 @@
3134
</div>
3235

3336
<div class="status-right">
37+
{#if sync.isDesktop}
38+
<div class="zoom-controls">
39+
<button
40+
class="zoom-btn"
41+
onclick={() => ui.zoomOut()}
42+
disabled={ui.zoomLevel <= 67}
43+
title="Zoom out ({mod}+-)"
44+
>
45+
&minus;
46+
</button>
47+
<button
48+
class="zoom-level"
49+
onclick={() => ui.resetZoom()}
50+
title="Reset zoom ({mod}+0)"
51+
>
52+
{ui.zoomLevel}%
53+
</button>
54+
<button
55+
class="zoom-btn"
56+
onclick={() => ui.zoomIn()}
57+
disabled={ui.zoomLevel >= 200}
58+
title="Zoom in ({mod}++)"
59+
>
60+
+
61+
</button>
62+
</div>
63+
<span class="sep">&middot;</span>
64+
{/if}
3465
{#if sync.updateAvailable && !sync.isDesktop}
3566
<button
3667
class="update-available"
@@ -137,4 +168,43 @@
137168
.version:hover {
138169
color: var(--text-secondary);
139170
}
171+
172+
.zoom-controls {
173+
display: flex;
174+
align-items: center;
175+
gap: 1px;
176+
}
177+
178+
.zoom-btn {
179+
width: 18px;
180+
height: 16px;
181+
display: flex;
182+
align-items: center;
183+
justify-content: center;
184+
font-size: 11px;
185+
font-weight: 500;
186+
color: var(--text-muted);
187+
border-radius: var(--radius-sm);
188+
line-height: 1;
189+
}
190+
191+
.zoom-btn:hover:not(:disabled) {
192+
background: var(--bg-surface-hover);
193+
color: var(--text-primary);
194+
}
195+
196+
.zoom-level {
197+
font-family: var(--font-mono);
198+
font-size: 10px;
199+
color: var(--text-muted);
200+
padding: 0 2px;
201+
min-width: 32px;
202+
text-align: center;
203+
border-radius: var(--radius-sm);
204+
}
205+
206+
.zoom-level:hover {
207+
background: var(--bg-surface-hover);
208+
color: var(--text-secondary);
209+
}
140210
</style>

frontend/src/lib/components/modals/ShortcutsModal.svelte

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
<script lang="ts">
22
import { ui } from "../../stores/ui.svelte.js";
3+
import { sync } from "../../stores/sync.svelte.js";
34
4-
const shortcuts = [
5-
{ key: "Cmd+K", action: "Open command palette" },
5+
const isMac = navigator.platform.toUpperCase().includes("MAC");
6+
const mod = isMac ? "Cmd" : "Ctrl";
7+
8+
const baseShortcuts = [
9+
{ key: `${mod}+K`, action: "Open command palette" },
610
{ key: "Esc", action: "Close palette / modal" },
711
{ key: "j / \u2193", action: "Next message" },
812
{ key: "k / \u2191", action: "Previous message" },
@@ -18,6 +22,16 @@
1822
{ key: "?", action: "Show this modal" },
1923
];
2024
25+
const zoomShortcuts = [
26+
{ key: `${mod}++`, action: "Zoom in" },
27+
{ key: `${mod}+-`, action: "Zoom out" },
28+
{ key: `${mod}+0`, action: "Reset zoom" },
29+
];
30+
31+
const shortcuts = sync.isDesktop
32+
? [...baseShortcuts, ...zoomShortcuts]
33+
: baseShortcuts;
34+
2135
function handleOverlayClick(e: MouseEvent) {
2236
if (
2337
(e.target as HTMLElement).classList.contains(

frontend/src/lib/stores/ui.svelte.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,32 @@ function readBlockFilters(): Set<BlockType> {
4747
}
4848

4949
const LAYOUT_KEY = "agentsview-message-layout";
50+
const ZOOM_KEY = "agentsview-zoom-level";
51+
52+
const IS_DESKTOP =
53+
typeof window !== "undefined" &&
54+
new URLSearchParams(window.location.search).has(
55+
"desktop",
56+
);
57+
58+
const ZOOM_STEPS = [
59+
67, 75, 80, 90, 100, 110, 125, 150, 175, 200,
60+
];
61+
const ZOOM_DEFAULT = 100;
62+
63+
function readStoredZoom(): number {
64+
if (!IS_DESKTOP) return ZOOM_DEFAULT;
65+
try {
66+
const raw = localStorage?.getItem(ZOOM_KEY);
67+
if (raw) {
68+
const val = Number(raw);
69+
if (ZOOM_STEPS.includes(val)) return val;
70+
}
71+
} catch {
72+
// ignore
73+
}
74+
return ZOOM_DEFAULT;
75+
}
5076
const VALID_LAYOUTS: MessageLayout[] = [
5177
"default",
5278
"compact",
@@ -87,6 +113,8 @@ class UIStore {
87113
pendingScrollOrdinal: number | null = $state(null);
88114
pendingScrollSession: string | null = $state(null);
89115

116+
zoomLevel: number = $state(readStoredZoom());
117+
90118
/** Set of block types currently visible. */
91119
visibleBlocks: Set<BlockType> = $state(readBlockFilters());
92120

@@ -118,6 +146,23 @@ class UIStore {
118146
// ignore
119147
}
120148
});
149+
150+
$effect(() => {
151+
if (!IS_DESKTOP) return;
152+
// "zoom" is non-standard but supported in WebKit/Chromium
153+
(
154+
document.documentElement.style as unknown as
155+
Record<string, string>
156+
).zoom = String(this.zoomLevel / 100);
157+
try {
158+
localStorage?.setItem(
159+
ZOOM_KEY,
160+
String(this.zoomLevel),
161+
);
162+
} catch {
163+
// ignore
164+
}
165+
});
121166
});
122167

123168
// Allow parent windows to control theme via postMessage
@@ -216,6 +261,24 @@ class UIStore {
216261
this.pendingScrollSession = sessionId ?? null;
217262
}
218263

264+
zoomIn() {
265+
const idx = ZOOM_STEPS.indexOf(this.zoomLevel);
266+
if (idx < ZOOM_STEPS.length - 1) {
267+
this.zoomLevel = ZOOM_STEPS[idx + 1]!;
268+
}
269+
}
270+
271+
zoomOut() {
272+
const idx = ZOOM_STEPS.indexOf(this.zoomLevel);
273+
if (idx > 0) {
274+
this.zoomLevel = ZOOM_STEPS[idx - 1]!;
275+
}
276+
}
277+
278+
resetZoom() {
279+
this.zoomLevel = ZOOM_DEFAULT;
280+
}
281+
219282
closeAll() {
220283
this.activeModal = null;
221284
}

frontend/src/lib/utils/keyboard.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,25 @@ export function registerShortcuts(
5555
return;
5656
}
5757

58+
// Zoom: Cmd+= / Cmd+- / Cmd+0 (desktop only)
59+
if (sync.isDesktop) {
60+
if (meta && (e.key === "=" || e.key === "+")) {
61+
e.preventDefault();
62+
ui.zoomIn();
63+
return;
64+
}
65+
if (meta && e.key === "-") {
66+
e.preventDefault();
67+
ui.zoomOut();
68+
return;
69+
}
70+
if (meta && e.key === "0") {
71+
e.preventDefault();
72+
ui.resetZoom();
73+
return;
74+
}
75+
}
76+
5877
// Esc — always works
5978
if (e.key === "Escape") {
6079
handleEscape();

0 commit comments

Comments
 (0)