Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,38 @@
# Changelog

## [1.15.3.0] - 2026-04-26

## **`browse viewport auto` unpins a fixed viewport without restarting Chrome.**

If a skill called `$B viewport 1280x520` for responsive testing, the browser stayed locked at that size for the rest of the session — visibly smaller than the real Chrome window. The only escape used to be `browse restart`, which kills the Chrome session. New `browse viewport auto` (alias `reset`) rebuilds the context with `viewport: null` in launched mode, or resyncs to the live window in headed mode. Cookies, storage, and URLs are preserved.

### What this means for you

Drop `$B viewport auto` after any responsive-test sequence and the browser goes back to following the window. In headed mode (real Chrome via `/connect-chrome`), the unpin is a one-shot resync to the current window size — re-run after a resize if needed. The headed/launched difference is real (persistent contexts can't be rebuilt the same way) and the command help documents it.

### The numbers that matter

| Mode | Before | After |
|---|---|---|
| Launched (headless) | Locked at last `viewport WxH` until `browse restart` | `viewport auto` rebuilds context with `viewport: null`; cookies + storage preserved |
| Headed (`/connect-chrome`) | Same lock; `browse restart` would drop the headed attachment | `viewport auto` resyncs once to `window.innerWidth/innerHeight`; tab/URL preserved |

Reported by @markddyer in #1059 with a clean Windows 11 reproducer in both connection modes.

### Itemized changes

#### Added
- `browse viewport auto` (alias `viewport reset`) — unpins a previously fixed viewport. (#1059)
- `BrowserManager.unpinViewport()` — connection-mode-aware unpin. Returns null on success, error string on degraded fallback (parity with `recreateContext`).

#### Fixed
- `recreateContext()` now builds two distinct context-option shapes. With `viewport: null`, Playwright rejects `deviceScaleFactor`, so the unpinned path omits it (the existing headed-launch path already obeys this constraint).

#### For contributors
- New `viewportPinned: boolean` field on `BrowserManager` tracks whether the viewport is fixed. `setViewport(w, h)` sets it to true so re-pinning after auto works.
- `--scale` plus `auto`/`reset` is rejected at the command parser — scale needs an explicit WxH to multiply.
- 4 new tests in `browse/test/commands.test.ts` in a dedicated end-of-file describe block (context recreation renumbers tab IDs; the existing "tab switches to specific tab" test hardcodes tab 1).

## [1.14.0.0] - 2026-04-25

## **The gstack browser sidebar is now an interactive Claude Code REPL with live tab awareness.**
Expand Down
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -987,7 +987,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
| `type <text>` | Type into focused element |
| `upload <sel> <file> [file2...]` | Upload file(s) |
| `useragent <string>` | Set user agent |
| `viewport [<WxH>] [--scale <n>]` | Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild. |
| `viewport [<WxH>|auto|reset] [--scale <n>]` | Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). `auto` or `reset` unpins a previously fixed viewport so it follows the window again (in launched mode this rebuilds the context; in headed mode it resyncs once to the live window size). --scale requires a context rebuild. |
| `wait <sel|--networkidle|--load>` | Wait for element, network idle, or page load (timeout: 15s) |

### Inspection
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.14.0.0
1.15.3.0
2 changes: 1 addition & 1 deletion browse/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -911,7 +911,7 @@ $B prettyscreenshot --cleanup --scroll-to ".pricing" --width 1440 ~/Desktop/hero
| `type <text>` | Type into focused element |
| `upload <sel> <file> [file2...]` | Upload file(s) |
| `useragent <string>` | Set user agent |
| `viewport [<WxH>] [--scale <n>]` | Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild. |
| `viewport [<WxH>|auto|reset] [--scale <n>]` | Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). `auto` or `reset` unpins a previously fixed viewport so it follows the window again (in launched mode this rebuilds the context; in headed mode it resyncs once to the live window size). --scale requires a context rebuild. |
| `wait <sel|--networkidle|--load>` | Wait for element, network idle, or page load (timeout: 15s) |

