Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f53df3e
fix(web-shell): surface cross-workspace sessions in split view & over…
wenshao Jul 11, 2026
23cc397
fix(web-shell): repair stale composerTagIcons import that broke the b…
wenshao Jul 12, 2026
c7e36b4
fix(web-shell): address review feedback for cross-workspace sessions
wenshao Jul 12, 2026
c58e667
feat(web-shell): show each split pane's workspace in its composer pla…
wenshao Jul 12, 2026
30f44d8
Revert "feat(web-shell): show each split pane's workspace in its comp…
wenshao Jul 12, 2026
e258178
Merge remote-tracking branch 'origin/main' into fix/web-shell-split-o…
wenshao Jul 12, 2026
ea32215
feat(web-shell): label each split pane's workspace in its composer to…
wenshao Jul 12, 2026
aadea86
feat(web-shell): show the workspace chip in the main composer too
wenshao Jul 12, 2026
bd3d928
fix(web-shell): keep a session when a shrink closes the split view
wenshao Jul 12, 2026
4c2f2fd
Merge remote-tracking branch 'origin/main' into fix/web-shell-split-o…
wenshao Jul 12, 2026
4cc5fb4
fix(web-shell): restore the split view when the screen grows back
wenshao Jul 12, 2026
4a04476
fix(web-shell): keep the chat's git branch when folding the split on …
wenshao Jul 12, 2026
5a21824
feat(web-shell): auto-collapse the sidebar in a narrow split view
wenshao Jul 12, 2026
3fecb6c
test(web-shell): cover the cross-workspace fetch race, quiet split-pi…
wenshao Jul 12, 2026
52f7fbe
test(web-shell): use a valid DaemonMode and assert the pane workspace…
wenshao Jul 12, 2026
3f7b67a
perf(web-shell): memoize pane toolbar actions; cover chip tooltip fal…
wenshao Jul 12, 2026
37f12f3
merge: resolve conflict with origin/main in ChatEditor imports
qwen-code-dev-bot Jul 12, 2026
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
155 changes: 151 additions & 4 deletions packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ vi.mock('./components/sidebar/WebShellSidebar', async () => {
return {
WebShellSidebar: (props: {
sessionListReloadToken?: number;
collapsed?: boolean;
onOpenDaemonStatus?: () => void;
onOpenSessions?: () => void;
onOpenSplitView?: () => void;
Expand All @@ -346,7 +347,10 @@ vi.mock('./components/sidebar/WebShellSidebar', async () => {
// exercise those activePanel branches (neither has a slash command).
return React.createElement(
'div',
{ 'data-testid': 'sidebar' },
{
'data-testid': 'sidebar',
'data-collapsed': String(Boolean(props.collapsed)),
},
React.createElement(
'button',
{
Expand Down Expand Up @@ -1958,7 +1962,7 @@ describe('App session callbacks', () => {
_type: string,
cb: (event: { matches: boolean }) => void,
) => {
if (query.includes('min-width')) changeHandler = cb;
if (query.includes('1024')) changeHandler = cb;
},
removeEventListener: vi.fn(),
})),
Expand Down Expand Up @@ -2002,7 +2006,7 @@ describe('App session callbacks', () => {
_type: string,
cb: (event: { matches: boolean }) => void,
) => {
if (query.includes('min-width')) changeHandler = cb;
if (query.includes('1024')) changeHandler = cb;
},
removeEventListener: vi.fn(),
})),
Expand Down Expand Up @@ -2031,6 +2035,149 @@ describe('App session callbacks', () => {
).toBeNull();
});

it('folds the split without switching the chat session on shrink', 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(),
})),
});
mockConnection.sessionId = 'session-1';
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, but folding must leave the chat's own
// connection untouched — switching sessions here would drop its session /
// git-branch / URL context and break the lossless restore on regrow.
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
expect(mockSessionActions.loadSession).not.toHaveBeenCalled();
} finally {
window.history.replaceState(null, '', '/');
}
});

it('restores the split view when the screen grows back after a shrink', 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(),
})),
});
window.history.replaceState(null, '', '/?split=s1,s2');

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

