opencode-voice-dictation/docs/decisions/0003-pr-31-question-dock-duplicate-button.md
Sergey 02601cb360
fix(ui): prevent duplicate mic button in question-dock (#31)
## Что сделано
- `findComposer()` в `src/ui.ts` теперь skip `session-prompt-dock` если
внутри есть `session-question-dock` — общий wrapper не используется как
composer-target пока открыт question
- `injectIntoComposer()` в `src/ui.ts` добавлен guard: не вставлять
composer-кнопку если в DOM есть `[data-slot="question-custom-input"]`
(открыт textarea "Свой ответ")
- Бамп `@version` 1.0.1 → 1.0.2 в `package.json` и `vite.config.ts` —
разблокировать автообновление

## Почему
`session-prompt-dock` в OpenCode v1.18.3 — общий wrapper И для
question-dock, И для composer. Когда агент задаёт вопрос
(`questionRequest`), composer скрывается (`blocked=true`), но wrapper
остаётся. `findComposer()` находил wrapper и вставлял кнопку в верх
блока вопросов → дубликат + кнопка застревала после закрытия question.
Defence-in-depth: guard в findComposer + guard в injectIntoComposer.

Closes #28

Closes #28

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-25 02:23:39 +03:00

12 KiB
Raw Blame History

ADR 0003: Prevent duplicate mic button in question-dock (defence-in-depth)

  • Date: 2026-07-24
  • PR: #31
  • Parent PR: #30
  • Issue: #28

Статус

Accepted.

Контекст

OpenCode web v1.18.3 (sst/opencode tag v1.18.3, packages/app/src/pages/session/composer/session-composer-region.tsx:26) использует <div data-component="session-prompt-dock"> как общий wrapper-контейнер для двух условно-рендеримых дочерних блоков:

  1. <Show when={controller.state.questionRequest()} keyed><SessionQuestionDock> — уточняющий вопрос от агента с опциями (включая textarea «Свой ответ» через [data-slot="question-custom-input"]).
  2. <Show when={controller.showComposer()}>PromptInput / PromptInputV2Composer — основной композер ввода.

controller.showComposer() (стр.62) = !state.blocked() || !!parentID(). Когда questionRequest truthy, state.blocked() === true и parentID() пуст → composer скрывается, но session-prompt-dock остаётся в DOM (он рендерится безусловно, стр.26).

findComposer() в src/ui.ts перебирает COMPOSER_SELECTORS (prompt-input-v2session-prompt-docksession-new-composersession-composer, ADR 0001). Когда prompt-input-v2 скрыт (composer не смонтирован), querySelector возвращает null для первого селектора, и findComposer проваливается на второй — session-prompt-dock, который валиден. injectIntoElement делает wrapper position: relative и append'ит контейнер с кнопкой 🎤 (position: absolute; top: 8px; right: 8px) в правый-верхний угол wrapper'а — то есть в верх блока вопросов, над SessionQuestionDock.

Одновременно injectIntoQuestionPrompts (вызывается в том же MutationObserver-цикле) находит [data-slot="question-custom-input"] и вставляет вторую кнопку 🎤 в question-option-main span (родитель textarea). Результат: 2 кнопки на экране.

После закрытия question-prompt (questionRequest → falsy, commitCustom()/pick()/selectOption()) SessionQuestionDock размонтируется, но кнопка в session-prompt-dock не удаляется автоматически: injectIntoElement имеет guard if (existing) return null (предотвращает дубликаты при повторном inject), но не имеет cleanup'а для размонтирования родителя. Кнопка остаётся видимой «в воздухе».

Дополнительные ограничения:

  • session-prompt-dock нельзя удалить из COMPOSER_SELECTORS — когда question-dock НЕ открыт, это валидный fallback-target (пока prompt-input-v2 ещё lazy-mount'ится, ADR 0001).
  • injectIntoQuestionPrompts должен продолжать работать независимо от injectIntoComposer — они могут вызываться в одном MutationObserver-цикле.
  • match: ["*://*/*"] + run-at: document-idle — userscript работает на всех сайтах, gate (PAGE_DETECT_SELECTORS, ADR 0002) отсеивает не-OpenCode.

Решение

Defence-in-depth: два независимых guard'а + бамп версии.

1. Guard в findComposer() (src/ui.ts). Skip session-prompt-dock в цикле COMPOSER_SELECTORS, если внутри есть [data-component="session-question-dock"]:

function findComposer(): HTMLElement | null {
  for (const selector of COMPOSER_SELECTORS) {
    const el = document.querySelector<HTMLElement>(selector);
    if (el) {
      if (
        el.getAttribute("data-component") === "session-prompt-dock" &&
        el.querySelector('[data-component="session-question-dock"]')
      ) {
        continue;
      }
      console.log(`[ocvd] Composer found via ${selector}`);
      return el;
    }
  }
  console.log("[ocvd] No composer found in DOM");
  return null;
}

Wrapper остаётся в массиве (нужен когда question-dock НЕ открыт), но не возвращается как composer-target во время активного вопроса. Цикл continue пропускает его и переходит к следующим fallback-селекторам.

2. Guard в injectIntoComposer() (src/ui.ts). Ранний return, если в DOM есть [data-slot="question-custom-input"]:

function injectIntoComposer(onToggle: (target: InsertTarget) => void, onCancel: () => void): void {
  if (document.querySelector('[data-slot="question-custom-input"]')) {
    return;
  }
  const composer = findComposer();
  if (composer) {
    injectIntoElement(composer, onToggle, onCancel, "composer");
    console.log("[ocvd] Mic button injected into composer");
  }
}

Защищает edge-case: prompt-input-v2 может быть в DOM одновременно с question-custom-input во время transition (SPA SolidJS не гарантирует синхронное размонтирование). Guard в findComposer пропустит session-prompt-dock (внутри есть session-question-dock), но НЕ пропустит prompt-input-v2а prompt-input-v2 тоже может быть в transition state. Второй guard ловит это на уровне insertion.

3. Бамп @version 1.0.1 → 1.0.2 в package.json и vite.config.ts (синхронно, ADR 0002 gotcha). Разблокировать автообновление userscript-менеджеров — пользователи на 1.0.1 не получат фикс без явного re-install.

Два guard'а независимы: любой один достаточно для типичного сценария, второй — страховка на race conditions и partial DOM state. Удаление любого сужает coverage, но не ломает основной фикс.

Альтернативы

1. Убрать session-prompt-dock из COMPOSER_SELECTORS

  • Плюс: минимальная правка — одна строка в массиве.
  • Минус: prompt-input-v2 lazy-mount'ится асинхронно (ADR 0001, session-composer-region-controller.ts:136 promptReady()). Между появлением session-prompt-dock и появлением prompt-input-v2 есть окно (до нескольких сотен ms на медленной сети), когда findComposer() вернёт null → кнопка 🎤 не появится до готовности prompt-input-v2. На v1.18.x это регрессия раннего появления кнопки (PR #29 explicitly добавил session-prompt-dock как fallback именно для этого). Текущее решение сохраняет fallback, но отключает его только когда он вреден (question-dock открыт).

2. CSS-only через :has() — скрыть застрявшую кнопку стилем

  • Плюс: ноль правок в JS, чистый CSS.
  • Минус: :has() работает внутри одного поддерева. Кнопка в session-prompt-dock и session-question-dockоба внутри session-prompt-dock, так что session-prompt-dock:has([data-component="session-question-dock"]) .ocvd-container { display: none } технически возможно. Но это маскирует симптом, не лечит root cause: контейнер остаётся в DOM, injectIntoElement guard if (existing) return null всё равно предотвратит правильную вставку после закрытия question (контейнер «существует», просто скрыт). После закрытия question кнопка НЕ появится в композере — UX regression хуже исходного бага. CSS-only не подходит для lifecycle-управления.

3. MutationObserver cleanup застрявших кнопок

  • Плюс: лечит и текущий баг, и будущие edge-cases (любая застрявшая кнопка автоматически удаляется).
  • Минус: сложнее. Требует observer на session-prompt-dock (или document.body), отслеживания удаления session-question-dock, проверки наличия .ocvd-container в session-prompt-dock, аккуратного remove. Не лечит root cause — кнопка всё равно вставляется в неверный target, просто быстрее убирается. Defence-in-depth через guards в findComposer + injectIntoComposer предотвращает вставку, что проще и надёжнее cleanup'а. Cleanup — кандидат на отдельный PR если обнаружатся другие сценарии застревания.

Последствия

  • findComposer() больше не возвращает session-prompt-dock как composer-target когда внутри session-question-dock — цикл continue пропускает его и переходит к session-new-composer / session-composer (legacy fallback, ADR 0001). На v1.18.x с открытым question-dock findComposer вернёт nullinjectIntoComposer ничего не вставит, injectIntoQuestionPrompts единственный источник кнопки 🎤.
  • injectIntoComposer() имеет ранний return при открытом question-custom-input — даже если findComposer найдёт валидный composer (edge-case transition state), вставка откладывается до закрытия question. На следующем MutationObserver-цикле (composer видим, textarea ушёл) вставка проходит нормально.
  • @version 1.0.2 в .meta.js разблокирует автообновление userscript-менеджеров — пользователи получают исправление PR #31 одним обновлением.
  • Два guard'а независимы: удаление любого оставляет race-condition window, но не ломает основной фикс. Не объединять в одну проверку — они защищают разные слои (target selection vs insertion).
  • session-prompt-dock остаётся в COMPOSER_SELECTORS как fallback для нормального состояния (question-dock НЕ открыт) — не удалять без замены.
  • Застрявшие кнопки у текущих пользователей (на 1.0.1) не удаляются автоматически — после автообновления до 1.0.2 новые вставки не происходят, но старые остаются до hard-refresh. Cosmetic, не blocking.

Источники

  • src/ui.ts (текущий): findComposer(), injectIntoComposer(), COMPOSER_SELECTORS, injectIntoElement
  • sst/opencode tag v1.18.3, packages/app/src/pages/session/composer/session-composer-region.tsx (session-prompt-dock wrapper, Show when={controller.state.questionRequest()})
  • sst/opencode tag v1.18.3, packages/app/src/pages/session/composer/session-question-dock.tsx (question-custom-input lifecycle, store.editing)
  • Предыдущий ADR: docs/decisions/0001-pr-29-v1-18-composer-selectors-fallback-order.md (COMPOSER_SELECTORS порядок, session-prompt-dock как fallback)
  • Предыдущий ADR: docs/decisions/0002-pr-30-broaden-page-detection-gate.md (vite-plugin-monkey @version gotcha, gate broaden)
  • Память: technical/opencode-web-ui-composer-selectors-1.18.4.md (session-prompt-dock = общий wrapper, lazy-mount)
  • Память: technical/opencode-question-custom-input-lifecycle-v1-18-3.md (question-custom-input условный рендер, session-prompt-dockSessionQuestionDock)