mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-09 04:20:32 -10:00
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:
1 parent
b2e4db8624
commit
70cf363395
23 files changed
+1319
-88
No files matched your search
+14
-22
@@ -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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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': '设置',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user