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:
Sergey 2026-07-26 17:18:08 +03:00 committed by GitHub
parent 02601cb360
commit e4ec12960f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 302 additions and 5 deletions

View 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

View 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

View file

@ -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` не экспортируется.

View file

@ -1,6 +1,6 @@
{
"name": "opencode-voice-dictation",
"version": "1.0.2",
"version": "1.0.3",
"private": true,
"type": "module",
"engines": {

View file

@ -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
View 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();
});
});

View file

@ -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",