## Что сделано Фикс бага из 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>
11 KiB
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-контейнер для трёх условно-рендеримых дочерних блоков:
<Show when={controller.state.questionRequest()} keyed>→<SessionQuestionDock>— уточняющий вопрос от агента (PR #31 закрыл этот case).<Show when={controller.child()} fallback={...}>→ disabled-блок "Prompt is disabled / Back to parent" — child session (subagent,session.parentIDзаполнен).<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-v2lazy-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"]:
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"]:
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-v2lazy-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,
injectIntoElementguardif (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 sessionfindComposerвернётnull—injectIntoComposerничего не вставит.injectIntoComposer()имеет ранний return при отсутствии реального composer в документе — даже еслиfindComposerнайдёт валидный target (edge-case), вставка откладывается до появленияprompt-input/prompt-input-v2. На следующем MutationObserver-цикле вставка проходит нормально.@version1.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,injectIntoElementanomalyco/opencodedev,packages/app/src/pages/session/composer/session-composer-region.tsx(session-prompt-dockwrapper,<Show when={controller.child()}>disabled-блок)anomalyco/opencodedev,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-dockguard) - Память:
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