feat(web-shell): maximize a single split pane (#6951)

* 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 <wenshao@example.com>
This commit is contained in:
Shaojin Wen 2026-07-15 20:10:29 +08:00 committed by GitHub
parent fa1c402c66
commit 93ccdf4070
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 401 additions and 3 deletions

View file

@ -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;

View file

@ -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(() =>

View file

@ -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({
<span className={styles.title} title={headerLabel}>
{headerLabel}
</span>
{onToggleMaximize && (
<button
type="button"
className={styles.maximizeButton}
onClick={onToggleMaximize}
aria-pressed={isMaximized}
aria-label={t(
isMaximized ? 'splitView.restorePane' : 'splitView.maximizePane',
)}
title={t(
isMaximized ? 'splitView.restorePane' : 'splitView.maximizePane',
)}
>
{/* Same icon vocabulary as the dialog fullscreen toggle. */}
{isMaximized ? (
<Minimize2Icon size={16} aria-hidden />
) : (
<Maximize2Icon size={16} aria-hidden />
)}
</button>
)}
{onClose && (
<button
type="button"

View file

@ -144,6 +144,12 @@
min-height: 0;
}
/* While one pane is maximized, its siblings are hidden (but stay mounted, so
their sessions keep streaming). The lone visible slot then fills the row. */
.paneSlot[data-pane-hidden] {
display: none;
}
.empty {
flex: 1 1 auto;
display: flex;

View file

@ -66,8 +66,17 @@ vi.mock('./ChatPane', () => ({
// Let a test force a render crash to exercise the per-pane ErrorBoundary.
if (props.title === 'BOOM') throw new Error('pane exploded');
return (
<div data-testid="chat-pane" data-pane-workspace={props.workspaceCwd}>
<div
data-testid="chat-pane"
data-pane-workspace={props.workspaceCwd}
data-maximized={props.isMaximized ? 'true' : 'false'}
>
<span data-testid="pane-title">{props.title}</span>
{props.onToggleMaximize && (
<button data-testid="pane-maximize" onClick={props.onToggleMaximize}>
max
</button>
)}
{props.onClose && (
<button data-testid="pane-close" onClick={props.onClose}>
x
@ -372,6 +381,258 @@ describe('SplitView', () => {
expect(titles()).toEqual(['Two']);
});
function maximizeButtons(): HTMLElement[] {
return Array.from(
container!.querySelectorAll('[data-testid="pane-maximize"]'),
);
}
function hiddenSlots(): HTMLElement[] {
return Array.from(container!.querySelectorAll('[data-pane-hidden]'));
}
it('offers a maximize toggle only when more than one pane is open', () => {
// A lone pane already fills the split — nothing to maximize against.
render();
expect(maximizeButtons()).toHaveLength(0);
// Adding a second pane makes the toggle available on both.
openPicker();
const options = container!.querySelectorAll('[role="option"] button');
act(() =>
options[0].dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
expect(maximizeButtons()).toHaveLength(2);
});
it('maximizing a pane hides the others but keeps them all mounted', () => {
render({ sessionIds: ['s1', 's2', 's3'] });
expect(panes()).toHaveLength(3);
expect(hiddenSlots()).toHaveLength(0);
act(() =>
maximizeButtons()[0].dispatchEvent(
new MouseEvent('click', { bubbles: true }),
),
);
// All three panes stay mounted (their sessions keep streaming)…
expect(panes()).toHaveLength(3);
// …but the two non-maximized slots are hidden, leaving one visible.
expect(hiddenSlots()).toHaveLength(2);
// The maximized pane reflects its state down to ChatPane.
const maximized = container!.querySelector('[data-maximized="true"]');
expect(
maximized?.querySelector('[data-testid="pane-title"]')?.textContent,
).toBe('One');
});
it('toggles back to the tiled layout when the maximized panes button is clicked again', () => {
render({ sessionIds: ['s1', 's2'] });
act(() =>
maximizeButtons()[0].dispatchEvent(
new MouseEvent('click', { bubbles: true }),
),
);
expect(hiddenSlots()).toHaveLength(1);
// The still-mounted maximized pane's own toggle restores the split.
act(() =>
maximizeButtons()[0].dispatchEvent(
new MouseEvent('click', { bubbles: true }),
),
);
expect(hiddenSlots()).toHaveLength(0);
expect(container!.querySelector('[data-maximized="true"]')).toBeNull();
});
it('restores the tiled layout on Escape', () => {
render({ sessionIds: ['s1', 's2'] });
act(() =>
maximizeButtons()[0].dispatchEvent(
new MouseEvent('click', { bubbles: true }),
),
);
expect(hiddenSlots()).toHaveLength(1);
// A plain Escape (not aimed at the composer or picker) restores all panes.
act(() =>
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
),
);
expect(hiddenSlots()).toHaveLength(0);
});
it('keeps a pane maximized when Escape originates from an editable field', () => {
render({ sessionIds: ['s1', 's2'] });
act(() =>
maximizeButtons()[0].dispatchEvent(
new MouseEvent('click', { bubbles: true }),
),
);
expect(hiddenSlots()).toHaveLength(1);
// Escape from the composer cancels the turn / closes its menus — it must not
// also un-maximize. An <input> stands in for the CodeMirror editor here.
const input = document.createElement('input');
container!.appendChild(input);
act(() =>
input.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
),
);
expect(hiddenSlots()).toHaveLength(1);
input.remove();
});
it('moves maximize to another pane when its toggle is clicked', () => {
render({ sessionIds: ['s1', 's2', 's3'] });
// Maximize s1.
act(() =>
maximizeButtons()[0].dispatchEvent(
new MouseEvent('click', { bubbles: true }),
),
);
let maximized = container!.querySelector('[data-maximized="true"]');
expect(
maximized?.querySelector('[data-testid="pane-title"]')?.textContent,
).toBe('One');
// Click s2's toggle while s1 is maximized — maximize MOVES to s2 (it does
// not restore to tiled). Guards the toggle's switch branch, which a
// "clear on any second click" regression would break.
act(() =>
maximizeButtons()[1].dispatchEvent(
new MouseEvent('click', { bubbles: true }),
),
);
maximized = container!.querySelector('[data-maximized="true"]');
expect(
maximized?.querySelector('[data-testid="pane-title"]')?.textContent,
).toBe('Two');
// Exactly one pane maximized, the other two hidden.
expect(container!.querySelectorAll('[data-maximized="true"]')).toHaveLength(
1,
);
expect(hiddenSlots()).toHaveLength(2);
});
it('closes the picker on Escape without un-maximizing', () => {
render({ sessionIds: ['s1', 's2'] });
act(() =>
maximizeButtons()[0].dispatchEvent(
new MouseEvent('click', { bubbles: true }),
),
);
expect(hiddenSlots()).toHaveLength(1);
// Open the add-session picker, then press Escape: it closes the picker but
// must NOT also un-maximize — Escape defers to the open picker first.
openPicker();
expect(container!.querySelector('[role="listbox"]')).not.toBeNull();
act(() =>
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
),
);
expect(container!.querySelector('[role="listbox"]')).toBeNull();
expect(hiddenSlots()).toHaveLength(1);
});
it('drops maximize when the maximized pane is closed', () => {
// Uncontrolled so the close button removes the pane locally (a controlled
// split only reports removals up via onPanesChange).
render();
openPicker();
const options = container!.querySelectorAll('[role="option"] button');
act(() =>
options[0].dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
expect(panes()).toHaveLength(2); // seed 'Three' + added 'One'
// Maximize the seed pane, then close it via its (visible) close button.
act(() =>
maximizeButtons()[0].dispatchEvent(
new MouseEvent('click', { bubbles: true }),
),
);
expect(hiddenSlots()).toHaveLength(1);
const closes = container!.querySelectorAll('[data-testid="pane-close"]');
act(() =>
closes[0].dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
// The lone survivor tiles normally — maximize was dropped with its pane.
expect(titles()).toEqual(['One']);
expect(hiddenSlots()).toHaveLength(0);
});
it('drops maximize when a controlled sync removes the maximized pane', () => {
render({ sessionIds: ['s1', 's2'] });
act(() =>
maximizeButtons()[0].dispatchEvent(
new MouseEvent('click', { bubbles: true }),
),
);
expect(hiddenSlots()).toHaveLength(1);
// The parent drops the maximized session (s1) from the split…
act(() =>
root!.render(
<I18nProvider language="en">
<SplitView onExit={() => {}} sessionIds={['s2']} />
</I18nProvider>,
),
);
// …so nothing stays maximized — the lone survivor tiles normally.
expect(titles()).toEqual(['Two']);
expect(hiddenSlots()).toHaveLength(0);
});
it('clears a stale maximize when a controlled split shrinks to one pane then regrows', () => {
const rerender = (ids: string[]) =>
act(() =>
root!.render(
<I18nProvider language="en">
<SplitView onExit={() => {}} sessionIds={ids} />
</I18nProvider>,
),
);
render({ sessionIds: ['s1', 's2'] });
act(() =>
maximizeButtons()[0].dispatchEvent(
new MouseEvent('click', { bubbles: true }),
),
);
expect(hiddenSlots()).toHaveLength(1); // s1 maximized, s2 hidden
// The parent drops the *other* pane, leaving the maximized one alone — the
// stale maximize must clear (it can't hold against a single pane)…
rerender(['s1']);
expect(titles()).toEqual(['One']);
expect(hiddenSlots()).toHaveLength(0);
// …so re-adding a pane shows a clean tiled split, not a silently re-hidden
// one from the earlier maximize.
rerender(['s1', 's2']);
expect(titles()).toEqual(['One', 'Two']);
expect(hiddenSlots()).toHaveLength(0);
});
it('reveals a newly added pane by exiting maximize', () => {
// Uncontrolled so the picker mounts the new pane locally.
render();
openPicker();
let options = container!.querySelectorAll('[role="option"] button');
act(() =>
options[0].dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
expect(panes()).toHaveLength(2);
act(() =>
maximizeButtons()[0].dispatchEvent(
new MouseEvent('click', { bubbles: true }),
),
);
expect(hiddenSlots()).toHaveLength(1);
// Adding another session drops the maximize so the new pane isn't hidden
// behind a still-maximized one.
openPicker();
options = container!.querySelectorAll('[role="option"] button');
act(() =>
options[0].dispatchEvent(new MouseEvent('click', { bubbles: true })),
);
expect(panes()).toHaveLength(3);
expect(hiddenSlots()).toHaveLength(0);
});
it('reloads the session list when the picker opens (never a stale list)', () => {
render({ sessionIds: ['s1'] });
// `useSessions` only fetches on mount; nothing reloads until the user acts.

View file

@ -31,6 +31,7 @@ import {
mergeSessionsById,
workspaceBasename,
} from '../utils/workspace';
import { isEditableTarget } from '../utils/dom';
import styles from './SplitView.module.css';
const MAX_PANES = MAX_SPLIT_PANES;
@ -133,6 +134,10 @@ export function SplitView({
return currentSessionId ? [currentSessionId] : [];
});
const [pickerOpen, setPickerOpen] = useState(false);
// Which pane, if any, is maximized to fill the whole split. Purely visual and
// ephemeral (not deep-linked via `?split=`, like the dialog fullscreen toggle
// it mirrors): the other panes stay mounted and streaming, just hidden.
const [maximizedPaneId, setMaximizedPaneId] = useState<string | null>(null);
const addWrapRef = useRef<HTMLDivElement | null>(null);
// A per-tab/per-mount nonce: two browser tabs opening the same split must not
// register the same daemon client id, or suppressOwnUserEcho would treat one
@ -249,6 +254,9 @@ export function SplitView({
return;
}
const next = [...currentPaneIds, sessionId];
// Reveal the freshly added pane rather than leaving it hidden behind a
// still-maximized one.
setMaximizedPaneId(null);
if (sessionIdsControlled) {
onPanesChange?.(next);
} else {
@ -292,11 +300,48 @@ export function SplitView({
[onPanesChange, sessionIdsControlled],
);
const toggleMaximize = useCallback((sessionId: string) => {
setMaximizedPaneId((current) => (current === sessionId ? null : sessionId));
}, []);
// Maximize only makes sense against another pane, so drop it whenever it no
// longer can hold: the maximized pane left the set (closed here, or removed by
// a controlled-mode sync), or the split shrank to a lone pane. Without the
// length guard a surviving maximized pane would keep a stale `maximizedPaneId`
// that silently re-hides the next pane a controlled parent adds back.
useEffect(() => {
if (
maximizedPaneId &&
(paneIds.length < 2 || !paneIds.includes(maximizedPaneId))
) {
setMaximizedPaneId(null);
}
}, [paneIds, maximizedPaneId]);
// Escape restores the tiled layout, but only when the key is otherwise unused:
// defer to an open picker (its own Escape closes it first), and never steal
// Escape from the composer — it cancels the in-flight turn / closes its menus —
// or from an open dialog. `isEditableTarget` covers `.cm-editor` and dialog
// keyboard scopes, so a maximized pane's composer keeps its Escape.
useEffect(() => {
if (!maximizedPaneId) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented) return;
if (pickerOpen || isEditableTarget(event.target)) return;
setMaximizedPaneId(null);
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [maximizedPaneId, pickerOpen]);
const available = useMemo(
() => allSessions.filter((session) => !paneIds.includes(session.sessionId)),
[allSessions, paneIds],
);
const canAdd = paneIds.length < MAX_PANES && available.length > 0;
// Only offer per-pane maximize once there's another pane to maximize against —
// a lone pane already fills the split.
const canMaximize = paneIds.length > 1;
return (
<div className={styles.split} data-testid="split-view">
@ -374,9 +419,14 @@ export function SplitView({
) : (
paneIds.map((sessionId) => {
const paneWorkspaceCwd = workspaceCwdById.get(sessionId);
const isMaximized = maximizedPaneId === sessionId;
// When one pane is maximized, the rest stay mounted (their sessions
// keep streaming) but are hidden via CSS — a purely visual solo.
const isHidden = maximizedPaneId !== null && !isMaximized;
return (
<div
className={styles.paneSlot}
data-pane-hidden={isHidden ? '' : undefined}
// Include the resolved workspace in the key on a multi-workspace
// daemon so a pane whose workspace resolves only after mount (e.g.
// a `?split=` deep link) remounts under the right workspace rather
@ -433,6 +483,12 @@ export function SplitView({
title={titleById.get(sessionId)}
workspaceCwd={paneWorkspaceCwd}
onClose={() => removePane(sessionId)}
onToggleMaximize={
canMaximize
? () => toggleMaximize(sessionId)
: undefined
}
isMaximized={isMaximized}
onError={onError}
onRightPanelOpen={onRightPanelOpen}
onPaneArtifactsChange={onPaneArtifactsChange}

View file

@ -1876,6 +1876,8 @@ const EN: Messages = {
'splitView.count': (v) => `${v?.count ?? 0} panes`,
'splitView.addPane': 'Add session',
'splitView.closePane': 'Close pane',
'splitView.maximizePane': 'Maximize pane',
'splitView.restorePane': 'Restore pane',
'splitView.paneError': 'This session pane hit an error',
'splitView.paneConnectionError': 'Connection lost',
'splitView.outerApprovalPending':
@ -3746,6 +3748,8 @@ const ZH: Messages = {
'splitView.count': (v) => `${v?.count ?? 0} 个窗格`,
'splitView.addPane': '添加会话',
'splitView.closePane': '关闭窗格',
'splitView.maximizePane': '最大化窗格',
'splitView.restorePane': '还原窗格',
'splitView.paneError': '此会话窗格出错',
'splitView.paneConnectionError': '连接已断开',
'splitView.outerApprovalPending': '主会话正在等待审批。',