From 352c72ef91f4ad6b7bcf06aae318cb3ad6bfe7dc Mon Sep 17 00:00:00 2001 From: wenshao Date: Thu, 16 Jul 2026 11:11:58 +0800 Subject: [PATCH] fix(web-shell): land on the split's first pane when a shrink folds the split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the viewport shrinks below the large-screen breakpoint the split view folds back to the single chat. If that chat had no session of its own — the common case when the split was entered from the Session Overview or a `?split=a,b` link — the user was stranded on an empty "new chat" instead of the split's first (leftmost) pane. This restores a fallback that was tried and then superseded during #6746: the fold-time re-point to the first pane was dropped because it wiped the git branch and changed the session+URL of a chat that already had a session. Re-add it but guarded on the *empty* chat (`!currentSessionId`) — an empty chat has no git branch or session to preserve — so it fixes the empty-new-chat case without regressing the branch-preservation case. Best-effort and standalone-split-only, as before. Covered by two tests: lands on the first pane when the outer chat is empty, and keeps the existing session (no re-point) when it isn't. --- packages/web-shell/client/App.test.tsx | 99 ++++++++++++++++++++++++++ packages/web-shell/client/App.tsx | 13 ++++ 2 files changed, 112 insertions(+) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 45eb3fe7f01..f35ff18c98f 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -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. diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 698f0f7b8eb..d157dfdea4a 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -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); + } } } }, [ @@ -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 — but not when the chat now shows an