From fb82299f44a107e02021d9f7630f0ab78d1532a0 Mon Sep 17 00:00:00 2001 From: Cameron Gagnon Date: Thu, 16 Jul 2026 15:13:59 +0000 Subject: [PATCH] fix(app): remember review pane width across sessions, default to smallest The review pane is flex-1, so its width is whatever the chat panel leaves. The chat width lived in the per-server layout store, so every server (each Forge task connects to its own) started at the 600px default -- the review pane grabbed the rest of a wide monitor -- and a width set in one task never carried to another. Persist the chat/review split width in the shared global scope instead, and default an unset width to the widest allowed chat panel (the review pane's smallest state) so it starts minimized. Terminal-tab isolation stays per-server. --- packages/app/src/context/layout-helpers.ts | 8 +++++++ packages/app/src/context/layout.test.ts | 16 ++++++++++++- packages/app/src/context/layout.tsx | 27 ++++++++++++++-------- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/packages/app/src/context/layout-helpers.ts b/packages/app/src/context/layout-helpers.ts index e2c5ce1c3c42..ee9fc5b2370a 100644 --- a/packages/app/src/context/layout-helpers.ts +++ b/packages/app/src/context/layout-helpers.ts @@ -1,5 +1,13 @@ import type { Accessor } from "solid-js" +// The review pane is `flex-1`, so it fills whatever the chat panel leaves — a wider chat +// means a narrower review pane. When the width was never set (`undefined`), fall back to the +// widest the chat panel is allowed to be, which is the review pane's smallest state, so the +// review pane starts minimized instead of grabbing the whole remainder of a wide monitor. +export function resolveSessionWidth(stored: number | undefined, maxWidth: number) { + return stored ?? maxWidth +} + export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) { touch(key) seed(key) diff --git a/packages/app/src/context/layout.test.ts b/packages/app/src/context/layout.test.ts index 895c0e85e217..7181d3857075 100644 --- a/packages/app/src/context/layout.test.ts +++ b/packages/app/src/context/layout.test.ts @@ -1,6 +1,20 @@ import { describe, expect, test } from "bun:test" import { createRoot, createSignal } from "solid-js" -import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" +import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys, resolveSessionWidth } from "./layout-helpers" + +describe("resolveSessionWidth", () => { + test("defaults to the max chat width (smallest review pane) when never set", () => { + expect(resolveSessionWidth(undefined, 900)).toBe(900) + }) + + test("uses the remembered width once the user has resized", () => { + expect(resolveSessionWidth(720, 900)).toBe(720) + }) + + test("keeps a remembered width of zero rather than falling back", () => { + expect(resolveSessionWidth(0, 900)).toBe(0) + }) +}) describe("layout session-key helpers", () => { test("couples touch and scroll seed in order", () => { diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index eb97b400c89f..b32eb941c524 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -15,7 +15,7 @@ import { createScrollPersistence, type SessionScroll } from "./layout-scroll" import { createPathHelpers } from "./file/path" import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2" import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope" -import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" +import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys, resolveSessionWidth } from "./layout-helpers" import { requireServerKey } from "@/utils/session-route" import { type DraftTab, useTabs } from "./tabs" @@ -28,6 +28,11 @@ const DEFAULT_SIDEBAR_WIDTH = 344 const DEFAULT_FILE_TREE_WIDTH = 200 const DEFAULT_SESSION_WIDTH = 600 const DEFAULT_TERMINAL_HEIGHT = 280 + +// Widest the chat panel is allowed to be (mirrors the `max` clamp on the resize handle), +// which is the review pane's smallest state. `resolveSessionWidth` uses it as the first-open +// default. SSR has no window, so fall back to the static default there. +const maxSessionWidth = () => (typeof window === "undefined" ? DEFAULT_SESSION_WIDTH : window.innerWidth * 0.45) export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number] export function getAvatarColors(key?: string) { @@ -286,9 +291,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" as "changes" | "all", }, - session: { - width: DEFAULT_SESSION_WIDTH, - }, mobileSidebar: { opened: false, }, @@ -303,6 +305,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }), ) + // Chat/review split width lives in the shared global scope, not the per-server layout + // store, so a width the user sets in one session is remembered across every session and + // server (each Forge task connects to its own server). `undefined` marks "never resized", + // which the getter resolves to the smallest review pane at read time. + const [sessionWidthStore, setSessionWidthStore] = persisted( + Persist.global("layout-session-width"), + createStore({ width: undefined as number | undefined }), + ) + const MAX_SESSION_KEYS = 50 const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000 const usage = { @@ -709,13 +720,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, }, session: { - width: createMemo(() => store.session?.width ?? DEFAULT_SESSION_WIDTH), + width: createMemo(() => resolveSessionWidth(sessionWidthStore.width, maxSessionWidth())), resize(width: number) { - if (!store.session) { - setStore("session", { width }) - return - } - setStore("session", "width", width) + setSessionWidthStore("width", width) }, }, mobileSidebar: {