From 93ccdf40708b3ef1e080c816bf30c2dedec58531 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Wed, 15 Jul 2026 20:10:29 +0800 Subject: [PATCH] feat(web-shell): maximize a single split pane (#6951) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web-shell): maximize a single split pane Add a per-pane maximize/restore toggle to the split view. Clicking it makes one pane fill the whole split and hides the others; the hidden panes stay mounted so their sessions keep streaming (a purely visual solo). Restore via the header button or Escape — Escape defers to the composer, the add-session picker, and open dialogs so it never steals their key. The toggle only appears with 2+ panes, adding a session exits maximize to reveal the new pane, and the maximize is dropped whenever its pane leaves the set or the split shrinks to a single pane. * refactor(web-shell): use lucide icons for the maximize toggle; cover switch + picker-Escape Address review on #6951: - Swap the hand-written Maximize2/Minimize2 SVG paths for the named lucide-react components, per the web-shell icon convention (README) and matching DialogShell. - Add tests for moving maximize between panes (guards the toggle's switch branch) and for Escape closing the add-session picker without un-maximizing (guards the pickerOpen deferral). --------- Co-authored-by: wenshao --- .../client/components/ChatPane.module.css | 13 +- .../client/components/ChatPane.test.tsx | 30 ++ .../web-shell/client/components/ChatPane.tsx | 32 +++ .../client/components/SplitView.module.css | 6 + .../client/components/SplitView.test.tsx | 263 +++++++++++++++++- .../web-shell/client/components/SplitView.tsx | 56 ++++ packages/web-shell/client/i18n.tsx | 4 + 7 files changed, 401 insertions(+), 3 deletions(-) diff --git a/packages/web-shell/client/components/ChatPane.module.css b/packages/web-shell/client/components/ChatPane.module.css index 15713b2a09..8cea48ea5d 100644 --- a/packages/web-shell/client/components/ChatPane.module.css +++ b/packages/web-shell/client/components/ChatPane.module.css @@ -48,7 +48,8 @@ color: var(--foreground); } -.closeButton { +.closeButton, +.maximizeButton { flex: 0 0 auto; display: inline-flex; align-items: center; @@ -62,11 +63,19 @@ cursor: pointer; } -.closeButton:hover { +.closeButton:hover, +.maximizeButton:hover { color: var(--foreground); background: color-mix(in srgb, var(--foreground) 8%, transparent); } +/* The maximized pane keeps its toggle visually "active" so it's clear which + control returns to the tiled layout. */ +.maximizeButton[aria-pressed='true'] { + color: var(--foreground); + background: color-mix(in srgb, var(--foreground) 10%, transparent); +} + /* The transcript takes the remaining height and scrolls independently per pane. */ .body { flex: 1 1 auto; diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx index 7b62ad3d67..4c79fa1c82 100644 --- a/packages/web-shell/client/components/ChatPane.test.tsx +++ b/packages/web-shell/client/components/ChatPane.test.tsx @@ -534,6 +534,36 @@ describe('ChatPane', () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it('renders no maximize toggle without onToggleMaximize', () => { + render({ onClose: () => {} }); + expect(container!.querySelector('[aria-label="Maximize pane"]')).toBeNull(); + expect(container!.querySelector('[aria-label="Restore pane"]')).toBeNull(); + }); + + it('invokes onToggleMaximize from the header maximize button', () => { + const onToggleMaximize = vi.fn(); + render({ onToggleMaximize }); + const maximizeBtn = container!.querySelector( + '[aria-label="Maximize pane"]', + ); + expect(maximizeBtn).not.toBeNull(); + // A toggle button always exposes its pressed state; not maximized here. + expect(maximizeBtn!.getAttribute('aria-pressed')).toBe('false'); + act(() => + maximizeBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })), + ); + expect(onToggleMaximize).toHaveBeenCalledTimes(1); + }); + + it('shows the restore affordance while maximized', () => { + render({ onToggleMaximize: () => {}, isMaximized: true }); + const restoreBtn = container!.querySelector('[aria-label="Restore pane"]'); + expect(restoreBtn).not.toBeNull(); + expect(restoreBtn!.getAttribute('aria-pressed')).toBe('true'); + // The label flips to "restore" — no stale "maximize" affordance remains. + expect(container!.querySelector('[aria-label="Maximize pane"]')).toBeNull(); + }); + it('cancels the active turn via the composer cancel action', () => { render(); act(() => diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index 8d202720c3..6e6142f792 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -5,6 +5,7 @@ */ import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { Maximize2Icon, Minimize2Icon } from 'lucide-react'; import { useActions, useConnection, @@ -77,6 +78,14 @@ export interface ChatPaneProps { */ workspaceCwd?: string; onClose?: () => void; + /** + * Toggle this pane between maximized (solo, filling the whole split) and the + * tiled layout. Omitted when only one pane is open — there's nothing to + * maximize against. + */ + onToggleMaximize?: () => void; + /** Whether this pane is currently the maximized (solo) one. */ + isMaximized?: boolean; onError?: (error: unknown, fallback: string) => void; onRightPanelOpen?: (request: TurnOutputOpenRequest) => void; onPaneArtifactsChange?: ( @@ -98,6 +107,8 @@ export function ChatPane({ title, workspaceCwd, onClose, + onToggleMaximize, + isMaximized = false, onError, onRightPanelOpen, onPaneArtifactsChange, @@ -397,6 +408,27 @@ export function ChatPane({ {headerLabel} + {onToggleMaximize && ( + + )} {onClose && ( + )} {props.onClose && (