## Что сделано Фикс бага из 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>
12 KiB
| pr_number | branch | parent_pr | title | status | created |
|---|---|---|---|---|---|
| 33 | fix/ui/child-session-composer-guard | 31 | skip composer injection in child session (subagent) | open | 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.tsfindComposer(): добавлен guard —session-prompt-dockskip'ается в цикле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/opencodedev (packages/app/src/pages/session/composer/session-composer-region.tsx)session-prompt-dockdiv рендерится всегда когда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 sessionprompt-input-v2отсутствует (первый промах),session-prompt-dockприсутствует (второй успех). Guard PR #31 (skip если внутриsession-question-dock) не срабатывал — внутри dock'а disabled-блок, не question-dock. →findComposerвозвращал dock как composer-target.- Клик падал с toast.
injectIntoElementделал dockposition: 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-v2lazy-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 внутри:
// 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 в документе:
// 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— без изменений.
Коммиты
fix(ui): skip composer injection in child session—src/ui.ts(findComposer + injectIntoComposer)chore: bump version to 1.0.3—package.json+vite.config.tstest(ui): cover dock without prompt-input case—tests/ui.test.tsdocs(handoff): add child-session composer guard handoff— этот handoff + ADR 0004docs(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