feat(web-shell): Improve split-view session navigation (#11250)

* feat(web-shell): Improve split-view session navigation

* fix(ci): Run Web Shell browser checks on hosted runners

* fix(web-shell): Keep split navigation outside approval shortcuts

* fix(web-shell): Address split-view review regressions

* fix(web-shell): Stabilize split approval reports

* fix(web-shell): Preserve history anchors during slow rendering

---------

Co-authored-by: 易良 <[email protected]>
This commit is contained in:
Shaojin Wenand易良 authored and GitHub committed 2026-09-08 17:33:19 +00:00
1 parent b2e4db8624
commit 70cf363395
23 files changed
+1319 -88

No files matched your search

+14 -22
View File
@@ -1304,23 +1304,18 @@ jobs:
needs.classify_pr.outputs.skip_ci != 'true' &&
needs.test.outputs.ci_profile == 'full'
}}
runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}'
# Routed like test's and lint's, so it needs their allowance too — it was
# the last job on the pool still priced flat, and one commit lost it twice:
# on hk3-9 `npm ci` alone ran 19m46s of the 20 and never finished, and on
# hk4-23 install took 10m33s against 4m53s on hk5-2, leaving the browser
# smoke 8m15s before the job died against 2m29s warm. Both phases 2-3x is
# the pool, not the diff: the same job passed on the previous commit, which
# touches no web-shell file. Hosted runners keep 20 for the reason lint's
# copy gives — a genuine hang there should not burn the ECS allowance.
timeout-minutes: '${{ fromJSON(contains(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'', ''ecs-qwen'') && ''40'' || ''20'') }}'
# The same commit repeatedly lost browser resources or exceeded the
# document performance budget on ECS, while both gates passed on hosted.
# Keep this browser job hosted without relaxing its test assertions.
runs-on: 'ubuntu-latest'
timeout-minutes: 20
permissions:
contents: 'read'
steps:
# Self-hosted runners reuse the workspace; a prior containerised job can
# leave root-owned, read-only files anywhere in it. Restore ownership and
# write permission unconditionally before checkout — see the test job's
# 'Restore workspace ownership' step for why probing first is unsafe.
# Retain the shared-pool recovery steps so rerouting needs only a runner
# change. Hosted workspaces are fresh; on a reused workspace a prior
# containerised job can leave root-owned, read-only files. Restore them
# unconditionally before checkout, as in the test job.
- name: 'Restore workspace ownership'
run: |-
set -uo pipefail
@@ -1331,11 +1326,9 @@ jobs:
fi
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
# Same pre-checkout recovery as the test job: this job lands on the
# same reused pool, so leftover review worktrees and branches from an
# interrupted review would break this checkout too. The
# `.qwen.root-orig` name's provenance (an external recovery tool) is
# documented on the test job's copy.
# Keep this retained sweep pinned to the test job's hardened recovery
# recipe, even while hosted. The `.qwen.root-orig` name's provenance
# (an external recovery tool) is documented on the test job's copy.
- name: 'Clean stale .qwen before checkout'
run: |-
set -uo pipefail
@@ -1461,9 +1454,8 @@ jobs:
npm config set fetch-retries 5
npm config set fetch-timeout 300000
# Same pre-install admission check as the test job (#10035): this job
# lands on the same self-hosted pool and installs before the gate would
# otherwise have freed space for it.
# Retained for a future shared-pool reroute: apply the test job's
# pre-install admission check (#10035) before dependency installation.
- name: 'Disk floor gate (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
run: 'bash .github/scripts/check-disk-floor.sh "${GITHUB_WORKSPACE}" "${RUNNER_TEMP:-/tmp}"'
@@ -0,0 +1,64 @@
# Web Shell split-view usability
The split view is intended for large displays and multiple monitors. Keep its
equal-width horizontal row, full-height transcripts, independent session
providers, six-pane limit, add/close controls, back navigation, maximize/restore,
and existing per-tab session persistence. Additional layout modes, width dragging,
and moving sessions between windows are outside this change.
Three small improvements make the existing view easier to use without adding a
toolbar row or reducing transcript height:
- Reuse the sidebar's session-details popover on the pane title. Keep its delayed
pointer hover, copy control, workspace, branch, PR/issue links, and status. Open
below the title and constrain it to its pane, inside the Web Shell root. Honor
the host's session-details action allowlist. Header action buttons
remain outside the hover target. Hovering must not focus or activate a pane.
- Mark the last clicked or keyboard-focused pane with an inset line in its
existing header. Adding, closing, and maximizing panes keep this selection
valid, choosing a neighbour when the selected pane closes. Expose the same
current location to assistive technology.
- Show a pending-session count in the existing toolbar only while a pane awaits
tool approval or a user answer. Clicking cycles through waiting panes in row
order, reveals a hidden maximized sibling when needed, scrolls the row to the
target, and focuses the labelled pane wrapper outside all approval keyboard
handlers. Enter, Escape, and digits immediately after navigation cannot submit
a response; Escape can restore the row. Tab or a deliberate click enters the
pane's controls. Announce pending counts without moving focus, and return focus
to Back if the focused pending button disappears. The outer session's notice
appears until a pane actually reports that approval, so failed or
still-attaching panes cannot hide the only available notice.
`ChatPane` already derives its pending tool/question request from its own
transcript. Report that boolean to `SplitView` with a stable callback, including
cleanup when the session changes or unmounts. The parent counts only currently
open panes, including hidden siblings. Do not infer waiting state from the
session-list running flag or introduce another daemon subscription.
`SplitView` passes session-list metadata to the pane; its live running state
comes from the pane's existing active-prompt bridge. Until metadata is available,
retain the native title fallback. Extend
`SessionDetailsTooltip` with bottom placement while preserving the sidebar's
right placement and scoped portal behavior. Keep the shared details markup and
styling, with no duplicate tooltip implementation.
Affected areas are `SplitView`, `ChatPane`, the shared sidebar details popover,
their CSS and focused tests, English/Chinese labels, and the split-view browser
regression suite. No daemon protocol, persistence format, package API, or layout
breakpoint changes are required. There are no open design questions.
Validation covers delayed hover without focus theft, action-button exclusion,
active-pane pointer/keyboard selection, pending tool and question transitions,
hidden-pane navigation, draft retention, and existing add/close/maximize/reload
behavior. The browser regressions are committed in
`packages/web-shell/client/e2e/web-shell.split-persist.spec.ts` and tagged
`@smoke` for PR CI. The baseline and verification evidence are published in
[PR #11250](https://github.com/QwenLM/qwen-code/pull/11250).
The PR's browser CI job runs on GitHub hosted Ubuntu. The unchanged feature
passed the document gate and all 50 smoke cases there, while three ECS runs
failed during browser resource loading or at the document performance budget.
Keep the existing 60-second document budget, smoke assertions, triggers, and
hosted 20-minute job limit. Other CI jobs retain their current runner routing.
This uses hosted capacity for the browser job and may incur hosted queue time;
it does not diagnose the underlying ECS network or performance bottleneck.
+107 -1
View File
@@ -27,6 +27,7 @@ import {
type GoalSnapshotV2,
} from '@qwen-code/sdk/daemon';
import type { WebShellApi } from './App';
import { DEFAULT_SESSION_ACTION_ITEMS } from './components/sidebar/WebShellSidebar';
import type { Message } from './adapters/types';
import type {
VoiceStatusRevision,
@@ -653,6 +654,8 @@ const {
settings: DaemonSettingDescriptor[];
} | null,
latestSplitViewProps: null as {
onPendingPanesChange?: (ids: string[]) => void;
showSessionDetails?: boolean;
includeOtherWorkspaces?: boolean;
workspaceCwd?: string;
sessionWorkflowEnabled?: boolean;
@@ -1315,9 +1318,14 @@ vi.mock('./components/dialogs/DialogShell', async () => {
};
});
vi.mock('./components/sidebar/WebShellSidebar', async () => {
vi.mock('./components/sidebar/WebShellSidebar', async (importOriginal) => {
const React = await import('react');
const actual =
await importOriginal<
typeof import('./components/sidebar/WebShellSidebar')
>();
return {
DEFAULT_SESSION_ACTION_ITEMS: actual.DEFAULT_SESSION_ACTION_ITEMS,
WebShellSidebar: (props: {
collapsed?: boolean;
onOpenSettings?: () => void;
@@ -1747,6 +1755,7 @@ vi.doMock('./components/SplitView', async () => {
onExit?: () => void;
sessionIds?: string[];
onPanesChange?: (ids: string[]) => void;
onPendingPanesChange?: (ids: string[]) => void;
includeOtherWorkspaces?: boolean;
workspaceCwd?: string;
sessionWorkflowEnabled?: boolean;
@@ -10515,6 +10524,12 @@ describe('App session workflow', () => {
await Promise.resolve();
});
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="split-report-panes"]')
?.click();
});
testState.settings = [sessionWorkflowSetting()];
rerender();
await flush();
@@ -28888,6 +28903,91 @@ describe('App session callbacks', () => {
).toBeNull();
});
it.each<undefined | Array<'details'>>([undefined, [], ['details']])(
'applies the session-details allowlist to split panes: %j',
async (items) => {
const { container } = renderApp({
sidebar: { sessionActions: { items } },
});
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-split-view"]')
?.click();
});
expect(testState.latestSplitViewProps?.showSessionDetails).toBe(
(items ?? DEFAULT_SESSION_ACTION_ITEMS).includes('details'),
);
},
);
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 its current session is reported', async () => {
const { container, rerender } = renderApp();
await flush();
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="open-split-view"]')
?.click();
});
await act(async () => {
testState.blocks = [makePendingPermissionBlock()];
rerender();
});
expect(
container.querySelector('[data-testid="split-initial"]')?.textContent,
).toContain(mockConnection.sessionId);
const notice = () =>
container.querySelector('[data-testid="split-approval-notice"]');
expect(notice()).not.toBeNull();
await act(async () => {
testState.latestSplitViewProps?.onPendingPanesChange?.([
mockConnection.sessionId!,
]);
});
expect(notice()).toBeNull();
const previous = testState.latestSplitViewProps!.onPendingPanesChange!;
const previousSessionId = mockConnection.sessionId!;
await act(async () => {
mockConnection.sessionId = 'outer-session-2';
rerender();
});
const next = testState.latestSplitViewProps!.onPendingPanesChange!;
expect(next).not.toBe(previous);
await act(async () => next([previousSessionId]));
expect(notice()).not.toBeNull();
await act(async () => next(['outer-session-2']));
expect(notice()).toBeNull();
await act(async () => {
testState.latestSplitViewProps?.onPendingPanesChange?.([]);
});
expect(notice()).not.toBeNull();
});
it('surfaces the outer approval as a split notice and returns to chat when clicked', async () => {
// The overlay is suppressed under the split, so the outer approval would be
// invisible; a notice banner (with a way back) is the only signal.
@@ -28900,6 +29000,12 @@ describe('App session callbacks', () => {
?.click();
await Promise.resolve();
});
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="split-report-panes"]')
?.click();
});
await act(async () => {
testState.blocks = [makePendingPermissionBlock()];
rerender();
+16 -3
View File
@@ -242,6 +242,7 @@ import {
} from './shadowDom';
import {
WebShellSidebar,
DEFAULT_SESSION_ACTION_ITEMS,
type WebShellSidebarBranding,
type WebShellSidebarFooterOptions,
type WebShellSidebarWorkspaceOverviewOptions,
@@ -8060,6 +8061,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 [outerSplitPanePending, setOuterSplitPanePending] = useState(false);
const handleSplitPendingPanesChange = useCallback(
(ids: string[]) =>
setOuterSplitPanePending(ids.includes(connection.sessionId ?? '')),
[connection.sessionId],
);
// False until the split bootstrap has decided whether a split view is
// coming (URL deep link, per-tab sessionStorage, or controlled prop). The
// pane-tab reclaim below must wait for it: at restore-commit time
@@ -17634,9 +17641,10 @@ export function App({
<div className={styles.fullPage} data-testid="split-view-page">
{/* The outer session's approval overlay is suppressed under the
split (it would own ghost keyboard shortcuts). If that
session isn't one of the panes, the approval would be
invisible — surface a notice with a way back to it. */}
{approvalOverlayActive && (
session's pane hasn't surfaced its approval (including
failed or still-attaching panes), show a way back to it. */}
{approvalOverlayActive &&
!outerSplitPanePending && (
<div
className={styles.splitApprovalNotice}
role="status"
@@ -17655,11 +17663,16 @@ export function App({
<WebShellCustomizationProvider value={customization}>
<SplitView
sessionIds={splitSessionIds}
showSessionDetails={
(sidebarOptions.sessionActions?.items ??
DEFAULT_SESSION_ACTION_ITEMS).includes('details')
}
// Mirror live pane add/remove back up so switching away
// and re-entering restores the same panes. Keep this
// callback stable to avoid looping SplitView's reporting
// effect.
onPanesChange={handleSplitPanesChange}
onPendingPanesChange={handleSplitPendingPanesChange}
includeOtherWorkspaces={!lockedWorkspaceCwd}
workspaceCwd={lockedWorkspaceCwd}
// Back returns to the Session Overview (the hub the split
@@ -15,6 +15,10 @@
border-radius: 0;
}
.pane[data-pane-active] .header {
box-shadow: inset 0 2px var(--primary);
}
.paneEmbedded .footer {
padding-bottom: 0;
}
@@ -13,6 +13,7 @@ import {
GOAL_PAUSE_REASON_COMMAND,
} from '@qwen-code/sdk/daemon';
import { I18nProvider } from '../i18n';
import { formatDateTime } from '../utils/formatDateTime';
import {
WebShellCustomizationProvider,
type WebShellComposerToolbarRenderInfo,
@@ -527,6 +528,133 @@ function deferred<T>() {
}
describe('ChatPane', () => {
it('exposes the selected pane without confusing it with a running session', () => {
const props = { isActive: true };
render(props);
expect(testid('chat-pane')?.hasAttribute('data-pane-active')).toBe(true);
expect(testid('chat-pane')?.getAttribute('aria-current')).toBe('location');
sessionHasActivePromptValue = true;
rerender(props);
expect(testid('chat-pane')?.hasAttribute('data-pane-active')).toBe(true);
expect(testid('chat-pane')?.getAttribute('aria-current')).toBe('location');
rerender();
expect(testid('chat-pane')?.hasAttribute('data-pane-active')).toBe(false);
expect(testid('chat-pane')?.hasAttribute('aria-current')).toBe(false);
});
it('reports tool and question waiting state while hidden and clears it on unmount', () => {
const onApprovalChange = vi.fn();
const props = { hidden: true, onApprovalChange };
render(props);
expect(onApprovalChange).toHaveBeenLastCalledWith(
connectionState.sessionId,
false,
);
pendingPermission = { id: 'perm-1', toolName: 'write_file', rawInput: {} };
rerender(props);
expect(onApprovalChange).toHaveBeenLastCalledWith(
connectionState.sessionId,
true,
);
pendingPermission = null;
rerender(props);
expect(onApprovalChange).toHaveBeenLastCalledWith(
connectionState.sessionId,
false,
);
pendingPermission = {
id: 'ask-1',
rawInput: { questions: [{ question: 'pick', options: [] }] },
};
rerender(props);
expect(onApprovalChange).toHaveBeenLastCalledWith(
connectionState.sessionId,
true,
);
expect(submitPermission).not.toHaveBeenCalled();
act(() => root!.unmount());
root = null;
expect(onApprovalChange).toHaveBeenLastCalledWith(
connectionState.sessionId,
false,
);
});
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 = {
title: 'Pane details',
workspaceCwd: '/work/split-project',
sessionSummary: {
sessionId: 'session-details',
workspaceCwd: '/work/split-project',
createdAt: '2026-01-01T00:00:00Z',
updatedAt,
hasActivePrompt: false,
branch: { name: 'codex/split', baseBranch: 'main' },
},
onToggleMaximize: vi.fn(),
};
if (multiWorkspace) {
connectionState.capabilities = {
features: [],
workspaces: [
{
id: 'w0',
cwd: '/work/web-shell',
primary: true,
trusted: true,
},
{
id: 'w1',
cwd: '/work/split-project',
displayName: 'Payments API',
primary: false,
trusted: true,
},
],
};
}
sessionHasActivePromptValue = true;
render(props);
const title = container!.querySelector('[data-slot="popover-anchor"]')!;
expect(title.textContent).toBe('Pane details');
expect(title.querySelector('button')).toBeNull();
const composerFocus = document.createElement('input');
container!.append(composerFocus);
composerFocus.focus();
await act(async () => {
title.dispatchEvent(new Event('pointerover', { bubbles: true }));
vi.advanceTimersByTime(300);
});
expect(
document.querySelector('[role="dialog"]')?.textContent,
).toContain(multiWorkspace ? 'Payments API' : 'split-project');
expect(
document.querySelector('[role="dialog"]')?.textContent,
).toContain('codex/split');
expect(
document.querySelector('[role="dialog"]')?.textContent,
).toContain('Running');
expect(
document.querySelector('[role="dialog"]')?.textContent,
).toContain(formatDateTime(updatedAt ?? '2026-01-01T00:00:00Z'));
expect(document.activeElement).toBe(composerFocus);
rerender({ ...props, hidden: true });
expect(document.querySelector('[role="dialog"]')).toBeNull();
} finally {
vi.useRealTimers();
}
},
);
it('polls workflow tasks from the daemon capability, not the UI setting', async () => {
connectionState.supportedCommands = { workflowsEnabled: true };
messagesState = [
@@ -27,6 +27,7 @@ import {
import {
type DaemonSessionArtifact,
type DaemonSessionMonitorTaskStatus,
type DaemonSessionSummary,
type DaemonWorkspaceCapability,
type ReasoningSelection,
} from '@qwen-code/sdk/daemon';
@@ -74,6 +75,7 @@ import { buildGoalControlRequest } from '../utils/goalControlRequest';
import { isGoalGateBlocked } from '../utils/goalGate';
import type { WebShellSlashCommandHandler } from '../App';
import { getModelDisplayName } from '../utils/modelDisplay';
import { formatDateTime } from '../utils/formatDateTime';
import {
hasMultipleWorkspaces,
workspaceLabelForCwd,
@@ -116,6 +118,7 @@ import {
getScheduledTasksByTurn,
} from './artifacts/turnOutputSelectors';
import { PaneHeaderActions } from './PaneHeaderActions';
import { SessionDetailsTooltip } from './sidebar/SessionDetailsTooltip';
import styles from './ChatPane.module.css';
import accentStyles from './WorkspaceAccent.module.css';
@@ -166,6 +169,12 @@ interface UnknownPromptAdmission {
export interface ChatPaneProps {
/** Header label; falls back to the session's own display name / id. */
title?: string;
/** Session-list metadata for the shared title details. */
sessionSummary?: DaemonSessionSummary;
/** Last interacted pane, independent of whether its session is running. */
isActive?: boolean;
/** Must be referentially stable; reports pending state and unmount cleanup. */
onApprovalChange?: (sessionId: string, pending: boolean) => void;
/**
* The workspace this pane's session lives in. Passed explicitly by the split
* view (which knows it per session) and shown as a composer-toolbar chip on a
@@ -237,6 +246,9 @@ export interface ChatPaneProps {
*/
export function ChatPane({
title,
sessionSummary,
isActive = false,
onApprovalChange,
workspaceCwd,
renderHeaderActions,
onClose,
@@ -558,6 +570,12 @@ export function ChatPane({
pendingToolApprovalRef.current = pendingToolApproval;
const approvalActive =
pendingToolApproval !== null || pendingAskUserApproval !== null;
useEffect(() => {
const sessionId = connection.sessionId;
if (!sessionId || !onApprovalChange) return;
onApprovalChange(sessionId, approvalActive);
return () => onApprovalChange(sessionId, false);
}, [connection.sessionId, approvalActive, onApprovalChange]);
const paneVoiceCwd =
connection.sessionId &&
connection.workspaceCwd &&
@@ -1192,6 +1210,7 @@ export function ChatPane({
const headerLabel =
title || connection.displayName || connection.sessionId?.slice(0, 8) || '';
const sessionStamp = sessionSummary?.updatedAt || sessionSummary?.createdAt;
// On a multi-workspace daemon, surface this pane's workspace as a composer-
// toolbar chip (next to where the git-branch chip sits), so it's clear which
@@ -1241,6 +1260,8 @@ export function ChatPane({
<section
className={`${styles.pane} ${embedded ? styles.paneEmbedded : ''}`.trim()}
data-testid="chat-pane"
data-pane-active={isActive ? '' : undefined}
aria-current={isActive ? 'location' : undefined}
aria-label={headerLabel}
>
{goalEditOpen && connection.goalState?.goal && (
@@ -1275,9 +1296,25 @@ export function ChatPane({
<span className={styles.workspaceTagText}>{workspaceLabel}</span>
</span>
)}
<span className={styles.title} title={headerLabel}>
{headerLabel}
</span>
{sessionSummary && !hidden ? (
<SessionDetailsTooltip
session={{
...sessionSummary,
hasActivePrompt: sessionHasActivePrompt,
}}
label={headerLabel}
time={sessionStamp ? formatDateTime(sessionStamp) : ''}
completedUnread={false}
workspaceLabel={workspaceLabel}
side="bottom"
>
<span className={styles.title}>{headerLabel}</span>
</SessionDetailsTooltip>
) : (
<span className={styles.title} title={headerLabel}>
{headerLabel}
</span>
)}
<PaneHeaderActions
trailing={
onToggleMaximize || onClose ? (
@@ -47,6 +47,17 @@
margin-left: auto;
}
.pendingButton {
flex: 0 0 auto;
padding: 4px 10px;
border: 1px solid var(--warning-border);
border-radius: 8px;
background: var(--warning-bg);
color: var(--warning-color);
font-size: 12px;
cursor: pointer;
}
.addButton {
appearance: none;
padding: 4px 12px;
@@ -28,17 +28,23 @@ let workspaceClient: {
// Stable across renders (assigned once per test) so SplitView's reload effects,
// which depend on `reload`'s identity, don't re-fire on every render.
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
@@ -87,13 +93,23 @@ vi.mock('../hooks/useScopedSessions', () => ({
vi.mock('./ChatPane', () => ({
ChatPane: (props: any) => {
const sessionId = React.useContext(PaneSessionContext);
const pending = !!sessionId && waitingSessions.has(sessionId);
const { onApprovalChange } = props;
React.useEffect(() => {
if (!sessionId) return;
onApprovalChange?.(sessionId, pending);
return () => onApprovalChange?.(sessionId, false);
}, [sessionId, pending, onApprovalChange]);
// Let a test force a render crash to exercise the per-pane ErrorBoundary.
if (props.title === 'BOOM') throw new Error('pane exploded');
return (
<div
data-testid="chat-pane"
data-pane-workspace={props.workspaceCwd}
data-session-details={props.sessionSummary?.sessionId}
data-maximized={props.isMaximized ? 'true' : 'false'}
data-pane-active={props.isActive ? '' : undefined}
data-slash-handler={props.onSlashCommand ? 'true' : 'false'}
data-hidden={props.hidden ? 'true' : 'false'}
data-report-catalog-turn-completion={
@@ -103,6 +119,14 @@ vi.mock('./ChatPane', () => ({
data-voice-workspace-count={String(props.voiceWorkspaces?.length ?? 0)}
>
<span data-testid="pane-title">{props.title}</span>
<input aria-label={`Draft ${props.title}`} />
{pending && (
<div data-testid="pane-approval">
<button tabIndex={0} onClick={confirmApproval}>
Approve
</button>
</div>
)}
{props.renderHeaderActions && (
<span data-testid="pane-header-slot">
{props.renderHeaderActions({
@@ -132,6 +156,10 @@ let root: Root | null = null;
let container: HTMLDivElement | null = null;
beforeEach(() => {
waitingSessions = new Set();
confirmApproval.mockClear();
scrollPaneIntoView.mockClear();
Element.prototype.scrollIntoView = scrollPaneIntoView;
connectionState = {
sessionId: 's3',
capabilities: { features: [] },
@@ -210,6 +238,274 @@ function openPicker(): void {
}
describe('SplitView', () => {
it('keeps a newly added controlled pane active without relying on composer autofocus', () => {
function ControlledSplit() {
const [ids, setIds] = React.useState(['s1', 's2']);
return (
<SplitView sessionIds={ids} onPanesChange={setIds} onExit={() => {}} />
);
}
render();
act(() =>
root!.render(
<I18nProvider language="en">
<ControlledSplit />
</I18nProvider>,
),
);
openPicker();
const third = Array.from(
container!.querySelectorAll<HTMLButtonElement>('[role="option"] button'),
).find((button) => button.textContent === 'Three')!;
act(() => third.click());
expect(titles()).toEqual(['One', 'Two', 'Three']);
expect(panes()[2].hasAttribute('data-pane-active')).toBe(true);
});
it('tracks pointer and keyboard activity without activating a hovered pane', () => {
render({ sessionIds: ['s1', 's2'] });
expect(panes()[0].hasAttribute('data-pane-active')).toBe(true);
act(() =>
panes()[1].dispatchEvent(new Event('pointerover', { bubbles: true })),
);
expect(panes()[0].hasAttribute('data-pane-active')).toBe(true);
act(() =>
panes()[1].dispatchEvent(new Event('pointerdown', { bubbles: true })),
);
expect(panes()[1].hasAttribute('data-pane-active')).toBe(true);
act(() => panes()[0].querySelector('input')!.focus());
expect(panes()[0].hasAttribute('data-pane-active')).toBe(true);
expect(panes()[1].hasAttribute('data-pane-active')).toBe(false);
});
it('cycles hidden pending panes, focuses outside approvals and preserves drafts without confirming', () => {
waitingSessions = new Set(['s2', 's3']);
render({ sessionIds: ['s1', 's2', 's3'] });
const draft = panes()[0].querySelector('input')!;
draft.value = 'keep this draft';
act(() =>
panes()[0]
.querySelector<HTMLButtonElement>('[data-testid="pane-maximize"]')!
.click(),
);
const pendingButton = container!.querySelector<HTMLButtonElement>(
'[title="Go to the next session awaiting input"]',
)!;
expect(pendingButton.textContent).toBe('2 awaiting input');
expect(pendingButton.getAttribute('aria-label')).toBe(
'2 awaiting input — Go to the next session awaiting input',
);
expect(pendingButton.getAttribute('aria-label')).toContain(
pendingButton.textContent,
);
expect(container!.querySelector('[role="status"]')?.textContent).toBe(
'2 awaiting input',
);
act(() => pendingButton.click());
expect(panes()[1].getAttribute('data-maximized')).toBe('true');
expect(document.activeElement).toBe(
panes()[1].closest('[data-pane-session-id]'),
);
act(() => pendingButton.click());
expect(panes()[2].getAttribute('data-maximized')).toBe('true');
expect(document.activeElement).toBe(
panes()[2].closest('[data-pane-session-id]'),
);
expect(confirmApproval).not.toHaveBeenCalled();
expect(scrollPaneIntoView).toHaveBeenCalledTimes(2);
expect(panes()[0].querySelector('input')).toBe(draft);
expect(draft.value).toBe('keep this draft');
});
it.each([false, true])(
'continues forward from an idle middle pane (maximized: %s)',
(maximized) => {
waitingSessions = new Set(['s1', 's3']);
render({ sessionIds: ['s1', 's2', 's3'] });
act(() => panes()[1].querySelector('input')!.focus());
if (maximized) {
act(() =>
panes()[1]
.querySelector<HTMLButtonElement>('[data-testid="pane-maximize"]')!
.click(),
);
}
const pending = container!.querySelector<HTMLButtonElement>(
'[title="Go to the next session awaiting input"]',
)!;
for (const index of [2, 0]) {
act(() => pending.click());
expect(panes()[index].hasAttribute('data-pane-active')).toBe(true);
expect(document.activeElement).toBe(
panes()[index].closest('[data-pane-session-id]'),
);
expect(panes()[index].getAttribute('data-maximized')).toBe(
String(maximized),
);
}
expect(confirmApproval).not.toHaveBeenCalled();
},
);
it('reports pending panes while hidden and clears the report on unmount', () => {
waitingSessions = new Set(['s1']);
const onPendingPanesChange = vi.fn();
render({ sessionIds: ['s1', 's2'], onPendingPanesChange });
act(() =>
panes()[1]
.querySelector<HTMLButtonElement>('[data-testid="pane-maximize"]')!
.click(),
);
expect(panes()[0].getAttribute('data-hidden')).toBe('true');
expect(onPendingPanesChange).toHaveBeenLastCalledWith(['s1']);
act(() => root!.unmount());
root = null;
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();
render({ sessionIds: ['s1', 's2'], onPendingPanesChange });
expect(onPendingPanesChange).toHaveBeenLastCalledWith(['s1']);
sessionsState = sessionsState.map((session) =>
session.sessionId === 's1'
? { ...session, displayName: 'BOOM' }
: session,
);
openPicker();
await flushAsync();
expect(container!.textContent).toContain('This session pane hit an error');
expect(
container!.querySelector('[data-pane-session-id="s1"]'),
).not.toBeNull();
expect(onPendingPanesChange).toHaveBeenLastCalledWith([]);
});
it('keeps selection and focus on a neighbour when a controlled pane is removed', () => {
waitingSessions = new Set(['s3']);
render({ sessionIds: ['s1', 's2', 's3'] });
act(() => panes()[2].querySelector('input')!.focus());
act(() =>
root!.render(
<I18nProvider language="en">
<SplitView sessionIds={['s1', 's2']} onExit={() => {}} />
</I18nProvider>,
),
);
expect(panes()).toHaveLength(2);
expect(panes()[0].hasAttribute('data-pane-active')).toBe(false);
expect(panes()[1].hasAttribute('data-pane-active')).toBe(true);
expect(document.activeElement).toBe(
panes()[1].closest('[data-pane-session-id]'),
);
expect(container!.querySelector('[role="status"]')?.textContent).toBe('');
});
it('omits title details when the host disables them', () => {
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', () => {
render({ sessionIds: ['s1', 's2'] });
expect(panes()).toHaveLength(2);
@@ -4,7 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
DaemonSessionProvider,
useConnection,
@@ -45,6 +52,8 @@ const MAX_PANES = MAX_SPLIT_PANES;
export interface SplitViewProps {
/** Sessions to show in the split view. */
sessionIds?: string[];
/** Respect the host's session-details action allowlist. */
showSessionDetails?: boolean;
/**
* Report the live pane set (after every add / remove) up to the parent so it
* survives this view unmounting. Switching away from the split and back must
@@ -53,6 +62,11 @@ export interface SplitViewProps {
* each render would re-fire the reporting effect and loop.
*/
onPanesChange?: (sessionIds: string[]) => void;
/**
* Report panes surfacing approvals, including hidden panes. Keep stable while
* consumer inputs are unchanged; a new callback receives the current list.
*/
onPendingPanesChange?: (sessionIds: string[]) => void;
/** Leave the split view (back to the single-session chat). */
onExit: () => void;
onError?: (error: unknown, fallback: string) => void;
@@ -98,7 +112,9 @@ export interface SplitViewProps {
*/
export function SplitView({
sessionIds,
showSessionDetails = true,
onPanesChange,
onPendingPanesChange,
onExit,
onError,
onImageIngestionNotice,
@@ -161,6 +177,61 @@ export function SplitView({
return currentSessionId ? [currentSessionId] : [];
});
const [pickerOpen, setPickerOpen] = useState(false);
const [activePaneId, setActivePaneId] = useState(paneIds[0]);
const [pendingPaneIds, setPendingPaneIds] = useState<Set<string>>(new Set());
const [paneFocusId, setPaneFocusId] = useState<string | null>(null);
const panesRef = useRef<HTMLDivElement>(null);
const previousPaneIdsRef = useRef(paneIds);
const activeId = paneIds.includes(activePaneId ?? '')
? activePaneId
: paneIds[
Math.min(
Math.max(previousPaneIdsRef.current.indexOf(activePaneId ?? ''), 0),
paneIds.length - 1,
)
];
useLayoutEffect(() => {
if (previousPaneIdsRef.current === paneIds) return;
previousPaneIdsRef.current = paneIds;
if (activeId === activePaneId) return;
setActivePaneId(activeId);
if (document.activeElement === document.body) {
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(
() => paneIds.filter((id) => pendingPaneIds.has(id)),
[paneIds, pendingPaneIds],
);
useEffect(() => {
onPendingPanesChange?.(pendingIds);
}, [pendingIds, onPendingPanesChange]);
useEffect(() => () => onPendingPanesChange?.([]), [onPendingPanesChange]);
const backButtonRef = useRef<HTMLButtonElement>(null);
const pendingButtonRef = useRef<HTMLButtonElement | null>(null);
const setPendingButtonRef = useCallback(
(button: HTMLButtonElement | null) => {
if (!button && pendingButtonRef.current === document.activeElement) {
backButtonRef.current?.focus();
}
pendingButtonRef.current = button;
},
[],
);
const handleApprovalChange = useCallback(
(sessionId: string, pending: boolean) => {
setPendingPaneIds((current) => {
if (current.has(sessionId) === pending) return current;
const next = new Set(current);
if (pending) next.add(sessionId);
else next.delete(sessionId);
return next;
});
},
[],
);
// Which pane, if any, is maximized to fill the whole split. Purely visual and
// ephemeral (not deep-linked via `?split=`, like the dialog fullscreen toggle
// it mirrors): the other panes stay mounted and streaming, just hidden.
@@ -226,6 +297,11 @@ export function SplitView({
return map;
}, [allSessions]);
const sessionById = useMemo(
() => new Map(allSessions.map((session) => [session.sessionId, session])),
[allSessions],
);
// The workspace each session lives in, so a pane attaches under its owning
// workspace (a non-primary session 409s if loaded with the primary cwd). The
// seed pane is the current session, whose workspace the connection already
@@ -260,6 +336,7 @@ export function SplitView({
// Reveal the freshly added pane rather than leaving it hidden behind a
// still-maximized one.
setMaximizedPaneId(null);
setActivePaneId(sessionId);
if (sessionIdsControlled) {
onPanesChange?.(next);
} else {
@@ -304,9 +381,33 @@ export function SplitView({
);
const toggleMaximize = useCallback((sessionId: string) => {
setActivePaneId(sessionId);
setMaximizedPaneId((current) => (current === sessionId ? null : sessionId));
}, []);
const goToPendingPane = () => {
const activeIndex = paneIds.indexOf(activeId ?? '');
const nextId =
pendingIds.find((id) => paneIds.indexOf(id) > activeIndex) ??
pendingIds[0];
if (!nextId) return;
setActivePaneId(nextId);
if (maximizedPaneId) setMaximizedPaneId(nextId);
setPaneFocusId(nextId);
};
useLayoutEffect(() => {
if (!paneFocusId) return;
const pane = Array.from(panesRef.current?.children ?? []).find(
(element) => element.getAttribute('data-pane-session-id') === paneFocusId,
);
pane?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
// Approval panels submit on Escape, Enter, and digits. Navigation must
// stop outside those keyboard scopes until the user deliberately enters.
if (pane instanceof HTMLElement) pane.focus({ preventScroll: true });
setPaneFocusId(null);
}, [paneFocusId]);
// Maximize only makes sense against another pane, so drop it whenever it no
// longer can hold: the maximized pane left the set (closed here, or removed by
// a controlled-mode sync), or the split shrank to a lone pane. Without the
@@ -352,6 +453,7 @@ export function SplitView({
<button
type="button"
className={styles.backButton}
ref={backButtonRef}
onClick={onExit}
aria-label={t('common.back')}
title={t('common.back')}
@@ -371,6 +473,25 @@ export function SplitView({
<span className={styles.count}>
{t('splitView.count', { count: paneIds.length })}
</span>
<span className="sr-only" role="status">
{pendingIds.length > 0
? t('splitView.pendingCount', { count: pendingIds.length })
: ''}
</span>
{pendingIds.length > 0 && (
<button
type="button"
className={styles.pendingButton}
onClick={goToPendingPane}
ref={setPendingButtonRef}
title={t('splitView.nextPending')}
aria-label={`${t('splitView.pendingCount', {
count: pendingIds.length,
})} — ${t('splitView.nextPending')}`}
>
{t('splitView.pendingCount', { count: pendingIds.length })}
</button>
)}
<div className={styles.addWrap} ref={addWrapRef}>
<button
type="button"
@@ -414,7 +535,7 @@ export function SplitView({
</div>
</header>
<div className={styles.panes}>
<div className={styles.panes} ref={panesRef}>
{paneIds.length === 0 ? (
<div className={styles.empty}>{t('splitView.empty')}</div>
) : (
@@ -427,7 +548,21 @@ export function SplitView({
return (
<div
className={styles.paneSlot}
data-pane-session-id={sessionId}
data-pane-hidden={isHidden ? '' : undefined}
role="group"
aria-label={titleById.get(sessionId) ?? sessionId.slice(0, 8)}
tabIndex={-1}
onPointerDownCapture={(event) => {
if (event.currentTarget.contains(event.target as Node)) {
setActivePaneId(sessionId);
}
}}
onFocusCapture={(event) => {
if (event.currentTarget.contains(event.target as Node)) {
setActivePaneId(sessionId);
}
}}
// Include the resolved workspace in the key on a multi-workspace
// daemon so a pane whose workspace resolves only after mount (e.g.
// a `?split=` deep link) remounts under the right workspace rather
@@ -486,6 +621,13 @@ export function SplitView({
>
<ChatPane
title={titleById.get(sessionId)}
sessionSummary={
showSessionDetails
? sessionById.get(sessionId)
: undefined
}
isActive={activeId === sessionId}
onApprovalChange={handleApprovalChange}
workspaceCwd={paneWorkspaceCwd}
reportCatalogTurnCompletion={
sessionId !== currentSessionId ||
@@ -18,6 +18,7 @@ const observed = vi.hoisted(() => ({
store: undefined as DaemonHistoryNavigationStore | undefined,
props: undefined as MessageListProps | undefined,
collapseRows: false,
hideRows: false,
}));
vi.mock('../daemon/session/DaemonSessionProvider', () => ({
useDaemonHistoryNavigationStore: () => observed.store,
@@ -37,7 +38,7 @@ vi.mock('./MessageList', () => ({
);
return (
<div data-web-shell-message-list>
{props.messages.flatMap((message) => [
{(observed.hideRows ? [] : props.messages).flatMap((message) => [
<div
key={message.id}
data-message-row-key={`msg:${message.id}`}
@@ -72,6 +73,7 @@ afterEach(() => {
root = undefined;
container?.remove();
observed.collapseRows = false;
observed.hideRows = false;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
@@ -281,6 +283,53 @@ async function setup(
}
describe('TranscriptViewport scroll restoration and fallback', () => {
it('waits for virtualized rows before loading and preserving the reading position', async () => {
const { click, list, row, getTranscriptPage, settleFrames, render } =
await setup();
await click('history.openEarlier');
settleFrames();
list().scrollTop = 170;
const targetKey = `msg:${observed.props!.messages[2]!.id}`;
const before = row(targetKey).getBoundingClientRect().top;
let resolve!: (value: DaemonSessionTranscriptPage) => void;
getTranscriptPage.mockImplementation(
() =>
new Promise((done) => {
resolve = done;
}),
);
observed.hideRows = true;
render();
await click('history.loadEarlier');
expect(getTranscriptPage).toHaveBeenCalledTimes(1);
observed.hideRows = false;
render();
settleFrames();
expect(getTranscriptPage).toHaveBeenCalledTimes(2);
await act(async () => resolve(page(['old1', 'old2'])));
settleFrames();
expect(row(targetKey).getBoundingClientRect().top).toBe(before);
});
it('cancels a deferred boundary load when the user interacts again', async () => {
const { click, list, getTranscriptPage, settleFrames, render } =
await setup();
await click('history.openEarlier');
settleFrames();
observed.hideRows = true;
render();
await click('history.loadEarlier');
act(() =>
list().dispatchEvent(new Event('pointerdown', { bubbles: true })),
);
observed.hideRows = false;
render();
settleFrames();
expect(getTranscriptPage).toHaveBeenCalledTimes(1);
await click('history.loadEarlier');
expect(getTranscriptPage).toHaveBeenCalledTimes(2);
});
it.each(['none', 'wheel', 'pointerdown', 'keydown', 'scroll'] as const)(
'preserves the reading row through a deferred prepend after %s input',
async (input) => {
@@ -212,9 +212,6 @@ describe('TranscriptViewport', () => {
hasOlder: true,
hasMore: false,
});
await click('history.loadEarlier');
expect(store.getViewportSnapshot().ranges[0]?.pageIds).toHaveLength(2);
const pin = vi.spyOn(store, 'setViewportAnchor');
const list = container!.querySelector<HTMLElement>(
'[data-web-shell-message-list]',
)!;
@@ -246,6 +243,9 @@ describe('TranscriptViewport', () => {
};
},
);
await click('history.loadEarlier');
expect(store.getViewportSnapshot().ranges[0]?.pageIds).toHaveLength(2);
const pin = vi.spyOn(store, 'setViewportAnchor');
act(() => {
list.dispatchEvent(new WheelEvent('wheel', { bubbles: true }));
list.dispatchEvent(new Event('scroll', { bubbles: true }));
@@ -59,6 +59,15 @@ export const TranscriptViewport = forwardRef<
const appliedTarget = useRef<number | undefined>(undefined);
const scrollIntent = useRef(0);
const restoring = useRef(false);
const loadFrame = useRef<number | undefined>(undefined);
useLayoutEffect(
() => () => {
if (loadFrame.current !== undefined)
cancelAnimationFrame(loadFrame.current);
loadFrame.current = undefined;
},
[viewKey],
);
useLayoutEffect(() => {
if (historical || loading) onCanScrollToBottomChange?.(true);
}, [historical, loading, onCanScrollToBottomChange]);
@@ -206,12 +215,31 @@ export const TranscriptViewport = forwardRef<
}, [messages, viewKey, historical, viewport.target, capture, rows, scroller]);
const load = (direction: 'older' | 'newer') => {
anchor.current = capture();
entryDirection.current = direction;
void viewport.load(direction);
if (loadFrame.current !== undefined) return;
const intent = scrollIntent.current;
let remaining = 8;
const loadWhenVisible = () => {
loadFrame.current = undefined;
if (intent !== scrollIntent.current) return;
const saved = capture();
// A scroll event can arrive before the virtualized rows mount. Loading
// without an anchor would leave no reading position to restore.
if (!saved) {
if (--remaining > 0)
loadFrame.current = requestAnimationFrame(loadWhenVisible);
return;
}
anchor.current = saved;
entryDirection.current = direction;
void viewport.load(direction);
};
loadWhenVisible();
};
const handleScrollIntent = () => {
scrollIntent.current += 1;
if (loadFrame.current !== undefined)
cancelAnimationFrame(loadFrame.current);
loadFrame.current = undefined;
viewport.cancelSelection();
if (!loading) anchor.current = undefined;
restoring.current = false;
@@ -263,6 +291,7 @@ export const TranscriptViewport = forwardRef<
state={viewport.navigation}
store={viewport.store}
onSelect={(ordinal) => {
handleScrollIntent();
anchor.current = undefined;
void viewport.selectOrdinal(ordinal);
}}
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act } from 'react';
import { act, forwardRef, type ComponentProps } from 'react';
import { createPortal } from 'react-dom';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -8,6 +8,21 @@ import { I18nProvider } from '../../i18n';
import { SessionDetailsTooltip } from './SessionDetailsTooltip';
import styles from '../SessionPrStateIcon.module.css';
const popoverContentProps = vi.hoisted(() => vi.fn());
vi.mock('../ui/popover', async (importOriginal) => {
const actual = await importOriginal<typeof import('../ui/popover')>();
return {
...actual,
PopoverContent: forwardRef<
HTMLDivElement,
ComponentProps<typeof actual.PopoverContent>
>(function ObservedPopoverContent(props, ref) {
popoverContentProps(props);
return <actual.PopoverContent {...props} ref={ref} />;
}),
};
});
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
function deferred<T>(): {
@@ -69,6 +84,9 @@ describe('SessionDetailsTooltip', () => {
await openDetails(container);
expect(popoverContentProps).toHaveBeenLastCalledWith(
expect.objectContaining({ side: 'right' }),
);
const details = document.querySelector('[role="dialog"]');
expect(details?.textContent).toContain('Improve sidebar');
expect(details?.textContent).toContain('2 weeks ago');
@@ -29,6 +29,7 @@ interface SessionDetailsTooltipProps {
completedUnread: boolean;
workspaceLabel?: string;
worktreeOnly?: boolean;
side?: 'right' | 'bottom';
children: ReactElement;
}
@@ -39,6 +40,7 @@ export function SessionDetailsTooltip({
completedUnread,
workspaceLabel,
worktreeOnly = false,
side = 'right',
children,
}: SessionDetailsTooltipProps) {
const { t } = useI18n();
@@ -53,9 +55,14 @@ export function SessionDetailsTooltip({
const closeTimerRef = useRef<number | undefined>(undefined);
const anchorRef = useRef<HTMLDivElement | null>(null);
const collisionBoundary = open
? resolveSessionDetailsCollisionBoundary(
anchorRef.current?.closest<HTMLElement>('aside') ?? null,
)
? side === 'bottom'
? [
anchorRef.current?.closest<HTMLElement>('[data-pane-session-id]'),
anchorRef.current?.closest<HTMLElement>('[data-web-shell-root]'),
].filter((element): element is HTMLElement => Boolean(element))
: resolveSessionDetailsCollisionBoundary(
anchorRef.current?.closest<HTMLElement>('aside') ?? null,
)
: null;
const folderPath = session.workspaceCwd;
const folderName = workspaceLabel ?? workspaceBasename(folderPath);
@@ -140,7 +147,7 @@ export function SessionDetailsTooltip({
{children}
</PopoverAnchor>
<PopoverContent
side="right"
side={side}
align={worktreeOnly ? 'center' : 'start'}
sideOffset={0}
collisionBoundary={collisionBoundary ?? undefined}
@@ -330,7 +330,7 @@ export interface WebShellSidebarSessionActionsOptions {
inlineItems?: readonly WebShellSidebarSessionInlineActionItem[];
}
const DEFAULT_SESSION_ACTION_ITEMS: readonly WebShellSidebarSessionActionItem[] =
export const DEFAULT_SESSION_ACTION_ITEMS: readonly WebShellSidebarSessionActionItem[] =
['details', 'rename', 'group', 'export', 'delete', 'pin', 'archive'];
const DEFAULT_INLINE_ACTION_ITEMS: readonly WebShellSidebarSessionInlineActionItem[] =
@@ -1,20 +1,11 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_SESSION_ACTION_ITEMS } from './WebShellSidebar';
import type {
WebShellSidebarSessionActionItem,
WebShellSidebarSessionInlineActionItem,
} from './WebShellSidebar';
const ALL_ITEMS: readonly WebShellSidebarSessionActionItem[] = [
'details',
'rename',
'group',
'export',
'delete',
'pin',
'archive',
];
const DEFAULT_ITEMS: readonly WebShellSidebarSessionActionItem[] = ALL_ITEMS;
const ALL_ITEMS = DEFAULT_SESSION_ACTION_ITEMS;
const DEFAULT_INLINE_ITEMS: readonly WebShellSidebarSessionInlineActionItem[] =
['pin'];
@@ -74,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([
@@ -92,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,
[],
);
@@ -115,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);
@@ -154,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 },
@@ -190,6 +190,10 @@ for (const pageRecords of [16, 200]) {
page,
baseURL,
}) => {
if (pageRecords === 200) {
const client = await page.context().newCDPSession(page);
await client.send('Emulation.setCPUThrottlingRate', { rate: 4 });
}
const fixture = await historyScenario(page, baseURL, pageRecords);
const ordinal = (fixture.count - 2 * pageRecords) / 2;
const rail = page.locator('[data-global-turn-navigation]');
@@ -1,4 +1,5 @@
import { expect, test, type Page, type TestInfo } from '@playwright/test';
import type { DaemonPermissionRequestEvent } from '@qwen-code/sdk/daemon';
import {
createWebShellDaemonScenario,
installMockDaemon,
@@ -135,3 +136,345 @@ test('leaving the split clears storage so a refresh does not restore it', async
await expect(page.locator('[data-web-shell-root]')).toBeVisible();
await expect(page.locator('[data-testid="split-view"]')).toHaveCount(0);
});
test('shows session details without moving focus and preserves drafts across pane controls @smoke', async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1920, height: 1080 });
await installScenario(page, createSplitScenario(), testInfo);
await page.goto(`/?split=${SESSION_A},${SESSION_B}`);
const split = page.getByTestId('split-view');
const paneA = page.locator(`[data-pane-session-id="${SESSION_A}"]`);
const paneB = page.locator(`[data-pane-session-id="${SESSION_B}"]`);
const editorA = paneA.locator('.cm-content');
const editorB = paneB.locator('.cm-content');
const titleB = paneB.locator('[data-slot="popover-anchor"]');
const details = page.getByRole('dialog', { name: 'Session B', exact: true });
await expect(editorA).toBeEditable();
await expect(editorB).toBeEditable();
await editorA.fill('Draft kept in session A');
await expect(paneA.getByTestId('chat-pane')).toHaveAttribute(
'data-pane-active',
);
await expect(paneA.locator('header')).not.toHaveCSS('box-shadow', 'none');
await expect(paneB.locator('header')).toHaveCSS('box-shadow', 'none');
const headerHeight = await paneB
.locator('header')
.evaluate((header) => header.getBoundingClientRect().height);
await expect(editorA).toBeFocused();
await titleB.hover();
await expect(details).toBeVisible();
await expect(details).toHaveAttribute('data-side', 'bottom');
await expect(details.getByText(SESSION_B, { exact: true })).toBeVisible();
await expect(
details.getByText('qwen-web-shell-e2e', { exact: true }),
).toBeVisible();
await expect(editorA).toBeFocused();
await expect(paneB.getByTestId('chat-pane')).not.toHaveAttribute(
'data-pane-active',
);
const rootBounds = await page.locator('[data-web-shell-root]').boundingBox();
const detailsBounds = await details.boundingBox();
expect(rootBounds).not.toBeNull();
expect(detailsBounds).not.toBeNull();
expect(detailsBounds!.x).toBeGreaterThanOrEqual(rootBounds!.x);
expect(detailsBounds!.x + detailsBounds!.width).toBeLessThanOrEqual(
rootBounds!.x + rootBounds!.width,
);
expect(
await paneB
.locator('header')
.evaluate((header) => header.getBoundingClientRect().height),
).toBe(headerHeight);
await page.screenshot({ path: testInfo.outputPath('split-details.png') });
await page.keyboard.press('Escape');
await expect(details).toHaveCount(0);
await paneB
.getByRole('button', { name: 'Maximize pane', exact: true })
.hover();
await page.waitForTimeout(350);
await expect(details).toHaveCount(0);
await paneB.locator('header').click({ position: { x: 5, y: 5 } });
await expect(paneB.getByTestId('chat-pane')).toHaveAttribute(
'data-pane-active',
);
await paneA.getByRole('button', { name: 'Close pane', exact: true }).focus();
await page.keyboard.press('Tab');
await expect(editorA).toBeFocused();
await expect(paneA.getByTestId('chat-pane')).toHaveAttribute(
'data-pane-active',
);
await editorB.fill('Draft kept in session B');
await paneB
.getByRole('button', { name: 'Maximize pane', exact: true })
.click();
await expect(paneA).toBeHidden();
await paneB
.getByRole('button', { name: 'Restore pane', exact: true })
.click();
await expect(editorA).toHaveText('Draft kept in session A');
await expect(editorB).toHaveText('Draft kept in session B');
const paneWidths = await split
.locator('[data-pane-session-id]')
.evaluateAll((panes) =>
panes.map((pane) => pane.getBoundingClientRect().width),
);
expect(Math.abs(paneWidths[0] - paneWidths[1])).toBeLessThan(1);
await split.getByRole('button', { name: 'Add session' }).click();
await split
.getByRole('button', { name: 'Main Session', exact: true })
.click();
const mainPane = page.locator(`[data-pane-session-id="${MAIN_SESSION}"]`);
await expect(mainPane.getByTestId('chat-pane')).toHaveAttribute(
'data-pane-active',
);
await mainPane
.getByRole('button', { name: 'Close pane', exact: true })
.click();
await expect(mainPane).toHaveCount(0);
await expect(split.locator('[data-pane-active]')).toHaveCount(1);
await expect(editorA).toHaveText('Draft kept in session A');
await expect(editorB).toHaveText('Draft kept in session B');
});
test('navigates hidden tool and question approvals without answering them @smoke', async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 900 });
const scenario = createSplitScenario();
const toolEvent: DaemonPermissionRequestEvent = {
v: 1,
id: 1,
type: 'permission_request',
data: {
requestId: 'split-tool-permission',
sessionId: SESSION_A,
toolCall: { name: 'Bash', input: { command: 'printf web-shell-e2e' } },
options: [
{ optionId: 'cancel', label: 'Reject', kind: 'reject_once' },
{
optionId: 'proceed_always_user',
label: 'Always allow for user',
kind: 'allow_always',
},
{
optionId: 'proceed_always_project',
label: 'Always allow for project',
kind: 'allow_always',
},
{ optionId: 'allow_once', label: 'Allow once', kind: 'allow_once' },
],
},
};
scenario.events = [toolEvent];
scenario.branch = {
sessionId: SESSION_B,
displayName: 'Session B',
events: [
{
v: 1,
id: 1,
type: 'permission_request',
data: {
requestId: 'split-question-permission',
sessionId: SESSION_B,
toolCall: {
name: 'ask_user_question',
input: {
questions: [
{
question: 'Which behavior should be checked?',
header: 'Check',
options: [
{ label: 'Focus', description: 'Check focus retention' },
{ label: 'Drafts', description: 'Check draft retention' },
],
multiSelect: false,
},
],
},
},
options: [
{ optionId: 'allow_once', label: 'Allow once', kind: 'allow_once' },
{ optionId: 'reject_once', label: 'Reject', kind: 'reject_once' },
],
},
},
],
};
const daemon = await installScenario(page, scenario, testInfo);
await page.goto(`/?split=${SESSION_A},${SESSION_B}`);
const split = page.getByTestId('split-view');
const paneA = page.locator(`[data-pane-session-id="${SESSION_A}"]`);
const paneB = page.locator(`[data-pane-session-id="${SESSION_B}"]`);
const pending = split.getByRole('button', {
name: '2 awaiting input — Go to the next session awaiting input',
exact: true,
});
await expect(
paneA.locator('[data-web-shell-permission-panel]'),
).toBeVisible();
await expect(paneB.locator('[data-web-shell-ask-panel]')).toBeVisible();
await expect(pending).toHaveText('2 awaiting input');
const toolbarHeight = await split
.locator(':scope > header')
.evaluate((header) => header.getBoundingClientRect().height);
await paneA
.getByRole('button', { name: 'Maximize pane', exact: true })
.click();
await expect(paneB).toBeHidden();
await expect(pending).toBeVisible();
await pending.click();
await expect(paneA).toBeHidden();
await expect(paneB).toBeVisible();
await expect(paneB).toBeFocused();
for (const key of [
'Enter',
'Control+Enter',
'Meta+Enter',
'2',
'3',
'Escape',
]) {
await page.keyboard.press(key);
await page.waitForTimeout(50);
expect(daemon.permissionRequests()).toHaveLength(0);
}
await expect(paneA).toBeVisible();
await paneB.getByRole('button', { name: 'Collapse', exact: true }).click();
await pending.click();
await pending.click();
await expect(paneB).toBeFocused();
await expect(paneB.locator('[data-web-shell-ask-option]')).toHaveCount(0);
await page.keyboard.press('Control+Enter');
await page.waitForTimeout(50);
expect(daemon.permissionRequests()).toHaveLength(0);
await paneB
.getByRole('button', { name: 'Maximize pane', exact: true })
.click();
await expect(paneB.getByTestId('chat-pane')).toHaveAttribute(
'data-pane-active',
);
expect(daemon.permissionRequests()).toHaveLength(0);
await pending.click();
await expect(paneB).toBeHidden();
await expect(paneA).toBeVisible();
await expect(paneA).toBeFocused();
for (const key of ['Enter', '2', '3', 'Escape']) {
await page.keyboard.press(key);
await page.waitForTimeout(50);
expect(daemon.permissionRequests()).toHaveLength(0);
}
await expect(paneB).toBeVisible();
await paneA
.getByRole('button', { name: 'Maximize pane', exact: true })
.click();
await page.mouse.move(0, 0);
expect(daemon.permissionRequests()).toHaveLength(0);
await page.screenshot({ path: testInfo.outputPath('split-pending.png') });
const reject = paneA.locator(
'[data-web-shell-permission-option][tabindex="0"]',
);
await reject.focus();
await page.keyboard.press('ArrowDown');
await expect(
paneA.locator('[data-web-shell-permission-option]').nth(1),
).toBeFocused();
expect(daemon.permissionRequests()).toHaveLength(0);
await daemon.sse.waitForConnection(SESSION_A);
await daemon.sendEvent({
v: 1,
id: 2,
type: 'permission_resolved',
data: {
requestId: 'split-tool-permission',
outcome: { outcome: 'selected', optionId: 'allow_once' },
},
});
const onePending = split.getByRole('button', {
name: '1 awaiting input — Go to the next session awaiting input',
exact: true,
});
await expect(onePending).toHaveText('1 awaiting input');
await expect(paneB).toBeHidden();
await onePending.focus();
await daemon.sendEvent({
v: 1,
id: 3,
type: 'permission_resolved',
data: {
requestId: 'split-question-permission',
outcome: { outcome: 'selected', optionId: 'allow_once' },
},
});
await expect(onePending).toHaveCount(0);
await expect(split.locator(':scope > header [role="status"]')).toBeEmpty();
await expect(
split.getByRole('button', { name: 'back', exact: true }),
).toBeFocused();
await page.keyboard.press('Escape');
await expect(paneB).toBeVisible();
await paneB.getByRole('button', { name: 'Close pane', exact: true }).click();
await expect(
split.getByRole('button', {
name: 'Go to the next session awaiting input',
}),
).toHaveCount(0);
await expect(paneA).toBeVisible();
await expect(paneA.getByTestId('chat-pane')).toHaveAttribute(
'data-pane-active',
);
expect(daemon.permissionRequests()).toHaveLength(0);
expect(
await split
.locator(':scope > header')
.evaluate((header) => header.getBoundingClientRect().height),
).toBe(toolbarHeight);
});
test('keeps title details inside narrow panes in an embedded shell @smoke', async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1920, height: 1080 });
await installScenario(page, createSplitScenario(), testInfo);
await page.goto(`/?split=${SESSION_A},${SESSION_B},${MAIN_SESSION}`);
const root = page.locator('[data-web-shell-root]');
await expect(page.getByTestId('split-view')).toBeVisible();
await root.evaluate((element) => {
element.style.width = '1100px';
element.style.marginLeft = '40px';
});
for (const [id, name] of [
[SESSION_B, 'Session B'],
[MAIN_SESSION, 'Main Session'],
]) {
const pane = page.locator(`[data-pane-session-id="${id}"]`);
await pane.locator('[data-slot="popover-anchor"]').hover();
const details = page.getByRole('dialog', { name, exact: true });
await expect(details).toBeVisible();
await expect
.poll(async () => {
const bounds = await details.boundingBox();
const paneBounds = await pane.boundingBox();
const rootBounds = await root.boundingBox();
return (
bounds &&
paneBounds &&
rootBounds &&
[paneBounds, rootBounds].every(
(host) =>
bounds.x >= host.x &&
bounds.x + bounds.width <= host.x + host.width,
)
);
})
.toBe(true);
await page.keyboard.press('Escape');
}
});
+4
View File
@@ -3191,6 +3191,8 @@ const EN: Messages = {
'splitView.outerApprovalPending':
'Your main session is waiting for approval.',
'splitView.goToApproval': 'Go to it',
'splitView.pendingCount': (v) => `${v?.count ?? 0} awaiting input`,
'splitView.nextPending': 'Go to the next session awaiting input',
'splitView.empty': 'No sessions in the split. Add one to get started.',
'splitView.composerPlaceholder': 'Message this session…',
'settings.title': 'Settings',
@@ -6582,6 +6584,8 @@ const ZH: Messages = {
'splitView.paneConnectionError': '连接已断开',
'splitView.outerApprovalPending': '主会话正在等待审批。',
'splitView.goToApproval': '前往处理',
'splitView.pendingCount': (v) => `${v?.count ?? 0} 个会话待处理`,
'splitView.nextPending': '前往下一个待处理会话',
'splitView.empty': '分屏中还没有会话,添加一个开始。',
'splitView.composerPlaceholder': '给这个会话发消息…',
'settings.title': '设置',
+3 -13
View File
@@ -102,19 +102,9 @@ it('keeps lint_and_static sized for cold-cache pool runs', () => {
expect(timeoutMinutesOn('lint_and_static', '')).toBe(45);
});
it('keeps web_shell_e2e_smoke above its build-plus-browser budget on ECS', () => {
// Both numbers are measured, not predicted. This was the last lane on the
// pool still priced flat, and one commit lost it twice at 20: on hk3-9
// `npm ci` alone ran 19m46s of the budget and never finished, and on hk4-23
// install took 10m33s against 4m53s on hk5-2, leaving the browser smoke
// 8m15s before the job died against 2m29s warm. The smoke phase is a vite
// dev server plus a headless browser and touches no npm cache, so both
// phases running 2-3x is the pool rather than the dependencies. The healthy
// path is 8-10 minutes: a flat 20 only ever tolerated a 2x slowdown.
expect(timeoutMinutesOn('web_shell_e2e_smoke', ECS_RUNNER)).toBe(40);
});
it('keeps web_shell_e2e_smoke on its pre-contention ceiling when hosted', () => {
it('keeps browser gates hosted independently of the shared Linux runner', () => {
expect(ci.jobs.web_shell_e2e_smoke['runs-on']).toBe('ubuntu-latest');
expect(timeoutMinutesOn('web_shell_e2e_smoke', ECS_RUNNER)).toBe(20);
expect(timeoutMinutesOn('web_shell_e2e_smoke', HOSTED_RUNNER)).toBe(20);
expect(timeoutMinutesOn('web_shell_e2e_smoke', '')).toBe(20);
});
+2 -2
View File
@@ -849,14 +849,14 @@ describe('no-AK integration CI wiring', () => {
);
});
it('does not install Linux packages on self-hosted Playwright runners', () => {
it('installs Playwright system dependencies only on hosted runners', () => {
const workflow = readFileSync(
path.join(ROOT, '.github/workflows/ci.yml'),
'utf8',
);
const webShellJob = getWorkflowJob(workflow, 'web_shell_e2e_smoke');
expect(webShellJob).toContain('ubuntu_runner');
expect(webShellJob).toContain("runs-on: 'ubuntu-latest'");
const hostedInstall = getWorkflowStep(
webShellJob,
'Install Playwright Chromium (hosted)',
@@ -53,12 +53,15 @@ const ciYaml = parse(readFileSync('.github/workflows/ci.yml', 'utf8'));
// possibly dirty workspace. Match the pool itself, not just the output
// reference that usually names it: jobs can also hard-code the shared label
// array, and a checkout on either form inherits the same leftovers.
// Enumerate by pool + checkout instead of job name so the next such job
// fails here instead of on the runners.
// Include retained recovery steps on hosted jobs too, so rerouting cannot
// silently restore an untested sweep. Select by properties, not job name.
const ciCleanSteps = Object.entries(ciYaml.jobs)
.filter(
([, job]) =>
/ubuntu_runner|ecs-qwen/.test(JSON.stringify(job['runs-on'] ?? '')) &&
(/ubuntu_runner|ecs-qwen/.test(JSON.stringify(job['runs-on'] ?? '')) ||
(job.steps ?? []).some(
(s) => s.name === 'Clean stale .qwen before checkout',
)) &&
(job.steps ?? []).some((s) =>
String(s.uses ?? '').includes('actions/checkout'),
),