// Shrinking below the breakpoint folds the split away...
await act(async () => {
large = false;
changeHandler?.({ matches: false });
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();

// ...and growing back past it restores the same split (a transient resize
// is lossless, not a permanent drop of the panes).
await act(async () => {
large = true;
changeHandler?.({ matches: true });
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();
} finally {
window.history.replaceState(null, '', '/');
}
});

it('auto-collapses the sidebar in a narrow split and expands it when wide', async () => {
let wide = false;
let changeHandler: ((event: { matches: boolean }) => void) | undefined;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
get matches() {
// Keep the large-screen (>=1024) query true so the split renders;
// the >=1200 "sidebar has room" query is the one under test.
if (query.includes('1200')) return wide;
return query.includes('min-width');
},
media: query,
addEventListener: (
_type: string,
cb: (event: { matches: boolean }) => void,
) => {
if (query.includes('1200')) changeHandler = cb;
},
removeEventListener: vi.fn(),
})),
});
window.history.replaceState(null, '', '/?split=s1,s2');

try {
const { container } = renderApp();
await flush();
const sidebar = () => container.querySelector('[data-testid="sidebar"]');
// Narrow split (< 1200px): the sidebar collapses to free room for panes.
expect(sidebar()?.getAttribute('data-collapsed')).toBe('true');

// Grow past 1200px: the sidebar expands again.
await act(async () => {
wide = true;
changeHandler?.({ matches: true });
await Promise.resolve();
});
expect(sidebar()?.getAttribute('data-collapsed')).toBe('false');
} 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 All @@ -2047,7 +2194,7 @@ describe('App session callbacks', () => {
_type: string,
cb: (event: { matches: boolean }) => void,
) => {
if (query.includes('min-width')) changeHandler = cb;
if (query.includes('1024')) changeHandler = cb;
},
removeEventListener: vi.fn(),
})),
Expand Down
71 changes: 61 additions & 10 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ import {
} from './utils/copyCommand';
import { isEditableTarget } from './utils/dom';
import { getModelDisplayName } from './utils/modelDisplay';
import { hasMultipleWorkspaces, workspaceBasename } from './utils/workspace';
import { isVisibleComposerModel } from './utils/composerModels';
import { filterModelSwitchMessages } from './utils/modelSwitchMessages';
import { decideEscapeIntent } from './utils/escapeIntent';
Expand Down Expand Up @@ -1003,6 +1004,11 @@ export function App({
// once) is only offered on large screens; below that there is no room for it
// to be useful.
const isLargeScreen = useIsLargeScreen();
// In split view the session sidebar competes with the panes for width. Below
// this width it auto-collapses to its icon rail so the panes get the room, and
// expands again once the window grows back. A wide split keeps the full
// sidebar (and the user's own collapse preference).
const splitSidebarHasRoom = useIsLargeScreen('(min-width: 1200px)');

useEffect(() => {
const mql = window.matchMedia('(max-width: 760px)');
Expand Down Expand Up @@ -1944,6 +1950,10 @@ export function App({
);
// Sessions to seed the split view with (e.g. the selection from the overview).
const [splitSessionIds, setSplitSessionIds] = useState<string[]>([]);
// 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);
splitSessionIdsRef.current = splitSessionIds;
const [showExtensionsDialog, setShowExtensionsDialog] = useState(false);
const [mcpDialogMessage, setMcpDialogMessage] =
useState<SerializedMcpStatusMessage | null>(null);
Expand Down Expand Up @@ -2089,23 +2099,53 @@ export function App({
openSplitView(ids);
}
}, [externalSplitControlled, openSplitView]);
// If the viewport shrinks below the large-screen breakpoint, close the Session
// Overview panel and the split view — both are large-screen-only surfaces
// whose entry points are hidden on small screens, so leaving them up would
// strand the user in a view they can no longer re-enter.
// When a shrink closes the split, its panes unmount and take keyboard focus
// with them; flag the composer to be refocused once the chat is shown again.
// If the viewport shrinks below the large-screen breakpoint, fold away the
// Session Overview panel and the split view — both are large-screen-only
// surfaces whose entry points are hidden on small screens. The split is only
// folded, not discarded: growing back past the breakpoint restores it, so a
// transient resize is lossless. When a shrink folds the split, its panes
// unmount and take keyboard focus with them; flag the composer to be refocused
// once the chat is shown again.
const focusComposerAfterSplitCloseRef = useRef(false);
// True while the split view is only *temporarily* folded away because the
// window is narrower than the large-screen breakpoint. Growing back past the
// breakpoint restores it, so a transient resize doesn't drop the user's panes.
const splitFoldedByShrinkRef = useRef(false);
useEffect(() => {
if (!isLargeScreen && activePanel === 'sessions') {
if (isLargeScreen) {
// Grew back above the breakpoint: restore a split that a shrink folded
// away. Standalone/uncontrolled only — a controlled host owns its split
// lifecycle and re-opens it itself.
if (splitFoldedByShrinkRef.current) {
splitFoldedByShrinkRef.current = false;
if (!externalSplitControlled && splitSessionIdsRef.current.length > 0) {
setMainView((prev) => (prev === 'chat' ? 'split' : prev));
}
}
return;
}
if (activePanel === 'sessions') {
setActivePanel(null);
}
if (!isLargeScreen && mainView === 'split') {
if (mainView === 'split') {
notifyControlledSplitClose();
setMainView('chat');
focusComposerAfterSplitCloseRef.current = true;
// Fold, don't discard: remember to restore the same split once the screen
// grows back, so a transient shrink is lossless. The chat's own connection
// (its session, git branch, URL, …) is left untouched — restoring the
// split, or dropping back to that chat, is exactly what it was before.
if (!externalSplitControlled) {
splitFoldedByShrinkRef.current = true;
}
}
}, [isLargeScreen, activePanel, mainView, notifyControlledSplitClose]);
}, [
isLargeScreen,
activePanel,
mainView,
notifyControlledSplitClose,
externalSplitControlled,
]);
// 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
// approval overlay (it owns the keyboard) or a panel (its Back self-focuses).
Expand Down Expand Up @@ -5396,7 +5436,11 @@ export function App({
aria-hidden="true"
/>
<WebShellSidebar
collapsed={sidebarCollapsed && !mobileDrawerOpen}
collapsed={
(sidebarCollapsed ||
(mainView === 'split' && !splitSidebarHasRoom)) &&
!mobileDrawerOpen
}
onCollapsedChange={handleSidebarCollapsedChange}
onOpenSettings={() => {
closeMobileDrawer();
Expand Down Expand Up @@ -5969,6 +6013,13 @@ export function App({
currentMode={currentMode}
currentModel={currentModel}
gitBranch={connection.gitBranch}
workspaceName={
hasMultipleWorkspaces(connection.capabilities) &&
connection.workspaceCwd
? workspaceBasename(connection.workspaceCwd)
: undefined
}
workspaceTitle={connection.workspaceCwd || undefined}
chatWidthMode={chatWidthMode}
showChatWidthToggle={!isChatEmptyState}
chatWidthToggleMin={chatWidthToggleMin}
Expand Down
49 changes: 48 additions & 1 deletion packages/web-shell/client/components/ChatEditor.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,42 @@
white-space: nowrap;
}

/* Which workspace a split-view pane's session lives in — parallel to the git
branch chip, shown only on a multi-workspace daemon. */
.workspaceChip {
display: inline-flex;
min-width: 0;
max-width: 160px;
height: 28px;
padding: 0 8px;
align-items: center;
gap: 5px;
border-radius: 6px;
color: var(--agent-gray-500);
font-family: var(--font-sans, system-ui, sans-serif);
font-size: 13px;
line-height: 1;
}

.workspaceChipIcon {
display: inline-flex;
width: 16px;
height: 16px;
flex: 0 0 16px;
}

.workspaceChipIcon svg {
width: 16px;
height: 16px;
}

.workspaceChipText {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.toolbarRight {
flex-shrink: 0;
margin-right: -5px;
Expand All @@ -965,7 +1001,7 @@
/* Keep the leading controls on a single row on narrow screens. The branch
chip yields space first — truncating via its ellipsis, down to just the
icon if needed — instead of pushing the mode/model buttons onto a second
line and making the composer taller. */
line and making the composer taller (#6753). */
.toolbarLeft {
flex-wrap: nowrap;
}
Expand All @@ -978,6 +1014,17 @@
max-width: 140px;
flex-shrink: 1;
}

/* Keep the workspace name legible in a narrow split pane — it is the pane's
identity, so unlike the action buttons it never collapses to an icon; it
only tightens and truncates (the full cwd stays in the tooltip). It yields
space the same way the branch chip does so the row never wraps. */
.toolbarLeft .workspaceChip {
max-width: 108px;
flex-shrink: 1;
padding: 0 6px;
gap: 4px;
}
}

.dropdownWrapper {
Expand Down
Loading
Loading