Skip to content

feat(app): keep narrow desktop windows in the desktop layout - #58894

Open
kitlangton wants to merge 3 commits into
devfrom
narrow-window
Open

kitlangton wants to merge 3 commits into
devfrom
narrow-window

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

What it does

A desktop window narrower than 768px switched to the phone layout: bottom navigation, no titlebar, no hover, mobile density. Now a narrow window keeps the desktop layout and makes room the way native apps do. The phone layout is only for touch screens ((hover: none) and (pointer: coarse) under 768px).

How it works

  1. At 1064px and wider everything docks. The side panel shrinks from 480px toward 320px before anything hides.
  2. Below 1064px with the side panel open, the left sidebar hides on its own (below 734px with it closed). ⌘B or hovering the leading edge floats it over the chat; Escape or a click outside closes it.
  3. Below 794px, the side panel stops docking and slides over the chat from the trailing edge as a sheet. Escape or its toggle closes it.
  4. Hiding for room never changes the saved sidebar or side-panel preference, and it has 16px of hysteresis so a window at the edge doesn't flap. Panes snap during a live window resize and only animate on a toggle.
  5. Electron windows get a 480×480 minimum, and a saved size below it is clamped on restore.

isMobileLayout in @opencode/ui/mobile-layout is now the one place that decides the phone layout; the screen-width checks that used to make that call read it instead. Room math lives in packages/app/src/shell/room.ts.

