feat(channels): edit bound-session action templates on demand (#16349)

* feat(channels): edit bound-session action templates on demand

The Web Shell channel editor rendered every boundSessionActions key as its own single-line input — 80 rows for GitHub's closed vocabulary — with one dense paragraph as the only explanation and no way to enlarge the dialog. Operators typically configure one or two keys.

Record fields now show only configured entries and add keys through a grouped picker whose options carry per-key descriptions; values edit in multi-line text areas with explicit delete buttons. GitHub's descriptor supplies the descriptions and role groups, mirrored through new optional option metadata on the channel-base and SDK descriptor types. The channel editor dialog gains a fullscreen toggle, and the templates field moves into the bound-sessions panel with the same delivery gating as watch events. Empty values stay editable in the draft and are filtered by the existing save serializer, so the draft/save contract is unchanged.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): generic record copy and smoke-tag the template editor E2E

Review follow-up on #16349. The shared record renderer's empty state and add-picker placeholder were template-worded, which mislabels the options-less messageRoutes field; the defaults are now entry-worded and the GitHub/GitLab template fields override them through their field label keys. A new unit case pins the options-less path (generic copy, no picker, stored rows still editable). The new channels E2E case gains the @smoke tag so the CI smoke job actually runs it.

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
Shaojin WenandQwen-Coder authored and GitHub committed 2026-11-05 16:48:27 +08:00
1 parent feb06ca823
commit e4c570a387
13 files changed
+1450 -84

No files matched your search

@@ -0,0 +1,243 @@
# Channel Action Template Editor Usability
[English](2026-11-05-channel-action-template-editor.md) | [简体中文](2026-11-05-channel-action-template-editor.zh-CN.md)
Status: implemented and verified locally (2026-11-05); pending PR
## Problem
The Web Shell channel editor renders `record` config fields as one labeled
single-line input per descriptor option. For the GitHub channel's
`boundSessionActions` field the descriptor enumerates the full closed key
vocabulary: `default` + 4 watch events + 15 notification reasons = 20 keys,
each also spelled with the 3 binding-role prefixes (`review:`, `verify:`,
`fix:`) — **80 rows** in one dialog. Operators typically configure one or two
keys. Three usability gaps follow:
1. **Too much content.** All 80 rows render whether configured or not, inside
a dialog body capped at `min(66vh, 650px)`.
2. **No explanation.** The field carries one dense paragraph (key syntax,
role routing, seven template variables). Individual keys have no
description, and a single-line `Input` cannot comfortably hold an
instruction template of up to 2,000 code points (the adapter's
`BOUND_ACTION_TEMPLATE_MAX_CODE_POINTS`).
3. **No fullscreen.** `ChannelEditorDialog` is fixed at `sm:max-w-xl`
(~576px) with no expand affordance, while other surfaces (DialogShell,
artifact panel) already offer fullscreen.
Two adjacent gaps surface while fixing the above: `boundSessionActions`
renders in the Credentials section instead of the bound-sessions panel next
to its `deliverToBoundSessions` prerequisite, and templates can be typed
while bound delivery is off only to be rejected by the daemon on save.
## Current state
- Descriptor: `packages/channels/github/src/index.ts` builds
`BOUND_ACTION_KEY_OPTIONS` from `PR_WATCH_EVENTS`,
`PR_NOTIFICATION_REASONS`, and `PR_BINDING_ROLES` so UI options cannot
drift from the adapter's key validator.
- Renderer: the generic `record` branch in
`packages/web-shell/client/components/channels/ChannelEditorDialog.tsx`
maps every option to a `Label` + `Input` row. Clearing a row deletes the
key from the draft because the renderer has no other delete affordance.
- Draft shape: `draft.values[field.key]` is a JSON string;
`parseRouteRecord` in `channel-editor-state.ts` filters empty values on
save, and `recordHasRoute` drives required-field validation.
- Option shape: `{ value: string; label: string }`, mirrored in
`packages/channels/base/src/types.ts` and
`packages/sdk-typescript/src/daemon/types.ts`; the cli
`channel-descriptor-sdk-mirror.test.ts` type-level mirror test keeps the
two identical. The wire-shape assertion pins field-level keys only.
- GitLab's `action_prompt_template` is the only other `record` field
(9 options, required, descriptive labels).
- i18n: `packages/web-shell/client/i18n.tsx` holds `EN` / `ZH` dictionaries;
record option labels already resolve through
`channels.editor.field.<type>.<field>.option.<value>` with fallback to the
descriptor label. `common.fullscreen` / `common.exitFullscreen` exist.
## Goals
- Show only configured templates; add keys on demand through a picker that
explains each key.
- Edit templates in multi-line text areas.
- Give every key a short description, in English and Chinese.
- Let the channel editor dialog go fullscreen.
- Keep the draft/save contract, the validator vocabulary, and the GitLab
record field working without behavior changes beyond the new UI shape.
## Scope boundaries
- No change to the adapter's key vocabulary, validation, routing, or
template substitution.
- No change to the draft representation (JSON string) or the save
serializer.
- Fullscreen is added to `ChannelEditorDialog` only, not to the shared
`ui/dialog` primitive.
- No searchable combobox; a grouped native-feel Select is sufficient for
80 keys.
## Proposed changes
### 1. Option metadata (channel-base + SDK mirror)
Extend the option object in both mirrored type definitions (base and enum
shapes, two places per package) with two optional fields:
- `description?: string` — one-line meaning of the key, shown in the picker
and under configured rows.
- `group?: string` — picker group identifier; options sharing a group render
under one labeled section.
Both are additive and optional; existing plugins compile unchanged. The
mirror type test fails if only one side is edited. The registry's
management-metadata validation does not strip option keys, and the catalog
route passes fields through, so no daemon route change is needed.
### 2. GitHub descriptor content
`BOUND_ACTION_KEY_OPTIONS` gains, per option:
- `description`: 20 base texts (default fallback, 4 watch transitions,
15 notification reasons). Role-qualified keys reuse their base event's
description; the role's meaning comes from the group header.
- `group`: `'any'` for the 20 unqualified keys, otherwise the role name.
The `default` option's label becomes the plain key; its current
"fallback for any unmatched event" wording moves into the description.
The field description is rewritten shorter: what the templates do, the
key shape, the bound-delivery prerequisite, role fan-out in one sentence,
the variable list, `%%` escaping, and the empty-means-default rule.
### 3. Generic record renderer (ChannelEditorDialog)
- **Rows on demand.** Render one row per key present in the draft record,
including keys the descriptor does not enumerate (stored data stays
visible, editable, and removable — never silently dropped). Each row:
the key in code style, a description line, a delete icon button, and a
`Textarea` for the value.
- **Add picker.** A `Select` under the rows lists only unconfigured
options, grouped via `SelectGroup` / `SelectLabel` when `group` is set.
Items show the option label plus its description. Choosing one inserts
the key with an empty value and resets the picker.
- **Empty values while editing.** A freshly added or cleared row keeps its
key in the draft with an empty value; the explicit delete button removes
the key. The save serializer already filters empty values
(`parseRouteRecord`), so no empty entry can reach the daemon. This
replaces the old clear-to-delete rule, whose comment is updated.
- **Delivery gating.** For GitHub `boundSessionActions` with
`deliverToBoundSessions` off, the add picker is disabled and a hint names
the prerequisite — the same pattern `prWatchEvents` uses. Existing rows
stay editable and removable so turning delivery off never strands keys.
- **Placement.** `boundSessionActions` joins `BOUND_SESSION_FIELD_KEYS` so
it renders inside the bound-sessions panel beside its prerequisite.
- **Empty state.** With no rows, one muted line says no entries are
configured yet. The copy is generic because the renderer is shared with
non-template record fields (message routes); the GitHub and GitLab
template fields override it with template wording through their field
label key, as they do the add-picker placeholder.
Description resolution for a row/picker item: translated
`...optionDescription.<value>` key, else the descriptor
`option.description`, else the translated option label when it differs
from the key (this keeps GitLab's descriptive labels working unchanged).
### 4. Fullscreen (ChannelEditorDialog only)
A header toggle button (Expand/Shrink icons, `common.fullscreen` /
`common.exitFullscreen` labels, `aria-pressed`) switches the dialog between
its current size and `calc(100vw - 2rem)` × `calc(100vh - 2rem)`. In
fullscreen the content becomes a flex column, the body loses its
`min(66vh, 650px)` cap and fills the remaining height. The state resets
when the dialog opens, alongside the other per-open resets.
### 5. i18n
- Rewritten `boundSessionActions` description (EN + ZH).
- zh-CN `...boundSessionActions.optionDescription.<key>` entries for the
20 base keys. The 60 role-qualified entries are generated in `i18n.tsx`
from the base entries (identical text; the group header carries the
role), so the dictionaries stay maintainable. English needs no entries:
the renderer falls back to the descriptor's own option descriptions,
keeping a single source of truth.
- Group headers `...boundSessionActions.group.<any|review|verify|fix>`
(EN + ZH), each naming the role's job.
- Record editor chrome: generic add-picker placeholder and empty-state
line (entry-worded, since the renderer is shared with non-template record
fields), template-worded overrides for the GitHub and GitLab fields,
delete button aria-label, delivery-gating hint (EN + ZH).
## Design decisions
1. **Additive option metadata instead of client-side key parsing.** The
renderer stays channel-agnostic; GitHub's role/event structure is
declared by the plugin that owns the vocabulary. The existing mirror
type test guards the two type definitions.
2. **Reuse the JSON-string draft contract.** No `channel-editor-state`
change; required validation, the save filter, and the advanced-panel
configured predicate keep working untouched.
3. **Generate role-qualified translations from base texts.** 20 zh-CN base
descriptions + 4 group headers per language instead of 80 duplicated
strings; a vocabulary change touches one place per language. English
reuses the descriptor text through the renderer's fallback chain.
4. **Fullscreen local to the channel editor.** Migrating the dialog to
`DialogShell` would change focus/IME behavior and the header layout for
no extra benefit; a local toggle matches the approved scope.
5. **Gate adding, not editing.** Mirrors `prWatchEvents`: the prerequisite
is named at the field before save, and stored keys remain clearable.
## Files affected
- `packages/channels/base/src/types.ts` — option `description`/`group`.
- `packages/sdk-typescript/src/daemon/types.ts` — mirrored option fields.
- `packages/channels/github/src/index.ts` — option descriptions/groups,
shorter field description.
- `packages/web-shell/client/components/channels/ChannelEditorDialog.tsx`
and `.module.css` — record renderer, gating, placement, fullscreen.
- `packages/web-shell/client/i18n.tsx` — texts above.
- Tests: `ChannelEditorDialog.test.tsx` (record interactions rewritten,
new cases), `web-shell.channels.spec.ts` (new E2E cases).
## Constraints and risks
- The SDK and channel-base type mirrors must land in the same change; the
mirror test enforces this.
- Stored records may contain keys outside the descriptor vocabulary; the
renderer must keep them visible and removable.
- Radix Select renders all 80 items; grouped sections keep it navigable.
If item count grows further, a searchable picker is the follow-up, not
this change.
- GitLab's required record field must still fail validation when no
non-empty template exists (unchanged `recordHasRoute` rule).
## Validation plan
- Unit: focused vitest in `packages/web-shell` for the editor dialog
(rows-on-demand, add/delete, empty-value filtering on save, unknown
stored keys, gating hint, fullscreen toggle, zh-CN texts) and the cli
descriptor mirror/wire-shape tests.
- E2E: Playwright `web-shell.channels.spec.ts` with the mock daemon —
add/edit/delete a template through the picker and assert the upsert
payload; toggle fullscreen. The case is tagged `@smoke` so the CI smoke
job (`test:e2e:smoke`, `--grep @smoke`) actually runs it. Baseline run
before implementation must fail (feature absent).
- `npm run build && npm run typecheck` from the repository root.
## Acceptance criteria
- A fresh GitHub channel draft shows zero template rows and one add
picker; the picker lists all 80 keys in 4 labeled groups with
descriptions.
- Configured keys render as multi-line rows with descriptions and delete
buttons; saving filters empty values and preserves untouched keys.
- With bound delivery off, adding is disabled with a hint naming the
switch; existing rows remain removable.
- The dialog toggles fullscreen and back; the body fills the enlarged
dialog.
- zh-CN shows translated descriptions, group headers, and chrome.
- GitLab's record field keeps its required validation and descriptive
labels under the new renderer.
## Open questions
None blocking. A searchable picker and per-template character counters
are possible follow-ups if operators ask for them.
@@ -0,0 +1,200 @@
# 渠道动作模板编辑器易用性改进
[English](2026-11-05-channel-action-template-editor.md) | [简体中文](2026-11-05-channel-action-template-editor.zh-CN.md)
状态:已实现并本地验证(2026-11-05);待提交 PR
## 问题
Web Shell 渠道编辑器把 `record` 类配置字段渲染成「描述符里每个选项一行
label + 单行输入框」。GitHub 渠道的 `boundSessionActions` 字段在描述符里
枚举了完整的封闭键词汇:`default` + 4 个 watch 事件 + 15 个通知 reason =
20 个键,每个键再带 3 个绑定角色前缀(`review:`、`verify:`、`fix:`)——
一个对话框里 **80 行**。而 operator 通常只配置其中一两个键。由此产生三个
易用性缺口:
1. **内容太多。** 无论是否配置,80 行全部渲染,而对话框内容区高度上限只有
`min(66vh, 650px)`。
2. **没有说明。** 字段只有一段密集文字(键语法、角色路由、7 个模板变量挤在
一起);每个键本身没有解释;单行 `Input` 也装不下最长 2,000 码点的指令
模板(adapter 的 `BOUND_ACTION_TEMPLATE_MAX_CODE_POINTS`)。
3. **不支持全屏。** `ChannelEditorDialog` 固定 `sm:max-w-xl`(约 576px),
没有展开能力,而其它界面(DialogShell、artifact 面板)已有全屏。
修复上述问题时还会暴露两个相邻缺口:`boundSessionActions` 渲染在
Credentials 分区,而不是紧挨其前置条件 `deliverToBoundSessions` 的绑定会话
面板;绑定投递关闭时仍能填写模板,直到保存才被 daemon 拒绝。
## 现状
- 描述符:`packages/channels/github/src/index.ts` 用 `PR_WATCH_EVENTS`、
`PR_NOTIFICATION_REASONS`、`PR_BINDING_ROLES` 构建
`BOUND_ACTION_KEY_OPTIONS`,保证 UI 选项与 adapter 键校验器不会漂移。
- 渲染器:
`packages/web-shell/client/components/channels/ChannelEditorDialog.tsx`
的通用 `record` 分支把每个选项映射成 `Label` + `Input` 行。清空某行会从
draft 删除该键,因为渲染器没有其它删除入口。
- Draft 形态:`draft.values[field.key]` 是 JSON 字符串;
`channel-editor-state.ts` 的 `parseRouteRecord` 在保存时过滤空值,
`recordHasRoute` 驱动必填校验。
- 选项形态:`{ value: string; label: string }`,在
`packages/channels/base/src/types.ts` 与
`packages/sdk-typescript/src/daemon/types.ts` 两处镜像;cli 的
`channel-descriptor-sdk-mirror.test.ts` 用类型级镜像测试保证两者一致。
wire-shape 断言只钉字段级键名。
- GitLab 的 `action_prompt_template` 是唯一的另一个 `record` 字段
(9 个选项、必填、label 自带说明)。
- i18n:`packages/web-shell/client/i18n.tsx` 持有 `EN` / `ZH` 字典;
record 选项 label 已经走
`channels.editor.field.<type>.<field>.option.<value>` 翻译并回退到描述符
label。`common.fullscreen` / `common.exitFullscreen` 已存在。
## 目标
- 只显示已配置的模板;通过带逐键说明的选择器按需添加键。
- 用多行文本框编辑模板。
- 每个键都有一句中英文说明。
- 渠道编辑对话框支持全屏。
- 保持 draft/保存契约、校验器词汇不变;GitLab record 字段除新 UI 形态外
行为不变。
## 范围边界
- 不改 adapter 的键词汇、校验、路由、模板变量替换。
- 不改 draft 表示(JSON 字符串)与保存序列化器。
- 全屏只加在 `ChannelEditorDialog`,不动共享 `ui/dialog` 原语。
- 不做可搜索 combobox;80 个键用分组 Select 足够。
## 方案
### 1. 选项元数据(channel-base + SDK 镜像)
在两处镜像类型定义(base 与 enum 形态,每包两处)的选项对象上增加两个
可选字段:
- `description?: string` —— 键的一句话含义,显示在选择器和已配置行里。
- `group?: string` —— 选择器分组标识;同组选项渲染在一个带标题的分区下。
两者均为可选的增量字段,现有插件无需改动即可编译。只改一侧会被镜像类型
测试拦下。registry 的 management 元数据校验不剥离选项键名,catalog 路由
原样透传字段,因此无需改 daemon 路由。
### 2. GitHub 描述符内容
`BOUND_ACTION_KEY_OPTIONS` 的每个选项增加:
- `description`:20 条基础文案(default 回退、4 个 watch 迁移、15 个通知
reason)。角色限定键复用其基础事件的描述;角色含义由分组标题承载。
- `group`:20 个无前缀键为 `'any'`,其余为角色名。
`default` 选项的 label 改为纯键名;原「fallback for any unmatched event」
措辞移入 description。字段描述重写得更短:模板用途、键形态、绑定投递前置
条件、一句话角色扇出、变量列表、`%%` 转义、留空即默认。
### 3. 通用 record 渲染器(ChannelEditorDialog)
- **按需成行。** 只为 draft record 中存在的键渲染行,包括描述符未枚举的键
(已存数据保持可见、可编辑、可删除——绝不静默丢弃)。每行:代码样式的
键名、一行说明、删除图标按钮、值的 `Textarea`。
- **添加选择器。** 行下方的 `Select` 只列未配置的选项,设置 `group` 时用
`SelectGroup` / `SelectLabel` 分组。选项显示 label + 描述。选中后以空值
插入该键并重置选择器。
- **编辑期允许空值。** 新添加或清空的行在 draft 中保留键与空值;显式删除
按钮才移除键。保存序列化器本就过滤空值(`parseRouteRecord`),空条目
到不了 daemon。这取代旧的「清空即删除」规则,相应注释同步更新。
- **投递门控。** GitHub `boundSessionActions` 在
`deliverToBoundSessions` 关闭时禁用添加选择器,并用 hint 指明前置条件——
与 `prWatchEvents` 同一模式。已有行保持可编辑、可删除,关闭投递不会把
键困住。
- **位置。** `boundSessionActions` 加入 `BOUND_SESSION_FIELD_KEYS`,渲染进
绑定会话面板,紧挨其前置条件。
- **空态。** 没有任何行时显示一行弱化文案:尚未配置条目。文案用通用的
「条目」措辞,因为渲染器与非模板类 record 字段(消息路由)共享;GitHub
与 GitLab 的模板字段通过各自字段 label 键覆盖为「模板」措辞,添加选择器
占位符同理。
行/选择器项的描述解析顺序:翻译键 `...optionDescription.<value>`,否则
描述符 `option.description`,否则在与键名不同时取翻译后的选项 label
(GitLab 自带说明的 label 因此原样可用)。
### 4. 全屏(仅 ChannelEditorDialog)
标题栏切换按钮(Expand/Shrink 图标、`common.fullscreen` /
`common.exitFullscreen` 文案、`aria-pressed`)在当前尺寸与
`calc(100vw - 2rem)` × `calc(100vh - 2rem)` 之间切换。全屏时内容区改为
flex 纵向布局,body 解除 `min(66vh, 650px)` 上限并占满剩余高度。状态在
对话框打开时随其它每开必重置的状态一起复位。
### 5. i18n
- 重写 `boundSessionActions` 描述(EN + ZH)。
- 20 个基础键的 zh-CN `...boundSessionActions.optionDescription.<key>`
文案。60 个角色限定条目在 `i18n.tsx` 里由基础条目生成(文案相同,角色由
分组标题承载),字典保持可维护。英文无需登记:渲染器回退到描述符自带的
选项描述,保持单一事实源。
- 分组标题 `...boundSessionActions.group.<any|review|verify|fix>`
(EN + ZH),各注明角色职责。
- record 编辑器界面文案:通用的添加选择器占位与空态行(用「条目」措辞,
因渲染器与非模板类 record 字段共享)、GitHub 与 GitLab 字段的「模板」
措辞覆盖、删除按钮 aria-label、投递门控 hint(EN + ZH)。
## 设计决策
1. **增量选项元数据,而不是客户端解析键名。** 渲染器保持渠道无关;
GitHub 的角色/事件结构由拥有该词汇的插件声明。现有镜像类型测试守护
两份类型定义。
2. **复用 JSON 字符串 draft 契约。** 不改 `channel-editor-state`;必填
校验、保存过滤、高级面板「已配置」判定原样工作。
3. **角色限定翻译由基础文案生成。** 20 条 zh-CN 基础描述 + 每种语言 4 个
分组标题,而不是 80 条重复字符串;词汇变更每种语言只改一处。英文经
渲染器回退链复用描述符文案。
4. **全屏局部于渠道编辑器。** 迁到 `DialogShell` 会改变焦点/IME 行为与
标题栏布局且无额外收益;局部切换符合已确认的范围。
5. **门控添加而非编辑。** 与 `prWatchEvents` 一致:保存前就在字段上指明
前置条件,已存键保持可清除。
## 涉及文件
- `packages/channels/base/src/types.ts` —— 选项 `description`/`group`。
- `packages/sdk-typescript/src/daemon/types.ts` —— 镜像选项字段。
- `packages/channels/github/src/index.ts` —— 选项描述/分组、更短的字段
描述。
- `packages/web-shell/client/components/channels/ChannelEditorDialog.tsx`
与 `.module.css` —— record 渲染器、门控、位置、全屏。
- `packages/web-shell/client/i18n.tsx` —— 上述文案。
- 测试:`ChannelEditorDialog.test.tsx`(record 交互重写、新增用例)、
`web-shell.channels.spec.ts`(新增 E2E 用例)。
## 约束与风险
- SDK 与 channel-base 类型镜像必须同一变更落地;镜像测试强制这一点。
- 已存 record 可能含描述符词汇之外的键;渲染器必须保持其可见、可删除。
- Radix Select 会渲染全部 80 项;分组分区保证可浏览。若未来键数继续增长,
可搜索选择器是后续工作,不在本次范围。
- GitLab 必填 record 字段在没有任何非空模板时仍须校验失败
(`recordHasRoute` 规则不变)。
## 验证计划
- 单元:`packages/web-shell` 内针对编辑器对话框的聚焦 vitest(按需成行、
添加/删除、保存过滤空值、未知已存键、门控 hint、全屏切换、zh-CN 文案),
以及 cli 描述符镜像/wire-shape 测试。
- E2E:Playwright `web-shell.channels.spec.ts` + mock daemon——通过选择器
添加/编辑/删除模板并断言 upsert 载荷;切换全屏。用例带 `@smoke` 标记,
确保 CI smoke 任务(`test:e2e:smoke`,`--grep @smoke`)真的会跑它。
实现前的基线运行必须失败(功能尚不存在)。
- 仓库根执行 `npm run build && npm run typecheck`。
## 验收标准
- 新建 GitHub 渠道 draft 显示 0 个模板行和 1 个添加选择器;选择器在 4 个
带标题分组里列出全部 80 个键并附描述。
- 已配置键渲染为多行行,带描述与删除按钮;保存过滤空值、保留未触碰的键。
- 绑定投递关闭时添加被禁用并给出指明开关的 hint;已有行仍可删除。
- 对话框可切换全屏并还原;body 占满放大后的对话框。
- zh-CN 显示翻译后的描述、分组标题与界面文案。
- GitLab record 字段在新渲染器下保持必填校验与自带说明的 label。
## 开放问题
无阻塞项。可搜索选择器与逐模板字数计数是 operator 提出需求后的可选后续。
+1
View File
@@ -154,6 +154,7 @@ export type {
ChannelConfigEnumFieldDescriptor,
ChannelConfigFieldDescriptor,
ChannelConfigFieldKind,
ChannelConfigFieldOption,
ChannelConfigNestedFieldDescriptor,
ChannelConfigNumberFieldDescriptor,
ChannelConfigObjectFieldDescriptor,
+15 -2
View File
@@ -539,10 +539,23 @@ export type ChannelConfigFieldKind =
| 'record'
| 'object';
/**
* One selectable value of a config field. `description` explains the value
* in management UIs; `group` buckets related values under one picker
* section. Both are optional display metadata — validators only ever read
* `value`.
*/
export interface ChannelConfigFieldOption {
value: string;
label: string;
description?: string;
group?: string;
}
interface ChannelConfigFieldDescriptorBase {
key: string;
label: string;
options?: ReadonlyArray<{ value: string; label: string }>;
options?: readonly ChannelConfigFieldOption[];
default?: string;
description?: string;
}
@@ -572,7 +585,7 @@ export interface ChannelConfigEnumFieldDescriptor
required?: boolean;
envResolvable?: never;
multiline?: never;
options: ReadonlyArray<{ value: string; label: string }>;
options: readonly ChannelConfigFieldOption[];
properties?: never;
}
+27 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { PR_BINDING_ROLES } from '@qwen-code/channel-base';
import { plugin } from './index.js';
import { PR_WATCH_EVENTS } from './GithubAdapter.js';
import { PR_NOTIFICATION_REASONS, PR_WATCH_EVENTS } from './GithubAdapter.js';
function validate(overrides: Record<string, unknown>): string | undefined {
return plugin.management?.validateConfig?.({
@@ -76,4 +77,29 @@ describe('github plugin validateConfig', () => {
...PR_WATCH_EVENTS,
]);
});
it('describes and groups every boundSessionActions key for management UIs', () => {
const field = plugin.management?.fields.find(
(candidate) => candidate.key === 'boundSessionActions',
);
const options = field?.options ?? [];
const baseCount =
1 + PR_WATCH_EVENTS.length + PR_NOTIFICATION_REASONS.length;
expect(options).toHaveLength(baseCount * (1 + PR_BINDING_ROLES.length));
expect(new Set(options.map((option) => option.value)).size).toBe(
options.length,
);
expect([...new Set(options.map((option) => option.group))]).toEqual([
'any',
...PR_BINDING_ROLES,
]);
const byValue = new Map(options.map((option) => [option.value, option]));
for (const option of options) {
expect(option.description).toBeTruthy();
// Role-qualified keys reuse their base event's description; the role's
// meaning is carried by the picker group header instead.
const base = option.value.replace(/^(review|verify|fix):/, '');
expect(option.description).toBe(byValue.get(base)?.description);
}
});
});
+50 -21
View File
@@ -10,33 +10,62 @@ import {
PR_WATCH_EVENTS,
} from './GithubAdapter.js';
/**
* One-line meaning of each unqualified boundSessionActions key, shown by
* management UIs next to the key and in the add picker. Role-qualified
* keys reuse their base event's description; the role's job is named by
* the picker group header instead.
*/
const BOUND_ACTION_DESCRIPTIONS: Record<string, string> = {
default: 'Fallback template for any event without a more specific key',
'watch:ci_failure': 'CI checks turn red on a head commit',
'watch:merge_conflict': 'The PR develops a merge conflict',
'watch:head_change': 'The PR’s head commit changes',
'watch:state_change': 'The PR is merged or closed',
mention: 'You are @mentioned in a comment',
review_requested: 'A review is requested from you',
assign: 'You are assigned to the PR',
author: 'Activity on a PR you authored',
comment: 'A new comment on a subscribed PR',
ci_activity: 'CI activity on a subscribed PR',
manual: 'You subscribed to the PR manually',
state_change: 'The PR is closed or merged',
subscribed: 'Activity on a PR you subscribed to',
team_mention: 'A team you belong to is @mentioned',
security_alert: 'A security alert on the repository',
approval_requested: 'Approval is requested from you',
invitation: 'You are invited to collaborate',
member_feature_requested: 'A member feature request notification',
security_advisory_credit: 'You are credited in a security advisory',
};
/**
* The boundSessionActions key options offered by management UIs: the
* fallback, every event class, and each role-qualified spelling — built
* from the same constants the validator enforces so the three cannot
* drift (the PR_WATCH_EVENTS pattern, R2-18).
* drift (the PR_WATCH_EVENTS pattern, R2-18). `group` buckets the picker
* by binding role; `description` explains each key.
*/
const BOUND_ACTION_BASE_OPTIONS = [
'default',
...PR_WATCH_EVENTS.map((event) => `watch:${event}`),
...PR_NOTIFICATION_REASONS,
].map((value) => ({
value,
label: value,
description: BOUND_ACTION_DESCRIPTIONS[value],
}));
const BOUND_ACTION_KEY_OPTIONS = [
{ value: 'default', label: 'default — fallback for any unmatched event' },
...PR_WATCH_EVENTS.map((event) => ({
value: `watch:${event}`,
label: `watch:${event}`,
})),
...PR_NOTIFICATION_REASONS.map((reason) => ({
value: reason,
label: reason,
})),
...PR_BINDING_ROLES.flatMap((role) => [
{ value: `${role}:default`, label: `${role}:default` },
...PR_WATCH_EVENTS.map((event) => ({
value: `${role}:watch:${event}`,
label: `${role}:watch:${event}`,
...BOUND_ACTION_BASE_OPTIONS.map((option) => ({ ...option, group: 'any' })),
...PR_BINDING_ROLES.flatMap((role) =>
BOUND_ACTION_BASE_OPTIONS.map((option) => ({
value: `${role}:${option.value}`,
label: `${role}:${option.value}`,
description: option.description,
group: role,
})),
...PR_NOTIFICATION_REASONS.map((reason) => ({
value: `${role}:${reason}`,
label: `${role}:${reason}`,
})),
]),
),
];
export { GithubChannel };
@@ -133,7 +162,7 @@ export const plugin: ChannelPlugin = {
label: 'Bound Session Actions',
kind: 'record',
description:
'Optional. Operator instruction templates delivered to PR-bound sessions, keyed by event class (watch:<type> or a notification reason), optionally role-qualified (review:, verify:, fix:). Requires Deliver To PR-Bound Sessions. With templates configured, each role gets its own delivery (one winner per role); a role with no matching key skips the event. Template variables: %repo%, %pr%, %url%, %title%, %event%, %sender%, %role%. Use %% for a literal %. Leave empty for the default generic prompt.',
'Optional. Instruction templates delivered to PR-bound sessions. Pick an event key below; a role prefix (review:, verify:, fix:) targets that binding role only. Requires Deliver To PR-Bound Sessions. Each role with a matching key gets its own delivery; roles without one skip the event. Variables: %repo%, %pr%, %url%, %title%, %event%, %sender%, %role%; %% for a literal %. Leave empty for the default generic prompt.',
options: BOUND_ACTION_KEY_OPTIONS,
},
],
+15 -2
View File
@@ -4898,10 +4898,23 @@ export type DaemonChannelConfigFieldKind =
| 'record'
| 'object';
/**
* One selectable value of a config field. `description` explains the value
* in management UIs; `group` buckets related values under one picker
* section. Both are optional display metadata — validators only ever read
* `value`.
*/
interface DaemonChannelConfigFieldOption {
value: string;
label: string;
description?: string;
group?: string;
}
interface DaemonChannelConfigFieldDescriptorBase {
key: string;
label: string;
options?: ReadonlyArray<{ value: string; label: string }>;
options?: readonly DaemonChannelConfigFieldOption[];
default?: string;
description?: string;
}
@@ -4931,7 +4944,7 @@ export interface DaemonChannelConfigEnumFieldDescriptor
required?: boolean;
envResolvable?: never;
multiline?: never;
options: ReadonlyArray<{ value: string; label: string }>;
options: readonly DaemonChannelConfigFieldOption[];
properties?: never;
}
@@ -4,6 +4,17 @@
gap: 12px;
}
.platformHeaderText {
min-width: 0;
flex: 1;
}
/* Clears the absolutely positioned dialog close button (top-2 right-2). */
.fullscreenToggle {
margin-right: 2rem;
flex: 0 0 auto;
}
.platformMark {
display: inline-flex;
width: 42px;
@@ -25,6 +36,10 @@
gap: 0;
}
.formFullscreen {
flex: 1;
}
.body {
display: flex;
max-height: min(66vh, 650px);
@@ -35,6 +50,11 @@
padding-inline: 1px;
}
.bodyFullscreen {
max-height: none;
flex: 1;
}
.section {
display: flex;
flex-direction: column;
@@ -233,10 +253,44 @@
gap: 3px;
}
.recordRowHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.recordLabel {
font-size: 12px;
font-weight: 500;
font-family: ui-monospace, monospace;
color: var(--muted-foreground);
overflow-wrap: anywhere;
}
.recordDescription {
margin: 0;
color: var(--muted-foreground);
font-size: 11px;
line-height: 1.45;
}
.recordEmpty {
margin: 0;
color: var(--muted-foreground);
font-size: 12px;
}
.recordOption {
display: flex;
flex-direction: column;
gap: 2px;
}
.recordOptionDescription {
color: var(--muted-foreground);
font-size: 11px;
line-height: 1.35;
}
.secretState {
@@ -1491,12 +1491,12 @@ describe('ChannelEditorDialog', () => {
);
});
it('deletes a record key when its row is cleared, never stranding an empty value', async () => {
// The channel validators reject empty template values. Two layers keep
// a touched-then-cleared row from stranding one: the editor drops the
// key from the form state on clear, and the save serializer filters
// empty values regardless. Pin the end-to-end outcome: the cleared key
// is absent from the upsert config, the untouched sibling survives.
it('renders record entries on demand and filters empty values on save', async () => {
// The channel validators reject empty template values. The editor keeps
// an added-but-empty row in the draft so it holds its place, and the save
// serializer filters empty values regardless. Pin the end-to-end outcome:
// the cleared key is absent from the upsert config, the untouched sibling
// survives.
const descriptor: DaemonChannelTypeDescriptor = {
...DINGTALK,
fields: [
@@ -1518,28 +1518,39 @@ describe('ChannelEditorDialog', () => {
const onSave = vi.fn().mockResolvedValue(undefined);
await renderDialog({ descriptor, instance: INSTANCE, onSave });
// Record input ids embed the option key (colons included), which is not
// a valid CSS selector — resolve through getElementById, not #id.
const recordInput = (labelText: string): HTMLInputElement | null => {
// A fresh draft shows the add picker, not one row per descriptor option.
expect(document.querySelectorAll('textarea')).toHaveLength(0);
expect(fieldByLabel('Bound Session Actions')).not.toBeNull();
// This fixture is dingtalk-typed, where the field has no label-key
// registration, so the generic entry copy applies (the template-worded
// override is pinned in the github-typed cases below).
expect(document.body.textContent).toContain('No entries configured yet.');
// Record row ids embed the option key (colons included), which is not a
// valid CSS selector — resolve through getElementById, not #id.
const recordTextarea = (labelText: string): HTMLTextAreaElement | null => {
const label = Array.from(document.querySelectorAll('label')).find(
(item) => item.textContent?.includes(labelText),
);
const id = label?.htmlFor;
return id
? (document.getElementById(id) as HTMLInputElement | null)
? (document.getElementById(id) as HTMLTextAreaElement | null)
: null;
};
const reviewRow = recordInput('review:watch:head_change');
const fixRow = recordInput('fix:comment');
expect(reviewRow).not.toBeNull();
expect(fixRow).not.toBeNull();
await selectOption('Bound Session Actions', 'review:watch:head_change');
await selectOption('Bound Session Actions', 'fix:comment');
const reviewRow = recordTextarea('review:watch:head_change');
const fixRow = recordTextarea('fix:comment');
expect(reviewRow).toBeInstanceOf(HTMLTextAreaElement);
expect(fixRow).toBeInstanceOf(HTMLTextAreaElement);
await act(async () => {
setInputValue(reviewRow!, 'Run /review %pr%');
setInputValue(fixRow!, 'Address the comments');
setTextareaValue(reviewRow!, 'Run /review %pr%');
setTextareaValue(fixRow!, 'Address the comments');
});
await act(async () => {
setInputValue(reviewRow!, '');
setTextareaValue(reviewRow!, '');
});
const save = Array.from(document.querySelectorAll('button')).find(
@@ -1559,6 +1570,204 @@ describe('ChannelEditorDialog', () => {
);
});
it('removes a record entry through its delete button', async () => {
const descriptor: DaemonChannelTypeDescriptor = {
...DINGTALK,
fields: [
...DINGTALK.fields,
{
key: 'boundSessionActions',
label: 'Bound Session Actions',
kind: 'record',
options: [{ value: 'fix:comment', label: 'fix:comment' }],
},
],
};
const onSave = vi.fn().mockResolvedValue(undefined);
await renderDialog({ descriptor, instance: INSTANCE, onSave });
await selectOption('Bound Session Actions', 'fix:comment');
const remove = Array.from(document.querySelectorAll('button')).find(
(button) => button.getAttribute('aria-label') === 'Remove fix:comment',
);
expect(remove).toBeDefined();
await act(async () => {
remove!.click();
});
expect(document.querySelectorAll('textarea')).toHaveLength(0);
const save = Array.from(document.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Save',
);
await act(async () => {
save?.click();
});
const request = onSave.mock.calls[0]?.[1] as {
config: Record<string, unknown>;
};
expect(request.config['boundSessionActions']).toBeUndefined();
});
it('keeps stored record keys the descriptor does not enumerate', async () => {
const descriptor: DaemonChannelTypeDescriptor = {
...DINGTALK,
fields: [
...DINGTALK.fields,
{
key: 'boundSessionActions',
label: 'Bound Session Actions',
kind: 'record',
options: [{ value: 'fix:comment', label: 'fix:comment' }],
},
],
};
const instance: DaemonChannelInstanceSnapshot = {
...INSTANCE,
config: {
...INSTANCE.config,
boundSessionActions: { 'custom:key': 'Do the custom thing' },
},
};
const onSave = vi.fn().mockResolvedValue(undefined);
await renderDialog({ descriptor, instance, onSave });
const label = Array.from(document.querySelectorAll('label')).find((item) =>
item.textContent?.includes('custom:key'),
);
expect(label).toBeDefined();
const textarea = document.getElementById(
label!.htmlFor,
) as HTMLTextAreaElement;
expect(textarea.value).toBe('Do the custom thing');
const save = Array.from(document.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Save',
);
await act(async () => {
save?.click();
});
expect(onSave).toHaveBeenCalledWith(
'release-bot',
expect.objectContaining({
config: expect.objectContaining({
boundSessionActions: { 'custom:key': 'Do the custom thing' },
}),
}),
);
});
it('uses generic copy and no picker for an options-less record field', async () => {
// messageRoutes is a record field with no options: the empty state must
// not call routes "templates", and there is nothing to add from.
const descriptor: DaemonChannelTypeDescriptor = {
...DINGTALK,
fields: [
...DINGTALK.fields,
{ key: 'messageRoutes', label: 'Message Routes', kind: 'record' },
],
};
await renderDialog({ descriptor });
await expandAdvancedSettings();
expect(document.body.textContent).toContain('No entries configured yet.');
expect(document.body.textContent).not.toContain(
'No templates configured yet.',
);
// No add picker: the field label targets no control.
expect(fieldByLabel('Message Routes')).toBeNull();
// Stored routes still render as editable rows without a picker.
const instance: DaemonChannelInstanceSnapshot = {
...INSTANCE,
config: {
...INSTANCE.config,
messageRoutes: { '/qa': 'Answer questions' },
},
};
await renderDialog({ descriptor, instance });
await expandAdvancedSettings();
const label = Array.from(document.querySelectorAll('label')).find((item) =>
item.textContent?.includes('/qa'),
);
expect(label).toBeDefined();
const textarea = document.getElementById(
label!.htmlFor,
) as HTMLTextAreaElement;
expect(textarea.value).toBe('Answer questions');
expect(fieldByLabel('Message Routes')).toBeNull();
});
it('gates adding templates on bound delivery and names the prerequisite', async () => {
const descriptor: DaemonChannelTypeDescriptor = {
type: 'github',
displayName: 'GitHub',
manageable: true,
fields: [
{
key: 'useLocalGh',
label: 'Use Local GitHub CLI Authentication',
kind: 'boolean',
},
{
key: 'deliverToBoundSessions',
label: 'Deliver To PR-Bound Sessions',
kind: 'boolean',
default: 'false',
},
{
key: 'boundSessionActions',
label: 'Bound Session Actions',
kind: 'record',
options: [{ value: 'fix:comment', label: 'fix:comment' }],
},
],
};
await renderDialog({ descriptor });
const picker = fieldByLabel('Bound Session Actions') as HTMLButtonElement;
expect(picker.disabled).toBe(true);
expect(document.body.textContent).toContain(
'Enable "Deliver To PR-Bound Sessions" above to add templates.',
);
// The github-typed field keeps the template-worded empty state and
// add-picker placeholder through its field label-key overrides.
expect(document.body.textContent).toContain('No templates configured yet.');
expect(document.body.textContent).toContain('Add template…');
// Resolve the delivery switch by its label instead of guessing ids.
const deliveryLabel = Array.from(document.querySelectorAll('label')).find(
(item) =>
item.textContent?.includes('Deliver To PR-Bound Sessions') &&
item.htmlFor,
);
const deliverySwitch = document.getElementById(deliveryLabel!.htmlFor)!;
await act(async () => {
deliverySwitch.click();
});
expect(
(fieldByLabel('Bound Session Actions') as HTMLButtonElement).disabled,
).toBe(false);
});
it('toggles the editor dialog fullscreen and back', async () => {
await renderDialog();
const toggle = Array.from(document.querySelectorAll('button')).find(
(button) => button.getAttribute('aria-label') === 'Fullscreen',
);
expect(toggle).toBeDefined();
expect(toggle!.getAttribute('aria-pressed')).toBe('false');
await act(async () => {
toggle!.click();
});
expect(toggle!.getAttribute('aria-pressed')).toBe('true');
expect(toggle!.getAttribute('aria-label')).toBe('Exit fullscreen');
await act(async () => {
toggle!.click();
});
expect(toggle!.getAttribute('aria-pressed')).toBe('false');
});
it('resolves the boundSessionActions label and description in Chinese', async () => {
// FIELD_LABEL_KEYS.github must register the field, or the zh-CN editor
// falls back to the descriptor's English label and description.
@@ -1567,19 +1776,55 @@ describe('ChannelEditorDialog', () => {
type: 'github',
displayName: 'GitHub',
fields: [
{
key: 'deliverToBoundSessions',
label: 'Deliver To PR-Bound Sessions',
kind: 'boolean',
default: 'false',
},
{
key: 'boundSessionActions',
label: 'Bound Session Actions',
kind: 'record',
options: [{ value: 'default', label: 'default' }],
options: [
{ value: 'default', label: 'default', group: 'any' },
{
value: 'watch:ci_failure',
label: 'watch:ci_failure',
group: 'any',
},
],
},
],
};
await renderDialog({ descriptor, instance: INSTANCE, language: 'zh-CN' });
const instance: DaemonChannelInstanceSnapshot = {
...INSTANCE,
config: { ...INSTANCE.config, deliverToBoundSessions: true },
};
await renderDialog({ descriptor, instance, language: 'zh-CN' });
expect(document.body.textContent).toContain('绑定会话动作模板');
expect(document.body.textContent).toContain(
'投递给 PR 绑定会话的 operator 指令模板',
);
// The template-worded empty state comes from the zh-CN field override.
expect(document.body.textContent).toContain('尚未配置模板。');
// The picker group header and the per-key description must both come
// from the zh-CN dictionary, not the English descriptor fallback.
const trigger = fieldByLabel('绑定会话动作模板')!;
await act(async () => {
trigger.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(document.body.textContent).toContain('任意角色');
const option = Array.from(
document.querySelectorAll<HTMLElement>('[role="option"]'),
).find((item) => item.textContent?.includes('watch:ci_failure'));
expect(option).toBeDefined();
expect(option!.textContent).toContain('head 提交上的 CI 检查变红');
await act(async () => {
option!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(document.body.textContent).toContain('head 提交上的 CI 检查变红');
});
it('submits a new instance with typed fields and the current revision', async () => {
@@ -12,7 +12,14 @@ import {
type FormEvent,
type ReactNode,
} from 'react';
import { CheckCircle2Icon, ChevronDownIcon, KeyRoundIcon } from 'lucide-react';
import {
CheckCircle2Icon,
ChevronDownIcon,
ExpandIcon,
KeyRoundIcon,
ShrinkIcon,
Trash2Icon,
} from 'lucide-react';
import type {
DaemonChannelConfigFieldDescriptor,
DaemonChannelInstanceSnapshot,
@@ -50,7 +57,9 @@ import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '../ui/select';
@@ -135,10 +144,12 @@ const SHARED_SESSION_FIELD_KEYS = new Set([
'multiSession',
'instructions',
]);
// One panel: watch events only mean something with bound delivery enabled.
// One panel: watch events and action templates only mean something with
// bound delivery enabled.
const BOUND_SESSION_FIELD_KEYS = new Set([
'deliverToBoundSessions',
'prWatchEvents',
'boundSessionActions',
]);
const SHARED_FIELD_LABEL_KEYS: Record<string, string> = {
@@ -331,6 +342,7 @@ export function ChannelEditorDialog({
const [saving, setSaving] = useState(false);
const [reloading, setReloading] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [scrollToErrorKey, setScrollToErrorKey] = useState<string>();
const dismissedRef = useRef(false);
/** The token draft as it stood before local gh auth overrode it. */
@@ -389,6 +401,7 @@ export function ChannelEditorDialog({
);
setErrors({});
setSubmitError(undefined);
setFullscreen(false);
}, [descriptor, instance, open]);
useEffect(() => {
@@ -919,26 +932,94 @@ export function ChannelEditorDialog({
try {
const parsed: unknown = JSON.parse(value);
if (isRecord(parsed)) {
record = parsed as Record<string, string>;
record = Object.fromEntries(
Object.entries(parsed).filter(
(entry): entry is [string, string] =>
typeof entry[1] === 'string',
),
);
}
} catch {
/* malformed — render empty */
}
}
const updateRecord = (key: string, val: string) => {
// Clearing a row DELETES the key: the channel validators reject
// empty template values, and this renderer offers no other delete
// affordance, so a touched-then-cleared row would otherwise strand
// an unsavable "" entry (GitHub boundSessionActions, GitLab
// action_prompt_template).
const next = { ...record };
if (val.trim() === '') {
delete next[key];
} else {
next[key] = val;
}
const options = field.options ?? [];
const optionByKey = new Map(
options.map((option) => [option.value, option]),
);
const labelKey =
FIELD_LABEL_KEYS[descriptor.type]?.[field.key] ??
SHARED_FIELD_LABEL_KEYS[field.key];
const translateWithFallback = (suffix: string, fallback: string) => {
if (!labelKey) return fallback;
const key = `${labelKey}.${suffix}`;
const translated = t(key);
return translated !== key ? translated : fallback;
};
// Translated description, else the descriptor's own, else a
// descriptive option label (GitLab spells the meaning into the label).
const optionDescription = (key: string): string => {
const option = optionByKey.get(key);
const translated = translateWithFallback(
`optionDescription.${key}`,
'',
);
if (translated) return translated;
if (option?.description) return option.description;
const optionLabel = option
? fieldOptionLabel(field, option.value, option.label)
: '';
return optionLabel !== key ? optionLabel : '';
};
// Empty values stay in the draft while editing so a freshly added or
// cleared row keeps its place; the explicit remove button deletes the
// key, and the save serializer (parseRouteRecord) filters empty values
// regardless, so no empty entry can reach the channel validators.
const writeRecord = (next: Record<string, string>) => {
update(JSON.stringify(next));
};
const setEntry = (key: string, val: string) => {
writeRecord({ ...record, [key]: val });
};
const removeEntry = (key: string) => {
const next = { ...record };
delete next[key];
writeRecord(next);
};
// Same gate as prWatchEvents: name the prerequisite at the field
// instead of surfacing a raw daemon rejection after submit. Stored
// rows stay editable and removable while gated.
const gated =
field.key === 'boundSessionActions' &&
descriptor.type === 'github' &&
draft.values['deliverToBoundSessions'] !== true;
const available = options.filter((option) => !(option.value in record));
const groups: Array<{
name: string | undefined;
options: typeof available;
}> = [];
for (const option of available) {
const last = groups[groups.length - 1];
if (last && last.name === option.group) last.options.push(option);
else groups.push({ name: option.group, options: [option] });
}
const renderOption = (option: (typeof available)[number]) => {
const label = fieldOptionLabel(field, option.value, option.label);
const description = optionDescription(option.value);
return (
<SelectItem key={option.value} value={option.value}>
<span className={styles.recordOption}>
<span>{label}</span>
{description && description !== label ? (
<span className={styles.recordOptionDescription}>
{description}
</span>
) : null}
</span>
</SelectItem>
);
};
const recordKeys = Object.keys(record);
return (
<FieldShell
key={field.key}
@@ -946,32 +1027,95 @@ export function ChannelEditorDialog({
label={fieldLabel(field)}
required={field.required}
description={fieldDescription(field)}
hint={
gated
? t(
'channels.editor.field.github.boundSessionActions.disabledHint',
)
: undefined
}
hintId={gated ? `${id}-disabled-hint` : undefined}
error={error}
>
<div className={styles.recordFields}>
{field.options?.map((option) => {
const optKey = `${FIELD_LABEL_KEYS[descriptor.type]?.[field.key] ?? ''}.option.${option.value}`;
const translated = t(optKey);
const displayLabel =
translated !== optKey ? translated : option.label;
{recordKeys.length === 0 ? (
<p className={styles.recordEmpty}>
{translateWithFallback(
'empty',
t('channels.editor.record.empty'),
)}
</p>
) : null}
{recordKeys.map((key) => {
const description = optionDescription(key);
return (
<div key={option.value} className={styles.recordRow}>
<Label
htmlFor={`${id}-${option.value}`}
className={styles.recordLabel}
>
{displayLabel}
</Label>
<Input
id={`${id}-${option.value}`}
value={record[option.value] ?? ''}
onChange={(event) =>
updateRecord(option.value, event.target.value)
}
<div key={key} className={styles.recordRow}>
<div className={styles.recordRowHeader}>
<Label
htmlFor={`${id}-${key}`}
className={styles.recordLabel}
>
{key}
</Label>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t('channels.editor.record.remove', { key })}
title={t('channels.editor.record.remove', { key })}
onClick={() => removeEntry(key)}
>
<Trash2Icon />
</Button>
</div>
{description ? (
<p className={styles.recordDescription}>{description}</p>
) : null}
<Textarea
id={`${id}-${key}`}
value={record[key] ?? ''}
onChange={(event) => setEntry(key, event.target.value)}
/>
</div>
);
})}
{options.length > 0 ? (
<Select
value=""
disabled={gated || available.length === 0}
onValueChange={(key) => setEntry(key, '')}
>
<SelectTrigger
id={id}
className="w-full"
aria-describedby={gated ? `${id}-disabled-hint` : undefined}
>
<SelectValue
placeholder={translateWithFallback(
'addPlaceholder',
t('channels.editor.record.addPlaceholder'),
)}
/>
</SelectTrigger>
<SelectContent>
{groups.map((group) =>
group.name === undefined ? (
group.options.map(renderOption)
) : (
<SelectGroup key={group.name}>
<SelectLabel>
{translateWithFallback(
`group.${group.name}`,
group.name,
)}
</SelectLabel>
{group.options.map(renderOption)}
</SelectGroup>
),
)}
</SelectContent>
</Select>
) : null}
</div>
</FieldShell>
);
@@ -1253,7 +1397,13 @@ export function ChannelEditorDialog({
onOpenChange(nextOpen);
}}
>
<DialogContent className="max-w-[calc(100%-2rem)] p-5 sm:max-w-xl">
<DialogContent
className={
fullscreen
? 'flex h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] flex-col p-5 sm:max-w-[calc(100vw-2rem)]'
: 'max-w-[calc(100%-2rem)] p-5 sm:max-w-xl'
}
>
<DialogHeader>
<div className={styles.platformHeader}>
<span className={styles.platformMark} aria-hidden="true">
@@ -1261,7 +1411,7 @@ export function ChannelEditorDialog({
descriptor.displayName[0]?.toUpperCase() ??
'?'}
</span>
<div>
<div className={styles.platformHeaderText}>
<DialogTitle>
{t(
instance
@@ -1278,10 +1428,37 @@ export function ChannelEditorDialog({
)}
</DialogDescription>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={styles.fullscreenToggle}
onClick={() => setFullscreen((value) => !value)}
aria-label={t(
fullscreen ? 'common.exitFullscreen' : 'common.fullscreen',
)}
aria-pressed={fullscreen}
title={t(
fullscreen ? 'common.exitFullscreen' : 'common.fullscreen',
)}
>
{fullscreen ? <ShrinkIcon /> : <ExpandIcon />}
</Button>
</div>
</DialogHeader>
<form className={styles.form} onSubmit={submit}>
<div className={styles.body}>
<form
className={
fullscreen ? `${styles.form} ${styles.formFullscreen}` : styles.form
}
onSubmit={submit}
>
<div
className={
fullscreen
? `${styles.body} ${styles.bodyFullscreen}`
: styles.body
}
>
{submitError ? (
<Alert variant="destructive">
<KeyRoundIcon />
@@ -5,6 +5,7 @@
*/
import { devices, expect, test, type Page } from '@playwright/test';
import { plugin as githubChannelPlugin } from '@qwen-code/channel-github';
import type {
DaemonEvent,
DaemonSessionSummary,
@@ -38,6 +39,13 @@ import {
const THEMES: readonly VisualTheme[] = ['dark', 'light'];
// The action-template editor captures use the plugin's own descriptor so the
// screenshots show the production key vocabulary, not a hand-pruned fixture.
const githubManagement = githubChannelPlugin.management;
if (!githubManagement) {
throw new Error('@qwen-code/channel-github declares no management metadata.');
}
test.use({ viewport: { ...VISUAL_VIEWPORT } });
/** Fixed so the subagent prompt ids below can name it before it is built. */
@@ -1363,6 +1371,93 @@ for (const theme of THEMES) {
await captureScreenshot(page, `channel-editor-existing-${theme}`);
});
test(`Channel action template editor`, async ({ page }, testInfo) => {
const scenario = createWebShellDaemonScenario({
capabilities: {
features: [
'session_events',
'permission_vote',
'session_permission_vote',
'session_scope_override',
'session_source_metadata',
'channel_management',
],
},
channelTypes: [
{
type: 'github',
displayName: 'GitHub',
manageable: true,
fields: githubManagement.fields,
},
],
});
await page.addInitScript(() => {
window.sessionStorage.setItem('qwen-daemon-token', 'visual-token');
});
const daemon = await installScenario(
page,
scenario,
resolveBaseURL(testInfo),
);
await gotoSession(page, scenario, daemon, theme);
await page.getByRole('button', { name: 'Channels' }).click();
await page.getByRole('button', { name: 'Configure GitHub' }).click();
const editor = page.getByRole('dialog');
await expect(
editor.getByRole('heading', { name: 'Configure GitHub' }),
).toBeVisible();
await editor
.getByRole('switch', { name: 'Use Local GitHub CLI Authentication' })
.click();
await editor
.getByRole('switch', { name: 'Deliver To PR-Bound Sessions' })
.click();
// Anchors: the on-demand editor shows the add picker and zero rows on a
// fresh draft. The pre-change renderer (one input per key, no picker
// control behind the label) fails here instead of silently shipping an
// old-UI capture.
const addPicker = editor.getByLabel('Bound Session Actions');
await addPicker.scrollIntoViewIfNeeded();
await expect(addPicker).toBeVisible();
await expect(editor.locator('textarea')).toHaveCount(0);
await captureScreenshot(page, `channel-action-templates-empty-${theme}`);
await addPicker.click();
const reviewOption = page.getByRole('option', {
name: /review:watch:ci_failure/,
});
await expect(reviewOption).toBeVisible();
await captureScreenshot(page, `channel-action-templates-picker-${theme}`);
await reviewOption.click();
await editor
.getByRole('textbox', { name: 'review:watch:ci_failure' })
.fill('Review the CI failure on %repo%#%pr% and propose a fix.');
await addPicker.click();
await page.getByRole('option', { name: /^fix:comment/ }).click();
await editor
.getByRole('textbox', { name: 'fix:comment' })
.fill('Address the new comments on %repo%#%pr%.');
await expect(editor.locator('textarea')).toHaveCount(2);
await editor
.getByRole('textbox', { name: 'review:watch:ci_failure' })
.scrollIntoViewIfNeeded();
await captureScreenshot(
page,
`channel-action-templates-configured-${theme}`,
);
const fullscreenToggle = editor.locator('button[aria-pressed]');
await fullscreenToggle.click();
await expect(fullscreenToggle).toHaveAttribute('aria-pressed', 'true');
await captureScreenshot(
page,
`channel-action-templates-fullscreen-${theme}`,
);
});
test(`Channel events`, async ({ page }, testInfo) => {
const scenario = createWebShellDaemonScenario({
capabilities: {
@@ -658,3 +658,190 @@ test('shows Qwen Live sessions in Tasks and excludes them from Channels @smoke',
page.getByText('Qwen Live task fixture', { exact: true }),
).toBeVisible();
});
test('edits GitHub bound-session action templates on demand and toggles fullscreen @smoke', async ({
page,
}, testInfo) => {
const workspaceCwd = '/tmp/qwen-web-shell-e2e';
const scenario = createWebShellDaemonScenario({
workspaceCwd,
capabilities: {
features: [
'session_events',
'permission_vote',
'session_permission_vote',
'session_scope_override',
'session_source_metadata',
'channel_management',
],
},
channelTypes: [
{
type: 'github',
displayName: 'GitHub',
manageable: true,
fields: [
{
key: 'useLocalGh',
label: 'Use Local GitHub CLI Authentication',
kind: 'boolean',
},
{
key: 'groupPolicy',
label: 'Group Policy',
kind: 'enum',
required: true,
default: 'open',
options: [
{ value: 'open', label: 'Open' },
{ value: 'allowlist', label: 'Allowlist' },
{ value: 'pairing', label: 'Pairing' },
{ value: 'disabled', label: 'Disabled' },
],
},
{
key: 'deliverToBoundSessions',
label: 'Deliver To PR-Bound Sessions',
kind: 'boolean',
default: 'false',
},
{
key: 'boundSessionActions',
label: 'Bound Session Actions',
kind: 'record',
options: [
{
value: 'default',
label: 'default',
description: 'Fallback for any unmatched event',
group: 'any',
},
{
value: 'watch:ci_failure',
label: 'watch:ci_failure',
description: 'CI checks turn red on a head commit',
group: 'any',
},
{
value: 'comment',
label: 'comment',
description: 'A new comment notification',
group: 'any',
},
{
value: 'review:watch:ci_failure',
label: 'review:watch:ci_failure',
description: 'CI checks turn red on a head commit',
group: 'review',
},
{
value: 'fix:comment',
label: 'fix:comment',
description: 'A new comment notification',
group: 'fix',
},
],
},
],
},
],
});
await page.addInitScript(() => {
window.sessionStorage.setItem('qwen-daemon-token', 'e2e-token');
});
const daemon = await installMockDaemon(page, scenario, {
baseURL: String(testInfo.project.use.baseURL),
});
await page.goto(`/session/${encodeURIComponent(scenario.sessionId)}`);
await expect(
page.locator('[data-web-shell-root]:not([data-web-shell-gate])'),
).toBeVisible();
const connection = await daemon.sse.waitForConnection(scenario.sessionId);
await daemon.sendEvent(
replayCompleteEvent({ sessionId: connection.sessionId }),
);
await expect(page.getByText('Loading...')).toHaveCount(0);
await page.getByRole('button', { name: 'Channels', exact: true }).click();
await page.getByRole('button', { name: 'Configure GitHub' }).click();
const editor = page.getByRole('dialog');
await expect(
editor.getByRole('heading', { name: 'Configure GitHub' }),
).toBeVisible();
// On-demand rows: a fresh draft shows the add picker, not one input per
// descriptor option.
const addPicker = editor.getByLabel('Bound Session Actions');
await expect(addPicker).toBeVisible();
await expect(editor.locator('textarea')).toHaveCount(0);
// Fullscreen toggle enlarges the dialog and back. Locate by aria-pressed:
// the accessible name flips between Fullscreen and Exit fullscreen.
const fullscreenButton = editor.locator('button[aria-pressed]');
await expect(fullscreenButton).toHaveAttribute('aria-label', 'Fullscreen');
await fullscreenButton.click();
await expect(fullscreenButton).toHaveAttribute('aria-pressed', 'true');
const viewport = page.viewportSize();
// The dialog size transitions over 100ms; poll the settled width.
await expect
.poll(async () => (await editor.boundingBox())?.width ?? 0)
.toBeGreaterThan((viewport?.width ?? 1280) - 64);
await fullscreenButton.click();
await expect(fullscreenButton).toHaveAttribute('aria-pressed', 'false');
await editor.getByLabel('Instance name').fill('gh-bot');
await editor
.getByRole('switch', { name: 'Use Local GitHub CLI Authentication' })
.click();
await editor
.getByRole('switch', { name: 'Deliver To PR-Bound Sessions' })
.click();
// Add two keys; leave one empty — the save must filter it out.
await addPicker.click();
await page.getByRole('option', { name: /review:watch:ci_failure/ }).click();
const template = 'Review CI failure on %repo%#%pr%\nRun /verify-pr';
await editor
.getByRole('textbox', { name: 'review:watch:ci_failure' })
.fill(template);
await addPicker.click();
await page.getByRole('option', { name: /fix:comment/ }).click();
await editor.getByRole('button', { name: 'Save' }).click();
await expect(
editor.getByRole('heading', { name: 'Configure GitHub' }),
).toHaveCount(0);
const upserts = () =>
daemon.requests.filter(
(request) =>
request.method === 'PUT' && request.path.endsWith('/channels/gh-bot'),
);
await expect.poll(() => upserts().length).toBe(1);
expect(upserts()[0]?.body).toEqual(
expect.objectContaining({
config: expect.objectContaining({
type: 'github',
useLocalGh: true,
deliverToBoundSessions: true,
boundSessionActions: { 'review:watch:ci_failure': template },
}),
}),
);
// Reopen: the stored row round-trips; deleting it drops the key on save.
await page.getByRole('button', { name: 'Edit gh-bot' }).click();
await expect(
editor.getByRole('textbox', { name: 'review:watch:ci_failure' }),
).toHaveValue(template);
await editor
.getByRole('button', { name: 'Remove review:watch:ci_failure' })
.click();
await expect(editor.locator('textarea')).toHaveCount(0);
await editor.getByRole('button', { name: 'Save' }).click();
await expect.poll(() => upserts().length).toBe(2);
const secondBody = upserts()[1]?.body as {
config: Record<string, unknown>;
};
expect(secondBody.config['boundSessionActions']).toBeUndefined();
});
+85 -2
View File
@@ -4613,7 +4613,29 @@ const EN: Messages = {
'state_change — the PR is merged or closed',
'channels.editor.field.github.boundSessionActions': 'Bound Session Actions',
'channels.editor.field.github.boundSessionActions.description':
'Optional. Operator instruction templates delivered to PR-bound sessions, keyed by event class (watch:<type> or a notification reason), optionally role-qualified (review:, verify:, fix:). Requires Deliver To PR-Bound Sessions. With templates configured, each role gets its own delivery (one winner per role); a role with no matching key skips the event. Template variables: %repo%, %pr%, %url%, %title%, %event%, %sender%, %role%. Use %% for a literal %. Leave empty for the default generic prompt.',
'Optional. Instruction templates delivered to PR-bound sessions. Pick an event key below; a role prefix (review:, verify:, fix:) targets that binding role only. Requires Deliver To PR-Bound Sessions. Each role with a matching key gets its own delivery; roles without one skip the event. Variables: %repo%, %pr%, %url%, %title%, %event%, %sender%, %role%; %% for a literal %. Leave empty for the default generic prompt.',
'channels.editor.field.github.boundSessionActions.disabledHint':
'Enable "Deliver To PR-Bound Sessions" above to add templates.',
'channels.editor.field.github.boundSessionActions.group.any': 'Any role',
'channels.editor.field.github.boundSessionActions.group.review':
'review — code review sessions',
'channels.editor.field.github.boundSessionActions.group.verify':
'verify — verification sessions',
'channels.editor.field.github.boundSessionActions.group.fix':
'fix — fix sessions',
'channels.editor.field.github.boundSessionActions.empty':
'No templates configured yet.',
'channels.editor.field.github.boundSessionActions.addPlaceholder':
'Add template…',
'channels.editor.field.gitlab.action_prompt_template.empty':
'No templates configured yet.',
'channels.editor.field.gitlab.action_prompt_template.addPlaceholder':
'Add template…',
// Generic defaults for every record field; template-worded fields override
// them through their field label key (messageRoutes is routes, not templates).
'channels.editor.record.addPlaceholder': 'Add entry…',
'channels.editor.record.empty': 'No entries configured yet.',
'channels.editor.record.remove': (v) => `Remove ${v?.key ?? ''}`,
'channels.editor.field.gitlab.token': 'Personal Access Token',
'channels.editor.field.gitlab.token.description':
'PAT with "read_api" + "api" scopes',
@@ -4888,6 +4910,48 @@ const EN: Messages = {
...TRAJECTORY_WINDOW_MESSAGES_EN,
};
/**
* zh-CN descriptions for the GitHub boundSessionActions keys. The 20 base
* event descriptions are written once; the 60 role-qualified spellings
* reuse them (the picker group header names the role). English needs no
* entries here: the renderer falls back to the descriptor's own option
* descriptions. The role list mirrors PR_BINDING_ROLES in
* @qwen-code/channel-base, which the client cannot import.
*/
function githubBoundActionOptionDescriptionsZh(): Record<string, string> {
const base: Record<string, string> = {
default: '没有更具体键的事件使用的回退模板',
'watch:ci_failure': 'head 提交上的 CI 检查变红',
'watch:merge_conflict': 'PR 出现合并冲突',
'watch:head_change': 'PR 的 head 提交变更',
'watch:state_change': 'PR 被合并或关闭',
mention: '你在评论中被 @提及',
review_requested: '有人请求你评审',
assign: '你被指派到该 PR',
author: '你创建的 PR 有新动态',
comment: '已订阅 PR 的新评论',
ci_activity: '已订阅 PR 的 CI 动态',
manual: '你手动订阅了该 PR',
state_change: 'PR 被关闭或合并',
subscribed: '你订阅的 PR 有新动态',
team_mention: '你所在的团队被 @提及',
security_alert: '仓库的安全告警',
approval_requested: '有人请求你批准',
invitation: '你收到协作邀请',
member_feature_requested: '成员功能请求通知',
security_advisory_credit: '你在安全公告中被署名致谢',
};
const prefix = 'channels.editor.field.github.boundSessionActions';
const entries: Record<string, string> = {};
for (const [key, description] of Object.entries(base)) {
entries[`${prefix}.optionDescription.${key}`] = description;
for (const role of ['review', 'verify', 'fix']) {
entries[`${prefix}.optionDescription.${role}:${key}`] = description;
}
}
return entries;
}
const ZH: Messages = {
...EN,
'remoteAccess.title': '允许远程连接',
@@ -9120,7 +9184,26 @@ const ZH: Messages = {
'state_change — PR 被合并或关闭',
'channels.editor.field.github.boundSessionActions': '绑定会话动作模板',
'channels.editor.field.github.boundSessionActions.description':
'可选。投递给 PR 绑定会话的 operator 指令模板,键为事件类别(watch:<type> 或通知 reason),可加角色前缀(review:、verify:、fix:)。需开启"投递到 PR 绑定会话"。配置模板后每个角色各自投递(每角色一个 winner);没有匹配键的角色跳过该事件。模板变量:%repo%、%pr%、%url%、%title%、%event%、%sender%、%role%。%% 表示字面 %。留空则使用默认通用 prompt。',
'可选。投递给 PR 绑定会话的 operator 指令模板。在下方选择事件键;加角色前缀(review:、verify:、fix:)可只定向该绑定角色。需开启"投递到 PR 绑定会话"。有匹配键的角色各自投递,没有匹配键的角色跳过该事件。变量:%repo%、%pr%、%url%、%title%、%event%、%sender%、%role%;%% 表示字面 %。留空则使用默认通用 prompt。',
'channels.editor.field.github.boundSessionActions.disabledHint':
'开启上方「投递到 PR 绑定会话」后即可添加模板。',
'channels.editor.field.github.boundSessionActions.group.any': '任意角色',
'channels.editor.field.github.boundSessionActions.group.review':
'review — 代码评审会话',
'channels.editor.field.github.boundSessionActions.group.verify':
'verify — 验证会话',
'channels.editor.field.github.boundSessionActions.group.fix':
'fix — 修复会话',
'channels.editor.field.github.boundSessionActions.empty': '尚未配置模板。',
'channels.editor.field.github.boundSessionActions.addPlaceholder':
'添加模板…',
'channels.editor.field.gitlab.action_prompt_template.empty': '尚未配置模板。',
'channels.editor.field.gitlab.action_prompt_template.addPlaceholder':
'添加模板…',
'channels.editor.record.addPlaceholder': '添加条目…',
'channels.editor.record.empty': '尚未配置条目。',
'channels.editor.record.remove': (v) => `删除 ${v?.key ?? ''}`,
...githubBoundActionOptionDescriptionsZh(),
'channels.editor.field.gitlab.token': '个人访问令牌',
'channels.editor.field.gitlab.token.description':
'需要 "read_api" + "api" 权限的 PAT',