opencode-voice-dictation/docs/handoff/pr-33-child-session-disabled-composer.md
Sergey e4ec12960f
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>
2026-07-26 17:18:08 +03:00

12 KiB
Raw Permalink Blame History

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 sessionsrc/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.3package.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 внутри:

// 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 — без изменений.

Коммиты

  1. fix(ui): skip composer injection in child sessionsrc/ui.ts (findComposer + injectIntoComposer)
  2. chore: bump version to 1.0.3package.json + vite.config.ts
  3. test(ui): cover dock without prompt-input casetests/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