Based on beta because the sidebar peek, titlebar and shell code this builds on (#58244, #58517, #58520) only exists there.

Visual

<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72" fill="none">
  <g stroke="currentColor" stroke-width="1.5">
    <rect x="10" y="8" width="220" height="56" rx="6" opacity=".6">
      <animate attributeName="width" dur="6s" repeatCount="indefinite" values="220;220;130;130;220;220" keyTimes="0;.15;.32;.68;.85;1" calcMode="spline" keySplines="0 0 1 1;.3 0 .2 1;0 0 1 1;.3 0 .2 1;0 0 1 1"/>
    </rect>
  </g>
  <g fill="currentColor">
    <rect x="16" y="14" width="36" height="44" rx="3" opacity=".22">
      <animate attributeName="width" dur="6s" repeatCount="indefinite" values="36;36;0;0;36;36" keyTimes="0;.15;.32;.68;.85;1" calcMode="spline" keySplines="0 0 1 1;.3 0 .2 1;0 0 1 1;.3 0 .2 1;0 0 1 1"/>
      <animate attributeName="opacity" dur="6s" repeatCount="indefinite" values=".22;.22;0;0;.22;.22" keyTimes="0;.15;.32;.68;.85;1"/>
    </rect>
    <rect x="58" y="14" width="92" height="44" rx="3" opacity=".1">
      <animate attributeName="x" dur="6s" repeatCount="indefinite" values="58;58;16;16;58;58" keyTimes="0;.15;.32;.68;.85;1" calcMode="spline" keySplines="0 0 1 1;.3 0 .2 1;0 0 1 1;.3 0 .2 1;0 0 1 1"/>
      <animate attributeName="width" dur="6s" repeatCount="indefinite" values="92;92;118;118;92;92" keyTimes="0;.15;.32;.68;.85;1" calcMode="spline" keySplines="0 0 1 1;.3 0 .2 1;0 0 1 1;.3 0 .2 1;0 0 1 1"/>
    </rect>
    <rect x="156" y="14" width="68" height="44" rx="3" opacity=".35">
      <animate attributeName="x" dur="6s" repeatCount="indefinite" values="156;156;80;80;156;156" keyTimes="0;.15;.32;.68;.85;1" calcMode="spline" keySplines="0 0 1 1;.3 0 .2 1;0 0 1 1;.3 0 .2 1;0 0 1 1"/>
      <animate attributeName="width" dur="6s" repeatCount="indefinite" values="68;68;54;54;68;68" keyTimes="0;.15;.32;.68;.85;1" calcMode="spline" keySplines="0 0 1 1;.3 0 .2 1;0 0 1 1;.3 0 .2 1;0 0 1 1"/>
    </rect>
  </g>
</svg>

Risk

UI only, desktop app and web. A narrow browser tab with a mouse now keeps the desktop layout too, so phone-only affordances no longer appear there. A touch device under 768px still gets the phone layout. RTL uses logical properties and a mirrored sheet transform; I checked it in the code, not on screen. Full-screen and real-Electron window resizing weren't exercised; the proof comes from a headless browser.

Proof

Today (left) and this PR (right) at 1200, 900, 760, 600 and 480px, side panel open. Headless Chromium with a mouse, mocked server.

Today and this PR at five window widths

A 700px window, real time: the sidebar floats on its toggle and Escape closes it, it floats again and a click outside closes it, then the side panel opens as a sheet and Escape closes it.

narrow-overlay.mp4

A resize sweep from 1300 to 480px and back with the side panel open (Playwright's recorder, about 25 fps):

narrow-resize.mp4
  • bun typecheck in app, ui, session-ui, gui-extensions and desktop; bun run typecheck:tsc in app, ui and desktop: clean.
  • bun script/lint-changed.ts origin/beta: 0 problems.
  • bun test session-panel-width.test.ts and window-state.test.ts: pass.
  • New e2e keeper in tabs.spec.ts: a 700px mouse window keeps the desktop layout, the sidebar floats on demand and the side panel becomes a sheet. Pass.
  • E2e and component specs that expect the phone layout under 768px now emulate touch. The touched files were run in full; the only failures also fail on clean beta (macOS Ctrl/Meta shortcut cases, two mobile-session cases, quarantined tests).
  • Review fixes: closing or opening a docked side panel between 778 and 1053px keeps it docked (the sheet decision reads the shell row and holds through the panel's flight); a peek called without the pointer hands focus back to its toggle on close; moving across the toggle's parts doesn't reopen a peek its click closed. New e2e cases for each fail before the fix and pass after.
  • CI: unit, build and standards pass. The red e2e legs fail on the same tests as beta's head (home.spec.ts:184, timeline-stream.spec.ts:2059, composer.spec.ts:268).
  • motion-lint on the 700px clip's trace: no pops, reversals or snaps. Its one finding is the side panel's existing contents fade (opacity ramps in over the first 15% of the slide), which docked panels already use.

Added by the opencode bot (AI-generated).

Related: #50459

Public API: changes the meaning of the GUI extension SDK's Layout.narrow() from "viewport under 768px" to "touch screen under 768px" (type-compatible)

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code looks sound. I couldn't see it working here: Chromium won't launch in this environment (missing system libraries), so the e2e and component specs didn't run, and the Electron minimum-size change needs the real desktop app. The window-state and session-panel-width unit tests pass, and typecheck:tsc is clean in app and desktop. I read through the room and hysteresis logic, how the sidebar auto-hide drives the peek and the toggle command, the sheet's Escape handling next to the peek's, and the move from width-only media queries to isMobileLayout. I found nothing blocking.

Public API: one behaviour change for GUI extensions. In @opencode/gui-extensions/sdk, Layout.narrow() changes from "viewport under 768px" to "touch screen without hover, under 768px". The type is unchanged, but an extension that used narrow() to adapt to a small desktop window will now get false there. @opencode/ui also gains a ./mobile-layout export, which is additive and internal to the workspace. Config, the server API, the CLI and the client SDK are unchanged.

A few width-only styles are left, so narrow mouse windows still get them: the md: padding in the timeline and session route, and @media (max-width: 767px) in session-ui/src/pierre/index.ts. The pierre rule looks like a deliberate width rule. Please check the padding ones are meant to stay.

The stills and clips you mentioned will help here, since none of this could be checked on screen.

Related: #50459

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commit only touches patch-sticky-header.spec.ts. Under 768px it now emulates touch, like the other phone-layout specs, so the 390px case still exercises the phone layout. The wrapper is small and easy to follow, and I have nothing new to raise. I couldn't run the spec here because headless Chromium won't start in this environment. The public API impact is unchanged from my previous review.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commit makes sense. The sheet decision now uses the shell's full row width, is made while the panel is open and is kept while it closes. That matches how the sidebar gives way: with the side panel open, the sidebar only stays docked if the content still has the docking width, so re-docking on close shouldn't turn a docked panel into a sheet. The peek's focus return and toggle-hover fix look right. I checked this by reading the code: typecheck:tsc in app is clean and the session-panel-width unit tests pass. I couldn't run the e2e specs here because headless Chromium won't launch. The public API impact is unchanged from earlier.

One small thing in the new test, inline.

// Records whether the side panel ever floats as a sheet, on any frame, through an open and a close.
await page.evaluate(() => {
const sample = () => {
if (document.querySelector('[data-slot="session-side-panel-presence"].z-20'))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sampler finds the sheet by its Tailwind class (.z-20). If that styling changes, the selector never matches and the "never became a sheet" check passes even when the bug is back. A dedicated attribute on the presence element (e.g. data-sheet set from sideSheet()) would keep this test checking the right thing.

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commit fixes the test selector point: the sampler now uses a data-sheet attribute, set under the same condition as the sheet styling, so the "never became a sheet" check can't pass by accident. typecheck:tsc in app is clean. I couldn't run the e2e spec here because headless Chromium won't launch. The public API impact is unchanged.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant