feat(web-shell): show a dismissible Workflow chip and menu summary (#16107)

* feat(web-shell): show a dismissible Workflow chip and menu summary

Workflow moved into the composer + menu, so once it was on the toolbar no
longer showed it. Show a Workflow chip in the toolbar while the saved
preference is on, the same way Plan does: hovering reveals a close mark,
clicking turns the mode off (also during a running response), and an
on-but-unavailable preference is tinted with the warning color. The menu
entry gains a one-line summary of what the mode does, or of its state when
it is not simply available.

* fix(web-shell): keep the Workflow chip warning off the Plan chip selectors
This commit is contained in:
qqqys authored and GitHub committed 2026-11-02 07:32:44 +00:00
1 parent 73a7268447
commit 6b6df2a8b4
11 files changed
+372 -34

No files matched your search

+15 -11
View File
@@ -42,23 +42,27 @@ capability or option. A failed write leaves the published mode unchanged.
## Web Shell and VS Code
The composer in Web Shell, split panes, and the modern VS Code chat includes
an independent **Workflow** control for supported sessions. The switch shows
the saved preference, not whether the mode can currently take effect. If the
preference is on but the tools are unavailable, it stays checked and can still
be turned off. Click the Workflow label or focus and activate it to open its status details;
the same details are available by tapping on touch devices.
The composer in Web Shell, split panes, and the modern VS Code chat lists
**Workflow** in its **+** menu for supported sessions. The check mark shows the
saved preference, not whether the mode can currently take effect; on wider
screens a second line summarizes what the mode does or its current state. While
the preference is on, the toolbar shows a **Workflow** chip; its tooltip carries
the status details, and clicking it turns the mode off. If the preference is on
but the tools are unavailable, the entry stays checked, the chip is shown in the
warning color, and both can still turn the mode off.
Before a session is created or restored, the control shows an availability
placeholder. It does not create a session or save a future preference. Hosts
that do not advertise support, including older servers, hide the mode switch.
that do not advertise support, including older servers, hide the entry. Hosts
that hide the **+** menu show neither the entry nor the chip.
Changes from another client update the same session's control automatically.
During a write or reconnection, the last confirmed value remains visible and
the switch is disabled until state is confirmed. A timeout is an unconfirmed
outcome, not a successful change; the client reads the current state instead
of automatically repeating the write. An active response does not disable the
switch: the change applies to the next fresh turn and leaves effort unchanged.
the entry and chip are disabled until state is confirmed. A timeout is an
unconfirmed outcome, not a successful change; the client reads the current
state instead of automatically repeating the write. An active response does
not disable the entry or chip: the change applies to the next fresh turn and
leaves effort unchanged.
## Reminder and input behavior
+10 -8
View File
@@ -33,18 +33,20 @@ Managed、SSH、派生/子代理、session-agent 及关闭聊天录制的会话
## Web Shell 与 VS Code
Web Shell、分屏和现代 VS Code 聊天的输入栏为受支持会话提供独立的 **Workflow**
控件。开关表示已保存的偏好,而不是当前是否实际生效。偏好已开启但工具不可用时,
开关仍保持选中,并允许关闭。点击 Workflow 标签可打开状态详情;键盘聚焦后激活标签
或在触屏设备上轻点,也能查看相同详情。
Web Shell、分屏和现代 VS Code 聊天的输入栏为受支持会话在 **+** 菜单中提供
**Workflow** 条目。勾选表示已保存的偏好,而不是当前是否实际生效;较宽的屏幕上,
条目第二行说明该模式的作用或当前状态。偏好开启期间,工具栏显示 **Workflow**
标签,悬停提示给出状态详情,点击即可关闭。偏好已开启但工具不可用时,条目仍保持
勾选,标签以警告色显示,两者都能关闭该模式。
尚未创建或恢复会话时,控件只显示可用性占位提示,不会创建会话或保存将来的偏好。
未广告支持能力的宿主(包括旧版服务端)不显示模式开关。
未广告支持能力的宿主(包括旧版服务端)不显示该条目;隐藏 **+** 菜单的宿主
既不显示条目,也不显示标签。
其他客户端的更改会自动同步到同一会话的控件。写入或重连期间保留最后确认值,
直到状态重新确认前禁用开关。超时表示结果尚未确认,不是修改成功;客户端读取当前
状态,而不会自动重复写入。正在输出回复不会禁用开关:更改对下一轮新输入生效,
并保持思考强度不变。
直到状态重新确认前禁用条目和标签。超时表示结果尚未确认,不是修改成功;客户端
读取当前状态,而不会自动重复写入。正在输出回复不会禁用条目或标签:更改对下一轮
新输入生效,并保持思考强度不变。
## 提醒与输入行为
@@ -1905,6 +1905,11 @@
color: inherit;
}
/* Workflow mode saved on but not currently able to take effect. */
.workflowChip[data-unavailable] {
color: var(--warning-color);
}
/* No dimming: like every other .toolBtn, including the permission button
beside it, the chip keeps its appearance while mode controls are busy. That
state is brief, and dimming one control of the pair would flicker. */
@@ -6909,6 +6909,90 @@ describe('ChatEditor Workflow mode', () => {
expect(composerCoreState.submitText).not.toHaveBeenCalled();
});
it('shows a dismissible chip while Workflow is requested, even mid-response', () => {
const onWorkflowModeChange = vi.fn();
const props = {
...ready,
isRunning: true,
disabled: true,
visibleToolbarActions: ['addMenu', 'workflowMode', 'model'] as const,
onWorkflowModeChange,
};
const container = renderChatEditor(props);
const chip = container.querySelector<HTMLButtonElement>(
'[data-web-shell-workflow-chip]',
)!;
expect(chip.getAttribute('aria-pressed')).toBe('true');
expect(chip.getAttribute('aria-disabled')).toBeNull();
expect(chip.hasAttribute('data-unavailable')).toBe(false);
act(() => chip.click());
expect(onWorkflowModeChange).toHaveBeenCalledExactlyOnceWith(false);
rerenderChatEditor(container, {
...props,
workflowMode: { requested: false, available: true, effective: false },
});
expect(
container.querySelector('[data-web-shell-workflow-chip]'),
).toBeNull();
});
it('keeps the chip dismissible but marked while the requested mode is unavailable', () => {
const onWorkflowModeChange = vi.fn();
const container = renderChatEditor({
workflowModeStatus: 'ready',
workflowMode: {
requested: true,
available: false,
effective: false,
reason: 'workflow_unavailable',
},
visibleToolbarActions: ['addMenu', 'workflowMode'],
onWorkflowModeChange,
});
const chip = container.querySelector<HTMLButtonElement>(
'[data-web-shell-workflow-chip]',
)!;
expect(chip.hasAttribute('data-unavailable')).toBe(true);
act(() => chip.click());
expect(onWorkflowModeChange).toHaveBeenCalledExactlyOnceWith(false);
});
it('makes the chip inert while a Workflow write is pending', () => {
const onWorkflowModeChange = vi.fn();
const container = renderChatEditor({
...ready,
workflowModePending: true,
visibleToolbarActions: ['addMenu', 'workflowMode'],
onWorkflowModeChange,
});
const chip = container.querySelector<HTMLButtonElement>(
'[data-web-shell-workflow-chip]',
)!;
expect(chip.getAttribute('aria-disabled')).toBe('true');
act(() => chip.click());
expect(onWorkflowModeChange).not.toHaveBeenCalled();
});
it('hands focus to the composer when the focused chip goes away', () => {
const props = {
...ready,
visibleToolbarActions: ['addMenu', 'workflowMode'] as const,
onWorkflowModeChange: vi.fn(),
};
const container = renderChatEditor(props);
const chip = container.querySelector<HTMLButtonElement>(
'[data-web-shell-workflow-chip]',
)!;
act(() => chip.focus());
composerCoreState.focus.mockClear();
rerenderChatEditor(container, {
...props,
workflowMode: { requested: false, available: true, effective: false },
});
expect(composerCoreState.focus).toHaveBeenCalled();
});
it('hides Workflow when the host excludes the add menu', () => {
const container = renderChatEditor({
...ready,
@@ -6917,6 +7001,9 @@ describe('ChatEditor Workflow mode', () => {
expect(
container.querySelector('[data-testid="composer-add-menu-trigger"]'),
).toBeNull();
expect(
container.querySelector('[data-web-shell-workflow-chip]'),
).toBeNull();
});
it.each([
@@ -80,6 +80,7 @@ import {
} from '../utils/composerTag';
import { isSafeImageSrc } from './messages/Markdown';
import { ModeIcon } from './ModeIcon';
import { getWorkflowModeControlState } from './workflow-mode-control-state';
import { planSlashSectionRows } from '../utils/slashSectionPlan';
import { getModelDisplayName } from '../utils/modelDisplay';
import { getContextUsageLevel } from '../utils/contextUsage';
@@ -109,6 +110,7 @@ import { WorkspaceIndicator } from './WorkspaceIndicator';
import {
ChevronDownIcon,
ChevronRightIcon,
GitBranchIcon,
PanelBottomCloseIcon,
Settings2Icon,
FolderClosedIcon,
@@ -1849,7 +1851,7 @@ export const ChatEditor = memo(
editorTheme: CHAT_EDITOR_THEME,
});
const { t } = useI18n();
const { t, language } = useI18n();
useImperativeHandle(ref, () => core.handle, [core.handle]);
@@ -2183,6 +2185,7 @@ export const ChatEditor = memo(
}, [core.hasAttachments, onAttachmentsChange]);
const planDescriptionId = useId();
const workflowChipDescriptionId = useId();
const modeItems = useMemo<DropdownItem[]>(
() =>
EXECUTION_APPROVAL_MODES.filter((id) =>
@@ -2349,6 +2352,7 @@ export const ChatEditor = memo(
gitBranch: false,
mode: false,
plan: false,
workflow: false,
model: false,
});
const toolbarLabelVisibilityRef = useRef(toolbarLabelVisibility);
@@ -2462,9 +2466,7 @@ export const ChatEditor = memo(
// so it is handed on: to the input, or past a disabled one to a toolbar
// control beside where the chip was.
const planChipHadFocusRef = useRef(false);
useLayoutEffect(() => {
if (showPlanChip || !planChipHadFocusRef.current) return;
planChipHadFocusRef.current = false;
const handOffChipFocus = useCallback(() => {
const toolbar = toolbarLeadingRef.current;
// Focus that is anywhere but lost is somewhere the user put it.
const doc = toolbar?.ownerDocument;
@@ -2485,7 +2487,41 @@ export const ChatEditor = memo(
'[data-web-shell-mode-button]:not(:disabled), [data-web-shell-model-button]',
)
?.focus();
}, [showPlanChip, disabled, focusComposer]);
}, [disabled, focusComposer]);
useLayoutEffect(() => {
if (showPlanChip || !planChipHadFocusRef.current) return;
planChipHadFocusRef.current = false;
handOffChipFocus();
}, [showPlanChip, handOffChipFocus]);
// Workflow, like Plan, is toggled from the add menu, so the toolbar shows
// a dismissible chip while the saved preference is on — including while
// it cannot take effect, since that is when turning it off matters most.
const workflowControl =
showAddMenuAction && showToolbarAction('workflowMode')
? getWorkflowModeControlState(
{
workflowMode,
workflowModeStatus,
workflowModePending,
onWorkflowModeChange,
},
language,
)
: null;
const showWorkflowChip = Boolean(workflowControl?.mode?.requested);
const workflowChipUnavailable =
showWorkflowChip && workflowControl?.mode?.effective === false;
const workflowChipTooltip = workflowControl
? workflowControl.disabled
? workflowControl.description
: `${workflowControl.description} ${workflowControl.messages.clickToTurnOff}`
: '';
const workflowChipHadFocusRef = useRef(false);
useLayoutEffect(() => {
if (showWorkflowChip || !workflowChipHadFocusRef.current) return;
workflowChipHadFocusRef.current = false;
handOffChipFocus();
}, [showWorkflowChip, handOffChipFocus]);
const showModelAction = showToolbarAction('model');
const showCommandAction = showToolbarAction('commands');
const modelItems = useMemo<DropdownItem[]>(
@@ -2729,6 +2765,7 @@ export const ChatEditor = memo(
const showGitBranchLabel = isMobile || toolbarLabelVisibility.gitBranch;
const showModeLabel = toolbarLabelVisibility.mode;
const showPlanLabel = toolbarLabelVisibility.plan;
const showWorkflowLabel = toolbarLabelVisibility.workflow;
const showModelLabel = toolbarLabelVisibility.model;
// One renderer per chip so the toolbar, the row under the composer, and the
// mobile row share them: the toolbar drops a label when its width budget is
@@ -2878,6 +2915,9 @@ export const ChatEditor = memo(
...(showPlanToolbarControl
? [{ id: 'plan', expansionWidth: expansionWidth('plan') }]
: []),
...(showWorkflowChip
? [{ id: 'workflow', expansionWidth: expansionWidth('workflow') }]
: []),
...(showModelAction
? [
{
@@ -2922,6 +2962,7 @@ export const ChatEditor = memo(
gitBranch: itemVisibility.gitBranch ?? false,
mode: itemVisibility.mode ?? false,
plan: itemVisibility.plan ?? false,
workflow: itemVisibility.workflow ?? false,
model: itemVisibility.model ?? false,
};
const unchanged = Object.keys(next).every(
@@ -2990,6 +3031,7 @@ export const ChatEditor = memo(
showModelAction,
showModeAction,
showPlanToolbarControl,
showWorkflowChip,
workspaceChipInToolbar,
workspaceIndicatorVisible,
workspaceName,
@@ -3263,6 +3305,57 @@ export const ChatEditor = memo(
</Tooltip>
</TooltipProvider>
)}
{showWorkflowChip && workflowControl && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={`${styles.toolBtn} ${styles.planChip} ${styles.workflowChip}`}
data-web-shell-workflow-chip
data-workflow-state={workflowControl.status}
data-unavailable={workflowChipUnavailable ? '' : undefined}
data-labelled={showWorkflowLabel ? '' : undefined}
aria-pressed
aria-label={workflowControl.messages.label}
aria-describedby={workflowChipDescriptionId}
aria-busy={workflowControl.pending || undefined}
// Not the native attribute, so focus survives the
// pending write the chip's own click starts.
aria-disabled={workflowControl.disabled || undefined}
onFocus={() => {
workflowChipHadFocusRef.current = true;
}}
onBlur={() => {
workflowChipHadFocusRef.current = false;
}}
onClick={() => {
if (workflowControl.disabled) return;
onWorkflowModeChange?.(false);
}}
>
<span
className={`${styles.toolBtnModeIcon} ${styles.planChipIcon}`}
>
<GitBranchIcon />
<span className={styles.planChipClose} aria-hidden="true">
<XIcon size={12} strokeWidth={2} />
</span>
</span>
{showWorkflowLabel && (
<span className={styles.toolBtnText}>Workflow</span>
)}
</button>
</TooltipTrigger>
<span id={workflowChipDescriptionId} className="sr-only">
{workflowChipTooltip}
</span>
<TooltipContent side="top">
{workflowChipTooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{showPlanSwitch && (
<TooltipProvider delayDuration={300}>
<Tooltip>
@@ -4451,6 +4544,27 @@ export const ChatEditor = memo(
</span>
</>
)}
{showWorkflowChip && (
<>
<span
data-toolbar-measure="workflow:collapsed"
className={`${styles.toolBtn} ${styles.planChip}`}
>
<span className={styles.toolBtnModeIcon}>
<GitBranchIcon />
</span>
</span>
<span
data-toolbar-measure="workflow:expanded"
className={`${styles.toolBtn} ${styles.planChip}`}
>
<span className={styles.toolBtnModeIcon}>
<GitBranchIcon />
</span>
<span className={styles.toolBtnText}>Workflow</span>
</span>
</>
)}
<span
data-toolbar-measure="model:collapsed"
className={`${styles.toolBtn} ${styles.modelToolBtn} ${styles.toolBtnCompact}`}
@@ -236,6 +236,11 @@ describe('AddMenu', () => {
}),
);
await openMenu();
expect(
menuItem('composer-add-menu-workflow')!.querySelector(
'[data-workflow-summary]',
)?.textContent,
).toBe('Let new tasks use workflows');
await act(async () => menuItem('composer-add-menu-workflow')!.click());
await settle();
expect(onWorkflowModeChange).toHaveBeenCalledExactlyOnceWith(true);
@@ -1193,8 +1193,15 @@ export function AddMenu({
}
>
<GitBranchIcon />
<span className="min-w-0 flex-1 truncate">
<span>Workflow</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate">Workflow</span>
<span
className="hidden truncate text-xs text-muted-foreground sm:block"
aria-hidden="true"
data-workflow-summary
>
{workflowControl.summary}
</span>
<span id={workflowDescriptionId} className="sr-only">
{workflowControl.description}
</span>
@@ -74,6 +74,34 @@ describe('getWorkflowModeControlState', () => {
);
});
it.each([
[{ workflowModeStatus: 'ready' as const }, 'Let new tasks use workflows'],
[
{ workflowModeStatus: 'ready' as const, workflowModePending: true },
'Updating…',
],
[{ workflowModeStatus: 'syncing' as const }, 'Syncing…'],
])('summarizes an available mode in one line (%o)', (props, summary) => {
expect(
getWorkflowModeControlState(
{ workflowMode: availableOff, ...props },
'en',
)?.summary,
).toBe(summary);
});
it('summarizes an unavailable saved preference by its state', () => {
expect(
getWorkflowModeControlState(
{
workflowModeStatus: 'ready',
workflowMode: { requested: true, available: false, effective: false },
},
'zh-CN',
)?.summary,
).toBe('已开启,当前不可用');
});
it('disables enabling an unavailable mode', () => {
const control = getWorkflowModeControlState(
{
@@ -61,5 +61,21 @@ export function getWorkflowModeControlState(
.filter(Boolean)
.join(' ');
return { mode, disabled, description, messages, status, pending };
const short = messages.short;
const summary =
status === 'unbound'
? short.unbound
: pending
? short.updating
: status === 'syncing'
? short.syncing
: status === 'unknown' || !mode
? short.unknown
: mode.available
? messages.menuDescription
: mode.requested
? short.onUnavailable
: short.unavailable;
return { mode, disabled, description, summary, messages, status, pending };
}
@@ -3,6 +3,15 @@ import type { WebShellLanguage } from '../i18n';
// Composer-only messages must not grow the read-only transcript renderer.
const EN = {
label: 'Workflow mode',
menuDescription: 'Let new tasks use workflows',
short: {
unbound: 'Needs an active session',
updating: 'Updating…',
syncing: 'Syncing…',
unknown: 'State not confirmed',
onUnavailable: 'On, but unavailable now',
unavailable: 'Unavailable now',
},
details: 'Workflow mode details',
unbound: 'Create or resume a session to check availability.',
updating: 'Updating Workflow mode…',
@@ -18,10 +27,20 @@ const EN = {
nextTurn:
'Changes apply to the next fresh turn without interrupting the current turn or changing effort.',
updateFailed: 'Failed to update Workflow mode',
clickToTurnOff: 'Click to turn off.',
};
const ZH: typeof EN = {
label: 'Workflow 模式',
menuDescription: '新任务可交给 workflow 执行',
short: {
unbound: '需要先有会话',
updating: '正在更新…',
syncing: '正在同步…',
unknown: '状态未确认',
onUnavailable: '已开启,当前不可用',
unavailable: '当前不可用',
},
details: 'Workflow 模式详情',
unbound: '创建或恢复会话后查看可用性。',
updating: '正在更新 Workflow 模式…',
@@ -36,6 +55,7 @@ const ZH: typeof EN = {
unsupported: '此会话不支持 Workflow 模式。',
nextTurn: '更改从下一轮新输入开始生效,不会中断当前轮次或修改思考强度。',
updateFailed: '更新 Workflow 模式失败',
clickToTurnOff: '点击关闭。',
};
export function getWorkflowModeMessages(language: WebShellLanguage) {
@@ -195,13 +195,11 @@ test('preserves requested on when unavailable, permits off, and clears omitted s
await expect(toggle).toHaveAccessibleDescription(
/Workflow mode is on, but currently unavailable\./,
);
const rowBounds = await toggle.boundingBox();
const referenceBounds = await page
.getByTestId('composer-add-menu-reference-file')
.boundingBox();
expect(rowBounds).not.toBeNull();
expect(referenceBounds).not.toBeNull();
expect(rowBounds!.height).toBeLessThanOrEqual(referenceBounds!.height + 1);
const summary = toggle.locator('[data-workflow-summary]');
await expect(summary).toHaveText('On, but unavailable now');
const summaryBounds = await summary.boundingBox();
expect(summaryBounds).not.toBeNull();
expect(summaryBounds!.height).toBeLessThanOrEqual(17);
await page.screenshot({
path: testInfo.outputPath('workflow-unavailable-on.png'),
});
@@ -218,6 +216,58 @@ test('preserves requested on when unavailable, permits off, and clears omitted s
]);
});
test('shows a dismissible toolbar chip while Workflow is requested @smoke', async ({
page,
baseURL,
}, testInfo) => {
const scenario = createWebShellDaemonScenario({
state: { configOptions: [workflowOption()] },
});
const daemon = await installMockDaemon(page, scenario, { baseURL });
await openSession(page, daemon);
const chip = page.locator('[data-web-shell-workflow-chip]');
await expect(chip).toHaveCount(0);
await (await modeSwitch(page)).click();
await expect(chip).toBeVisible();
await expect(chip).toHaveAttribute('aria-pressed', 'true');
await expect(chip).not.toHaveAttribute('data-unavailable', /.*/);
await chip.hover();
await expect(page.getByRole('tooltip')).toContainText('Click to turn off.');
await page.screenshot({ path: testInfo.outputPath('workflow-chip-on.png') });
await chip.click();
await expect(chip).toHaveCount(0);
expect(daemon.configOptionRequests().map((request) => request.body)).toEqual([
{ configId: 'workflow_mode', value: 'on' },
{ configId: 'workflow_mode', value: 'off' },
]);
expect(daemon.promptRequests()).toHaveLength(0);
});
test('marks the chip while the requested mode is unavailable', async ({
page,
baseURL,
}, testInfo) => {
const scenario = createWebShellDaemonScenario({
state: { configOptions: [workflowOption(true, false)] },
});
const daemon = await installMockDaemon(page, scenario, { baseURL });
await openSession(page, daemon);
const chip = page.locator('[data-web-shell-workflow-chip]');
await expect(chip).toHaveAttribute('data-unavailable', '');
await expect(chip).toHaveAccessibleDescription(
/Workflow mode is on, but currently unavailable\./,
);
await page.screenshot({
path: testInfo.outputPath('workflow-chip-unavailable.png'),
});
await chip.click();
await expect(chip).toHaveCount(0);
expect(daemon.configOptionRequests().map((request) => request.body)).toEqual([
{ configId: 'workflow_mode', value: 'off' },
]);
});
test('a live config push beats an already pending context response', async ({
page,
baseURL,