## Что сделано - `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>
12 KiB
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-контейнер для двух условно-рендеримых дочерних блоков:
<Show when={controller.state.questionRequest()} keyed>→<SessionQuestionDock>— уточняющий вопрос от агента с опциями (включая textarea «Свой ответ» через[data-slot="question-custom-input"]).<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-v2 → session-prompt-dock → session-new-composer → session-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-v2lazy-mount'ится асинхронно (ADR 0001,session-composer-region-controller.ts:136promptReady()). Между появлением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,injectIntoElementguardif (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-dockfindComposerвернётnull—injectIntoComposerничего не вставит,injectIntoQuestionPromptsединственный источник кнопки 🎤.injectIntoComposer()имеет ранний return при открытомquestion-custom-input— даже еслиfindComposerнайдёт валидный composer (edge-case transition state), вставка откладывается до закрытия question. На следующем MutationObserver-цикле (composer видим, textarea ушёл) вставка проходит нормально.@version1.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,injectIntoElementsst/opencodetagv1.18.3,packages/app/src/pages/session/composer/session-composer-region.tsx(session-prompt-dockwrapper,Show when={controller.state.questionRequest()})sst/opencodetagv1.18.3,packages/app/src/pages/session/composer/session-question-dock.tsx(question-custom-inputlifecycle,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@versiongotcha, 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-dock→SessionQuestionDock)