### Inspection
Expand Down
77 changes: 68 additions & 9 deletions browse/src/browser-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ export class BrowserManager {
// track the latest so context recreation restores it instead of hardcoding 1280x720.
private deviceScaleFactor: number = 1;
private currentViewport: { width: number; height: number } = { width: 1280, height: 720 };
// Whether the viewport is pinned to a fixed size (true) or follows the window
// (false). Launched mode starts pinned at 1280x720; headed mode starts unpinned
// (viewport: null in launchHeaded). `viewport auto` / `viewport reset` toggles
// back to unpinned. Tracked so recreateContext() can rebuild with the right
// viewport setting.
private viewportPinned: boolean = true;

/** Server port — set after server starts, used by cookie-import-browser command */
public serverPort: number = 0;
Expand Down Expand Up @@ -817,9 +823,54 @@ export class BrowserManager {
// ─── Viewport ──────────────────────────────────────────────
async setViewport(width: number, height: number) {
this.currentViewport = { width, height };
this.viewportPinned = true;
await this.getPage().setViewportSize({ width, height });
}

/**
* Unpin the viewport so it follows the real window size again (#1059).
*
* Once `setViewport(w, h)` pins via `setViewportSize`, Playwright has no
* page-level API to restore window-following. The only fix is to rebuild
* the context with `viewport: null`.
*
* Modes:
* - launched (headless): full `recreateContext()` with viewport:null.
* Cookies/storage/URL are preserved via the standard save/restore path.
* - headed (persistent context): `recreateContext()` is forbidden because
* persistent contexts are tied to the user's Chrome window. Instead,
* read the live window's innerWidth/innerHeight and resync the viewport
* once. This is a snapshot, not true follow — re-call after a resize
* if needed. Documented in the command help.
*
* Returns null on success, or an error string when the recreation fallback
* left the browser in a degraded state (parity with recreateContext).
*/
async unpinViewport(): Promise<string | null> {
this.viewportPinned = false;
if (this.connectionMode === 'headed') {
// Headed: persistent context can't be rebuilt. Resync the viewport to
// the real window size as a one-shot. The viewport is still technically
// pinned in Playwright's eyes, but it now matches the window — which
// is what the user sees, and what they wanted.
try {
const dims = await this.getPage().evaluate(() => ({
width: window.innerWidth,
height: window.innerHeight,
}));
if (dims.width > 0 && dims.height > 0) {
this.currentViewport = { width: dims.width, height: dims.height };
await this.getPage().setViewportSize(dims);
}
return null;
} catch (err) {
return `Could not read window size: ${err instanceof Error ? err.message : String(err)}`;
}
}
// Launched: full context rebuild with viewport:null restores true follow.
return await this.recreateContext();
}

// ─── Extra Headers ─────────────────────────────────────────
async setExtraHeader(name: string, value: string) {
this.extraHeaders[name] = value;
Expand Down Expand Up @@ -1018,11 +1069,17 @@ export class BrowserManager {
this.tabSessions.clear();
await this.context.close().catch(() => {});

// 3. Create new context with updated settings
const contextOptions: BrowserContextOptions = {
viewport: { width: this.currentViewport.width, height: this.currentViewport.height },
deviceScaleFactor: this.deviceScaleFactor,
};
// 3. Create new context with updated settings.
// When viewport is unpinned (null), deviceScaleFactor must be omitted —
// Playwright rejects DSF without an explicit viewport. The headed launch
// path uses the same constraint (launchPersistentContext with viewport:null
// sets no DSF). #1059
const contextOptions: BrowserContextOptions = this.viewportPinned
? {
viewport: { width: this.currentViewport.width, height: this.currentViewport.height },
deviceScaleFactor: this.deviceScaleFactor,
}
: { viewport: null };
if (this.customUserAgent) {
contextOptions.userAgent = this.customUserAgent;
}
Expand All @@ -1043,10 +1100,12 @@ export class BrowserManager {
this.tabSessions.clear();
if (this.context) await this.context.close().catch(() => {});

const contextOptions: BrowserContextOptions = {
viewport: { width: this.currentViewport.width, height: this.currentViewport.height },
deviceScaleFactor: this.deviceScaleFactor,
};
const contextOptions: BrowserContextOptions = this.viewportPinned
? {
viewport: { width: this.currentViewport.width, height: this.currentViewport.height },
deviceScaleFactor: this.deviceScaleFactor,
}
: { viewport: null };
if (this.customUserAgent) {
contextOptions.userAgent = this.customUserAgent;
}
Expand Down
2 changes: 1 addition & 1 deletion browse/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
'scroll': { category: 'Interaction', description: 'Scroll element into view, or scroll to page bottom if no selector', usage: 'scroll [sel]' },
'wait': { category: 'Interaction', description: 'Wait for element, network idle, or page load (timeout: 15s)', usage: 'wait <sel|--networkidle|--load>' },
'upload': { category: 'Interaction', description: 'Upload file(s)', usage: 'upload <sel> <file> [file2...]' },
'viewport':{ category: 'Interaction', description: 'Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild.', usage: 'viewport [<WxH>] [--scale <n>]' },
'viewport':{ category: 'Interaction', description: 'Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). `auto` or `reset` unpins a previously fixed viewport so it follows the window again (in launched mode this rebuilds the context; in headed mode it resyncs once to the live window size). --scale requires a context rebuild.', usage: 'viewport [<WxH>|auto|reset] [--scale <n>]' },
'cookie': { category: 'Interaction', description: 'Set cookie on current page domain', usage: 'cookie <name>=<value>' },
'cookie-import': { category: 'Interaction', description: 'Import cookies from JSON file', usage: 'cookie-import <json>' },
'cookie-import-browser': { category: 'Interaction', description: 'Import cookies from installed Chromium browsers (opens picker, or use --domain for direct import)', usage: 'cookie-import-browser [browser] [--domain d]' },
Expand Down
22 changes: 19 additions & 3 deletions browse/src/write-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,7 @@ export async function handleWriteCommand(
}

case 'viewport': {
// Parse args: [<WxH>] [--scale <n>]. Either may be omitted, but NOT both.
// Parse args: [<WxH> | auto | reset] [--scale <n>]. Either may be omitted, but NOT both.
let sizeArg: string | undefined;
let scaleArg: number | undefined;
for (let i = 0; i < args.length; i++) {
Expand All @@ -516,12 +516,28 @@ export async function handleWriteCommand(
} else if (sizeArg === undefined) {
sizeArg = args[i];
} else {
throw new Error(`Unexpected positional arg: ${args[i]}. Usage: viewport [WxH] [--scale <n>]`);
throw new Error(`Unexpected positional arg: ${args[i]}. Usage: viewport [WxH|auto|reset] [--scale <n>]`);
}
}

// `viewport auto` / `viewport reset` — unpin a previously fixed viewport (#1059).
// Skills that call `$B viewport WxH` for responsive checks would otherwise leave
// the browser locked at that size for the rest of the session.
if (sizeArg === 'auto' || sizeArg === 'reset') {
if (scaleArg !== undefined) {
throw new Error(`viewport ${sizeArg}: cannot combine with --scale (scale needs an explicit WxH).`);
}
const err = await bm.unpinViewport();
if (err) return `Viewport unpin partially failed: ${err}`;
if (bm.getConnectionMode() === 'headed') {
const v = bm.getCurrentViewport();
return `Viewport synced to current window size (${v.width}x${v.height}). Re-run \`viewport auto\` after resizing.`;
}
return 'Viewport unpinned — now follows window size (context recreated; refs and load-html content replayed).';
}

if (sizeArg === undefined && scaleArg === undefined) {
throw new Error('Usage: browse viewport [<WxH>] [--scale <n>] (e.g. 375x812, or --scale 2 to keep current size)');
throw new Error('Usage: browse viewport [<WxH>|auto|reset] [--scale <n>] (e.g. 375x812, auto to unpin, or --scale 2 to keep current size)');
}

// Resolve width/height: either from sizeArg or from current viewport if --scale-only.
Expand Down
56 changes: 56 additions & 0 deletions browse/test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,15 @@ describe('Interaction', () => {
await handleWriteCommand('viewport', ['1280x720'], bm);
});

test('viewport auto rejects --scale combination', async () => {
try {
await handleWriteCommand('viewport', ['auto', '--scale', '2'], bm);
expect(true).toBe(false);
} catch (err: any) {
expect(err.message).toContain('cannot combine with --scale');
}
});

test('type and press work', async () => {
await handleWriteCommand('goto', [baseUrl + '/forms.html'], bm);
await handleWriteCommand('click', ['#name'], bm);
Expand Down Expand Up @@ -2425,3 +2434,50 @@ describe('Command aliases', () => {
expect(result).toContain('Loaded HTML:');
});
});

// ─── viewport auto / reset (#1059) ─────────────────────────────────
//
// These tests rebuild the browser context, which renumbers tab IDs and
// invalidates earlier locator refs. Run them last so they don't disturb
// the rest of the suite (Tabs > "tab switches to specific tab" hardcodes
// tab id 1, which gets gone after a recreate).
describe('viewport auto / reset (#1059)', () => {
test('viewport auto unpins a fixed size', async () => {
// Pin to a non-default size, unpin via `viewport auto`, then re-pin to
// a different size. The re-pin must take effect — confirms the previous
// pin didn't survive the unpin.
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
await handleWriteCommand('viewport', ['400x600'], bm);
let size = await handleReadCommand('js', ['`${window.innerWidth}x${window.innerHeight}`'], bm);
expect(size).toBe('400x600');

const result = await handleWriteCommand('viewport', ['auto'], bm);
expect(result.toLowerCase()).toMatch(/unpin|sync/);

await handleWriteCommand('viewport', ['800x600'], bm);
size = await handleReadCommand('js', ['`${window.innerWidth}x${window.innerHeight}`'], bm);
expect(size).toBe('800x600');
});

test('viewport reset is an alias for auto', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
await handleWriteCommand('viewport', ['400x600'], bm);
const result = await handleWriteCommand('viewport', ['reset'], bm);
expect(result.toLowerCase()).toMatch(/unpin|sync/);
});

test('viewport auto preserves cookies across context recreation', async () => {
await handleWriteCommand('goto', [baseUrl + '/basic.html'], bm);
await handleWriteCommand('cookie', ['gstack_test=preserved'], bm);
const before = await handleReadCommand('cookies', [], bm);
expect(before).toContain('gstack_test');

await handleWriteCommand('viewport', ['400x600'], bm);
await handleWriteCommand('viewport', ['auto'], bm);

const after = await handleReadCommand('cookies', [], bm);
expect(after).toContain('gstack_test');
// No cleanup needed: this describe runs last, cookie tests upstream
// already finished.
});
});