fix(web-shell): make mobile access opt-in and simplify split composer (#12537)

Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
ytahdnand钉萁 authored and GitHub committed 2026-09-24 01:37:22 +00:00
1 parent d0cd622a68
commit 906418aa9b
7 files changed
+78 -25

No files matched your search

+2
View File
@@ -422,6 +422,8 @@ daemon 参数的完整含义和配置方式见
| `settings` | `WebShellSettingsOptions` | 可选。控制原生 `/settings` 页面的呈现;见 [原生设置呈现](#原生设置呈现)。 |
| `modelManagement` | `WebShellModelManagementOptions` | 可选。控制 WebShell 内模型新增/删除交互,默认均允许;见 [模型增删交互](#模型增删交互)。 |
移动访问二维码入口由 `header.showMobileAccess?: boolean` 控制,默认隐藏,适用于主聊天和分屏页头。独立入口 `main.tsx` 显式设为 `true`,保留本地 Qwen Code 用户的入口。
宿主可以通过 `onContextUsageOpen?: (sessionId: string) => void` 接管上下文
详情的打开操作:
+38 -3
View File
@@ -15286,6 +15286,19 @@ describe('App session callbacks', () => {
).toBeNull();
});
it('hides Mobile access by default in built-in and custom chat headers', async () => {
const { container, rerender } = renderApp();
await flush();
expect(container.querySelector('[aria-label="Mobile access"]')).toBeNull();
const renderChatHeader = vi.fn(() => null);
rerender({ renderChatHeader });
await flush();
expect(renderChatHeader).toHaveBeenLastCalledWith(
expect.objectContaining({ onOpenLocalControlSettings: undefined }),
);
});
it('exposes the Local Control settings deep link to a custom chat header', async () => {
const renderChatHeader = vi.fn(
({ onOpenLocalControlSettings }: ChatHeaderRenderInfo) => (
@@ -15294,7 +15307,10 @@ describe('App session callbacks', () => {
</button>
),
);
const { container } = renderApp({ renderChatHeader });
const { container } = renderApp({
renderChatHeader,
header: { showMobileAccess: true },
});
await flush();
expect(renderChatHeader).toHaveBeenCalledWith(
@@ -15319,7 +15335,7 @@ describe('App session callbacks', () => {
mockConnection.sessionContext = { kind: 'standalone' };
mockConnection.workspaceCwd = '';
const renderChatHeader = vi.fn(() => null);
renderApp({ renderChatHeader });
renderApp({ renderChatHeader, header: { showMobileAccess: true } });
await flush();
expect(renderChatHeader).toHaveBeenCalledWith(
@@ -15331,7 +15347,7 @@ describe('App session callbacks', () => {
});
it('opens Settings deep-linked to Daemon from the main chat header QR entry', async () => {
const { container } = renderApp();
const { container } = renderApp({ header: { showMobileAccess: true } });
await flush();
const entry = container.querySelector<HTMLButtonElement>(
@@ -32117,9 +32133,27 @@ describe('App session callbacks', () => {
}
});
it('hides the split header Mobile access entry by default and when disabled', async () => {
const props = { sidebar: false as const, splitSessionIds: ['s1'] };
const { container, rerender } = renderApp(props);
await flush();
expect(container.querySelector('[aria-label="Mobile access"]')).toBeNull();
rerender({ ...props, header: { showMobileAccess: true } });
await flush();
expect(
container.querySelector('[aria-label="Mobile access"]'),
).not.toBeNull();
rerender({ ...props, header: { showMobileAccess: false } });
await flush();
expect(container.querySelector('[aria-label="Mobile access"]')).toBeNull();
});
it('does not add a token usage pane action unless it is enabled', async () => {
const { container } = renderApp({
sidebar: false,
header: { showMobileAccess: true },
splitSessionIds: ['s1'],
});
await flush();
@@ -32142,6 +32176,7 @@ describe('App session callbacks', () => {
it('deep-links Settings to Daemon from the QR entry and clears the link on any panel close', async () => {
const { container, rerender } = renderApp({
sidebar: false,
header: { showMobileAccess: true },
splitSessionIds: ['s1'],
});
await flush();
+13 -7
View File
@@ -3249,6 +3249,7 @@ export function App({
() => resolveSidebarOptions(sidebar),
[sidebar],
);
const showMobileAccess = header?.showMobileAccess ?? false;
const chatHeaderItems = header?.items ?? DEFAULT_CHAT_HEADER_ITEMS;
const chatHeaderEnabled =
chatHeaderItems.length > 0 && Boolean(header || renderChatHeader);
@@ -9411,13 +9412,16 @@ export function App({
setSettingsInitialCategory('Daemon');
openPanel('settings');
}, [openPanel]);
// Built-in pane actions: Local Control QR entry is always shown; usage
// actions follow the same opt-ins as the chat header.
// Built-in pane actions follow the same opt-ins as the chat header.
// Hosts can override via `renderPaneHeaderActions` to replace or extend it.
const defaultPaneHeaderActions = useCallback<PaneHeaderActionsRenderer>(
({ sessionId, sessionActions }) => (
<>
<LocalControlQrButton onOpenSettings={handleOpenLocalControlSettings} />
{showMobileAccess && (
<LocalControlQrButton
onOpenSettings={handleOpenLocalControlSettings}
/>
)}
{contextUsageHeaderItemVisible && (
<button
type="button"
@@ -9452,6 +9456,7 @@ export function App({
),
[
handleOpenLocalControlSettings,
showMobileAccess,
openTokenUsagePanel,
openContextUsagePanel,
t,
@@ -19472,9 +19477,10 @@ export function App({
),
}
: {}),
onOpenLocalControlSettings: workspaceContextActive
? handleOpenLocalControlSettings
: undefined,
onOpenLocalControlSettings:
showMobileAccess && workspaceContextActive
? handleOpenLocalControlSettings
: undefined,
})}
</div>
) : (
@@ -19523,7 +19529,7 @@ export function App({
: undefined
}
onOpenLocalControlSettings={
workspaceContextActive
showMobileAccess && workspaceContextActive
? handleOpenLocalControlSettings
: undefined
}
@@ -1698,7 +1698,7 @@ describe('ChatPane', () => {
expect(latestChatEditorProps.builtinAtProviders).toBeUndefined();
});
it('shows the pane workspace as a toolbar chip on a multi-workspace daemon', () => {
it('keeps the workspace in the split header and only shows the toolbar chip when embedded', () => {
connectionState.capabilities = {
features: [],
workspaceCwd: '/work/web-shell',
@@ -1715,6 +1715,19 @@ describe('ChatPane', () => {
};
// The split view hands each pane its own workspace explicitly.
render({ title: 'Add pagination', workspaceCwd: '/work/api' });
expect(latestChatEditorProps.visibleToolbarActions).not.toContain(
'workspace',
);
expect(
container!.querySelector('[data-web-shell-pane-workspace]')?.textContent,
).toContain('Payments API');
rerender({
title: 'Add pagination',
workspaceCwd: '/work/api',
embedded: true,
});
expect(container!.querySelector('header')).toBeNull();
expect(latestChatEditorProps.visibleToolbarActions).toContain('workspace');
expect(latestChatEditorProps.workspaceName).toBe('Payments API');
expect(latestChatEditorProps.workspaceTitle).toBe('/work/api');
@@ -192,8 +192,8 @@ export interface ChatPaneProps {
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
* multi-workspace daemon; falls back to the connection's own workspace.
* view (which knows it per session); falls back to the connection's own
* workspace.
*/
workspaceCwd?: string;
/**
@@ -1498,10 +1498,8 @@ export function ChatPane({
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
// workspace a message goes to. Multi-workspace-ness comes from the shared
// workspace provider (the pane's own session connection may not carry it).
// Multi-workspace-ness comes from the shared workspace provider (the
// pane's own session connection may not carry it).
const showWorkspaceChip =
hasMultipleWorkspaces(workspace.capabilities) && !!paneWorkspaceCwd;
const prepareContextCompression = useCallback(() => {
@@ -1533,11 +1531,11 @@ export function ChatPane({
// `React.memo`, and a fresh `[...]` each render would defeat it.
const paneToolbarActions = useMemo(
() =>
(showWorkspaceChip
(embedded && showWorkspaceChip
? [...PANE_TOOLBAR_ACTIONS, 'workspace' as const]
: PANE_TOOLBAR_ACTIONS
).filter((action) => action !== 'plan' || planControlVisible),
[showWorkspaceChip, planControlVisible],
[embedded, showWorkspaceChip, planControlVisible],
);
const headerActions =
connection.sessionId && renderHeaderActions
@@ -1548,12 +1546,8 @@ export function ChatPane({
})
: null;
// Also surface the workspace in the pane HEADER (always visible at the top),
// not just the composer chip at the bottom — on a narrow split the composer
// chip collapses to a bare folder icon, so the header is where you tell panes
// apart. A stable per-workspace accent color (same palette as the sidebar
// session-group dots) lets same-workspace panes read as a group at a glance,
// and keeps them distinguishable even when the header name ellipsizes.
// The header identifies each split pane's workspace; only embedded panes
// without a header need the composer chip. The accent matches sidebar dots.
const workspaceLabel =
showWorkspaceChip && paneWorkspaceCwd
? workspaceLabelForCwd(
@@ -193,6 +193,8 @@ export type WebShellChatHeaderItem =
| 'contextUsage';
export interface WebShellChatHeaderOptions {
/** Show the mobile-access QR entry in chat headers. Defaults to false. */
showMobileAccess?: boolean;
/** Built-in header actions to show. Token and context usage are opt-in. */
items?: readonly WebShellChatHeaderItem[];
}
+1
View File
@@ -325,6 +325,7 @@ export function StandaloneApp({ daemonToken }: { daemonToken?: string }) {
? MACOS_TITLEBAR_CLASS
: undefined,
header: {
showMobileAccess: true,
items: [
'title',
'environment',