Skip to content

Commit e77c8b4

Browse files
committed
Merge mobile-viewport-browser-ux into main
2 parents 3aca03b + ecced1c commit e77c8b4

10 files changed

Lines changed: 1082 additions & 19 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ This terminal runs inside Codemux. Check: `test -n "$CODEMUX"`
4040
- `codemux browser click "<selector>"` — click an element
4141
- `codemux browser fill "<selector>" "<text>"` — type into input
4242
- `codemux browser screenshot` — capture screenshot
43+
- `codemux browser viewport <mobile|tablet|desktop|...|WxH|reset>` — resize viewport for responsive testing (CSS media queries fire at the new width); use this instead of iframe-wrapping the page for mobile preview. `codemux browser viewport-presets` lists available presets.
4344

4445
Always get a snapshot before interacting so you know what elements exist.
4546

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ This terminal runs inside Codemux. Check: `test -n "$CODEMUX"`
3939
- `codemux browser click "<selector>"` — click an element
4040
- `codemux browser fill "<selector>" "<text>"` — type into input
4141
- `codemux browser screenshot` — capture screenshot
42+
- `codemux browser viewport <mobile|tablet|desktop|...|WxH|reset>` — resize viewport for responsive testing (CSS media queries fire at the new width); `codemux browser viewport-presets` lists available presets
4243

4344
Always get a snapshot before interacting so you know what elements exist.
4445

docs/core/STATUS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ The repo structure is clean and domain-split:
4646
- Neutral dark shell theming with Omarchy accent sync
4747
- Sans-serif shell chrome, monospace terminals
4848
- Built-in file editor with CodeMirror, syntax highlighting, and markdown preview
49-
- MCP server exposing 29 tools via JSON-RPC 2.0 (browser, workspace, pane, git, notification)
49+
- MCP server exposing 31 tools via JSON-RPC 2.0 (browser, workspace, pane, git, notification, viewport presets)
5050
- Cross-provider MCP server runtime (Claude-side): Codemux hosts user-installed MCP servers, discovers configs across Codemux / Claude / Cursor paths, spawns each child once, dedupes identical configs, exposes tools to the Claude SDK via an in-process facade with dynamic `setMcpServers` refresh. Settings panel and `+` popup both surface enable/disable + status badges + tool list modal + 50-tool cap warning. Codex MCP support planned for Step 11 via HTTP gateway (see `docs/plans/step-9-codex-mcp-spike.md`).
5151
- Session persistence: terminal scrollback saved/restored across restarts, adapter-based resume for CLI tools (Claude Code)
5252
- Multi-provider chat (Step 12, Stages 1-9 shipped): the chat composer's model picker drives session creation across Claude + Codex + OpenCode in one unified popover (provider rail + searchable model list). OpenCode federates 100+ upstream providers behind a single rail entry; only the connected upstreams surface (filtered at the data layer). New OpenCode adapter is Rust-direct-HTTP against a managed `opencode serve` child (`kill_on_drop`, generated `OPENCODE_SERVER_PASSWORD`). `ChatModelInfo.sub_provider` threads upstream provider id through the harvest so federated models render with `OpenCode · {sub_provider}` subtitles + namespaced `${provider_id}/${model_id}` slugs. Codex finally selectable in the GUI (was hidden behind a stale `ENABLE_PROVIDER_PICKER` flag pre-Step-12). Favorites persist via zustand + `localStorage` and bubble to the top of search results across all surfaces. Live-tested against OpenCode 1.14.31 (116 providers / 4,354 models). Multi-instance per provider + picker keyboard shortcuts deferred to v2; OpenFlow capabilities convergence tracked as future cleanup. See `docs/features/multi-provider-chat.md`.
@@ -61,6 +61,7 @@ The repo structure is clean and domain-split:
6161
- OpenFlow: orchestration works but large-run reliability and intervention flow still maturing
6262
- AI merge resolver: backend and frontend working, needs testing depth and live validation
6363
- Browser automation depth: DOM commands, coordinate commands, and OS-level input work; wait conditions and JS evaluation added in v0.24.0
64+
- Browser viewport presets: `codemux browser viewport <mobile|tablet|desktop|WxH|reset>` resizes the actual viewport via CDP so CSS media queries fire and screenshots capture at the simulated dimensions. Names are size buckets (not specific device models) so they don't go stale across hardware refreshes. MCP exposes `browser_viewport` + `browser_viewport_presets`. Replaces the older "wrap the page in a 375px iframe" workaround.
6465

