feat(web-shell): simplify mobile composer actions (#12252)

* feat(web-shell): simplify mobile composer actions

* fix(web-shell): restore mobile input history navigation

* fix(web-shell): address mobile composer review feedback
This commit is contained in:
Shaojin Wen authored and GitHub committed 2026-09-20 05:42:25 +00:00
1 parent 39b22efb6f
commit 1bc0514fae
18 files changed
+2714 -1239

No files matched your search

@@ -0,0 +1,121 @@
# Mobile composer actions
[English](mobile-composer-actions.md) | [简体中文](mobile-composer-actions.zh-CN.md)
## Problem and scope
The touch composer exposes a grid of up to 18 actions and six keyboard keys.
Several actions duplicate navigation and settings; Tab does nothing on the
textarea backend. The stop button disappears when a running session has a
draft. Small controls and horizontal add-menu submenus make phone use harder.
This change is confined to Web Shell UI. Desktop editing, daemon routes,
permissions, attachment limits, and host toolbar opt-ins retain their existing
contracts. Mobile means the existing touch textarea backend, including its URL
override; narrow desktop windows keep desktop editing.
## Design
### Toolbar and editing
Remove the legacy touch action grid and simulated Tab/Esc/left/right keys. Keep add,
model, approval, dictation, and send controls. Hide the width toggle on touch;
retain the context-usage control when enabled by the host. Put workspace
selection and Git controls
in a separate context row so starting a task in a particular workspace or
branch remains possible. Preserve host-provided toolbar slots.
Use 44px touch targets and wrap controls on very narrow viewports rather than
clip them. Keep Plan as an active-state chip. While a turn runs, stop remains
available alongside send when there is a draft; stopping never clears that
draft. Voice capture retains its existing reduced toolbar and hides the context and
editing rows.
Add a small editing row with previous/next input history on the left, expand,
and, while the textarea is focused, hide keyboard. The history arrows call the
existing navigation actions directly: up recalls older inputs, down recalls
newer inputs and then restores the draft saved before browsing. They never
submit. History search in Add remains available for finding a specific input.
The row wraps on extra-narrow screens and its history controls are disabled with the
composer. Hide keyboard blurs without returning focus through the composer
surface click handler. A modal expanded editor edits the same controlled draft,
preserves its selection when returning, and does not submit when closed. Its
Done and keyboard controls are touch sized. Shell mode gets an explicit exit
action. Session/workspace changes and disabled state close expanded editing.
### Touch add menu
Keep the existing desktop dropdown. On touch, use the shared portal-aware
bottom Drawer with one visible page at a time, a Back button, and a Close
button. Reuse provider searches, availability checks, attachment destinations,
skill prefix insertion, and session/workspace invalidation.
Offer photos, camera, message files, and upload to workspace as explicit
actions. Photos use an image-only file picker; camera requests rear capture.
Native file-picker cancellation preserves the draft. Browser/device support
determines how the native picker presents capture.
File references, extensions, MCP server references, and skills have dedicated
pages with scrolling. Reference search remains workspace-wide. Plan stays a
checked row. Add input history and a searchable command page: choosing a
command prefixes its invocation to the existing draft for review, and never
automatically runs it. This keeps Shell, Goal, and infrequent session commands
available without independent shortcuts or draft replacement. A labelled Live
voice entry moves into the drawer, with an active-call indicator retained in
the toolbar.
## Implementation and constraints
Changes affect ChatEditor, AddMenu, their CSS and tests, Git popover sizing,
the shared paste handler, LiveVoiceButton, and English/Chinese strings.
Reuse the existing Dialog and Drawer primitives and
Web Shell portal root. No new dependency or public customization API is needed.
The add control remains host opt-in. A host that explicitly enables a command
button retains a reachable command picker on mobile outside Shell mode.
Hosts without Add retain Shell and history-search buttons in the editing row;
this fallback does not enable file ingestion or references.
The history search must have a visible close action and remain touch usable.
File selection must stay in a user gesture, and deferred insertions must run
after the drawer releases focus. Expanded editing must preserve attachment
and reference state in the shared composer, including IME input. Image/file
paste uses the existing ingestion lane; long-text paste uses the active
editor's selection when deciding whether to fold it into an attachment.
All imperative text insertion and focus target the expanded textarea while it
is mounted. The dialog retains the input placeholder and mobile text settings,
shows the shared attachment/image count, and returns focus without stealing it
from a newly opened approval dialog. Closing idle Live voice returns to Add.
The command page uses shared completion labels, sections, priority, hints and
subcommand results (up to 50 matches), normalizes a leading slash and surrounding
whitespace, and retains description search as a fallback. Search stays visible
while scrolling. Commands and skills are unavailable in Shell mode. Skills only
announce an empty catalog after loading has completed.
## Validation and acceptance
Use the global qwen CLI for a baseline attempt and the repository browser
harness for deterministic UI coverage. Verify local bundled UI when available.
Record any runtime limitations separately from mocked browser results.
- At phone widths, the old action grid and simulated keyboard keys are absent;
labelled history arrows remain in the editing row. Toolbar
actions are reachable with no horizontal overflow. Desktop behavior remains.
- Stop works with a pending draft and keeps the draft; send still queues it.
- Hide keyboard blurs the editor; expand/edit/Done preserves text, selection,
attachments, and references. Switching sessions cannot leak expanded drafts.
- Drawer pages support tap/back/close; native photos/camera/files choose the
correct destination; cancel leaves text intact. Provider errors and disabled
actions remain honest, and Plan toggles exactly once.
- Commands and skills preserve existing drafts and tags; history can be opened,
selected, or dismissed. Live voice remains accessible.
- History arrows recall older/newer inputs and restore a multiline draft
without submitting. Empty history is inert, and both arrows remain reachable
with 44px targets at 390px and 240px widths.
- Run focused unit/browser tests, build, typecheck, bundle, and two clean
self-audit passes plus independent review.
## Open questions
None for implementation. Real-device iOS/Android keyboard and camera behavior
must be distinguished from browser emulation in the verification report.
@@ -0,0 +1,89 @@
# 移动端输入框操作
[English](mobile-composer-actions.md) | [简体中文](mobile-composer-actions.zh-CN.md)
## 问题与范围
触屏输入框展示最多 18 个操作和 6 个键盘按键组成的网格。
多个操作与导航、设置重复;Tab 在 textarea 路径中不执行任何动作。
运行中的会话只要有草稿,停止按钮就会消失。按钮偏小,添加菜单的横向子菜单也不适合手机。
本次仅修改 Web Shell UI。桌面编辑、daemon 路由、权限、附件限制及宿主工具栏开关
保持现有约定。移动端沿用触屏 textarea 的判定,包括 URL 覆盖;窄桌面窗口继续使用桌面编辑。
## 设计
### 工具栏与编辑
移除旧触屏操作网格和模拟 Tab、Esc、左右方向键。保留添加、模型、审批、语音输入及发送控件。
触屏下隐藏宽度切换;保留宿主启用的上下文占用控件。工作区选择和 Git 控件放到独立上下文行,
保留在指定工作区、分支中开始任务的能力。保留宿主自定义工具栏插槽。
触摸区域为 44px;极窄视口允许换行,不裁剪控件。Plan 保持开启状态标签。
任务运行时,有草稿也同时提供停止和发送;停止不清空草稿。
语音录制继续使用现有精简工具栏,并隐藏上下文行与编辑操作行。
增加小型编辑操作行,左侧提供上一条、下一条输入历史,另有展开和输入框聚焦时的收起键盘按钮。
历史上下箭头直接调用现有导航:向上回看较早输入,向下查看较新输入,最后恢复开始翻阅前的草稿。
它们不会发送消息;添加抽屉中的历史搜索继续用于查找特定输入。
极窄屏允许操作行换行,历史按钮随输入框禁用。
收起键盘只取消聚焦,不被输入框外壳点击处理重新聚焦。
展开编辑通过模态界面编辑同一受控草稿,返回时保留选区,关闭不发送消息。
完成和键盘控件使用触摸尺寸。Shell 模式有明确退出操作。
切换会话、工作区或禁用输入时关闭展开编辑。
### 触屏添加菜单
保留桌面下拉菜单。触屏使用共享的、支持 portal 的底部 Drawer,
一次只显示一页,提供返回和关闭按钮。复用已有搜索源、可用性判断、附件去向、
技能前置插入和会话/工作区失效处理。
明确提供照片、拍照、消息文件附件、上传到工作区操作。
照片选择器只接受图片;拍照请求后置摄像头。取消系统文件选择保留草稿。
系统选择器如何呈现拍照由浏览器和设备支持决定。
文件引用、扩展、MCP server 引用和技能使用可滚动的独立页面。
文件搜索仍覆盖整个工作区。Plan 保持勾选行。
增加输入历史和可搜索命令页:选择命令会将调用文本前置到现有草稿,供用户检查,
不自动执行。Shell、Goal 及低频会话命令仍可用,不再占独立快捷按钮,也不覆盖草稿。
带文字的实时语音入口移入抽屉,通话进行时工具栏仍显示状态入口。
## 实现与约束
涉及 ChatEditor、AddMenu、对应 CSS 和测试、Git 浮层尺寸、共享粘贴处理、LiveVoiceButton 及中英文文案。
复用现有 Dialog、Drawer 和 Web Shell portal root,不新增依赖或公开自定义 API。
添加控件仍由宿主显式开启。宿主显式开启命令按钮时,移动端在非 Shell 模式仍可打开命令选择器。
未启用 Add 的宿主在编辑操作行保留 Shell 和历史搜索入口;该兜底不会启用文件接收或引用。
历史搜索必须有可见关闭操作,并能通过触摸使用。
文件选择必须由用户手势直接触发,延迟插入必须在抽屉释放焦点后执行。
展开编辑使用共享输入状态,保留附件和引用,兼容输入法组合输入。
图片和文件粘贴复用现有接收通道;长文本粘贴根据当前编辑器选区判断是否折叠为附件。
展开框挂载期间,所有外部文本插入与聚焦都使用展开框。对话框保留输入提示及移动端文本属性,
显示共享附件/图片数量;关闭后恢复焦点时不抢占新打开的审批对话框。关闭空闲实时语音时返回 Add。
命令页复用共享补全的标签、分区、优先级、参数提示和子命令结果(最多 50 条),
归一化前导斜杠和首尾空白,并保留按描述搜索的兜底。滚动时搜索框保持可见。
Shell 模式不提供命令和技能;技能目录加载完成后才能播报空目录。
## 验证与验收
先尝试全局 qwen CLI 基线,再使用仓库浏览器夹具进行确定性 UI 验证。
可用时验证本地打包 UI。实际运行环境限制与模拟浏览器结果分别记录。
- 手机宽度下不再显示旧操作网格和模拟键盘按键;编辑行保留带明确标签的历史箭头。
工具栏可达且无横向溢出。桌面行为保持。
- 存在草稿时仍可停止,且草稿保留;发送仍可排队。
- 收起键盘取消聚焦;展开、编辑、完成保留文本、选区、附件和引用。
切换会话不会泄漏展开草稿。
- 抽屉支持点击、返回、关闭;照片、拍照、文件使用正确去向;取消不影响文本。
搜索错误和禁用状态准确呈现,Plan 每次仅切换一次。
- 命令和技能保留原有草稿与标签;历史可打开、选择、关闭;实时语音仍可达。
- 历史箭头可回看较早、较新的输入并恢复多行草稿,不自动发送。空历史不改变内容;
390px 和 240px 宽度下两个箭头均可达,触控区域为 44px。
- 运行定向单元和浏览器测试、构建、类型检查、打包,再完成两轮干净自审和独立审查。
## 待定问题
实现无待定问题。验证报告必须区分 iOS/Android 真机键盘、摄像头行为与浏览器模拟结果。
@@ -96,9 +96,13 @@ These are facts about the current system that shape the design, and each one rul
## Touch
`+` shows the same items on touch and desktop. Attachment and reference insertion reuse their existing lanes; skill prepend targets CodeMirror on desktop and the native textarea on touch.
**Unverified:** right-side flyouts depend on hover, and touch has none. The menu primitive is expected to fall back to click/focus for non-mouse pointers, but that needs a real-device check. If it fails, touch keeps the single item that does not need a submenu and this section is rewritten.
The mobile layout and its additional actions are now specified by
[Mobile composer actions](mobile-composer-actions.md). It replaces horizontal
flyouts with a bottom drawer with Back and Close controls, and adds explicit
photo/camera, command, history, Shell, and Live voice entries. The non-goals below
continue to describe the desktop dropdown; the linked design supersedes them
for touch. Attachment and reference insertion reuse their existing lanes; skill
and command prefixes preserve the native textarea draft.
## Non-goals
@@ -140,7 +144,7 @@ Behavior-level, not tied to an implementation shape:
5. Selecting a skill with an empty draft yields the invocation followed by a space. Selecting a skill with a non-empty draft yields the invocation, a space, then the previous draft — with any reference chips intact.
6. No insert loses already-typed text or already-inserted references.
7. When an individual capability is unavailable, its item is hidden or disabled with a reason; `+` itself remains visible whenever `addMenu` is in the host's toolbar item list. If every inner item is unavailable, `+` opens to an empty-state row and does not silently vanish.
8. Touch and desktop show the same set, subject to the real-device submenu check.
8. Desktop keeps the set described here. Touch uses the pages and additional editing actions defined in [Mobile composer actions](mobile-composer-actions.md); verify both surfaces against their respective designs.
9. A long submenu scrolls instead of overflowing the viewport.
10. Pointer interaction is covered; keyboard navigation is unsupported and has no feature-specific implementation.
11. With `plan` listed and a toggle supplied, Plan mode is the last row, shows a check while Plan is on, toggles once per choice, returns focus to the composer, and is disabled with the reason "Switching mode" while mode controls are busy. Without `plan` or without a toggle the row is absent. It remains under the empty-state row.
@@ -96,9 +96,10 @@ Composer 已经能做很多「往这条消息里加点东西」的事,但几
## 触屏
`+` 在触屏和桌面上显示相同的项。附件和引用插入复用各自现有的通道;技能前置在桌面上针对 CodeMirror,在触屏上针对原生 textarea。
**未验证:** 右侧浮层依赖悬停,而触屏没有悬停。菜单基元预期会对非鼠标指针回退到点击/焦点,但这需要真机检查。如果不行,触屏只保留那一项不需要子菜单的条目,并重写本节。
移动端布局和新增操作现由[移动端输入框操作](mobile-composer-actions.zh-CN.md)定义。
横向子菜单改为带返回、关闭按钮的底部抽屉,增加明确的照片、拍照、命令、历史、
Shell 和实时语音入口。下方非目标继续描述桌面下拉菜单;触屏以链接设计为准。
附件和引用插入复用现有通道;技能、命令前置保留原生 textarea 草稿。
## 非目标
@@ -140,7 +141,7 @@ Composer 已经能做很多「往这条消息里加点东西」的事,但几
5. 草稿为空时选中技能,得到调用文本后跟一个空格。草稿非空时选中技能,得到调用文本、一个空格,然后是之前的草稿——引用标签全部保留。
6. 任何插入都不会丢失已输入的文本或已插入的引用。
7. 某项能力不可用时,对应的项被隐藏,或以禁用状态显示并写明原因;只要 `addMenu` 在宿主的工具栏项列表里,`+` 本身就保持可见。如果所有内部项都不可用,`+` 会打开一个空状态行,而不是悄悄消失。
8. 触屏和桌面显示同一组项,但要以真机上的子菜单检查为准。
8. 桌面保留本文规定的集合;触屏采用[移动端输入框操作](mobile-composer-actions.zh-CN.md)中定义的页面及额外编辑操作,分别按对应设计验证。
9. 长子菜单滚动,而不是溢出视口。
10. 覆盖指针交互;键盘导航不受支持,也没有针对本功能的专门实现。
11. 列出 `plan` 并提供切换回调时,Plan mode 是最后一行,Plan 开启时显示勾选,每次选择切换一次,把焦点交还 Composer,并在模式控件忙碌时以「模式切换中」为原因被禁用。没有 `plan` 或没有切换回调时,该行不存在。它始终保留在空状态行下面。
@@ -1,6 +1,10 @@
.picker {
width: 380px;
max-height: 480px;
max-width: calc(100vw - 16px);
max-height: min(
480px,
var(--radix-popover-content-available-height, calc(100dvh - 16px))
);
display: flex;
flex-direction: column;
padding: 0;
@@ -1963,15 +1963,6 @@
height: 18px;
}
.quickActionsBtn {
display: inline-flex;
}
.quickActionsBtn .toolBtnIcon svg {
width: 18px;
height: 18px;
}
.toolBtnModeIcon,
.toolBtnModelIcon {
display: inline-flex;
@@ -2019,121 +2010,6 @@
height: 16px;
}
.quickActionsPanel {
display: block;
position: relative;
z-index: 2;
margin-top: 8px;
padding: 8px;
border: 1px solid var(--chat-editor-border-color);
border-radius: 12px;
background: var(--background);
box-shadow: 0 8px 24px color-mix(in srgb, #000 12%, transparent);
cursor: default;
}
.quickActionsHeader {
padding: 6px 2px;
color: var(--chat-editor-text-dimmed);
font-size: 12px;
line-height: 1.2;
}
.quickActionsLayout {
display: grid;
grid-template-columns: minmax(0, 1.8fr) minmax(104px, 0.7fr);
gap: 7px;
align-items: stretch;
}
.quickActionsGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-rows: minmax(22px, auto);
gap: 3px;
padding-right: 6px;
border-right: 1px solid var(--chat-editor-border-color);
}
.quickAction {
display: flex;
min-width: 0;
min-height: 22px;
align-items: center;
justify-content: center;
padding: 3px 5px;
border: 1px solid var(--chat-editor-border-color);
border-radius: 6px;
background: var(--background);
color: var(--chat-editor-text-primary);
font-family: var(--font-sans, system-ui, sans-serif);
text-align: center;
cursor: pointer;
}
.quickAction:hover,
.quickAction:focus-visible {
background: var(--chat-editor-bg-tertiary);
outline: none;
}
.quickActionLabel {
display: -webkit-box;
overflow: hidden;
max-width: 100%;
font-family: var(--font-sans, system-ui, sans-serif);
font-size: 10px;
line-height: 1.15;
text-overflow: ellipsis;
white-space: normal;
word-break: keep-all;
overflow-wrap: anywhere;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.quickKeysGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(3, minmax(0, 1fr));
align-self: stretch;
gap: 4px;
height: 100%;
min-height: 100%;
}
.quickKey {
display: flex;
min-width: 0;
min-height: 0;
align-items: center;
justify-content: center;
padding: 3px 4px;
border: 1px solid var(--chat-editor-border-color);
border-radius: 6px;
background: var(--background);
color: var(--chat-editor-text-primary);
font-family: var(--font-mono);
font-size: 11px;
line-height: 1;
text-align: center;
white-space: nowrap;
cursor: pointer;
}
.quickKeyLabel {
overflow: hidden;
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
}
.quickKey:hover,
.quickKey:focus-visible {
background: var(--chat-editor-bg-tertiary);
outline: none;
}
.sendBtn {
flex-shrink: 0;
width: 28px;
@@ -2820,3 +2696,111 @@
margin-left: 8px;
color: var(--muted-foreground);
}
.editorShell[data-web-shell-mobile-composer] .container {
--chat-editor-max-height: min(480px, 60dvh);
}
.editorShell[data-web-shell-mobile-composer] .uploadRowAction,
.editorShell[data-web-shell-mobile-composer] .toolBtn,
.editorShell[data-web-shell-mobile-composer] .sendBtn,
.editorShell[data-web-shell-mobile-composer] .toolbarRight button,
.editorShell[data-web-shell-mobile-composer] .mobileContextRow button,
.editorShell[data-web-shell-mobile-composer] .dropdownWrapperCompact {
min-width: 44px;
height: 44px;
flex-shrink: 0;
}
.editorShell[data-web-shell-mobile-composer] .toolbar,
.editorShell[data-web-shell-mobile-composer] .toolbarLeft {
flex-wrap: wrap;
}
.editorShell[data-web-shell-mobile-composer] .toolbarLeading {
max-width: 100%;
margin-left: 0;
}
.editorShell[data-web-shell-mobile-composer] .toolbarRight {
margin-left: auto;
margin-right: 0;
}
.mobileEditingActions,
.mobileContextRow,
.mobileExpandedHeader {
display: flex;
align-items: center;
gap: 4px;
}
.mobileContextRow {
flex-wrap: wrap;
}
.mobileContextRow > *,
.mobileContextRow [data-web-shell-git-branch] {
max-width: 100%;
}
.editorShell[data-web-shell-mobile-composer] .mobileContextRow button {
max-width: 100%;
flex-shrink: 1;
}
.mobileEditingActions {
flex-wrap: wrap;
margin-top: -8px;
}
.mobileEditingActions svg {
width: 18px;
height: 18px;
}
.mobileEditingSpacer {
flex: 1;
}
.mobileExpandedEditor {
display: flex;
width: calc(100% - 16px);
max-width: 720px;
height: 85dvh;
max-height: 100%;
flex-direction: column;
padding: 12px;
gap: 8px;
}
.mobileExpandedHeader h2 {
flex: 1;
}
.mobileExpandedTextarea {
flex: 1;
min-height: 0;
width: 100%;
resize: none;
border: 0;
outline: none;
background: transparent;
color: inherit;
font: inherit;
font-size: 16px;
line-height: 1.5;
}
.editorShell[data-web-shell-mobile-composer] .uploadRowPreview {
min-height: 44px;
}
.editorShell[data-web-shell-mobile-composer] .searchInput {
min-height: 44px;
font-size: 16px;
}
.editorShell[data-web-shell-mobile-composer] .searchResult {
min-height: 44px;
}
@@ -155,8 +155,11 @@ const composerCoreState = vi.hoisted(() => ({
imageDragActive: false,
navigatePrevHistory: vi.fn(),
navigateNextHistory: vi.fn(),
hasContent: false,
shellMode: false,
setShellMode: vi.fn(),
toggleShellMode: vi.fn(),
submitText: vi.fn(),
onFileUploadRequest: undefined as
| ((targetDir: string, restoreQuery?: () => void) => void)
| undefined,
@@ -229,7 +232,7 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => {
workspaceActionsRef: composerCoreState.workspaceActionsRef,
mobileComposer: composerCoreState.mobileComposer,
focus: composerCoreState.focus,
submitText: vi.fn(),
submitText: composerCoreState.submitText,
clearText: vi.fn(),
getText: composerCoreState.getText,
hasInput: vi.fn(() => false),
@@ -237,8 +240,11 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => {
mockComposerCoreState.pastedImages.length > 0 ||
mockComposerCoreState.pastedFiles.length > 0 ||
mockComposerCoreState.composerTags.length > 0,
hasContent: false,
canSubmit: false,
hasContent: composerCoreState.hasContent,
canSubmit:
!options?.disabled &&
!options?.workspaceUploadBusy &&
composerCoreState.hasContent,
pendingImageBatchCount: 0,
imageDragActive: composerCoreState.imageDragActive,
clearImageDragState: composerCoreState.clearImageDragState,
@@ -277,7 +283,7 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => {
replaceEditorText: vi.fn(),
shellMode: composerCoreState.shellMode,
setShellMode: composerCoreState.setShellMode,
toggleShellMode: vi.fn(),
toggleShellMode: composerCoreState.toggleShellMode,
currentMode: 'default',
sessionName: undefined,
searchState: {
@@ -298,7 +304,7 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => {
navigateNextHistory: composerCoreState.navigateNextHistory,
showShortcutHints: false,
followupState: { isVisible: false, suggestion: '' },
disabled: false,
disabled: Boolean(options?.disabled),
onAcceptFollowup: vi.fn(),
onDismissFollowup: vi.fn(),
slashMenu: composerCoreState.slashMenu,
@@ -329,9 +335,25 @@ vi.mock('../voice/VoiceButton', () => ({
},
}));
vi.mock('../live/LiveVoiceButton', () => ({
LiveVoiceButton: () => <span data-testid="live-voice-button" />,
}));
const liveVoiceState = vi.hoisted(() => ({ supported: false }));
vi.mock('../live/LiveVoiceButton', async () => {
const React = await import('react');
return {
LiveVoiceButton: ({
open,
onSupportedChange,
}: {
open?: boolean;
onSupportedChange?: (supported: boolean) => void;
}) => {
const supported = liveVoiceState.supported;
React.useEffect(() => {
onSupportedChange?.(supported);
}, [onSupportedChange, supported]);
return <span data-testid="live-voice-button" data-open={open} />;
},
};
});
const mounted: Array<{
root: Root;
@@ -357,11 +379,15 @@ afterEach(() => {
composerCoreState.navigatePrevHistory.mockReset();
composerCoreState.navigateNextHistory.mockReset();
composerCoreState.setShellMode.mockReset();
composerCoreState.toggleShellMode.mockReset();
composerCoreState.submitText.mockReset();
composerCoreState.shellMode = false;
composerCoreState.hasContent = false;
composerCoreState.workspaceActionsRef.current = undefined;
composerCoreState.imageDragActive = false;
composerCoreState.onFileUploadRequest = undefined;
voiceButtonState.onActiveChange = undefined;
liveVoiceState.supported = false;
for (const { root, container, portalRoot } of mounted.splice(0)) {
act(() => root.unmount());
container.remove();
@@ -399,6 +425,7 @@ interface ChatEditorRenderProps
modeControlsDisabled?: boolean;
onTogglePlan?: () => void;
isRunning?: boolean;
onSubmit?: ComponentProps<typeof ChatEditor>['onSubmit'];
onCancel?: () => void;
currentModel?: string;
availableModels?: Array<{ id: string; label?: string }>;
@@ -415,6 +442,8 @@ interface ChatEditorRenderProps
tokenCount?: number;
contextWindow?: number;
onShowContextUsage?: () => void;
contextUsageAlwaysVisible?: boolean;
commands?: ComponentProps<typeof ChatEditor>['commands'];
disabled?: boolean;
atWorkspaceCwd?: string;
composerScopeKey?: string;
@@ -726,7 +755,10 @@ describe('ChatEditor add menu (+)', () => {
)!.portalRoot;
await act(async () => {
trigger(container)!.dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true, button: 0 }),
new MouseEvent(
composerCoreState.mobileComposer ? 'click' : 'pointerdown',
{ bubbles: true, button: 0 },
),
);
});
await act(async () => {
@@ -897,10 +929,11 @@ describe('ChatEditor add menu (+)', () => {
it('prepends a skill through the textarea backend on touch', async () => {
composerCoreState.mobileComposer = {
textareaRef: createRef<HTMLTextAreaElement>(),
expandedTextareaRef: createRef<HTMLTextAreaElement>(),
value: 'existing draft',
onChange: vi.fn(),
onBlur: vi.fn(),
placeholder: '',
placeholder: 'Ask anything',
} satisfies MobileComposerBackend;
composerCoreState.getText.mockReturnValue('existing draft');
const container = renderChatEditor({
@@ -3208,217 +3241,389 @@ describe('ChatEditor slash command popovers', () => {
});
});
describe('ChatEditor mobile composer quick actions', () => {
const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(
Navigator.prototype,
'maxTouchPoints',
);
function withTouchDevice(run: () => void) {
Object.defineProperty(navigator, 'maxTouchPoints', {
value: 5,
configurable: true,
});
try {
run();
} finally {
if (originalMaxTouchPoints) {
Object.defineProperty(
Navigator.prototype,
'maxTouchPoints',
originalMaxTouchPoints,
);
} else {
delete (navigator as unknown as Record<string, unknown>)[
'maxTouchPoints'
];
}
}
}
function mobileComposerStub(): MobileComposerBackend {
return {
describe('ChatEditor mobile composer actions', () => {
function mobileComposer(value = ''): MobileComposerBackend {
const backend = {
textareaRef: createRef<HTMLTextAreaElement>(),
value: '',
expandedTextareaRef: createRef<HTMLTextAreaElement>(),
value,
onChange: vi.fn(),
onBlur: vi.fn(),
placeholder: '',
placeholder: 'Ask anything',
};
composerCoreState.mobileComposer = backend;
return backend;
}
function openQuickActions(container: HTMLElement) {
const toggle = container.querySelector<HTMLButtonElement>(
'button[aria-label="more actions"]',
async function clickButton(label: string) {
const button = Array.from(document.querySelectorAll('button')).find(
(candidate) =>
candidate.textContent === label ||
candidate.getAttribute('aria-label') === label,
);
expect(toggle).not.toBeNull();
act(() => toggle!.click());
expect(button).toBeDefined();
await act(async () => {
button!.click();
await new Promise((resolve) => setTimeout(resolve, 50));
});
}
it('maps the history quick action to the search UI on the mobile composer', () => {
withTouchDevice(() => {
composerCoreState.mobileComposer = mobileComposerStub();
const container = renderChatEditor({});
openQuickActions(container);
const historyButton = Array.from(
container.querySelectorAll('button'),
).find((button) => button.textContent === 'Question history');
expect(historyButton).not.toBeUndefined();
act(() => historyButton!.click());
expect(composerCoreState.openHistorySearch).toHaveBeenCalledTimes(1);
});
it('opens history from the add drawer without simulated keyboard keys', async () => {
mobileComposer();
renderChatEditor({ visibleToolbarActions: ['addMenu'] });
await clickButton('Add to message');
expect(
document.querySelector('[data-web-shell-mobile-add-menu]'),
).not.toBeNull();
expect(document.querySelector('[aria-label="more actions"]')).toBeNull();
expect(
Array.from(document.querySelectorAll('button')).some((button) =>
['Tab', 'Esc', '←', '→'].includes(button.textContent ?? ''),
),
).toBe(false);
await clickButton('Input history');
expect(composerCoreState.openHistorySearch).toHaveBeenCalledOnce();
});
it('shows the keyboard shortcut hints grid on the mobile composer', () => {
withTouchDevice(() => {
composerCoreState.mobileComposer = mobileComposerStub();
const mobileContainer = renderChatEditor({});
openQuickActions(mobileContainer);
it('returns focus to the composer after entering Shell from the drawer', async () => {
mobileComposer('draft');
renderChatEditor({ visibleToolbarActions: ['addMenu'] });
await clickButton('Add to message');
composerCoreState.focus.mockClear();
await clickButton('Shell mode');
expect(composerCoreState.toggleShellMode).toHaveBeenCalledOnce();
expect(composerCoreState.focus).toHaveBeenCalledOnce();
expect(
document.querySelector('[data-web-shell-mobile-add-menu]'),
).toBeNull();
});
it('calls history navigation directly without submitting or moving focus', async () => {
const backend = mobileComposer('working draft');
const onSubmit = vi.fn();
const container = renderChatEditor({ onSubmit });
act(() => backend.textareaRef.current!.focus());
const previous = container.querySelector<HTMLButtonElement>(
'[aria-label="Previous input"]',
)!;
const pointerDown = new Event('pointerdown', {
bubbles: true,
cancelable: true,
});
previous.dispatchEvent(pointerDown);
expect(pointerDown.defaultPrevented).toBe(true);
composerCoreState.focus.mockClear();
await clickButton('Previous input');
await clickButton('Next input');
expect(composerCoreState.navigatePrevHistory).toHaveBeenCalledOnce();
expect(composerCoreState.navigateNextHistory).toHaveBeenCalledOnce();
expect(composerCoreState.focus).not.toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();
expect(document.activeElement).toBe(backend.textareaRef.current);
});
it('disables both history buttons when the composer is disabled', () => {
mobileComposer('draft');
const container = renderChatEditor({ disabled: true });
for (const label of ['Previous input', 'Next input', 'Expand editor']) {
const button = container.querySelector<HTMLButtonElement>(
`[aria-label="${label}"]`,
)!;
expect(button.disabled).toBe(true);
act(() => button.click());
}
expect(composerCoreState.navigatePrevHistory).not.toHaveBeenCalled();
expect(composerCoreState.navigateNextHistory).not.toHaveBeenCalled();
});
it.each([['approvalMode', 'model'], ['commands']] as const)(
'preserves shell and history without add opt-in: %j',
async (...actions) => {
mobileComposer();
renderChatEditor({ visibleToolbarActions: actions });
await clickButton('Input history');
await clickButton('Shell mode');
expect(composerCoreState.openHistorySearch).toHaveBeenCalledOnce();
expect(composerCoreState.toggleShellMode).toHaveBeenCalledOnce();
expect(
Array.from(mobileContainer.querySelectorAll('button')).some(
(button) => button.textContent === 'Tab',
),
).toBe(true);
document.querySelector('[aria-label="Add to message"]'),
).toBeNull();
},
);
composerCoreState.mobileComposer = null;
const desktopContainer = renderChatEditor({});
openQuickActions(desktopContainer);
it('opens commands-only without exposing attachments and hides it in shell mode', async () => {
mobileComposer();
const props: ChatEditorRenderProps = {
visibleToolbarActions: ['commands'],
commands: [{ name: 'goal', description: 'Set a goal' }],
};
const container = renderChatEditor(props);
await clickButton('All commands');
const drawer = document.querySelector('[data-web-shell-mobile-add-menu]')!;
expect(drawer.textContent).toContain('/goal');
expect(drawer.textContent).not.toContain('Photos');
await clickButton('close');
composerCoreState.shellMode = true;
rerenderChatEditor(container, props);
expect(
document.querySelector('[data-testid="composer-add-menu-trigger"]'),
).toBeNull();
});
it('cannot submit a disabled draft or exit shell', async () => {
mobileComposer('draft');
composerCoreState.hasContent = true;
composerCoreState.shellMode = true;
const container = renderChatEditor({ disabled: true });
expect(
container.querySelector<HTMLButtonElement>(
'[data-web-shell-composer-submit]',
)!.disabled,
).toBe(true);
await clickButton('Exit Shell');
expect(composerCoreState.toggleShellMode).not.toHaveBeenCalled();
});
it('stops with an empty draft and from the expanded editor without submitting', async () => {
mobileComposer();
const onCancel = vi.fn();
const container = renderChatEditor({ isRunning: true, onCancel });
expect(
container.querySelector('[data-web-shell-composer-stop]'),
).toBeNull();
await clickButton('Stop');
expect(onCancel).toHaveBeenCalledOnce();
expect(composerCoreState.submitText).not.toHaveBeenCalled();
await clickButton('Expand editor');
const stop = [
...document.querySelectorAll<HTMLButtonElement>(
'[data-web-shell-expanded-editor] button',
),
].find((b) => b.textContent === 'Stop')!;
act(() => stop.click());
expect(onCancel).toHaveBeenCalledTimes(2);
expect(composerCoreState.submitText).not.toHaveBeenCalled();
});
it.each([
{ tokenCount: 10, contextWindow: 100 },
{ contextUsageAlwaysVisible: true },
])('retains the requested context control on mobile: %j', (props) => {
mobileComposer();
const container = renderChatEditor({
...props,
visibleToolbarActions: ['contextUsage'],
});
expect(
container.querySelector('[data-web-shell-context-usage]'),
).not.toBeNull();
});
it.each([{ sessionId: 'b' }, { atWorkspaceCwd: '/b' }, { disabled: true }])(
'closes expanded editing only when its owner changes: %j',
async (change) => {
const backend = mobileComposer('draft');
const props = { sessionId: 'a', atWorkspaceCwd: '/a' };
const container = renderChatEditor(props);
await clickButton('Expand editor');
rerenderChatEditor(container, { ...props, currentModel: 'another' });
expect(
Array.from(desktopContainer.querySelectorAll('button')).some(
(button) => button.textContent === 'Tab',
),
).toBe(true);
});
});
it('walks prompt history from the hint arrows on the mobile composer', () => {
withTouchDevice(() => {
composerCoreState.mobileComposer = mobileComposerStub();
const container = renderChatEditor({});
openQuickActions(container);
const pressHint = (label: string) => {
const button = Array.from(container.querySelectorAll('button')).find(
(candidate) => candidate.textContent === label,
);
expect(button).not.toBeUndefined();
act(() => button!.click());
};
pressHint('↑');
expect(composerCoreState.navigatePrevHistory).toHaveBeenCalledTimes(1);
pressHint('↓');
expect(composerCoreState.navigateNextHistory).toHaveBeenCalledTimes(1);
// Tab has no completion target on the textarea backend.
pressHint('Tab');
expect(composerCoreState.navigatePrevHistory).toHaveBeenCalledTimes(1);
expect(composerCoreState.navigateNextHistory).toHaveBeenCalledTimes(1);
});
});
it('cancels the running turn from the Esc hint on the mobile composer', () => {
withTouchDevice(() => {
composerCoreState.mobileComposer = mobileComposerStub();
const onCancel = vi.fn();
const container = renderChatEditor({ isRunning: true, onCancel });
openQuickActions(container);
const escButton = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent === 'Esc',
);
expect(escButton).not.toBeUndefined();
act(() => escButton!.click());
expect(onCancel).toHaveBeenCalledTimes(1);
});
});
it('moves the textarea caret from the cursor hints on the mobile composer', () => {
withTouchDevice(() => {
composerCoreState.mobileComposer = mobileComposerStub();
const container = renderChatEditor({});
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
expect(textarea).not.toBeNull();
openQuickActions(container);
// Setting the value after the panel opens keeps React's controlled
// state from resetting it on the panel re-render.
textarea!.value = 'abcd';
textarea!.setSelectionRange(1, 3);
const pressHint = (label: string) => {
const button = Array.from(container.querySelectorAll('button')).find(
(candidate) => candidate.textContent === label,
);
expect(button).not.toBeUndefined();
act(() => button!.click());
};
// An existing selection collapses to its far edge first.
pressHint('→');
expect(textarea!.selectionStart).toBe(3);
pressHint('→');
expect(textarea!.selectionStart).toBe(4);
pressHint('←');
expect(textarea!.selectionStart).toBe(3);
// Movement skips whole code points so an emoji is never split.
textarea!.value = 'a😀b';
textarea!.setSelectionRange(1, 1);
pressHint('→');
expect(textarea!.selectionStart).toBe(3);
pressHint('←');
expect(textarea!.selectionStart).toBe(1);
});
});
it('exits shell mode from the Esc hint on the mobile composer', () => {
withTouchDevice(() => {
composerCoreState.mobileComposer = mobileComposerStub();
composerCoreState.shellMode = true;
const container = renderChatEditor({ isRunning: true });
openQuickActions(container);
const escButton = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent === 'Esc',
);
expect(escButton).not.toBeUndefined();
act(() => escButton!.click());
expect(composerCoreState.setShellMode).toHaveBeenCalledWith(false);
});
});
it('hides other toolbar actions while mobile voice capture is active', () => {
withTouchDevice(() => {
const container = renderChatEditor({
currentModel: 'qwen-test',
availableModels: [{ id: 'qwen-test' }],
document.querySelector('[data-web-shell-expanded-editor]'),
).not.toBeNull();
const expanded = document.querySelector<HTMLTextAreaElement>(
'[data-web-shell-expanded-editor] textarea',
)!;
expect(expanded.getAttribute('aria-label')).toBe('Ask anything');
expect(expanded.placeholder).toBe('Ask anything');
expect(expanded.getAttribute('autocapitalize')).toBe('off');
expect(expanded.getAttribute('autocorrect')).toBe('off');
expect(expanded.getAttribute('spellcheck')).toBe('false');
await act(async () => {
rerenderChatEditor(container, { ...props, ...change });
});
expect(
container.querySelector('[data-web-shell-toolbar-leading]'),
).toBeTruthy();
expect(
container.querySelector(
'button[aria-label="more actions"][data-hide-during-mobile-voice]',
),
).toBeTruthy();
expect(
container.querySelector('[data-web-shell-composer-submit]'),
).toBeTruthy();
act(() => {
voiceButtonState.onActiveChange?.(true);
await act(async () => {
await new Promise((r) => setTimeout(r, 50));
});
expect(
container.querySelector('[data-mobile-voice-active="true"]'),
).toBeTruthy();
document.querySelector('[data-web-shell-expanded-editor]'),
).toBeNull();
expect(document.activeElement).not.toBe(document.body);
if ('disabled' in change)
expect(document.activeElement).not.toBe(backend.textareaRef.current);
},
);
act(() => {
voiceButtonState.onActiveChange?.(false);
it('does not steal focus from the next dialog when expanded editing becomes disabled', async () => {
mobileComposer('draft');
const container = renderChatEditor({});
await clickButton('Expand editor');
const approval = document.createElement('button');
document.body.append(approval);
try {
await act(async () => rerenderChatEditor(container, { disabled: true }));
approval.focus();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
expect(document.activeElement).toBe(approval);
} finally {
approval.remove();
}
});
expect(
container.querySelector('[data-mobile-voice-active="true"]'),
).toBeFalsy();
it('keeps Hide keyboard in sync with focus across session changes and allows clicks to bubble', async () => {
const backend = mobileComposer('draft');
const container = renderChatEditor({ sessionId: 'a' });
act(() => backend.textareaRef.current!.focus());
rerenderChatEditor(container, { sessionId: 'b' });
expect(
document.querySelector('[aria-label="Hide keyboard"]'),
).not.toBeNull();
const onClick = vi.fn();
document.addEventListener('click', onClick);
try {
await clickButton('Hide keyboard');
expect(onClick).toHaveBeenCalledOnce();
expect(document.activeElement).not.toBe(backend.textareaRef.current);
} finally {
document.removeEventListener('click', onClick);
}
});
it('keeps the history arrow controls out of the desktop composer', () => {
const container = renderChatEditor({});
expect(container.querySelector('[aria-label="Previous input"]')).toBeNull();
expect(container.querySelector('[aria-label="Next input"]')).toBeNull();
});
it('gates the drawer Live entry by support and host voice opt-in and opens the controlled dialog', async () => {
mobileComposer();
const props: ChatEditorRenderProps = {
visibleToolbarActions: ['addMenu', 'voice'],
};
const container = renderChatEditor(props);
await clickButton('Add to message');
expect(
document.querySelector('[data-web-shell-mobile-add-menu]')!.textContent,
).not.toContain('Open Live Voice');
await clickButton('close');
liveVoiceState.supported = true;
rerenderChatEditor(container, props);
await clickButton('Add to message');
await clickButton('Open Live Voice');
expect(
container
.querySelector('[data-testid="live-voice-button"]')!
.getAttribute('data-open'),
).toBe('true');
rerenderChatEditor(container, { visibleToolbarActions: ['addMenu'] });
await clickButton('Add to message');
expect(
document.querySelector('[data-web-shell-mobile-add-menu]')!.textContent,
).not.toContain('Open Live Voice');
});
it('keeps stop and send reachable with a draft without clearing it', async () => {
const backend = mobileComposer('keep this draft');
composerCoreState.hasContent = true;
const onCancel = vi.fn();
const container = renderChatEditor({ isRunning: true, onCancel });
const stop = container.querySelector<HTMLButtonElement>(
'[data-web-shell-composer-stop]',
)!;
expect(stop.disabled).toBe(false);
expect(
container.querySelector<HTMLButtonElement>(
'[data-web-shell-composer-submit]',
)!.disabled,
).toBe(false);
act(() => stop.click());
expect(onCancel).toHaveBeenCalledOnce();
expect(backend.textareaRef.current!.value).toBe('keep this draft');
expect(composerCoreState.setText).not.toHaveBeenCalled();
});
it('blurs the textarea without bubbling back into composer focus', async () => {
const backend = mobileComposer('draft');
renderChatEditor({});
act(() => backend.textareaRef.current!.focus());
composerCoreState.focus.mockClear();
await clickButton('Hide keyboard');
expect(document.activeElement).not.toBe(backend.textareaRef.current);
expect(composerCoreState.focus).not.toHaveBeenCalled();
expect(backend.textareaRef.current!.value).toBe('draft');
});
it('expands the same draft and restores selection without dropping attachments', async () => {
const backend = mobileComposer('hello world');
const container = renderChatEditor({
pastedFiles: [
{ name: 'notes.txt', media_type: 'text/plain', text: 'notes' },
],
});
backend.textareaRef.current!.setSelectionRange(2, 5);
await clickButton('Expand editor');
const expanded = document.querySelector<HTMLTextAreaElement>(
'[data-web-shell-expanded-editor] textarea',
)!;
expect(expanded.value).toBe('hello world');
expect([expanded.selectionStart, expanded.selectionEnd]).toEqual([2, 5]);
act(() => {
expanded.setSelectionRange(6, 6);
expanded.focus();
expanded.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
expanded.blur();
});
await clickButton('Done');
expect(backend.textareaRef.current!.value).toBe('hello world');
expect([
backend.textareaRef.current!.selectionStart,
backend.textareaRef.current!.selectionEnd,
]).toEqual([6, 6]);
expect(
container.querySelector('[data-web-shell-composer-attachments]')!
.textContent,
).toContain('notes');
expect(mockComposerCoreState.pastedFiles).toHaveLength(1);
});
it('closes expanded editing when the owning session changes', async () => {
mobileComposer('draft');
const container = renderChatEditor({ sessionId: 'a' });
await clickButton('Expand editor');
rerenderChatEditor(container, { sessionId: 'b' });
expect(
document.querySelector('[data-web-shell-expanded-editor]'),
).toBeNull();
});
it('offers explicit shell exit without canceling a turn', async () => {
mobileComposer();
composerCoreState.shellMode = true;
const onCancel = vi.fn();
renderChatEditor({ isRunning: true, onCancel });
await clickButton('Exit Shell');
expect(composerCoreState.toggleShellMode).toHaveBeenCalledOnce();
expect(onCancel).not.toHaveBeenCalled();
});
it('retains the reduced toolbar during mobile voice capture', () => {
mobileComposer();
const container = renderChatEditor({
currentModel: 'qwen-test',
availableModels: [{ id: 'qwen-test' }],
});
act(() => voiceButtonState.onActiveChange?.(true));
expect(
container.querySelector('[data-mobile-voice-active="true"]'),
).not.toBeNull();
expect(
container.querySelector('[data-web-shell-composer-submit]'),
).not.toBeNull();
act(() => voiceButtonState.onActiveChange?.(false));
expect(
container.querySelector('[data-mobile-voice-active="true"]'),
).toBeNull();
});
});
File diff suppressed because it is too large. Load diff
@@ -97,6 +97,7 @@
/* ── popover ──────────────────────────────── */
.popover {
width: 310px;
max-width: calc(100vw - 16px);
padding: 6px;
gap: 2px;
}
@@ -907,3 +907,274 @@ describe('AddMenu', () => {
});
});
});
describe('mobile AddMenu', () => {
function mobileProps(overrides: Partial<AddMenuProps> = {}) {
return baseProps({
mobileActions: {
commands: [{ name: 'goal', description: 'Set a goal' }],
onHistory: vi.fn(),
onToggleShell: vi.fn(),
shellMode: false,
},
...overrides,
});
}
async function tap(label: string) {
const button = Array.from(portalRoot!.querySelectorAll('button')).find(
(item) =>
item.textContent === label || item.getAttribute('aria-label') === label,
);
expect(button).toBeDefined();
await act(async () => {
button!.click();
});
await settle();
}
it('uses the scoped bottom drawer and supports reference search and back', async () => {
const props = mobileProps({
getWorkspaceActions: () => ({
globWorkspace: vi.fn().mockResolvedValue({ matches: ['src/main.ts'] }),
}),
});
renderWith(props);
await openMenu();
expect(
portalRoot!.querySelector('[data-web-shell-mobile-add-menu]'),
).not.toBeNull();
await tap('Reference file');
await typeIntoSearch('composer-add-menu-reference-file-search', 'main');
expect(menuItem('composer-add-menu-reference-file-item')!.textContent).toBe(
'src/main.ts',
);
await tap('back');
expect(menuItem('composer-add-menu-reference-file-search')).toBeNull();
await tap('close');
expect(
portalRoot!.querySelector('[data-web-shell-mobile-add-menu]'),
).toBeNull();
expect(props.onInsertReference).not.toHaveBeenCalled();
});
it.each([
['Photos', 'image/*', null, 'attach'],
['Take photo', 'image/*', 'environment', 'attach'],
['Attach files', '', null, 'attach'],
['Upload to workspace', '', null, 'upload'],
] as const)(
'opens %s with the correct picker and destination',
async (label, accept, capture, destination) => {
const props = mobileProps({ uploadAvailable: true });
renderWith(props);
await openMenu();
const input =
container!.querySelector<HTMLInputElement>('input[type="file"]')!;
const click = vi
.spyOn(input, 'click')
.mockImplementation(() => undefined);
input.accept = 'video/*';
input.multiple = label === 'Take photo';
input.setAttribute('capture', 'user');
await tap(label);
expect(input.multiple).toBe(label !== 'Take photo');
expect(click).toHaveBeenCalledOnce();
expect(input.accept).toBe(accept);
expect(input.getAttribute('capture')).toBe(capture);
const file = new File(['image'], 'photo.png', { type: 'image/png' });
Object.defineProperty(input, 'files', {
configurable: true,
value: [file],
});
await act(async () =>
input.dispatchEvent(new Event('change', { bubbles: true })),
);
expect(props.onAddFiles).toHaveBeenCalledWith([file], destination);
},
);
it('waits for the mobile skills catalog before announcing an empty result', async () => {
const props = mobileProps({
skills: [],
skillsLoaded: false,
onSkillsOpenChange: vi.fn(),
});
renderWith(props);
await openMenu();
await tap('Skills');
expect(portalRoot!.querySelector('[role="status"]')).toBeNull();
rerenderWith({ ...props, skillsLoading: true });
expect(portalRoot!.querySelector('[role="status"]')!.textContent).toContain(
'Loading',
);
rerenderWith({ ...props, skillsLoaded: true });
expect(portalRoot!.querySelector('[role="status"]')!.textContent).toContain(
'No results',
);
});
it.each([false, true])(
'does not insert slash commands in shell mode (commandsOnly=%s)',
async (commandsOnly) => {
const props = mobileProps({ commandsOnly });
props.mobileActions!.shellMode = true;
renderWith(props);
await openMenu();
const drawer = portalRoot!.querySelector(
'[data-web-shell-mobile-add-menu]',
)!;
expect(
[...drawer.querySelectorAll('button')].some(
(b) =>
b.textContent === 'All commands' ||
b.textContent?.includes('/goal'),
),
).toBe(false);
expect(props.onPrependSkill).not.toHaveBeenCalled();
},
);
it('preserves command metadata, ordering, slash normalization and description search', async () => {
const props = mobileProps();
props.mobileActions!.commands = [
{ name: 'later', description: 'Secondary choice', completionPriority: 5 },
{
name: 'goal',
description: 'Plan an objective',
completionLabel: 'Goal shortcut',
completionSection: 'Host actions',
completionPriority: -1,
argumentHint: '<objective>',
},
];
renderWith(props);
await openMenu();
await tap('All commands');
const rows = () =>
[
...portalRoot!.querySelectorAll(
'[data-web-shell-mobile-add-menu] button',
),
].filter(
(b) =>
b.textContent?.includes('Goal shortcut') ||
b.textContent?.includes('Secondary choice'),
);
expect(rows()[0]!.textContent).toContain('Goal shortcut');
expect(rows()[0]!.textContent).toContain('<objective>');
const search = portalRoot!.querySelector<HTMLInputElement>(
'[aria-label="Search commands"]',
)!;
for (const query of [' /goal ', 'objective']) {
await act(async () => {
Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)!.set!.call(search, query);
search.dispatchEvent(new Event('input', { bubbles: true }));
});
expect(rows()).toHaveLength(1);
expect(rows()[0]!.textContent).toContain('Goal shortcut');
}
await act(async () => (rows()[0] as HTMLButtonElement).click());
await settle();
expect(props.onPrependSkill).toHaveBeenCalledExactlyOnceWith('/goal');
});
it('inserts a mobile reference only after the drawer releases focus', async () => {
const props = mobileProps({
getWorkspaceActions: () => ({
globWorkspace: vi.fn().mockResolvedValue({ matches: ['src/main.ts'] }),
}),
});
props.onInsertReference.mockImplementation(() => {
expect(
portalRoot!.querySelector('[data-web-shell-mobile-add-menu]'),
).toBeNull();
});
renderWith(props);
await openMenu();
await tap('Reference file');
await typeIntoSearch('composer-add-menu-reference-file-search', 'main');
await act(async () =>
menuItem('composer-add-menu-reference-file-item')!.click(),
);
await settle();
expect(props.onInsertReference).toHaveBeenCalledOnce();
expect(props.onInsertReference.mock.calls[0]![0].serialized).toContain(
'src/main.ts',
);
});
it.each([false, true])(
'closes before toggling shell from shellMode=%s',
async (shellMode) => {
const props = mobileProps();
props.mobileActions!.shellMode = shellMode;
const onToggle = vi.fn(() => {
expect(
portalRoot!.querySelector('[data-web-shell-mobile-add-menu]'),
).toBeNull();
});
props.mobileActions!.onToggleShell = onToggle;
renderWith(props);
await openMenu();
await tap(shellMode ? 'Exit Shell' : 'Shell mode');
expect(onToggle).toHaveBeenCalledOnce();
},
);
it('prefixes a selected command through the insertion lane and closes', async () => {
const props = mobileProps();
renderWith(props);
await openMenu();
await tap('All commands');
const command = Array.from(portalRoot!.querySelectorAll('button')).find(
(button) => button.textContent?.startsWith('/goal'),
)!;
await act(async () => command.click());
await settle();
expect(props.onPrependSkill).toHaveBeenCalledExactlyOnceWith('/goal');
expect(
portalRoot!.querySelector('[data-web-shell-mobile-add-menu]'),
).toBeNull();
});
it.each(['en', 'zh-CN'] as const)(
'localizes the busy mobile Plan row in %s',
async (language) => {
const onToggle = vi.fn();
renderWith(
mobileProps({ plan: { checked: false, disabled: true, onToggle } }),
language,
);
await openMenu();
const row = menuItem('composer-add-menu-plan') as HTMLButtonElement;
const t = getTranslator(language);
expect(row.textContent).toBe(
`${t('composerAdd.plan.label')}${t('composerAdd.plan.busy')}`,
);
expect(row.disabled).toBe(true);
await act(async () => row.click());
expect(onToggle).not.toHaveBeenCalled();
},
);
it('toggles Plan once and notifies history only after closing', async () => {
const onToggle = vi.fn();
const props = mobileProps({
plan: {
checked: false,
onToggle,
},
});
props.mobileActions!.onHistory = vi.fn(() => {
expect(
portalRoot!.querySelector('[data-web-shell-mobile-add-menu]'),
).toBeNull();
});
renderWith(props);
await openMenu();
await tap('Plan mode');
expect(onToggle).toHaveBeenCalledOnce();
await openMenu();
await tap('Input history');
expect(props.mobileActions!.onHistory).toHaveBeenCalledOnce();
});
});
File diff suppressed because it is too large. Load diff
@@ -205,6 +205,38 @@ async function openSettingsPanel(page: Page): Promise<void> {
for (const theme of THEMES) {
test.describe(`web-shell screenshots (${theme})`, () => {
test('mobile composer controls', async ({ browser }, testInfo) => {
const baseURL = resolveBaseURL(testInfo);
const context = await browser.newContext({
...devices['Pixel 7'],
viewport: { width: 390, height: 844 },
baseURL,
reducedMotion: 'reduce',
});
try {
const page = await context.newPage();
const scenario = createWebShellDaemonScenario();
const daemon = await installScenario(page, scenario, baseURL);
await gotoSession(page, scenario, daemon, theme);
const textarea = page.locator(
'textarea[data-web-shell-composer-editor]',
);
await textarea.fill(
'Review the mobile layout\nKeep this working draft.',
);
await captureScreenshot(page, `mobile-composer-${theme}`);
await page
.getByRole('button', { name: 'Add to message', exact: true })
.tap();
await expect(
page.getByText('Reference file', { exact: true }),
).toBeVisible();
await captureScreenshot(page, `mobile-composer-add-${theme}`);
} finally {
await context.close();
}
});
test('context usage', async ({ page }, testInfo) => {
const scenario = createWebShellDaemonScenario({
supportedCommands: {
@@ -63,7 +63,7 @@ test('anchors the empty mobile composer with the textarea backend', async ({
);
});
test('keeps voice controls reachable on an extra-narrow touch viewport', async ({
test('keeps voice controls reachable on an extra-narrow touch viewport @smoke', async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 240, height: 700 });
@@ -85,11 +85,11 @@ test('keeps voice controls reachable on an extra-narrow touch viewport', async (
await gotoSession(page, scenario, daemon);
const model = page.locator('[data-web-shell-model-button]');
const more = page.getByRole('button', { name: 'more actions' });
const add = page.getByRole('button', { name: 'Add to message' });
const voice = page.getByRole('button', { name: 'Start voice dictation' });
const send = page.locator('[data-web-shell-composer-submit]');
await expect(model).toBeVisible();
await expect(more).toBeVisible();
await expect(add).toBeVisible();
await expect(voice).toBeVisible();
await expect(send).toBeVisible();
@@ -98,9 +98,13 @@ test('keeps voice controls reachable on an extra-narrow touch viewport', async (
const activeToolbar = page.locator('[data-mobile-voice-active="true"]');
await expect(activeToolbar).toBeVisible();
await expect(model).toBeHidden();
await expect(more).toBeHidden();
await expect(add).toBeHidden();
await expect(send).toBeVisible();
await expect(activeToolbar.locator('button:visible')).toHaveCount(2);
await expect(
page.locator('[data-web-shell-mobile-editing-actions]'),
).toBeHidden();
await expect(page.locator('[data-web-shell-git-branch]')).toBeHidden();
});
test('keeps a persisted wide sidebar inside the mobile drawer and exposes close', async ({
@@ -301,6 +305,230 @@ test('?composer=codemirror escape hatch forces the CodeMirror path', async ({
await expect(page.locator(COMPOSER_TEXTAREA)).toHaveCount(0);
});
for (const width of [390, 240]) {
test(`history arrows restore the draft without submitting at ${width}px @smoke`, async ({
page,
}, testInfo) => {
await page.setViewportSize({ width, height: 844 });
await page.addInitScript(() => {
localStorage.setItem(
'qwen-web-shell-history',
JSON.stringify(['first input', 'second input']),
);
});
const scenario = createWebShellDaemonScenario();
const daemon = await installScenario(page, scenario, testInfo);
await gotoSession(page, scenario, daemon);
const textarea = page.locator(COMPOSER_TEXTAREA);
const draft = '当前草稿 😀\nsecond line';
await textarea.fill(draft);
const previous = page.getByRole('button', {
name: 'Previous input',
exact: true,
});
const next = page.getByRole('button', { name: 'Next input', exact: true });
for (const button of [
previous,
next,
page.getByRole('button', { name: 'Hide keyboard', exact: true }),
page.getByRole('button', { name: 'Expand editor', exact: true }),
]) {
const bounds = await button.boundingBox();
expect(bounds).not.toBeNull();
expect(bounds!.width).toBeGreaterThanOrEqual(44);
expect(bounds!.height).toBeGreaterThanOrEqual(44);
expect(bounds!.x).toBeGreaterThanOrEqual(0);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(width);
}
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width);
await next.tap();
await expect(textarea).toHaveValue(draft);
await previous.tap();
await expect(textarea).toHaveValue('second input');
await expect(textarea).toBeFocused();
await previous.tap();
await expect(textarea).toHaveValue('first input');
await previous.tap();
await expect(textarea).toHaveValue('first input');
await next.tap();
await expect(textarea).toHaveValue('second input');
await next.tap();
await expect(textarea).toHaveValue(draft);
await next.tap();
await expect(textarea).toHaveValue(draft);
expect(daemon.promptRequests()).toHaveLength(0);
});
}
test('history arrows leave the draft intact when history is empty', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario();
const daemon = await installScenario(page, scenario, testInfo);
await gotoSession(page, scenario, daemon);
const textarea = page.locator(COMPOSER_TEXTAREA);
await textarea.fill('keep this draft');
await page.getByRole('button', { name: 'Previous input', exact: true }).tap();
await page.getByRole('button', { name: 'Next input', exact: true }).tap();
await expect(textarea).toHaveValue('keep this draft');
expect(daemon.promptRequests()).toHaveLength(0);
});
test('mobile editing preserves draft, selection and keyboard dismissal @smoke', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario();
const daemon = await installScenario(page, scenario, testInfo);
await gotoSession(page, scenario, daemon);
const textarea = page.locator(COMPOSER_TEXTAREA);
await textarea.fill('检查 😀 draft\nsecond line');
await textarea.evaluate((element: HTMLTextAreaElement) =>
element.setSelectionRange(3, 5),
);
await page.getByRole('button', { name: 'Expand editor' }).tap();
const dialog = page.locator('[data-web-shell-expanded-editor]');
const expanded = dialog.getByRole('textbox');
await expect(expanded).toHaveValue('检查 😀 draft\nsecond line');
expect(
await expanded.evaluate((element: HTMLTextAreaElement) => [
element.selectionStart,
element.selectionEnd,
]),
).toEqual([3, 5]);
await expanded.fill('edited 😀\nsecond line');
await expanded.evaluate((element: HTMLTextAreaElement) => {
element.setSelectionRange(2, 6);
element.dispatchEvent(new Event('select', { bubbles: true }));
});
await dialog.getByRole('button', { name: 'Done', exact: true }).tap();
await expect(dialog).toBeHidden();
await expect(textarea).toHaveValue('edited 😀\nsecond line');
expect(
await textarea.evaluate((element: HTMLTextAreaElement) => [
element.selectionStart,
element.selectionEnd,
]),
).toEqual([2, 6]);
await page.getByRole('button', { name: 'Hide keyboard' }).tap();
await expect(textarea).not.toBeFocused();
await expect(textarea).toHaveValue('edited 😀\nsecond line');
expect(daemon.promptRequests()).toHaveLength(0);
});
test('expanded mobile editing retains attachment paste and selection exemptions @smoke', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario();
const daemon = await installScenario(page, scenario, testInfo);
await gotoSession(page, scenario, daemon);
await page.locator(COMPOSER_TEXTAREA).fill('keep draft');
await page.getByRole('button', { name: 'Expand editor' }).tap();
const expanded = page.locator('[data-web-shell-expanded-editor] textarea');
const pasteLongText = (selected: boolean) =>
expanded.evaluate((element: HTMLTextAreaElement, replace) => {
element.setSelectionRange(0, replace ? element.value.length : 0);
const clipboard = new DataTransfer();
clipboard.setData('text/plain', 'pasted line\n'.repeat(250));
const event = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: clipboard,
});
element.dispatchEvent(event);
return event.defaultPrevented;
}, selected);
expect(await pasteLongText(true)).toBe(false);
await expect(
page.locator('[data-web-shell-composer-attachments]'),
).toHaveCount(0);
expect(await pasteLongText(false)).toBe(true);
await expect(
page.locator('[data-web-shell-expanded-attachments]'),
).toContainText('Attached files/images: 1');
await expanded.evaluate((element) => {
const clipboard = new DataTransfer();
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const bytes = Uint8Array.from(
atob(canvas.toDataURL().split(',')[1]),
(value) => value.charCodeAt(0),
);
clipboard.items.add(new File([bytes], 'pasted.png', { type: 'image/png' }));
element.dispatchEvent(
new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: clipboard,
}),
);
});
await expect(
page.locator('[data-web-shell-expanded-attachments]'),
).toContainText('Attached files/images: 2');
await page.getByRole('button', { name: 'Done', exact: true }).tap();
const attachments = page.locator('[data-web-shell-composer-attachments]');
await expect(attachments).toContainText('pasted line');
await expect(
page.locator('[data-web-shell-composer-images] img'),
).toHaveCount(1);
await expect(page.locator(COMPOSER_TEXTAREA)).toHaveValue('keep draft');
expect(daemon.promptRequests()).toHaveLength(0);
});
test('mobile drawer inserts commands without replacing or submitting the draft @smoke', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario();
const daemon = await installScenario(page, scenario, testInfo);
await gotoSession(page, scenario, daemon);
const textarea = page.locator(COMPOSER_TEXTAREA);
await textarea.fill('keep my instructions');
await page.getByRole('button', { name: 'Add to message' }).tap();
const drawer = page.locator('[data-web-shell-mobile-add-menu]');
await expect(drawer).toBeVisible();
await page.getByRole('button', { name: 'All commands', exact: true }).tap();
await page.getByRole('textbox', { name: 'Search commands' }).fill('goal');
await drawer.getByRole('button', { name: /^\/goal/ }).tap();
await expect(drawer).toBeHidden();
await expect(textarea).toHaveValue('/goal keep my instructions');
expect(daemon.promptRequests()).toHaveLength(0);
await page.getByRole('button', { name: 'Add to message' }).tap();
await page.getByRole('button', { name: 'Input history', exact: true }).tap();
await expect(
page.locator('[data-web-shell-composer-history-search]'),
).toBeVisible();
await page
.locator('[data-web-shell-composer-surface]')
.getByRole('button', { name: 'close', exact: true })
.tap();
await expect(textarea).toHaveValue('/goal keep my instructions');
});
test('mobile stop remains reachable with a queued draft @smoke', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario();
const daemon = await installScenario(page, scenario, testInfo);
await gotoSession(page, scenario, daemon);
const textarea = page.locator(COMPOSER_TEXTAREA);
await textarea.fill('start a turn');
await page.locator('[data-web-shell-composer-submit]').tap();
await expect.poll(() => daemon.promptRequests().length).toBe(1);
await textarea.fill('keep this follow-up');
const stop = page.locator('[data-web-shell-composer-stop]');
await expect(stop).toBeVisible();
await expect(page.locator('[data-web-shell-composer-submit]')).toBeEnabled();
await stop.tap();
await expect
.poll(() =>
daemon.requests.some((request) => request.path.endsWith('/cancel')),
)
.toBe(true);
await expect(textarea).toHaveValue('keep this follow-up');
});
async function installScenario(
page: Page,
scenario: WebShellDaemonScenario,
@@ -366,3 +594,73 @@ function expectPromptBodyToContainText(
),
).toBe(true);
}
test('mobile history search restores results and draft focus @smoke', async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 240, height: 700 });
await page.addInitScript(() =>
localStorage.setItem(
'qwen-web-shell-history',
JSON.stringify(['older saved input', 'newer saved input']),
),
);
const scenario = createWebShellDaemonScenario();
const daemon = await installScenario(page, scenario, testInfo);
await gotoSession(page, scenario, daemon);
const textarea = page.locator(COMPOSER_TEXTAREA);
await textarea.fill('working draft');
const open = async () => {
await page.getByRole('button', { name: 'Add to message' }).tap();
await page
.getByRole('button', { name: 'Input history', exact: true })
.tap();
};
await open();
const search = page.locator('[data-web-shell-composer-history-search]');
await expect(search).toBeFocused();
expect((await search.boundingBox())!.height).toBeGreaterThanOrEqual(44);
await expect(search).toHaveCSS('font-size', '16px');
await search.fill('older');
await page.getByRole('button', { name: /older saved input/ }).tap();
await expect(textarea).toHaveValue('older saved input');
await expect(textarea).toBeFocused();
await textarea.fill('another draft');
await open();
await page
.locator('[data-web-shell-composer-surface]')
.getByRole('button', { name: 'close', exact: true })
.tap();
await expect(textarea).toHaveValue('another draft');
await expect(textarea).toBeFocused();
expect(daemon.promptRequests()).toHaveLength(0);
});
test('mobile Shell entry closes the drawer and returns editor focus @smoke', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario();
const daemon = await installScenario(page, scenario, testInfo);
await gotoSession(page, scenario, daemon);
const textarea = page.locator(COMPOSER_TEXTAREA);
await textarea.fill('keep draft');
await page.getByRole('button', { name: 'Add to message', exact: true }).tap();
await page.getByRole('button', { name: 'Shell mode', exact: true }).tap();
await expect(textarea).toBeFocused();
await expect(textarea).toHaveValue('keep draft');
await expect(
page.getByRole('button', { name: 'Exit Shell', exact: true }),
).toBeVisible();
await page.getByRole('button', { name: 'Add to message', exact: true }).tap();
const drawer = page.locator('[data-web-shell-mobile-add-menu]');
await expect(
drawer.getByRole('button', { name: 'All commands', exact: true }),
).toHaveCount(0);
await expect(
drawer.getByRole('button', { name: 'Skills', exact: true }),
).toHaveCount(0);
await drawer.getByRole('button', { name: 'Exit Shell', exact: true }).tap();
await expect(textarea).toBeFocused();
await expect(textarea).toHaveValue('keep draft');
expect(daemon.promptRequests()).toHaveLength(0);
});
@@ -49,12 +49,14 @@ function Harness({
onInputTextChange,
sessionId,
atWorkspaceCwd,
expanded = false,
}: {
composerInput?: WebShellComposerInput;
onSubmit: ReturnType<typeof vi.fn>;
onInputTextChange?: (text: string) => void;
sessionId?: string;
atWorkspaceCwd?: string;
expanded?: boolean;
}) {
const composer = useComposerCore({
onSubmit,
@@ -85,6 +87,15 @@ function Harness({
) : (
<div ref={composer.containerRef} data-web-shell-composer-editor />
)}
{expanded && composer.mobileComposer && (
<textarea
ref={composer.mobileComposer.expandedTextareaRef}
value={composer.mobileComposer.value}
onChange={composer.mobileComposer.onChange}
onPasteCapture={composer.imageTransferHandlers.onPasteCapture}
data-expanded
/>
)}
</div>
);
}
@@ -95,12 +106,14 @@ async function mount({
onInputTextChange,
sessionId,
atWorkspaceCwd,
expanded = false,
}: {
composerInput?: WebShellComposerInput;
onSubmit?: ReturnType<typeof vi.fn>;
onInputTextChange?: (text: string) => void;
sessionId?: string;
atWorkspaceCwd?: string;
expanded?: boolean;
} = {}) {
container = document.createElement('div');
document.body.append(container);
@@ -115,6 +128,7 @@ async function mount({
onInputTextChange={onInputTextChange}
sessionId={sessionId}
atWorkspaceCwd={atWorkspaceCwd}
expanded={expanded}
/>
</I18nProvider>
</WebShellPortalRootContext.Provider>,
@@ -482,6 +496,73 @@ describe('useComposerCore mobile textarea backend', () => {
expect(latest!.pastedFiles[0].text).toBe(text);
});
it('inserts into the expanded caret and restores its caret without replacing the collapsed selection', async () => {
mockTouchDevice();
await mount({ expanded: true });
typeText('hello world');
const collapsed = container!.querySelector<HTMLTextAreaElement>(
'[data-web-shell-composer-editor]',
)!;
const expanded =
container!.querySelector<HTMLTextAreaElement>('[data-expanded]')!;
act(() => {
collapsed.setSelectionRange(3, 5);
expanded.focus();
expanded.setSelectionRange(11, 11);
latest!.insertText(' dictated words ');
});
await act(async () => {
await new Promise((r) => setTimeout(r, 30));
});
expect(expanded.value).toBe('hello world dictated words ');
expect(document.activeElement).toBe(expanded);
expect([expanded.selectionStart, expanded.selectionEnd]).toEqual([27, 27]);
act(() => {
expanded.setSelectionRange(0, 5);
latest!.insertText('Hi');
latest!.focus();
});
await act(async () => {
await new Promise((r) => setTimeout(r, 30));
});
expect(expanded.value).toBe('Hi world dictated words ');
expect([expanded.selectionStart, expanded.selectionEnd]).toEqual([2, 2]);
expect(document.activeElement).toBe(expanded);
});
it('uses the expanded selection for long paste exemptions and folds only once', async () => {
mockTouchDevice();
await mount({ expanded: true });
typeText('draft');
const expanded =
container!.querySelector<HTMLTextAreaElement>('[data-expanded]')!;
const collapsed = container!.querySelector<HTMLTextAreaElement>(
'[data-web-shell-composer-editor]',
)!;
const paste = () => {
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: {
files: [],
items: [],
types: ['text/plain'],
getData: () => 'line\n'.repeat(250),
},
});
act(() => expanded.dispatchEvent(event));
return event;
};
expanded.setSelectionRange(0, 5);
collapsed.setSelectionRange(0, 0);
expect(paste().defaultPrevented).toBe(false);
expect(latest!.pastedFiles).toHaveLength(0);
expanded.setSelectionRange(0, 0);
collapsed.setSelectionRange(0, 5);
expect(paste().defaultPrevented).toBe(true);
expect(latest!.pastedFiles).toHaveLength(1);
expect(expanded.value).toBe('draft');
});
it('moves a folded paste into the touch textarea on request', async () => {
mockTouchDevice();
await mount();
@@ -1348,6 +1348,7 @@ function handleMultilineHistoryBoundary(
*/
export interface MobileComposerBackend {
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
expandedTextareaRef: React.RefObject<HTMLTextAreaElement | null>;
value: string;
onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
onBlur: () => void;
@@ -1355,7 +1356,7 @@ export interface MobileComposerBackend {
}
export interface ComposerImageTransferHandlers {
onPasteCapture: ClipboardEventHandler<HTMLDivElement>;
onPasteCapture: ClipboardEventHandler<HTMLDivElement | HTMLTextAreaElement>;
onDragEnterCapture: DragEventHandler<HTMLDivElement>;
onDragOverCapture: DragEventHandler<HTMLDivElement>;
onDragLeaveCapture: DragEventHandler<HTMLDivElement>;
@@ -1514,6 +1515,7 @@ export function useComposerCore(
// mirrored into a ref so submit/getText read synchronously.
const isTouchComposer = useIsTouchComposer();
const mobileTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const expandedTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const mobileMaxHeightRef = useRef<number | null>(null);
const [mobileText, setMobileTextState] = useState(() =>
isTouchComposer ? (loadComposerDraft(composerDraftStorageKey) ?? '') : '',
@@ -1968,43 +1970,48 @@ export function useComposerCore(
* and a disabled composer, a shell command, or a host with attachments turned
* off has no card to fold into, so each leaves today's behavior alone.
*/
const foldPastedTextIntoCard = useCallback((text: string): boolean => {
// The exemptions are checked before the measurement: sizing a paste costs a
// full UTF-8 encode, which a multi-megabyte paste in shell mode should not
// pay for something that is about to be discarded.
if (
disabledRef.current ||
shellModeRef.current ||
!attachmentsEnabledRef.current
) {
return false;
}
const view = viewRef.current;
const textarea = mobileTextareaRef.current;
if (
view?.state.selection.ranges.some((range) => !range.empty) ||
(textarea && textarea.selectionStart !== textarea.selectionEnd)
) {
return false;
}
const draft = view?.state.doc.toString() ?? mobileTextRef.current;
const caret =
view?.state.selection.main.from ?? textarea?.selectionStart ?? 0;
if (
/^[!/]/.test(draft.trimStart()) ||
/^[!/]/.test(
(draft.slice(0, caret) + text + draft.slice(caret)).trimStart(),
)
) {
return false;
}
if (!shouldFoldPastedText(text)) return false;
const taken = new Set(pastedFilesRef.current.map((file) => file.name));
const next = [...pastedFilesRef.current, createPastedTextFile(text, taken)];
pastedFilesRef.current = next;
setPastedFiles(next);
return true;
}, []);
const foldPastedTextIntoCard = useCallback(
(text: string, textarea = mobileTextareaRef.current): boolean => {
// The exemptions are checked before the measurement: sizing a paste costs a
// full UTF-8 encode, which a multi-megabyte paste in shell mode should not
// pay for something that is about to be discarded.
if (
disabledRef.current ||
shellModeRef.current ||
!attachmentsEnabledRef.current
) {
return false;
}
const view = viewRef.current;
if (
view?.state.selection.ranges.some((range) => !range.empty) ||
(textarea && textarea.selectionStart !== textarea.selectionEnd)
) {
return false;
}
const draft = view?.state.doc.toString() ?? mobileTextRef.current;
const caret =
view?.state.selection.main.from ?? textarea?.selectionStart ?? 0;
if (
/^[!/]/.test(draft.trimStart()) ||
/^[!/]/.test(
(draft.slice(0, caret) + text + draft.slice(caret)).trimStart(),
)
) {
return false;
}
if (!shouldFoldPastedText(text)) return false;
const taken = new Set(pastedFilesRef.current.map((file) => file.name));
const next = [
...pastedFilesRef.current,
createPastedTextFile(text, taken),
];
pastedFilesRef.current = next;
setPastedFiles(next);
return true;
},
[],
);
const imageTransferHandlers = useMemo<ComposerImageTransferHandlers>(
() => ({
onPasteCapture: (event) => {
@@ -2014,8 +2021,15 @@ export function useComposerCore(
// controls that take text of their own (the history search box), so
// only a paste aimed at the editor may be folded.
if (
pastedInEditor(event.target) &&
foldPastedTextIntoCard(pastedText)
(pastedInEditor(event.target) ||
(event.target === event.currentTarget &&
event.currentTarget instanceof HTMLTextAreaElement)) &&
foldPastedTextIntoCard(
pastedText,
event.target instanceof HTMLTextAreaElement
? event.target
: undefined,
)
) {
event.preventDefault();
event.stopPropagation();
@@ -4042,7 +4056,7 @@ export function useComposerCore(
const focus = useCallback(() => {
if (isTouchComposer) {
mobileTextareaRef.current?.focus();
(expandedTextareaRef.current ?? mobileTextareaRef.current)?.focus();
return;
}
viewRef.current?.focus();
@@ -4051,13 +4065,13 @@ export function useComposerCore(
const insertText = useCallback(
(text: string, options?: WebShellComposerTextOptions) => {
if (isTouchComposer) {
const el = expandedTextareaRef.current ?? mobileTextareaRef.current;
if (text) {
if (options?.mode === 'replace') {
setMobileText(text);
} else {
// No slash/at menus on the textarea backend: '/' and '@' are
// inserted literally and interpreted from the submitted text.
const el = mobileTextareaRef.current;
const current = mobileTextRef.current;
const start = el ? el.selectionStart : current.length;
const end = el ? el.selectionEnd : current.length;
@@ -4067,7 +4081,7 @@ export function useComposerCore(
// value changes; put it back after React re-renders, matching
// the CodeMirror path's explicit selection anchor.
const restoreCaret = () => {
mobileTextareaRef.current?.setSelectionRange(caret, caret);
el?.setSelectionRange(caret, caret);
};
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(restoreCaret);
@@ -4076,7 +4090,7 @@ export function useComposerCore(
}
}
}
mobileTextareaRef.current?.focus();
el?.focus();
return;
}
const view = viewRef.current;
@@ -4551,11 +4565,9 @@ export function useComposerCore(
? shellHistoryActionsRef.current
: historyActionsRef.current;
history.resetSearch();
if (keepFocus) {
viewRef.current?.focus();
}
if (keepFocus) focus();
},
[replaceEditorText],
[focus, replaceEditorText],
);
useEffect(() => {
@@ -4815,6 +4827,7 @@ export function useComposerCore(
mobileComposer: isTouchComposer
? {
textareaRef: mobileTextareaRef,
expandedTextareaRef,
value: mobileText,
onChange: handleMobileChange,
onBlur: () => saveCurrentDraftRef.current(),
+27 -48
View File
@@ -756,6 +756,20 @@ const EN: Messages = {
'composer.dropChoice.reference': 'Attach to message',
'composer.dropChoice.moreFiles': (v) => `and ${v?.count ?? 0} more files`,
'composerAdd.trigger': 'Add to message',
'composerMobile.expand': 'Expand editor',
'composerMobile.hideKeyboard': 'Hide keyboard',
'composerMobile.done': 'Done',
'composerMobile.photos': 'Photos',
'composerMobile.camera': 'Take photo',
'composerMobile.files': 'Attach files',
'composerMobile.commands': 'All commands',
'composerMobile.searchCommands': 'Search commands',
'composerMobile.history': 'Input history',
'composerMobile.previousInput': 'Previous input',
'composerMobile.nextInput': 'Next input',
'composerMobile.stop': 'Stop',
'composerMobile.attachments': (v) =>
`Attached files/images: ${v?.count ?? 0}`,
'composerAdd.emptyState': 'No add actions are available here',
'composerAdd.noResults': 'No results',
'composerAdd.loadError': 'Failed to load results',
@@ -1362,32 +1376,13 @@ const EN: Messages = {
'editor.newSessionSuggestionStart': 'Send in new session',
'editor.btwSuggestionTitle': 'This looks like a side question',
'editor.btwSuggestionSend': 'Ask with BTW',
'quickActions.open': 'more actions',
'quickActions.title': 'more actions',
'quickActions.mcp': 'MCP',
'quickActions.context': 'Context',
'quickActions.status': 'Status',
'quickActions.stats': 'Session',
'quickActions.memory': 'Memory',
'quickActions.extensions': 'Extensions',
'quickActions.skills': 'Skills',
'quickActions.tools': 'Tools',
'quickActions.agents': 'Agents',
'quickActions.help': 'Help',
'quickActions.theme': 'Set theme',
'quickActions.auth': 'Auth',
'quickActions.settings': 'Settings',
'quickActions.new': 'New session',
'quickActions.resume': 'Switch session',
'quickActions.delete': 'Delete session',
'quickActions.branch': 'Copy session',
'quickActions.rewind': 'Rewind session',
'quickActions.historyQuestion': 'Question history',
'quickActions.recap': 'Generate recap',
'quickActions.copy': 'Copy output',
'quickActions.shellMode': 'Shell mode',
'quickActions.exitShellMode': 'Exit Shell',
'quickActions.setGoal': 'Set goal',
'session.missing': 'Current session does not exist',
'session.new': 'New session',
'workspace.loadFailed': 'Failed to load workspace',
@@ -1970,12 +1965,7 @@ const EN: Messages = {
'sidebar.groupColor.picker': 'Choose custom group color',
'sidebar.groupColor.hex': 'Hex color',
'sidebar.groupColor.invalid': 'Enter a six-digit Hex color such as #416ef5.',
'quickKeys.cursor': 'Move cursor',
'quickKeys.escape': 'Cancel run',
'quickKeys.history': 'History',
'quickKeys.retry': 'Retry failed',
'quickKeys.searchHistory': 'Search history',
'quickKeys.tab': 'Accept completion',
'error.unsupportedTheme':
'Unsupported theme. Use /theme light or /theme dark.',
'queue.delete': 'Delete',
@@ -4797,6 +4787,19 @@ const ZH: Messages = {
'composer.dropChoice.reference': '添加为附件',
'composer.dropChoice.moreFiles': (v) => `另有 ${v?.count ?? 0} 个文件`,
'composerAdd.trigger': '添加到消息',
'composerMobile.expand': '展开编辑',
'composerMobile.hideKeyboard': '收起键盘',
'composerMobile.done': '完成',
'composerMobile.photos': '照片',
'composerMobile.camera': '拍照',
'composerMobile.files': '添加文件附件',
'composerMobile.commands': '全部命令',
'composerMobile.searchCommands': '搜索命令',
'composerMobile.history': '输入历史',
'composerMobile.previousInput': '上一条输入',
'composerMobile.nextInput': '下一条输入',
'composerMobile.stop': '停止',
'composerMobile.attachments': (v) => `已添加附件/图片:${v?.count ?? 0}`,
'composerAdd.emptyState': '此处没有可用的添加操作',
'composerAdd.noResults': '没有结果',
'composerAdd.loadError': '加载失败',
@@ -5353,32 +5356,13 @@ const ZH: Messages = {
'editor.newSessionSuggestionStart': '在新会话发送',
'editor.btwSuggestionTitle': '这条消息看起来像顺带一问',
'editor.btwSuggestionSend': '用 BTW 提问',
'quickActions.open': '更多操作',
'quickActions.title': '更多操作',
'quickActions.mcp': 'MCP',
'quickActions.context': '上下文',
'quickActions.status': '运行状态',
'quickActions.stats': '会话统计',
'quickActions.memory': '记忆管理',
'quickActions.extensions': '扩展',
'quickActions.skills': '技能详情',
'quickActions.tools': '工具详情',
'quickActions.agents': '智能体',
'quickActions.help': '帮助',
'quickActions.theme': '设置主题',
'quickActions.auth': '认证',
'quickActions.settings': '设置',
'quickActions.new': '新建会话',
'quickActions.resume': '切换会话',
'quickActions.delete': '删除会话',
'quickActions.branch': '复制会话',
'quickActions.rewind': '回退会话',
'quickActions.historyQuestion': '历史提问',
'quickActions.recap': '生成摘要',
'quickActions.copy': '复制输出',
'quickActions.shellMode': 'Shell模式',
'quickActions.exitShellMode': '退出Shell',
'quickActions.setGoal': '设置目标',
'session.missing': '当前会话不存在',
'session.new': '新建会话',
'workspace.loadFailed': '工作区加载失败',
@@ -5917,12 +5901,7 @@ const ZH: Messages = {
'sidebar.groupColor.picker': '选择自定义分组颜色',
'sidebar.groupColor.hex': 'Hex 颜色',
'sidebar.groupColor.invalid': '请输入六位 Hex 颜色,例如 #416ef5。',
'quickKeys.cursor': '移动光标',
'quickKeys.escape': '取消运行',
'quickKeys.history': '切换历史',
'quickKeys.retry': '重试失败请求',
'quickKeys.searchHistory': '搜索历史',
'quickKeys.tab': '接受补全',
'error.unsupportedTheme':
'不支持该主题。请使用 /theme light 或 /theme dark。',
'queue.delete': '删除',
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act } from 'react';
import { act, type ComponentProps } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LiveVoiceButton } from './LiveVoiceButton';
@@ -45,11 +45,13 @@ vi.mock('./useLiveVoice', () => ({
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
function mount(): HTMLElement {
function mount(
props: ComponentProps<typeof LiveVoiceButton> = {},
): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(<LiveVoiceButton />));
act(() => root.render(<LiveVoiceButton {...props} />));
mounted.push({ root, container });
return container;
}
@@ -381,3 +383,94 @@ describe('LiveVoiceButton as a browser Host', () => {
expect(buttonNamed('live.browser.connect')).toBeTruthy();
});
});
describe('mobile Live voice entry', () => {
it('opens from a controlled secondary entry while keeping the idle trigger hidden', () => {
const onSupportedChange = vi.fn();
const container = mount({
hideInactiveTrigger: true,
open: true,
onOpenChange: vi.fn(),
onSupportedChange,
});
expect(container.querySelector('button')).toBeNull();
expect(
document.querySelector('[data-web-shell-live-dialog]'),
).not.toBeNull();
expect(onSupportedChange).toHaveBeenCalledWith(true);
expect(mocks.result.refresh).toHaveBeenCalledOnce();
});
it('reports capability arrival and removal on the same mounted root', () => {
mocks.result.supported = false;
const onSupportedChange = vi.fn();
mount({ onSupportedChange });
expect(onSupportedChange).toHaveBeenLastCalledWith(false);
for (const supported of [true, false]) {
mocks.result.supported = supported;
act(() =>
mounted
.at(-1)!
.root.render(
<LiveVoiceButton onSupportedChange={onSupportedChange} />,
),
);
expect(onSupportedChange).toHaveBeenLastCalledWith(supported);
}
expect(onSupportedChange).toHaveBeenCalledTimes(3);
});
it('forwards controlled trigger opening and closing through the parent', () => {
const onOpenChange = vi.fn();
const container = mount({ open: false, onOpenChange });
click(container.querySelector('button')!);
expect(onOpenChange).toHaveBeenLastCalledWith(true);
expect(document.querySelector('[data-web-shell-live-dialog]')).toBeNull();
act(() =>
mounted
.at(-1)!
.root.render(<LiveVoiceButton open onOpenChange={onOpenChange} />),
);
expect(
document.querySelector('[data-web-shell-live-dialog]'),
).not.toBeNull();
expect(mocks.result.refresh).toHaveBeenCalledOnce();
click(
document.querySelector(
'[data-web-shell-live-dialog] [data-slot="dialog-close"]',
)!,
);
expect(onOpenChange).toHaveBeenLastCalledWith(false);
});
it('returns focus to the supplied mobile entry when the hidden-trigger dialog closes', async () => {
const fallback = document.createElement('button');
document.body.append(fallback);
const onRequestFocusFallback = () => fallback.focus();
mount({ hideInactiveTrigger: true, open: true, onRequestFocusFallback });
await act(async () => {
mounted
.at(-1)!
.root.render(
<LiveVoiceButton
hideInactiveTrigger
open={false}
onRequestFocusFallback={onRequestFocusFallback}
/>,
);
});
await act(async () => {
await new Promise((r) => setTimeout(r, 50));
});
expect(document.activeElement).toBe(fallback);
});
it('keeps the toolbar trigger available during an active call', () => {
mocks.result.status = {
...mocks.result.status!,
available: true,
state: 'listening',
};
const container = mount({ hideInactiveTrigger: true });
expect(container.querySelector('[data-active="true"]')).not.toBeNull();
});
});
@@ -5,6 +5,7 @@
*/
import type React from 'react';
import { useEffect } from 'react';
import type {
DaemonLiveRequirementState,
DaemonLiveStatus,
@@ -117,7 +118,19 @@ function liveStateLabel(
return t(`live.state.${status?.state ?? 'unavailable'}`);
}
export function LiveVoiceButton(): React.JSX.Element | null {
export function LiveVoiceButton({
hideInactiveTrigger = false,
open,
onOpenChange,
onSupportedChange,
onRequestFocusFallback,
}: {
hideInactiveTrigger?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
onSupportedChange?: (supported: boolean) => void;
onRequestFocusFallback?: () => void;
} = {}): React.JSX.Element | null {
const { t } = useI18n();
const {
supported,
@@ -132,6 +145,12 @@ export function LiveVoiceButton(): React.JSX.Element | null {
stop,
setMute,
} = useLiveVoice();
useEffect(() => {
onSupportedChange?.(supported);
}, [onSupportedChange, supported]);
useEffect(() => {
if (open && supported) void refresh();
}, [open, supported, refresh]);
if (!supported) return null;
const active = isActive(status);
@@ -167,27 +186,40 @@ export function LiveVoiceButton(): React.JSX.Element | null {
return (
<Dialog
onOpenChange={(open) => {
if (open) void refresh();
open={open}
onOpenChange={(nextOpen) => {
onOpenChange?.(nextOpen);
if (nextOpen && open === undefined) void refresh();
}}
>
<DialogTrigger asChild>
<button
type="button"
className={styles.trigger}
aria-label={label}
title={label}
data-active={active}
data-state={status?.state ?? 'unavailable'}
data-available={status?.available === true}
>
<LiveIcon />
</button>
</DialogTrigger>
{(!hideInactiveTrigger || active) && (
<DialogTrigger asChild>
<button
type="button"
className={styles.trigger}
aria-label={label}
title={label}
data-active={active}
data-state={status?.state ?? 'unavailable'}
data-available={status?.available === true}
>
<LiveIcon />
</button>
</DialogTrigger>
)}
{/* Wider than the default dialog, with a wrapping footer: three footer
buttons do not fit 384px and used to push the requirement states
outside the dialog. */}
<DialogContent data-web-shell-live-dialog className="sm:max-w-md">
<DialogContent
data-web-shell-live-dialog
className="sm:max-w-md"
onCloseAutoFocus={(event) => {
if (hideInactiveTrigger && !active && onRequestFocusFallback) {
event.preventDefault();
onRequestFocusFallback();
}
}}
>
<DialogHeader>
<DialogTitle>{t('live.title')}</DialogTitle>
<DialogDescription>