mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-22 15:15:18 +00:00
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:
parent
fa1c402c66
commit
93ccdf4070
7 changed files with 401 additions and 3 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(() =>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 pane’s 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.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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': '主会话正在等待审批。',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue