diff --git a/docs/decisions/0004-pr-33-child-session-composer-guard.md b/docs/decisions/0004-pr-33-child-session-composer-guard.md
new file mode 100644
index 0000000..71e4483
--- /dev/null
+++ b/docs/decisions/0004-pr-33-child-session-composer-guard.md
@@ -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`) использует `
` как общий wrapper-контейнер для трёх условно-рендеримых дочерних блоков:
+
+1. `` → `` — уточняющий вопрос от агента (PR #31 закрыл этот case).
+2. `` → disabled-блок "Prompt is disabled / Back to parent" — child session (subagent, `session.parentID` заполнен).
+3. `` → `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, `` 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
\ No newline at end of file
diff --git a/docs/handoff/pr-33-child-session-disabled-composer.md b/docs/handoff/pr-33-child-session-disabled-composer.md
new file mode 100644
index 0000000..b94d179
--- /dev/null
+++ b/docs/handoff/pr-33-child-session-disabled-composer.md
@@ -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. Внутри условно: `` → `SessionQuestionDock`, `` → disabled-блок "Prompt is disabled / Back to parent", `` → `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` ``)
+- Память: `technical/opencode-web-ui-composer-selectors-1.18.4.md` (`session-prompt-dock` как wrapper, lazy-mount)
+- Issue: #32
\ No newline at end of file
diff --git a/docs/project-map/tests.md b/docs/project-map/tests.md
index 6c4611e..46f48ce 100644
--- a/docs/project-map/tests.md
+++ b/docs/project-map/tests.md
@@ -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 файлов).
\ No newline at end of file
+- vitest run, 52 теста (6 файлов).
+- UI-тесты используют интеграционный подход через `setupUI` + проверку side-effect в DOM (`.ocvd-btn`), т.к. `findComposer` не экспортируется.
\ No newline at end of file
diff --git a/package.json b/package.json
index 1ae0e73..3706af3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "opencode-voice-dictation",
- "version": "1.0.2",
+ "version": "1.0.3",
"private": true,
"type": "module",
"engines": {
diff --git a/src/ui.ts b/src/ui.ts
index c13dd57..0ef16c0 100644
--- a/src/ui.ts
+++ b/src/ui.ts
@@ -251,7 +251,7 @@ function findComposer(): HTMLElement | null {
for (const selector of COMPOSER_SELECTORS) {
const el = document.querySelector(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");
diff --git a/tests/ui.test.ts b/tests/ui.test.ts
new file mode 100644
index 0000000..3cf27dd
--- /dev/null
+++ b/tests/ui.test.ts
@@ -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 =
+ '