Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix(web-shell): Stabilize split approval reports
  • Loading branch information
wenshao committed Sep 7, 2026
commit 9098dd3330e79b59e0daa1b49c5abbf0766185ca
25 changes: 25 additions & 0 deletions packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27782,6 +27782,31 @@ describe('App session callbacks', () => {
},
);

it.each([false, true])(
'does not rerender App for other split sessions (outer pending: %s)',
async (outerPending) => {
const { container, rerender } = renderApp();
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-split-view"]')
?.click();
});
await flush();
const report = testState.latestSplitViewProps!.onPendingPanesChange!;
const ownerIds = outerPending ? [mockConnection.sessionId!] : [];
await act(async () => report(ownerIds));
rerender();
expect(testState.latestSplitViewProps!.onPendingPanesChange).toBe(report);
expect(mockUseDaemonActivePromptBridge).toHaveBeenCalled();
mockUseDaemonActivePromptBridge.mockClear();
for (const ids of [['foreign-session'], ['another-session'], []]) {
await act(async () => report([...ownerIds, ...ids]));
expect(mockUseDaemonActivePromptBridge).not.toHaveBeenCalled();
}
},
);

it('keeps the outer approval notice until a split pane reports its approval', async () => {
const { container, rerender } = renderApp();
await flush();
Expand Down
13 changes: 8 additions & 5 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7951,9 +7951,12 @@ export function App({
}, [artifactPanelOpen, useFloatingArtifactPanel]);
// Sessions to seed the split view with (e.g. the selection from the overview).
const [splitSessionIds, setSplitSessionIds] = useState<string[]>([]);
const [splitPendingSessionIds, setSplitPendingSessionIds] = useState<
string[]
>([]);
const [outerSplitPanePending, setOuterSplitPanePending] = useState(false);
const handleSplitPendingPanesChange = useCallback(
(ids: string[]) =>
setOuterSplitPanePending(ids.includes(connection.sessionId ?? '')),
[connection.sessionId],
Comment thread
wenshao marked this conversation as resolved.
);
// Latest pane list, readable from the shrink-close effect without making it a
// dependency (it changes on every pane add/remove).
const splitSessionIdsRef = useRef<string[]>(splitSessionIds);
Expand Down Expand Up @@ -17377,7 +17380,7 @@ export function App({
session's pane hasn't surfaced its approval (including
failed or still-attaching panes), show a way back to it. */}
{approvalOverlayActive &&
!splitPendingSessionIds.includes(connection.sessionId ?? '') && (
!outerSplitPanePending && (
<div
className={styles.splitApprovalNotice}
role="status"
Expand Down Expand Up @@ -17405,7 +17408,7 @@ export function App({
// callback stable to avoid looping SplitView's reporting
// effect.
onPanesChange={handleSplitPanesChange}
onPendingPanesChange={setSplitPendingSessionIds}
onPendingPanesChange={handleSplitPendingPanesChange}
includeOtherWorkspaces={!lockedWorkspaceCwd}
workspaceCwd={lockedWorkspaceCwd}
// Back returns to the Session Overview (the hub the split
Expand Down
14 changes: 9 additions & 5 deletions packages/web-shell/client/components/ChatPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -580,9 +580,13 @@ describe('ChatPane', () => {
);
});

it.each([false, true])(
'reuses title details without including actions and dismisses them when hidden (multiple workspaces: %s)',
async (multiWorkspace) => {
it.each([
[false, '2026-01-02T00:00:00Z'],
[true, '2026-01-02T00:00:00Z'],
[false, undefined],
] as const)(
'reuses title details without including actions and dismisses them when hidden (multiple workspaces: %s, updatedAt: %s)',
async (multiWorkspace, updatedAt) => {
vi.useFakeTimers();
try {
const props = {
Expand All @@ -592,7 +596,7 @@ describe('ChatPane', () => {
sessionId: 'session-details',
workspaceCwd: '/work/split-project',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
updatedAt,
hasActivePrompt: false,
branch: { name: 'codex/split', baseBranch: 'main' },
},
Expand Down Expand Up @@ -641,7 +645,7 @@ describe('ChatPane', () => {
).toContain('Running');
expect(
document.querySelector('[role="dialog"]')?.textContent,
).toContain(formatDateTime(props.sessionSummary.updatedAt));
).toContain(formatDateTime(updatedAt ?? '2026-01-01T00:00:00Z'));
expect(document.activeElement).toBe(composerFocus);
rerender({ ...props, hidden: true });
expect(document.querySelector('[role="dialog"]')).toBeNull();
Expand Down
121 changes: 110 additions & 11 deletions packages/web-shell/client/components/SplitView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,20 @@ let reloadMock: ReturnType<typeof vi.fn>;
let waitingSessions: Set<string>;
const scrollPaneIntoView = vi.fn();
const confirmApproval = vi.fn();
const PaneSessionContext = React.createContext<string | undefined>(undefined);

vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
DaemonSessionProvider: (props: any) => (
<div
data-session={props.sessionId}
data-clientid={props.clientId}
data-workspace={props.workspaceCwd}
data-restart-sse={props.restartEventStreamOnPrompt ? 'true' : 'false'}
>
{props.children}
</div>
<PaneSessionContext.Provider value={props.sessionId}>
<div
data-session={props.sessionId}
data-clientid={props.clientId}
data-workspace={props.workspaceCwd}
data-restart-sse={props.restartEventStreamOnPrompt ? 'true' : 'false'}
>
{props.children}
</div>
</PaneSessionContext.Provider>
),
useConnection: () => connectionState,
// `client` is a stable object; `capabilities` mirrors the connection so a test
Expand Down Expand Up @@ -90,8 +93,8 @@ vi.mock('../hooks/useScopedSessions', () => ({

vi.mock('./ChatPane', () => ({
ChatPane: (props: any) => {
const sessionId = props.sessionSummary?.sessionId;
const pending = waitingSessions.has(sessionId);
const sessionId = React.useContext(PaneSessionContext);
const pending = !!sessionId && waitingSessions.has(sessionId);
const { onApprovalChange } = props;
React.useEffect(() => {
if (!sessionId) return;
Expand Down Expand Up @@ -360,6 +363,87 @@ describe('SplitView', () => {
expect(onPendingPanesChange).toHaveBeenLastCalledWith([]);
});

it('reports changed pending panes without clearing the still-pending panes', () => {
waitingSessions = new Set(['s1']);
const onPendingPanesChange = vi.fn();
const sessionIds = ['s1', 's2'];
render({ sessionIds, onPendingPanesChange });
expect(onPendingPanesChange).toHaveBeenLastCalledWith(['s1']);
onPendingPanesChange.mockClear();
waitingSessions = new Set(['s1', 's2']);
act(() =>
root!.render(
<I18nProvider language="en">
<SplitView
sessionIds={sessionIds}
onExit={() => {}}
onPendingPanesChange={onPendingPanesChange}
/>
</I18nProvider>,
),
);
expect(onPendingPanesChange).toHaveBeenCalledExactlyOnceWith(['s1', 's2']);
});

it('clears the old report callback before sending pending panes to its replacement', () => {
waitingSessions = new Set(['s1']);
const oldReport = vi.fn();
render({ sessionIds: ['s1', 's2'], onPendingPanesChange: oldReport });
expect(oldReport).toHaveBeenLastCalledWith(['s1']);
oldReport.mockClear();
const nextReport = vi.fn(() => {
expect(oldReport).toHaveBeenCalledExactlyOnceWith([]);
});
act(() =>
root!.render(
<I18nProvider language="en">
<SplitView
sessionIds={['s1', 's2']}
onExit={() => {}}
onPendingPanesChange={nextReport}
/>
</I18nProvider>,
),
);
expect(nextReport).toHaveBeenCalledExactlyOnceWith(['s1']);
act(() => root!.unmount());
root = null;
expect(nextReport).toHaveBeenLastCalledWith([]);
});

it('keeps pending reports stable when their parent stores them in state', () => {
const onReport = vi.fn();
const sessionIds = ['s1', 's2'];
function Parent() {
const [reportedIds, setReportedIds] = React.useState<string[]>([]);
const handleReport = React.useCallback((ids: string[]) => {
onReport(ids);
// Bound a broken render/effect loop so the regression fails promptly.
if (onReport.mock.calls.length > 10) {
throw new Error('Pending reports did not settle');
}
setReportedIds(ids);
}, []);
return (
<I18nProvider language="en">
<output>{reportedIds.length}</output>
<SplitView
sessionIds={sessionIds}
onExit={() => {}}
onPendingPanesChange={handleReport}
/>
</I18nProvider>
);
}
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => root!.render(<Parent />));
expect(onReport).toHaveBeenCalledExactlyOnceWith([]);
act(() => root!.render(<Parent />));
expect(onReport).toHaveBeenCalledExactlyOnceWith([]);
});

it('stops reporting a crashed pane even though its slot remains', async () => {
waitingSessions = new Set(['s1']);
const onPendingPanesChange = vi.fn();
Expand Down Expand Up @@ -400,11 +484,26 @@ describe('SplitView', () => {
});

it('omits title details when the host disables them', () => {
render({ sessionIds: ['s1', 's2'], showSessionDetails: false });
waitingSessions = new Set(['s1']);
const onPendingPanesChange = vi.fn();
render({
sessionIds: ['s1', 's2'],
showSessionDetails: false,
onPendingPanesChange,
});
expect(titles()).toEqual(['One', 'Two']);
expect(
panes().some((pane) => pane.hasAttribute('data-session-details')),
).toBe(false);
expect(
container!.querySelector(
'[title="Go to the next session awaiting input"]',
)?.textContent,
).toBe('1 awaiting input');
expect(container!.querySelector('[role="status"]')?.textContent).toBe(
'1 awaiting input',
);
expect(onPendingPanesChange).toHaveBeenLastCalledWith(['s1']);
});

it('renders one pane per initial session, each under its own provider', () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/web-shell/client/components/SplitView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,14 +196,16 @@ export function SplitView({
setPaneFocusId(activeId ?? null);
}
}, [paneIds, activeId, activePaneId]);
// Keep report identity stable across parent renders: consumers may store it
// in state, which would otherwise retrigger the reporting effect below.
const pendingIds = useMemo(
Comment thread
wenshao marked this conversation as resolved.
() => paneIds.filter((id) => pendingPaneIds.has(id)),
[paneIds, pendingPaneIds],
);
useEffect(() => {
onPendingPanesChange?.(pendingIds);
return () => onPendingPanesChange?.([]);
}, [pendingIds, onPendingPanesChange]);
useEffect(() => () => onPendingPanesChange?.([]), [onPendingPanesChange]);
const backButtonRef = useRef<HTMLButtonElement>(null);
const pendingButtonRef = useRef<HTMLButtonElement | null>(null);
const setPendingButtonRef = useCallback(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,7 @@ import type {
WebShellSidebarSessionInlineActionItem,
} from './WebShellSidebar';

const ALL_ITEMS: readonly WebShellSidebarSessionActionItem[] = [
'details',
'rename',
'group',
'export',
'delete',
'pin',
'archive',
];

const DEFAULT_ITEMS = DEFAULT_SESSION_ACTION_ITEMS;
const ALL_ITEMS = DEFAULT_SESSION_ACTION_ITEMS;

const DEFAULT_INLINE_ITEMS: readonly WebShellSidebarSessionInlineActionItem[] =
['pin'];
Expand Down Expand Up @@ -75,7 +65,7 @@ describe('session action visibility matrix', () => {
describe('defaults (no consumer config)', () => {
it('shows details on hover, pin inline, and archive plus mutations in the dropdown', () => {
const { inline, dropdown, hover, showDropdownTrigger } =
computeVisibility(DEFAULT_ITEMS, DEFAULT_INLINE_ITEMS);
computeVisibility(ALL_ITEMS, DEFAULT_INLINE_ITEMS);

expect([...inline].sort()).toEqual(['pin']);
expect([...dropdown].sort()).toEqual([
Expand All @@ -93,7 +83,7 @@ describe('session action visibility matrix', () => {
describe('items × inlineItems interaction', () => {
it('inlineItems: [] — all items fall to dropdown', () => {
const { inline, dropdown, showDropdownTrigger } = computeVisibility(
DEFAULT_ITEMS,
ALL_ITEMS,
[],
);

Expand All @@ -116,7 +106,7 @@ describe('session action visibility matrix', () => {
});

it('inlineItems: ["delete"] — delete inline only, not in dropdown', () => {
const { inline, dropdown } = computeVisibility(DEFAULT_ITEMS, ['delete']);
const { inline, dropdown } = computeVisibility(ALL_ITEMS, ['delete']);

expect(inline.has('delete')).toBe(true);
expect(dropdown.has('delete')).toBe(false);
Expand Down Expand Up @@ -155,10 +145,10 @@ describe('session action visibility matrix', () => {
items: readonly WebShellSidebarSessionActionItem[];
inlineItems: readonly WebShellSidebarSessionInlineActionItem[];
}> = [
{ items: DEFAULT_ITEMS, inlineItems: DEFAULT_INLINE_ITEMS },
{ items: DEFAULT_ITEMS, inlineItems: [] },
{ items: DEFAULT_ITEMS, inlineItems: ['pin', 'delete'] },
{ items: DEFAULT_ITEMS, inlineItems: ['rename', 'export', 'delete'] },
{ items: ALL_ITEMS, inlineItems: DEFAULT_INLINE_ITEMS },
{ items: ALL_ITEMS, inlineItems: [] },
{ items: ALL_ITEMS, inlineItems: ['pin', 'delete'] },
{ items: ALL_ITEMS, inlineItems: ['rename', 'export', 'delete'] },
{ items: ['delete', 'rename'], inlineItems: ['delete', 'rename'] },
{ items: ['pin', 'archive'], inlineItems: [] },
{ items: [], inlineItems: DEFAULT_INLINE_ITEMS },
Expand Down
Loading