fix(ui): skip composer injection in child session (#33)
## Что сделано Фикс бага из issue #32: в child session (subagent) кнопка 🎤 появлялась над disabled-блоком "Prompt is disabled / Back to parent", а клик падал с toast "Could not find input field". - **`src/ui.ts` `findComposer()`** — добавлен guard: `session-prompt-dock` skip'ается в цикле `COMPOSER_SELECTORS`, если внутри него НЕТ `[data-component="prompt-input"]` или `[data-component="prompt-input-v2"]`. В child session внутри dock'а только disabled-блок — guard срабатывает, `findComposer()` возвращает `null`, кнопка не вставляется. - **`src/ui.ts` `injectIntoComposer()`** — defence-in-depth: ранний return, если в документе нет `[data-component="prompt-input"], [data-component="prompt-input-v2"]`. Страховка от любых future-случаев отсутствия реального composer (не только child session). - **Бамп `@version`** 1.0.2 → 1.0.3 синхронно в `package.json` и `vite.config.ts` — разблокировать автообновление userscript-менеджеров (ловушка PR #29 → #30). - **`tests/ui.test.ts`** (новый, 5 кейсов): dock с `prompt-input-v2` → кнопка есть; dock с `prompt-input` → кнопка есть; dock без composer (child session disabled-блок) → кнопки нет; пустой dock → кнопки нет; question-dock открыт → composer-кнопки нет (PR #31 regression guard). Тестирует через публичный `setupUI` + side-effect (`.ocvd-btn` в DOM). - **Handoff** `docs/handoff/pr-XX-child-session-disabled-composer.md` и **ADR 0004** `docs/decisions/0004-pr-XX-child-session-composer-guard.md` с placeholder'ом PR-номера (исправлю после получения номера). Это тот же класс бага, что и PR #31 (question-dock duplicate button) — `session-prompt-dock` использовался как composer-target когда реальный composer внутри не отрендерен. PR #31 пофиксил question-dock case, этот PR закрывает child-session case. ## Почему - **`session-prompt-dock` — общий wrapper для трёх состояний.** В `anomalyco/opencode` (`session-composer-region.tsx`) dock рендерится всегда когда `showComposer()` truthy (`!blocked() || !!parentID()` → в child session всегда truthy). Внутри условно: question-dock (PR #31), disabled-блок "Prompt is disabled / Back to parent" (child session, этот PR), или реальный composer. - **`findComposer()` выбирал wrapper.** `COMPOSER_SELECTORS` = [`prompt-input-v2`, `session-prompt-dock`, `session-new-composer`, `session-composer`] (ADR 0001). В child session `prompt-input-v2` отсутствует, `session-prompt-dock` присутствует. Guard PR #31 (skip если внутри `session-question-dock`) не срабатывал — внутри disabled-блок, не question-dock. - **Клик падал.** Кнопка вставлялась в dock через `injectIntoElement` (`position: absolute; top: 8px; right: 8px`). При клике → `insertIntoContenteditable()` → `querySelector('[data-component="prompt-input"]')` = `null` → toast "Could not find input field". - **Defence-in-depth.** Guard в `findComposer` (не возвращать dock без composer внутри) + guard в `injectIntoComposer` (не вставлять если в документе нет composer). Оба слоя независимы — любой один достаточно, второй страховка на race conditions. Аналог паттерна PR #31 / ADR 0003. Closes #32 Closes #32 --------- Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
parent
02601cb360
commit
e4ec12960f
7 changed files with 302 additions and 5 deletions
101
docs/decisions/0004-pr-33-child-session-composer-guard.md
Normal file
101
docs/decisions/0004-pr-33-child-session-composer-guard.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# ADR 0004: Composer-target guard checks for real input inside dock (child session)
|
||||
|
||||
- **Date**: 2026-07-26
|
||||
- **PR**: 33
|
||||
- **Parent PR**: #31
|
||||
- **Issue**: #32
|
||||
|
||||
## Статус
|
||||
|
||||
Accepted.
|
||||
|
||||
## Контекст
|
||||
|
||||
OpenCode web (`anomalyco/opencode` dev, `packages/app/src/pages/session/composer/session-composer-region.tsx`) использует `<div data-component="session-prompt-dock">` как общий wrapper-контейнер для трёх условно-рендеримых дочерних блоков:
|
||||
|
||||
1. `<Show when={controller.state.questionRequest()} keyed>` → `<SessionQuestionDock>` — уточняющий вопрос от агента (PR #31 закрыл этот case).
|
||||
2. `<Show when={controller.child()} fallback={...}>` → disabled-блок "Prompt is disabled / Back to parent" — child session (subagent, `session.parentID` заполнен).
|
||||
3. `<Show when={controller.showComposer()}>` → `PromptInput` / `PromptInputV2Composer` — основной композер ввода.
|
||||
|
||||
`controller.showComposer()` = `!state.blocked() || !!parentID()`. В child session `parentID()` truthy → `showComposer()` всегда truthy → `session-prompt-dock` рендерится. Но `controller.child()` тоже truthy → disabled-блок (ветка 2), composer (ветка 3) НЕ монтируется. `prompt-input` / `prompt-input-v2` отсутствуют внутри dock'а.
|
||||
|
||||
`findComposer()` в `src/ui.ts` перебирает `COMPOSER_SELECTORS` (`prompt-input-v2` → `session-prompt-dock` → `session-new-composer` → `session-composer`, ADR 0001). В child session `prompt-input-v2` скрыт (первый промах), `session-prompt-dock` присутствует (второй успех). Guard PR #31 (skip dock если внутри `session-question-dock`) не срабатывал — внутри dock'а disabled-блок, не question-dock. → `findComposer` возвращал dock как composer-target.
|
||||
|
||||
`injectIntoElement` делал dock `position: relative` и append'ил контейнер с кнопкой 🎤 (`position: absolute; top: 8px; right: 8px`) в правый-верхний угол wrapper'а — над disabled-блоком. При клике → `toggleDictation("composer")` → `insertIntoContenteditable()` → `document.querySelector('[data-component="prompt-input"]')` = `null` → toast "Could not find input field". Кнопка бесполезна, но видна.
|
||||
|
||||
Дополнительные ограничения:
|
||||
- `session-prompt-dock` нельзя удалить из `COMPOSER_SELECTORS` — когда question-dock НЕ открыт и session НЕ child, это валидный fallback-target (пока `prompt-input-v2` lazy-mount'ится, ADR 0001).
|
||||
- `injectIntoQuestionPrompts` должен продолжать работать независимо от `injectIntoComposer`.
|
||||
- `match: ["*://*/*"]` + `run-at: document-idle` — userscript работает на всех сайтах, gate (`PAGE_DETECT_SELECTORS`, ADR 0002) отсеивает не-OpenCode.
|
||||
|
||||
## Решение
|
||||
|
||||
**Defence-in-depth: guard на наличие реального composer внутри dock + guard на уровне документа + бамп версии.**
|
||||
|
||||
**1. Guard в `findComposer()` (`src/ui.ts`).** Skip `session-prompt-dock` в цикле `COMPOSER_SELECTORS`, если внутри НЕТ `[data-component="prompt-input"]` или `[data-component="prompt-input-v2"]`:
|
||||
|
||||
```ts
|
||||
if (
|
||||
el.getAttribute("data-component") === "session-prompt-dock" &&
|
||||
!el.querySelector('[data-component="prompt-input"], [data-component="prompt-input-v2"]')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
```
|
||||
|
||||
В child session внутри dock'а только disabled-блок — guard срабатывает, цикл `continue` переходит к legacy-селекторам (удалены в v1.18.x), `findComposer()` возвращает `null`. Wrapper остаётся в массиве (нужен в нормальном состоянии), но не возвращается как composer-target когда внутри нет реального composer.
|
||||
|
||||
**2. Guard в `injectIntoComposer()` (`src/ui.ts`).** Ранний return, если в документе НЕТ `[data-component="prompt-input"]` или `[data-component="prompt-input-v2"]`:
|
||||
|
||||
```ts
|
||||
if (
|
||||
!document.querySelector('[data-component="prompt-input"], [data-component="prompt-input-v2"]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
Defence-in-depth: защищает от любых future-случаев отсутствия реального composer (не только child session). Срабатывает на уровне документа, независим от `findComposer`.
|
||||
|
||||
**3. Бамп `@version` 1.0.2 → 1.0.3** в `package.json` и `vite.config.ts` (синхронно, ADR 0002 gotcha). Разблокировать автообновление userscript-менеджеров.
|
||||
|
||||
Два guard'а независимы: любой один достаточно для типичного сценария, второй — страховка на race conditions и partial DOM state. Удаление любого сужает coverage, но не ломает основной фикс.
|
||||
|
||||
## Альтернативы
|
||||
|
||||
### 1. Детектировать child session по disabled-блоку (текст "Prompt is disabled" / кнопка "Back to parent")
|
||||
- **Плюс**: явная детекция именно child session.
|
||||
- **Минус**: хрупко — зависит от локали (i18n ключи `session.child.promptDisabled` / `session.child.backToParent`), текст может меняться между версиями. Проверка отсутствия `prompt-input` внутри dock — структурная, не зависит от текста. Предпочтительнее.
|
||||
|
||||
### 2. Убрать `session-prompt-dock` из `COMPOSER_SELECTORS`
|
||||
- **Плюс**: минимальная правка — одна строка.
|
||||
- **Минус**: `prompt-input-v2` lazy-mount'ится асинхронно (ADR 0001). Между появлением `session-prompt-dock` и `prompt-input-v2` есть окно (до сотен ms), когда `findComposer()` вернёт `null` → кнопка 🎤 не появится до готовности `prompt-input-v2`. Регрессия раннего появления кнопки (PR #29 explicitly добавил `session-prompt-dock` как fallback). Текущее решение сохраняет fallback, но отключает только когда он вреден.
|
||||
|
||||
### 3. CSS-only через `:has()` — скрыть кнопку когда внутри dock нет composer
|
||||
- **Плюс**: ноль правок в JS.
|
||||
- **Минус**: маскирует симптом, не лечит root cause. Контейнер остаётся в DOM, `injectIntoElement` guard `if (existing) return null` предотвратит правильную вставку после возврата в parent session (контейнер «существует», просто скрыт). UX regression. CSS-only не подходит для lifecycle-управления (аналогично ADR 0003 альтернативе 2).
|
||||
|
||||
### 4. MutationObserver cleanup застрявших кнопок
|
||||
- **Плюс**: лечит и текущий баг, и будущие edge-cases.
|
||||
- **Минус**: сложнее, не лечит root cause — кнопка всё равно вставляется в неверный target, просто быстрее убирается. Defence-in-depth через guards предотвращает вставку, что проще и надёжнее. Cleanup — кандидат на отдельный PR если обнаружатся другие сценарии (аналогично ADR 0003).
|
||||
|
||||
## Последствия
|
||||
|
||||
- `findComposer()` больше не возвращает `session-prompt-dock` как composer-target когда внутри нет `prompt-input`/`prompt-input-v2` — цикл `continue` пропускает его и переходит к legacy-селекторам (удалены в v1.18.x). В child session `findComposer` вернёт `null` — `injectIntoComposer` ничего не вставит.
|
||||
- `injectIntoComposer()` имеет ранний return при отсутствии реального composer в документе — даже если `findComposer` найдёт валидный target (edge-case), вставка откладывается до появления `prompt-input`/`prompt-input-v2`. На следующем MutationObserver-цикле вставка проходит нормально.
|
||||
- `@version` 1.0.3 в `.meta.js` разблокирует автообновление userscript-менеджеров.
|
||||
- Два guard'а независимы: удаление любого оставляет race-condition window, но не ломает основной фикс. Не объединять в одну проверку — они защищают разные слои (target selection vs insertion).
|
||||
- `session-prompt-dock` остаётся в `COMPOSER_SELECTORS` как fallback для нормального состояния — не удалять без замены.
|
||||
- Застрявшие кнопки у текущих пользователей (на 1.0.2) не удаляются автоматически — после автообновления до 1.0.3 новые вставки не происходят, но старые остаются до hard-refresh. Cosmetic, не blocking (аналогично PR #31).
|
||||
- Guard устойчив к future-вариантам: любой сценарий где `session-prompt-dock` есть, а `prompt-input`/`prompt-input-v2` внутри нет — пропустит dock. Не привязан к конкретной структуре disabled-блока.
|
||||
|
||||
## Источники
|
||||
|
||||
- `src/ui.ts` (текущий): `findComposer()`, `injectIntoComposer()`, `COMPOSER_SELECTORS`, `injectIntoElement`
|
||||
- `anomalyco/opencode` dev, `packages/app/src/pages/session/composer/session-composer-region.tsx` (`session-prompt-dock` wrapper, `<Show when={controller.child()}>` disabled-блок)
|
||||
- `anomalyco/opencode` dev, `packages/app/src/pages/session/composer/session-composer-region-controller.ts` (`child()` = `!!parentID()`, `showComposer()` = `!blocked() || !!parentID()`)
|
||||
- Предыдущий ADR: `docs/decisions/0001-pr-29-v1-18-composer-selectors-fallback-order.md` (`COMPOSER_SELECTORS` порядок, `session-prompt-dock` как fallback)
|
||||
- Предыдущий ADR: `docs/decisions/0003-pr-31-question-dock-duplicate-button.md` (defence-in-depth паттерн, `session-question-dock` guard)
|
||||
- Память: `technical/opencode-web-ui-child-session-composer-disabled.md` (child session рендерит disabled-блок, варианты фикса)
|
||||
- Память: `technical/opencode-web-ui-composer-selectors-1.18.4.md` (`session-prompt-dock` = общий wrapper, lazy-mount)
|
||||
- Issue: #32
|
||||
108
docs/handoff/pr-33-child-session-disabled-composer.md
Normal file
108
docs/handoff/pr-33-child-session-disabled-composer.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
---
|
||||
pr_number: 33
|
||||
branch: fix/ui/child-session-composer-guard
|
||||
parent_pr: 31
|
||||
title: skip composer injection in child session (subagent)
|
||||
status: open
|
||||
created: 2026-07-26
|
||||
---
|
||||
|
||||
# Handoff — PR 33: skip composer injection in child session
|
||||
|
||||
## Контекст
|
||||
|
||||
Userscript `opencode-voice-dictation` добавляет кнопку 🎤 в composer OpenCode web UI. В **child session** (subagent, созданный через `task` tool) composer заменяется на disabled-блок "Prompt is disabled / Back to parent", но `session-prompt-dock` (внешний wrapper) остаётся в DOM (`showComposer()` = `!blocked() || !!parentID()` → всегда truthy в child). `findComposer()` в `src/ui.ts` использовал этот wrapper как валидный composer-target — кнопка 🎤 рендерилась над disabled-блоком, а клик падал с toast "Could not find input field" (нет `prompt-input` внутри dock'а, `insertIntoContenteditable()` возвращает `false`).
|
||||
|
||||
Это тот же класс бага, что и PR #31 (question-dock duplicate button) — `session-prompt-dock` используется как composer-target в ситуации, когда реальный composer внутри не отрендерен. PR #31 пофиксил только question-dock case (guard: skip dock если внутри `session-question-dock`), child-session case остался latent. Корневая причина та же: wrapper валиден как fallback-target в _нормальном_ состоянии, но вреден когда внутри нет реального composer.
|
||||
|
||||
Issue: #32. Предшественник: PR #31 (`docs/handoff/pr-31-question-dock-duplicate-button.md`).
|
||||
|
||||
## Что сделано
|
||||
|
||||
Три кодовых коммита на ветке `fix/ui/child-session-composer-guard`:
|
||||
|
||||
- **`fix(ui): skip composer injection in child session`** — `src/ui.ts` `findComposer()`: добавлен guard — `session-prompt-dock` skip'ается в цикле `COMPOSER_SELECTORS`, если внутри него НЕТ `[data-component="prompt-input"]` или `[data-component="prompt-input-v2"]`. В child session внутри dock'а только disabled-блок "Prompt is disabled / Back to parent" — guard срабатывает, цикл `continue` переходит к legacy-селекторам (`session-new-composer` / `session-composer`, удалены в v1.18.x), `findComposer()` возвращает `null`. Дополнительно `injectIntoComposer()`: ранний return если в документе нет `[data-component="prompt-input"], [data-component="prompt-input-v2"]` — defence-in-depth, страховка от future-случаев отсутствия реального composer (не только child session).
|
||||
- **`chore: bump version to 1.0.3`** — `package.json` и `vite.config.ts` синхронно 1.0.2 → 1.0.3. Разблокировать автообновление userscript-менеджеров (ловушка PR #29 → #30: рассинхрон `@version` ломает `updateURL`).
|
||||
- **`test(ui): cover dock without prompt-input case`** — новый `tests/ui.test.ts` (5 кейсов): dock с `prompt-input-v2` → кнопка есть; dock с `prompt-input` → кнопка есть; dock без composer (child session disabled-блок) → кнопки нет; пустой dock → кнопки нет; question-dock открыт → composer-кнопки нет (PR #31 regression guard). Тестирует через публичный `setupUI` + side-effect (`.ocvd-btn` в DOM), `findComposer` не экспортируется.
|
||||
|
||||
Четвёртый коммит — docs (этот handoff + ADR 0004).
|
||||
|
||||
## Почему
|
||||
|
||||
- **`session-prompt-dock` — общий wrapper для трёх состояний.** В `anomalyco/opencode` dev (`packages/app/src/pages/session/composer/session-composer-region.tsx`) `session-prompt-dock` div рендерится всегда когда `showComposer()` truthy. Внутри условно: `<Show when={questionRequest}>` → `SessionQuestionDock`, `<Show when={child()}>` → disabled-блок "Prompt is disabled / Back to parent", `<Show when={promptReady()}>` → `PromptInput`/`PromptInputV2Composer`. В child session (`session.parentID` заполнен) `child()=true` → disabled-блок, composer НЕ монтируется, но `session-prompt-dock` остаётся.
|
||||
- **`findComposer()` выбирал wrapper.** `COMPOSER_SELECTORS` = [`prompt-input-v2`, `session-prompt-dock`, `session-new-composer`, `session-composer`] (ADR 0001). В child session `prompt-input-v2` отсутствует (первый промах), `session-prompt-dock` присутствует (второй успех). Guard PR #31 (skip если внутри `session-question-dock`) не срабатывал — внутри dock'а disabled-блок, не question-dock. → `findComposer` возвращал dock как composer-target.
|
||||
- **Клик падал с toast.** `injectIntoElement` делал dock `position: relative` и append'ил контейнер с кнопкой (`position: absolute; top: 8px; right: 8px`) в правый-верхний угол wrapper'а — над disabled-блоком. При клике → `toggleDictation("composer")` → `insertIntoContenteditable()` → `document.querySelector('[data-component="prompt-input"]')` = `null` → toast "Could not find input field". Кнопка бесполезна, но видна.
|
||||
- **Defence-in-depth.** Guard в `findComposer` (не возвращать dock без composer внутри) + guard в `injectIntoComposer` (не вставлять если в документе нет `prompt-input`/`prompt-input-v2`). Оба слоя независимы — любой один достаточно для фикса, второй страховка на edge-cases (race conditions, partial DOM state, future-варианты отсутствия composer). Аналог паттерна PR #31.
|
||||
|
||||
## Pending
|
||||
|
||||
- Live-verify на opencode.slaid098.dev после merge: открыть main session → вызвать subagent через `task` → дождаться перехода в child session → проверить, что над disabled-блоком "Prompt is disabled / Back to parent" НЕТ кнопки 🎤. Вернуться в parent session → проверить, что в композере кнопка 🎤 появилась (регресса нет).
|
||||
- Подтвердить, что userscript-менеджеры подтянули автообновление: сравнить `@version` в `about:` для установленного скрипта с `1.0.3`. Если менеджер не видит обновление — проверить, что `updateURL` в `vite.config.ts` указывает на свежий `.meta.js` в ветке `dist/` (или `main` после merge).
|
||||
- Проверить regression PR #31: открыть сессию → дождаться вопроса от агента → кликнуть «Свой ответ» → проверить, что composer-кнопки сверху блока вопросов НЕТ (только question-кнопка в textarea).
|
||||
- После `oldInterfaceSunset` (2026-09-14) legacy-селекторы `session-composer` / `session-new-composer` в `COMPOSER_SELECTORS` и `PAGE_DETECT_SELECTORS` станут мёртвым кодом — удалить отдельным PR (общая pending-задача с PR #29/#30/#31).
|
||||
|
||||
## Watch out
|
||||
|
||||
- **`session-prompt-dock` остаётся в `COMPOSER_SELECTORS` как fallback.** НЕ удалять — когда question-dock НЕ открыт и session НЕ child, `session-prompt-dock` — валидный composer-target (пока `prompt-input-v2` lazy-mount'ится, ADR 0001). Удаление селектора сломало бы раннее появление кнопки на свежих сессиях. Guard пропускает dock только когда внутри нет реального composer.
|
||||
- **Два guard'а независимы — не объединять.** Guard в `findComposer` (skip dock без composer) и guard в `injectIntoComposer` (skip insert без composer в документе) защищают разные слои: первый предотвращает выбор неверного target, второй — неверную вставку даже если target выбран (edge-case: `prompt-input-v2` в transition state). Удаление любого оставляет race-condition window.
|
||||
- **Guard в `injectIntoComposer` может задержать появление composer-кнопки на 1 кадр при переходе в parent session.** Если child session закрывается (disabled-блок ушёл, но `prompt-input-v2` ещё не смонтировался), `injectIntoComposer` вернёт `null` от `findComposer()`, следующий retry-цикл MutationObserver поймает появление composer. На практике незаметно (~16ms).
|
||||
- **Нет cleanup застрявших кнопок у текущих пользователей.** Если баг уже проявился (кнопка вставлена в dock в child session), после автообновления до 1.0.3 новые вставки не происходят, но старые остаются до hard-refresh. Cosmetic, не blocking. Аналогично PR #31.
|
||||
- **`findComposer` не экспортируется.** Тест `tests/ui.test.ts` тестирует через публичный `setupUI` + side-effect (`.ocvd-btn` в DOM), не через прямой вызов `findComposer`. Это интеграционный тест, но покрывает end-to-end сценарий бага.
|
||||
|
||||
## Изменения
|
||||
|
||||
### `src/ui.ts`
|
||||
|
||||
`findComposer()` — добавлен skip-блок для `session-prompt-dock` без реального composer внутри:
|
||||
```ts
|
||||
// session-prompt-dock валиден как composer-target ТОЛЬКО если внутри есть реальный composer;
|
||||
// child session (subagent) рендерит disabled-блок "Prompt is disabled / Back to parent" вместо composer
|
||||
if (
|
||||
el.getAttribute("data-component") === "session-prompt-dock" &&
|
||||
!el.querySelector('[data-component="prompt-input"], [data-component="prompt-input-v2"]')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
```
|
||||
|
||||
`injectIntoComposer()` — добавлен ранний return при отсутствии реального composer в документе:
|
||||
```ts
|
||||
// Defence-in-depth: не вставлять, если в документе нет реального composer (child session / disabled-блок)
|
||||
if (
|
||||
!document.querySelector('[data-component="prompt-input"], [data-component="prompt-input-v2"]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### `package.json`
|
||||
- `"version": "1.0.2"` → `"version": "1.0.3"`.
|
||||
|
||||
### `vite.config.ts`
|
||||
- `userscript.version: "1.0.2"` → `"1.0.3"`.
|
||||
|
||||
### `tests/ui.test.ts` (новый)
|
||||
- 5 кейсов: dock с composer → кнопка есть; dock без composer (child session) → кнопки нет; пустой dock → кнопки нет; question-dock → composer-кнопки нет.
|
||||
|
||||
### Файлы вне спеки
|
||||
- `src/audio.ts`, `src/transcribe.ts`, `src/config.ts`, `src/keyboard.ts`, `src/types.ts`, `src/insert.ts`, `src/index.ts` — без изменений.
|
||||
|
||||
## Коммиты
|
||||
|
||||
1. `fix(ui): skip composer injection in child session` — `src/ui.ts` (findComposer + injectIntoComposer)
|
||||
2. `chore: bump version to 1.0.3` — `package.json` + `vite.config.ts`
|
||||
3. `test(ui): cover dock without prompt-input case` — `tests/ui.test.ts`
|
||||
4. `docs(handoff): add child-session composer guard handoff` — этот handoff + ADR 0004
|
||||
5. `docs(handoff): set PR number` — после получения PR номера
|
||||
|
||||
## ADR
|
||||
|
||||
См. `docs/decisions/0004-pr-33-child-session-composer-guard.md`.
|
||||
|
||||
## Источники
|
||||
|
||||
- Предыдущий handoff: `docs/handoff/pr-31-question-dock-duplicate-button.md` (родительский PR, тот же класс бага)
|
||||
- Предыдущий ADR: `docs/decisions/0003-pr-31-question-dock-duplicate-button.md` (defence-in-depth паттерн)
|
||||
- Память: `technical/opencode-web-ui-child-session-composer-disabled.md` (child session рендерит disabled-блок, `session-composer-region.tsx` `<Show when={controller.child()}>`)
|
||||
- Память: `technical/opencode-web-ui-composer-selectors-1.18.4.md` (`session-prompt-dock` как wrapper, lazy-mount)
|
||||
- Issue: #32
|
||||
|
|
@ -8,8 +8,9 @@ key_files:
|
|||
- tests/insert.test.ts — тесты insert
|
||||
- tests/keyboard.test.ts — тесты keyboard
|
||||
- tests/transcribe.test.ts — тесты transcribe
|
||||
- tests/ui.test.ts — тесты UI-инъекции кнопки 🎤 (composer-dock, child session, question-dock)
|
||||
dependencies: [src]
|
||||
last_updated: 2026-07-24
|
||||
last_updated: 2026-07-26
|
||||
---
|
||||
|
||||
# tests/
|
||||
|
|
@ -21,6 +22,8 @@ last_updated: 2026-07-24
|
|||
- `insert.test.ts` — тесты insert
|
||||
- `keyboard.test.ts` — тесты keyboard
|
||||
- `transcribe.test.ts` — тесты transcribe
|
||||
- `ui.test.ts` — тесты UI-инъекции кнопки 🎤 (5 кейсов: dock с prompt-input-v2/prompt-input → кнопка есть; dock без composer / пустой dock / question-dock → кнопки нет). Тестирует через публичный `setupUI` + side-effect (`.ocvd-btn` в DOM), `findComposer` не экспортируется.
|
||||
|
||||
## Patterns
|
||||
- vitest run, 47 тестов (5 файлов).
|
||||
- vitest run, 52 теста (6 файлов).
|
||||
- UI-тесты используют интеграционный подход через `setupUI` + проверку side-effect в DOM (`.ocvd-btn`), т.к. `findComposer` не экспортируется.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "opencode-voice-dictation",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
|
|
|||
16
src/ui.ts
16
src/ui.ts
|
|
@ -251,7 +251,7 @@ function findComposer(): HTMLElement | null {
|
|||
for (const selector of COMPOSER_SELECTORS) {
|
||||
const el = document.querySelector<HTMLElement>(selector);
|
||||
if (el) {
|
||||
// session-prompt-dock — общий wrapper для question-dock и composer;
|
||||
// session-prompt-dock — общий wrapper для question-dock, composer и child-session disabled-блока;
|
||||
// если внутри question-dock, не использовать как composer-target
|
||||
if (
|
||||
el.getAttribute("data-component") === "session-prompt-dock" &&
|
||||
|
|
@ -259,6 +259,14 @@ function findComposer(): HTMLElement | null {
|
|||
) {
|
||||
continue;
|
||||
}
|
||||
// session-prompt-dock валиден как composer-target ТОЛЬКО если внутри есть реальный composer;
|
||||
// child session (subagent) рендерит disabled-блок "Prompt is disabled / Back to parent" вместо composer
|
||||
if (
|
||||
el.getAttribute("data-component") === "session-prompt-dock" &&
|
||||
!el.querySelector('[data-component="prompt-input"], [data-component="prompt-input-v2"]')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
console.log(`[ocvd] Composer found via ${selector}`);
|
||||
return el;
|
||||
}
|
||||
|
|
@ -312,6 +320,12 @@ function injectIntoComposer(onToggle: (target: InsertTarget) => void, onCancel:
|
|||
if (document.querySelector('[data-slot="question-custom-input"]')) {
|
||||
return;
|
||||
}
|
||||
// Defence-in-depth: не вставлять, если в документе нет реального composer (child session / disabled-блок)
|
||||
if (
|
||||
!document.querySelector('[data-component="prompt-input"], [data-component="prompt-input-v2"]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const composer = findComposer();
|
||||
if (composer) {
|
||||
injectIntoElement(composer, onToggle, onCancel, "composer");
|
||||
|
|
|
|||
71
tests/ui.test.ts
Normal file
71
tests/ui.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setupUI } from "../src/ui.js";
|
||||
|
||||
const COMPOSER_BTN_SELECTOR = ".ocvd-btn";
|
||||
|
||||
function setupCallbacks() {
|
||||
return {
|
||||
onToggle: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("setupUI composer injection", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("should inject mic button when prompt-input-v2 is present", () => {
|
||||
document.body.innerHTML =
|
||||
'<div data-component="session-prompt-dock"><div data-component="prompt-input-v2"></div></div>';
|
||||
|
||||
setupUI(setupCallbacks());
|
||||
|
||||
const btn = document.querySelector(COMPOSER_BTN_SELECTOR);
|
||||
expect(btn).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should inject mic button when prompt-input is present inside dock", () => {
|
||||
document.body.innerHTML =
|
||||
'<div data-component="session-prompt-dock"><div data-component="prompt-input" contenteditable="true"></div></div>';
|
||||
|
||||
setupUI(setupCallbacks());
|
||||
|
||||
const btn = document.querySelector(COMPOSER_BTN_SELECTOR);
|
||||
expect(btn).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should NOT inject mic button when dock has no prompt-input (child session disabled block)", () => {
|
||||
document.body.innerHTML =
|
||||
'<div data-component="session-prompt-dock"><div>Prompt is disabled</div><button type="button">Back to parent</button></div>';
|
||||
|
||||
setupUI(setupCallbacks());
|
||||
|
||||
const btn = document.querySelector(COMPOSER_BTN_SELECTOR);
|
||||
expect(btn).toBeNull();
|
||||
});
|
||||
|
||||
it("should NOT inject mic button when only session-prompt-dock exists without any composer inside", () => {
|
||||
document.body.innerHTML = '<div data-component="session-prompt-dock"></div>';
|
||||
|
||||
setupUI(setupCallbacks());
|
||||
|
||||
const btn = document.querySelector(COMPOSER_BTN_SELECTOR);
|
||||
expect(btn).toBeNull();
|
||||
});
|
||||
|
||||
it("should NOT inject composer mic button when question-custom-input is open (PR #31 regression guard)", () => {
|
||||
document.body.innerHTML =
|
||||
'<div data-component="session-prompt-dock"><div data-component="session-question-dock"><textarea data-slot="question-custom-input"></textarea></div></div>';
|
||||
|
||||
setupUI(setupCallbacks());
|
||||
|
||||
const dock = document.querySelector('[data-component="session-prompt-dock"]');
|
||||
const composerContainer = dock?.querySelector(":scope > .ocvd-container");
|
||||
expect(composerContainer).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -8,7 +8,7 @@ export default defineConfig({
|
|||
userscript: {
|
||||
name: "OpenCode Voice Dictation",
|
||||
namespace: "https://github.com/slaid098/opencode-voice-dictation",
|
||||
version: "1.0.2",
|
||||
version: "1.0.3",
|
||||
description:
|
||||
"Voice dictation for OpenCode web using Whisper (Groq API) - works on PC and mobile",
|
||||
author: "slaid098",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue