diff --git a/docs/design/2026-11-05-channel-action-template-editor.md b/docs/design/2026-11-05-channel-action-template-editor.md new file mode 100644 index 00000000000..7f0428aba5c --- /dev/null +++ b/docs/design/2026-11-05-channel-action-template-editor.md @@ -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...option.` 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.` 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.` 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.` + (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. diff --git a/docs/design/2026-11-05-channel-action-template-editor.zh-CN.md b/docs/design/2026-11-05-channel-action-template-editor.zh-CN.md new file mode 100644 index 00000000000..f0561ea61e8 --- /dev/null +++ b/docs/design/2026-11-05-channel-action-template-editor.zh-CN.md @@ -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...option.` 翻译并回退到描述符 + 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.`,否则 +描述符 `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.` + 文案。60 个角色限定条目在 `i18n.tsx` 里由基础条目生成(文案相同,角色由 + 分组标题承载),字典保持可维护。英文无需登记:渲染器回退到描述符自带的 + 选项描述,保持单一事实源。 +- 分组标题 `...boundSessionActions.group.` + (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 提出需求后的可选后续。 diff --git a/packages/channels/base/src/index.ts b/packages/channels/base/src/index.ts index eb69557fb4f..57205989527 100644 --- a/packages/channels/base/src/index.ts +++ b/packages/channels/base/src/index.ts @@ -154,6 +154,7 @@ export type { ChannelConfigEnumFieldDescriptor, ChannelConfigFieldDescriptor, ChannelConfigFieldKind, + ChannelConfigFieldOption, ChannelConfigNestedFieldDescriptor, ChannelConfigNumberFieldDescriptor, ChannelConfigObjectFieldDescriptor, diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index 49eeed40572..a94fd5f9986 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -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; } diff --git a/packages/channels/github/src/index.test.ts b/packages/channels/github/src/index.test.ts index d8c568e15ae..5310940892d 100644 --- a/packages/channels/github/src/index.test.ts +++ b/packages/channels/github/src/index.test.ts @@ -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 | 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); + } + }); }); diff --git a/packages/channels/github/src/index.ts b/packages/channels/github/src/index.ts index e08bd36fae1..671ad852539 100644 --- a/packages/channels/github/src/index.ts +++ b/packages/channels/github/src/index.ts @@ -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 = { + 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: 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, }, ], diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index c52639b9afd..8f65d435c13 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -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; } diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.module.css b/packages/web-shell/client/components/channels/ChannelEditorDialog.module.css index e9804616362..e6189996efe 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.module.css +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.module.css @@ -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 { diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx index f7f18c956a8..631fcc0e6c0 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx @@ -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; + }; + 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('[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 () => { diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx index 723dcd3c8b8..040639f4665 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx @@ -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 = { @@ -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(); 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; + 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) => { 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 ( + + + {label} + {description && description !== label ? ( + + {description} + + ) : null} + + + ); + }; + const recordKeys = Object.keys(record); return (
- {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 ? ( +

+ {translateWithFallback( + 'empty', + t('channels.editor.record.empty'), + )} +

+ ) : null} + {recordKeys.map((key) => { + const description = optionDescription(key); return ( -
- - - updateRecord(option.value, event.target.value) - } +
+
+ + +
+ {description ? ( +

{description}

+ ) : null} +