6566
## Known Constraints
6667

docs/features/browser.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ The browser pane uses a screenshot-driven Chromium session backed by `agent-brow
2323
- dynamic stream ports (9223-9299) for concurrent workspace browsers
2424
- browser data management in Settings (clear cookies, clear all data, view data size)
2525
- inspector panel for debugging web content
26+
- viewport presets for mobile / tablet / desktop responsive testing via `codemux browser viewport <preset>` (e.g. `mobile`, `tablet`, `desktop-large`, `reset`) or custom `WxH` dimensions — applies real viewport resize + DPR through CDP, so CSS media queries fire and screenshots capture at the simulated dimensions (replaces the older iframe trick)
2627

2728
## Expected Operating Model
2829

@@ -45,8 +46,11 @@ The browser pane uses a screenshot-driven Chromium session backed by `agent-brow
4546

4647
## Important Touch Points
4748

48-
- `src-tauri/src/agent_browser.rs``AgentBrowserManager`, stealth flags, stream port allocation, spawning
49+
- `src-tauri/src/agent_browser.rs``AgentBrowserManager`, stealth flags, stream port allocation, spawning, viewport argv builder (`resolve_viewport_params`, `format_dpr`)
50+
- `src-tauri/src/browser_viewport.rs` — preset table (`PRESETS`, `RESET_SPEC`) and `parse_spec` for preset / `WxH` / `reset` parsing
4951
- `src-tauri/src/commands/browser.rs` — Tauri commands for pane creation, URL navigation, automation
50-
- `src/components/browser/BrowserPane.tsx` — screenshot rendering, toolbar, address bar
52+
- `src-tauri/src/cli.rs``BrowserCommand::Viewport` and `BrowserCommand::ViewportPresets` CLI subcommands
53+
- `src-tauri/src/mcp_server.rs``browser_viewport` and `browser_viewport_presets` MCP tools
54+
- `src/components/browser/BrowserPane.tsx` — screenshot rendering, toolbar, address bar, auto-syncs viewport on pane resize via legacy `width`/`height` payload (which the new socket handler accepts unchanged)
5155
- `src/components/browser/InspectorPanel.tsx` — browser inspector/DevTools panel
5256
- `docs/reference/BROWSER-AGENT-COMMANDS.md` — CLI and socket command reference

docs/reference/BROWSER-AGENT-COMMANDS.md

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,44 @@ codemux browser console-logs [browser_id]
9090

9191
Returns JavaScript console output from the page. Useful for debugging errors.
9292

93+
### Set Viewport (Mobile / Tablet / Desktop Testing)
94+
95+
```bash
96+
codemux browser viewport <preset> # mobile / tablet / desktop etc.
97+
codemux browser viewport 390x844 # custom dimensions
98+
codemux browser viewport mobile --dpr 2 # preset with DPR override
99+
codemux browser viewport reset # restore default
100+
codemux browser viewport-presets # list available presets
101+
```
102+
103+
Resizes the actual browser viewport so CSS media queries fire, `window.devicePixelRatio` reflects the simulated device, and subsequent screenshots capture at the new dimensions. Use this instead of wrapping the page in a 375px iframe — viewport resizing gives true mobile rendering (responsive layout, retina assets, real touch-target sizes) and produces clean screenshots without surrounding desktop chrome.
104+
105+
Available presets (use `viewport-presets` to see the live list):
106+
107+
| Preset | W × H | DPR | Matches |
108+
|---|---|---|---|
109+
| `mobile-small` | 320 × 568 | 2 | iPhone SE class, smaller Androids |
110+
| `mobile` | 390 × 844 | 3 | iPhone 13/14/15, Pixel 7 |
111+
| `mobile-large` | 430 × 932 | 3 | Pro Max, Pixel Pro |
112+
| `tablet` | 768 × 1024 | 2 | iPad portrait |
113+
| `tablet-large` | 1024 × 1366 | 2 | iPad Pro 12.9" |
114+
| `desktop` | 1280 × 800 | 1 | Standard laptop, Tailwind `xl:` breakpoint |
115+
| `desktop-large` | 1920 × 1080 | 1 | Full HD |
116+
| `reset` | 1280 × 800 | 1 | Restore default |
117+
118+
Preset names are deliberately size-bucket labels (not "iPhone 15") so they stay accurate across future hardware refreshes.
119+
120+
Typical workflow:
121+
122+
```bash
123+
codemux browser open https://mysite.local
124+
codemux browser viewport mobile
125+
codemux browser screenshot # mobile screenshot
126+
codemux browser viewport tablet
127+
codemux browser screenshot # tablet screenshot
128+
codemux browser viewport reset
129+
```
130+
93131
## Socket API Commands
94132

