Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
99 changes: 99 additions & 0 deletions packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3201,6 +3201,105 @@ describe('App session callbacks', () => {
}
});

it('lands on the first pane, not an empty new chat, when a shrink closes a URL-driven split', async () => {
let large = true;
let changeHandler: ((event: { matches: boolean }) => void) | undefined;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
get matches() {
return query.includes('min-width') ? large : false;
},
media: query,
addEventListener: (
_type: string,
cb: (event: { matches: boolean }) => void,
) => {
// Capture the isLargeScreen (1024px) query specifically — not the
// separate 1200px split-sidebar query — so flipping it drives the fold.
if (query.includes('1024')) changeHandler = cb;
},
removeEventListener: vi.fn(),
})),
});
// The single chat has no session of its own — the split was entered from a
// `?split=` deep link — so a naive close would strand on an empty new chat.
mockConnection.sessionId = undefined;
window.history.replaceState(null, '', '/?split=s1,s2');

try {
const { container } = renderApp();
await flush();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();

await act(async () => {
large = false;
changeHandler?.({ matches: false });
await Promise.resolve();
});

// The split folds back to chat and re-attaches to the first pane's
// session instead of stranding the user on an empty new chat.
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
expect(mockSessionActions.loadSession).toHaveBeenCalledWith('s1');
} finally {
window.history.replaceState(null, '', '/');
}
});

it('keeps the chat on its own session (does not re-point to the first pane) when a shrink closes the split', async () => {
let large = true;
let changeHandler: ((event: { matches: boolean }) => void) | undefined;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
get matches() {
return query.includes('min-width') ? large : false;
},
media: query,
addEventListener: (
_type: string,
cb: (event: { matches: boolean }) => void,
) => {
if (query.includes('1024')) changeHandler = cb;
},
removeEventListener: vi.fn(),
})),
});
// This chat HAS a session of its own — folding must leave it (and its git
// branch / URL) untouched rather than re-pointing at the split's first pane.
mockConnection.sessionId = 'own-session';
window.history.replaceState(null, '', '/?split=s1,s2');

try {
const { container } = renderApp();
await flush();
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
mockSessionActions.loadSession.mockClear();

await act(async () => {
large = false;
changeHandler?.({ matches: false });
await Promise.resolve();
});

// Folded back to chat, but the guard kept the existing session — no
// re-point to the first pane.
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
expect(mockSessionActions.loadSession).not.toHaveBeenCalled();
} finally {
window.history.replaceState(null, '', '/');
}
});

it('auto-closes the Session Overview when the screen shrinks below the breakpoint', async () => {
// Drive isLargeScreen through a controllable media query: open the panel on
// a large screen, then flip below the breakpoint and confirm it closes.
Expand Down
13 changes: 13 additions & 0 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2290,6 +2290,18 @@ export function App({
// split, or dropping back to that chat, is exactly what it was before.
if (!externalSplitControlled) {
splitFoldedByShrinkRef.current = true;
// …except when the chat has no session of its own — the common case
// when the split was entered from the Session Overview or a `?split=a,b`
// link. A bare fold would then strand the user on an empty "new chat",
// so land on the split's first (leftmost) pane instead. Guarded on the
// *empty* chat so it never re-points a chat that already has a session
// (which would wipe its git branch / change the session+URL it drops
// back to). Best-effort: a load failure (e.g. a non-primary-workspace
// pane the single connection can't own) just leaves the empty chat.
const firstPane = splitSessionIdsRef.current[0];
if (firstPane && !currentSessionIdRef.current) {
void sessionActions.loadSession(firstPane).catch(() => undefined);
}
}
}
}, [
Expand All @@ -2298,6 +2310,7 @@ export function App({
mainView,
notifyControlledSplitClose,
externalSplitControlled,
sessionActions,
]);
// Land focus on the composer after a shrink-driven split close so keyboard
// users aren't dropped onto <body> — but not when the chat now shows an
Expand Down
Loading