mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
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(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user