95133
These actions are available via the Codemux control socket. They cover additional functionality not exposed as CLI subcommands.
@@ -191,12 +229,18 @@ Reload the current page.
191229

192230
#### viewport
193231

194-
Set the browser viewport size.
232+
Set the browser viewport size. Accepts either a preset name or explicit dimensions.
195233

196234
```json
197-
{"command":"browser_automation","params":{"browser_id":"default","action":{"kind":"viewport","width":1024,"height":768}}}
235+
// Preset (recommended)
236+
{"command":"browser_automation","params":{"browser_id":"default","action":{"kind":"viewport","preset":"mobile"}}}
237+
238+
// Explicit dimensions, optional `scale` for DPR
239+
{"command":"browser_automation","params":{"browser_id":"default","action":{"kind":"viewport","width":390,"height":844,"scale":3.0}}}
198240
```
199241

242+
When both `preset` and `width`/`height` are present, `preset` wins. When neither is set, dimensions default to 1280×720×1.0 (backwards-compat with the pre-preset shape that `BrowserPane.tsx`'s ResizeObserver emits when the user manually resizes the pane).
243+
200244
#### console / console_logs
201245

202246
Get console output.

src-tauri/src/agent_browser.rs

Lines changed: 202 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,53 @@ fn windows_executable_path_args() -> Vec<String> {
796796
}
797797
}
798798

799+
/// Resolve the JSON params passed to a `viewport` action into a concrete
800+
/// `ViewportSpec`. Accepts (in priority order):
801+
///
802+
/// 1. `preset` — a named entry from `browser_viewport::PRESETS` or the
803+
/// literal `"reset"`. Highest priority because it's what the CLI and
804+
/// MCP tool send; explicit width/height/scale beneath are only used
805+
/// as fallbacks for legacy callers (e.g. `BrowserPane.tsx`'s
806+
/// `ResizeObserver` that auto-syncs the pane size on every resize).
807+
/// 2. `width` + `height` (+ optional `scale` for DPR). This is the
808+
/// pre-preset shape the frontend already emits, so it stays a
809+
/// first-class input. Missing fields fall back to 1280×720×1.0.
810+
///
811+
/// An unknown preset name falls through to the (1280, 720, 1.0)
812+
/// fallback because the calling layer (CLI / MCP) is expected to have
813+
/// already validated the input via `browser_viewport::parse_spec`. The
814+
/// fallback prevents a typo from breaking the live browser pane.
815+
fn resolve_viewport_params(params: &serde_json::Value) -> crate::browser_viewport::ViewportSpec {
816+
// Preset path (CLI / MCP path).
817+
if let Some(name) = params.get("preset").and_then(|v| v.as_str()) {
818+
let dpr_override = params.get("scale").and_then(|v| v.as_f64());
819+
if let Ok(spec) = crate::browser_viewport::parse_spec(name, dpr_override) {
820+
return spec;
821+
}
822+
// Fall through to width/height path on unknown preset — see doc
823+
// comment for the rationale.
824+
}
825+
826+
let width = params.get("width").and_then(|v| v.as_u64()).unwrap_or(1280) as u32;
827+
let height = params.get("height").and_then(|v| v.as_u64()).unwrap_or(720) as u32;
828+
let dpr = params.get("scale").and_then(|v| v.as_f64()).unwrap_or(1.0);
829+
crate::browser_viewport::ViewportSpec::new(width, height, dpr)
830+
}
831+
832+
/// Format a device-pixel-ratio float for the `agent-browser set viewport
833+
/// W H [scale]` CLI argument. Trims trailing zeros so `2.0` becomes
834+
/// `"2"` (cleaner shell echo, smaller argv) but preserves precision when
835+
/// users pass odd values like `1.5`.
836+
fn format_dpr(dpr: f64) -> String {
837+
if (dpr - dpr.round()).abs() < f64::EPSILON {
838+
format!("{}", dpr as u64)
839+
} else {
840+
// Two decimal places is more than enough — DPR > 3.0 is
841+
// already exotic and nobody cares about the third decimal.
842+
format!("{dpr:.2}")
843+
}
844+
}
845+
799846
/// Argv-form sibling of `build_agent_browser_command`. Returns a list of
800847
/// argument vectors, one per agent-browser invocation: most actions are
801848
/// single-shot, but the historical `open_url` shell form was
@@ -893,16 +940,24 @@ fn build_agent_browser_argv_groups(
893940
"forward" => vec![vec!["forward".into(), "--session".into(), s]],
894941
"reload" => vec![vec!["reload".into(), "--session".into(), s]],
895942
"viewport" => {
896-
let w = params.get("width").and_then(|v| v.as_u64()).unwrap_or(1280);
897-
let h = params.get("height").and_then(|v| v.as_u64()).unwrap_or(720);
898-
vec![vec![
943+
let spec = resolve_viewport_params(params);
944+
let mut argv = vec![
899945
"set".into(),
900946
"viewport".into(),
901-
w.to_string(),
902-
h.to_string(),
903-
"--session".into(),
904-
s,
905-
]]
947+
spec.width.to_string(),
948+
spec.height.to_string(),
949+
];
950+
// Only pass the scale argument when it differs from 1.0 so
951+
// legacy callers that didn't set DPR keep producing the same
952+
// exact `set viewport W H` argv. agent-browser treats a
953+
// missing 3rd arg as 1.0 anyway, so this is a pure
954+
// backwards-compat preservation.
955+
if (spec.dpr - 1.0).abs() > f64::EPSILON {
956+
argv.push(format_dpr(spec.dpr));
957+
}
958+
argv.push("--session".into());
959+
argv.push(s);
960+
vec![argv]
906961
}
907962
"get_styles" => {
908963
let selector = params
@@ -1045,9 +1100,25 @@ fn build_agent_browser_command(session: &str, action: &str, params: &serde_json:
10451100
"forward" => format!("{} forward --session {}", bin, session),
10461101
"reload" => format!("{} reload --session {}", bin, session),
10471102
"viewport" => {
1048-
let w = params.get("width").and_then(|v| v.as_u64()).unwrap_or(1280);
1049-
let h = params.get("height").and_then(|v| v.as_u64()).unwrap_or(720);
1050-
format!("{} set viewport {} {} --session {}", bin, w, h, session)
1103+
let spec = resolve_viewport_params(params);
1104+
// Match the argv builder: only emit the scale arg when it
1105+
// differs from 1.0 so existing call sites that pass plain
1106+
// {width, height} produce the same exact shell string.
1107+
if (spec.dpr - 1.0).abs() > f64::EPSILON {
1108+
format!(
1109+
"{} set viewport {} {} {} --session {}",
1110+
bin,
1111+
spec.width,
1112+
spec.height,
1113+
format_dpr(spec.dpr),
1114+
session
1115+
)
1116+
} else {
1117+
format!(
1118+
"{} set viewport {} {} --session {}",
1119+
bin, spec.width, spec.height, session
1120+
)
1121+
}
10511122
}
10521123
// New v0.24.0 commands
10531124
"get_styles" => {
@@ -1907,6 +1978,126 @@ mod tests {
19071978
assert!(cmd.contains("set viewport 800 600"), "v0.24.0 uses 'set viewport', got: {}", cmd);
19081979
}
19091980

1981+
#[cfg(not(target_os = "windows"))]
1982+
#[test]
1983+
fn build_command_viewport_omits_dpr_when_one() {
1984+
// Backwards-compat with the pre-preset call sites (BrowserPane's
1985+
// ResizeObserver, the old socket clients): a plain {width, height}
1986+
// payload must produce the same exact `set viewport W H` shell
1987+
// string with no trailing scale arg.
1988+
let cmd = build_agent_browser_command(
1989+
"s",
1990+
"viewport",
1991+
&serde_json::json!({"width": 800, "height": 600, "scale": 1.0}),
1992+
)
1993+
.unwrap();
1994+
assert!(
1995+
cmd.contains("set viewport 800 600 --session"),
1996+
"scale=1.0 should be omitted to keep argv tight: {}",
1997+
cmd
1998+
);
1999+
assert!(!cmd.contains(" 1 --session"), "no stray '1' DPR arg: {}", cmd);
2000+
}
2001+
2002+
#[cfg(not(target_os = "windows"))]
2003+
#[test]
2004+
fn build_command_viewport_emits_dpr_when_retina() {
2005+
// Mobile presets always pass scale=2 or scale=3 — that must
2006+
// surface as a 3rd positional arg so agent-browser actually
2007+
// applies the retina factor (not just resizes the box).
2008+
let cmd = build_agent_browser_command(
2009+
"s",
2010+
"viewport",
2011+
&serde_json::json!({"width": 390, "height": 844, "scale": 3.0}),
2012+
)
2013+
.unwrap();
2014+
assert!(
2015+
cmd.contains("set viewport 390 844 3 --session"),
2016+
"DPR=3 must be 3rd positional arg: {}",
2017+
cmd
2018+
);
2019+
}
2020+
2021+
#[cfg(not(target_os = "windows"))]
2022+
#[test]
2023+
fn build_command_viewport_resolves_preset_name() {
2024+
// Preset-name path (CLI / MCP path) must resolve through
2025+
// browser_viewport::parse_spec and produce the right argv —
2026+
// catches a regression where someone forgets to wire preset
2027+
// resolution into the legacy width/height path.
2028+
let cmd = build_agent_browser_command(
2029+
"s",
2030+
"viewport",
2031+
&serde_json::json!({"preset": "mobile"}),
2032+
)
2033+
.unwrap();
2034+
// mobile = 390x844 @ DPR 3
2035+
assert!(
2036+
cmd.contains("set viewport 390 844 3 --session"),
2037+
"'mobile' preset → 390x844x3: {}",
2038+
cmd
2039+
);
2040+
}
2041+
2042+
#[cfg(not(target_os = "windows"))]
2043+
#[test]
2044+
fn build_command_viewport_preset_unknown_falls_back() {
2045+
// An unknown preset must fall back to width/height defaults
2046+
// (1280×720) rather than fail mid-stream. The CLI / MCP layer is
2047+
// expected to validate up front; this is the last-line defence.
2048+
let cmd = build_agent_browser_command(
2049+
"s",
2050+
"viewport",
2051+
&serde_json::json!({"preset": "iphone-99-pro-max-ultra"}),
2052+
)
2053+
.unwrap();
2054+
assert!(
2055+
cmd.contains("set viewport 1280 720"),
2056+
"unknown preset should fall back to defaults: {}",
2057+
cmd
2058+
);
2059+
}
2060+
2061+
#[cfg(not(target_os = "windows"))]
2062+
#[test]
2063+
fn build_command_viewport_preset_reset() {
2064+
// "reset" preset = RESET_SPEC = 1280x800 @ DPR 1.0; DPR 1.0 must
2065+
// be omitted from argv (same backwards-compat rule).
2066+
let cmd = build_agent_browser_command(
2067+
"s",
2068+
"viewport",
2069+
&serde_json::json!({"preset": "reset"}),
2070+
)
2071+
.unwrap();
2072+
assert!(
2073+
cmd.contains("set viewport 1280 800 --session"),
2074+
"'reset' preset → 1280x800 with no scale: {}",
2075+
cmd
2076+
);
2077+
}
2078+
2079+
#[test]
2080+
fn format_dpr_integer_round_trips() {
2081+
// DPRs are floats internally but the CLI argv looks cleaner with
2082+
// integer DPRs as integers (no trailing ".0").
2083+
assert_eq!(format_dpr(1.0), "1");
2084+
assert_eq!(format_dpr(2.0), "2");
2085+
assert_eq!(format_dpr(3.0), "3");
2086+
}
2087+
2088+
#[test]
2089+
fn format_dpr_preserves_fractional() {
2090+
// Odd values (1.5x, 2.75x — yes, some Android phones) must
2091+
// round-trip to two decimals so we don't silently truncate to 1x.
2092+
// Note: Rust's `{:.2}` uses banker's rounding (round half to even),
2093+
// so 2.625 actually rounds to 2.62 — pick values that don't hit
2094+
// the half-to-even ambiguity to keep this test stable across
2095+
// platforms and rustc versions.
2096+
assert_eq!(format_dpr(1.5), "1.50");
2097+
assert_eq!(format_dpr(2.75), "2.75");
2098+
assert_eq!(format_dpr(2.626), "2.63");
2099+
}
2100+
19102101
#[cfg(not(target_os = "windows"))]
19112102
#[test]
19122103
fn build_command_unknown_action_returns_error() {

0 commit comments

Comments
 (0)