fix(web-shell): keep "+ Add session" clickable when the catalog is empty (#15835)

* fix(web-shell): keep + Add session clickable when the catalog is empty

The add button was gated on the cached session catalog already containing an
addable session, so the picker could never open to refresh itself in the one
case its reload-on-open effect exists for. Gate the button on pane capacity
alone and let the picker report an empty list after reloading.

Closes #15678

* test(web-shell): pin the empty-picker guard with a negative assertion

* test(web-shell): assert add-session button disables at MAX_PANES

The PR narrowed canAdd to the capacity term only, and the new test
pinned just the removed half (empty catalog stays enabled). Without a
cap assertion, dropping the pane-count check would leave every test
green while the picker could open a seventh pane.

* test(web-shell): pin the add button stays enabled below MAX_PANES

---------

Co-authored-by: 2SinceJuly <[email protected]>
This commit is contained in:
2SinceJulyand2SinceJuly authored and GitHub committed 2026-10-30 23:40:51 +08:00
1 parent f3af3dd857
commit 3a4a077362
4 files changed
+87 -2

No files matched your search

@@ -110,6 +110,12 @@
cursor: pointer;
}
.pickerEmpty {
padding: 7px 10px;
color: var(--muted-foreground);
font-size: 13px;
}
.pickerItemLabel {
flex: 1 1 auto;
min-width: 0;
@@ -10,6 +10,7 @@ import * as React from 'react';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { I18nProvider } from '../i18n';
import { MAX_SPLIT_PANES } from '../utils/splitUrl';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
@@ -779,6 +780,30 @@ describe('SplitView', () => {
render({ sessionIds: sessionsState.map((s) => s.sessionId) });
// Eight requested, but only six live panes mount.
expect(panes()).toHaveLength(6);
// At the cap there is no room left, so the entry point is disabled.
const addButton = container!.querySelector(
'button[aria-haspopup="listbox"]',
) as HTMLButtonElement;
expect(addButton.disabled).toBe(true);
});
it('keeps the add button enabled one below MAX_PANES', () => {
sessionsState = Array.from({ length: MAX_SPLIT_PANES + 2 }, (_, i) => ({
sessionId: `x${i}`,
workspaceCwd: '/w',
displayName: `Pane ${i}`,
}));
// One slot spare and a session left to add, so the entry point must work.
render({
sessionIds: sessionsState
.slice(0, MAX_SPLIT_PANES - 1)
.map((session) => session.sessionId),
});
expect(panes()).toHaveLength(MAX_SPLIT_PANES - 1);
const addButton = container!.querySelector(
'button[aria-haspopup="listbox"]',
) as HTMLButtonElement;
expect(addButton.disabled).toBe(false);
});
it('isolates a crashing pane so the rest of the split survives', () => {
@@ -1072,6 +1097,45 @@ describe('SplitView', () => {
expect(hiddenSlots()).toHaveLength(0);
});
it('keeps the add button enabled when the catalog has no other sessions', () => {
sessionsState = [
{ sessionId: 's3', workspaceCwd: '/w', displayName: 'Three' },
];
render({ sessionIds: ['s3'] });
const addButton = container!.querySelector(
'button[aria-haspopup="listbox"]',
) as HTMLButtonElement;
// The cached catalog is not authoritative: another session may exist
// elsewhere, so an empty list must not disable the entry point.
expect(addButton.disabled).toBe(false);
openPicker();
expect(pickerOptions()).toEqual([]);
expect(container!.querySelector('[role="listbox"]')?.textContent).toContain(
'No other sessions',
);
});
it('picks up a session created elsewhere after opening the empty picker', () => {
sessionsState = [
{ sessionId: 's3', workspaceCwd: '/w', displayName: 'Three' },
];
render({ sessionIds: ['s3'] });
openPicker();
expect(pickerOptions()).toEqual([]);
// A session is created elsewhere while the picker was open.
sessionsState = [
...sessionsState,
{ sessionId: 's6', workspaceCwd: '/w', displayName: 'Six' },
];
act(() =>
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
),
);
openPicker();
expect(pickerOptions()).toEqual(['Six']);
});
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.
@@ -1104,6 +1168,11 @@ describe('SplitView', () => {
);
openPicker();
expect(pickerOptions()).toEqual(['Two', 'Three', 'Four', 'Five']);
// The empty row is `role="presentation"`, so only a text read sees it: a
// populated picker must not keep advertising "No other sessions".
expect(
container!.querySelector('[role="listbox"]')?.textContent,
).not.toContain('No other sessions');
});
it('mirrors the live pane set up to the parent as panes change', () => {
@@ -471,7 +471,10 @@ export function SplitView({
() => allSessions.filter((session) => !paneIds.includes(session.sessionId)),
[allSessions, paneIds],
);
const canAdd = paneIds.length < MAX_PANES && available.length > 0;
// The cached catalog may lag behind sessions created elsewhere, so the button
// must stay clickable whenever another pane fits. The picker reloads on open
// and reports an empty list itself.
const canAdd = paneIds.length < MAX_PANES;
// 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;
@@ -532,8 +535,13 @@ export function SplitView({
>
+ {t('splitView.addPane')}
</button>
{pickerOpen && available.length > 0 && (
{pickerOpen && (
<ul className={styles.picker} role="listbox">
{available.length === 0 && (
<li className={styles.pickerEmpty} role="presentation">
{t('splitView.noOtherSessions')}
</li>
)}
{available.map((session) => (
<li key={session.sessionId} role="option" aria-selected="false">
<button
+2
View File
@@ -4295,6 +4295,7 @@ const EN: Messages = {
'splitView.goToApproval': 'Go to it',
'splitView.pendingCount': (v) => `${v?.count ?? 0} awaiting input`,
'splitView.nextPending': 'Go to the next session awaiting input',
'splitView.noOtherSessions': 'No other sessions',
'splitView.empty': 'No sessions in the split. Add one to get started.',
'splitView.composerPlaceholder': 'Message this session…',
'channels.title': 'Channels',
@@ -8620,6 +8621,7 @@ const ZH: Messages = {
'splitView.goToApproval': '前往处理',
'splitView.pendingCount': (v) => `${v?.count ?? 0} 个会话待处理`,
'splitView.nextPending': '前往下一个待处理会话',
'splitView.noOtherSessions': '没有其他会话',
'splitView.empty': '分屏中还没有会话,添加一个开始。',
'splitView.composerPlaceholder': '给这个会话发消息…',
'channels.title': '频道',