diff --git a/docs/decisions/0003-pr-31-question-dock-duplicate-button.md b/docs/decisions/0003-pr-31-question-dock-duplicate-button.md
new file mode 100644
index 0000000..9bd1195
--- /dev/null
+++ b/docs/decisions/0003-pr-31-question-dock-duplicate-button.md
@@ -0,0 +1,112 @@
+# 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`) использует `
` как общий wrapper-контейнер для двух условно-рендеримых дочерних блоков:
+
+1. `` → `` — уточняющий вопрос от агента с опциями (включая textarea «Свой ответ» через `[data-slot="question-custom-input"]`).
+2. `` → `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"]`:
+
+```ts
+function findComposer(): HTMLElement | null {
+ for (const selector of COMPOSER_SELECTORS) {
+ const el = document.querySelector(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"]`:
+
+```ts
+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` вернёт `null` — `injectIntoComposer` ничего не вставит, `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-dock` → `SessionQuestionDock`)
\ No newline at end of file
diff --git a/docs/handoff/pr-31-question-dock-duplicate-button.md b/docs/handoff/pr-31-question-dock-duplicate-button.md
new file mode 100644
index 0000000..2977f45
--- /dev/null
+++ b/docs/handoff/pr-31-question-dock-duplicate-button.md
@@ -0,0 +1,115 @@
+---
+pr_number: 31
+branch: fix/ui/question-dock-duplicate-button
+parent_pr: 30
+title: prevent duplicate mic button in question-dock
+status: open
+created: 2026-07-24
+---
+
+# Handoff — PR #31: prevent duplicate mic button in question-dock
+
+## Контекст
+
+PR #30 (`fix/ui: broaden page gate + debug logging + version bump`) расширил gate детекции страницы и добавил логирование, но оставил latent-баг с дубликатом кнопки 🎤 в question-dock. OpenCode v1.18.3 использует `session-prompt-dock` (`packages/app/src/pages/session/composer/session-composer-region.tsx:26`) как общий wrapper и для question-dock, и для composer. Когда агент задаёт уточняющий вопрос (`questionRequest`), композер скрывается (`blocked=true`), но `session-prompt-dock` остаётся в DOM. `findComposer()` в `src/ui.ts` находил этот wrapper (он стоит вторым в `COMPOSER_SELECTORS` после `prompt-input-v2`) и вставлял кнопку 🎤 в верх блока — одновременно с тем, как `injectIntoQuestionPrompts` вставлял вторую кнопку в `question-option-main` span (textarea «Свой ответ»). Результат: 2 кнопки на экране, верхняя застревала и не исчезала после закрытия question-prompt.
+
+Issue: #28 (родительский). PR #31 — продолжение без новой issue.
+
+## Что сделано
+
+Два коммита с правками кода на ветке `fix/ui/question-dock-duplicate-button`:
+
+- **`fix(ui): skip session-prompt-dock as composer target when question-dock open`** — `src/ui.ts` `findComposer()`: добавлен guard — `session-prompt-dock` skip'ается в цикле `COMPOSER_SELECTORS`, если внутри него присутствует `[data-component="session-question-dock"]`. Wrapper остаётся в массиве как fallback (нужен когда question-dock НЕ открыт), но не используется как composer-target во время активного вопроса.
+- **`fix(ui): guard composer injection when question textarea open + bump 1.0.2`** — `src/ui.ts` `injectIntoComposer()`: добавлен ранний return, если в DOM есть `[data-slot="question-custom-input"]` (textarea «Свой ответ» открыта). Бамп `@version` 1.0.1 → 1.0.2 в `package.json` и `vite.config.ts` (синхронно) — разблокировать автообновление userscript-менеджеров.
+
+Третий коммит — docs (этот handoff + ADR 0003).
+
+## Почему
+
+- **`session-prompt-dock` — общий wrapper.** В `sst/opencode` v1.18.3 (`session-composer-region.tsx`) `session-prompt-dock` div рендерится сразу (стр.26), внутри `` — `SessionQuestionDock`, и отдельно `` — `PromptInput`/`PromptInputV2Composer`. Когда `questionRequest` truthy, composer скрывается (`blocked=true`), но `session-prompt-dock` остаётся. `findComposer()` (вторая итерация цикла после неудачи с `prompt-input-v2`) находил wrapper и возвращал его как composer-target.
+- **Кнопка вставлялась в верх блока.** `injectIntoElement` делает target `position: relative` и append'ит контейнер с кнопкой. На `session-prompt-dock` (широкий wrapper) кнопка с `position: absolute; top: 8px; right: 8px` садится в правый-верхний угол всего блока вопросов, над `SessionQuestionDock`. Одновременно `injectIntoQuestionPrompts` вставлял вторую кнопку в `question-option-main` span (узкий контейнер textarea).
+- **Застревание после закрытия.** Когда question-prompt закрывается (`questionRequest` становится falsy), `SessionQuestionDock` размонтируется, textarea исчезает — но `session-prompt-dock` остаётся (теперь с composer внутри). Кнопка, вставленная в `session-prompt-dock` во время question, не размонтируется автоматически (нет MutationObserver-cleanup), и `injectIntoElement` имеет guard `if (existing) return null`, который предотвращает повторную вставку, но не удаляет старую. Кнопка оставалась видимой «в воздухе».
+
+Defence-in-depth: guard в `findComposer()` (не возвращать wrapper с question-dock) + guard в `injectIntoComposer()` (не вставлять, если textarea открыта). Оба слоя независимы — любой один достаточно для фикса, второй — страховка на edge-cases (race conditions, partial DOM state).
+
+## Pending
+
+- Live-verify на opencode.slaid098.dev v1.18.3 после merge: открыть сессию → дождаться вопроса от агента → кликнуть «Свой ответ» → проверить, что в textarea ровно 1 кнопка 🎤 (в `question-option-main`), и что сверху блока вопросов НЕТ второй кнопки. Закрыть question (выбрать опцию или Escape) → проверить, что верхняя кнопка исчезла (точнее — не появилась заново при re-inject), и в композере появилась ровно 1 кнопка.
+- Подтвердить, что userscript-менеджеры подтянули автообновление: сравнить `@version` в `about:` для установленного скрипта с `1.0.2`. Если менеджер не видит обновление — проверить, что `updateURL` в `vite.config.ts` указывает на свежий `.meta.js` в ветке `dist/` (или `main` после merge).
+- После `oldInterfaceSunset` (2026-09-14) legacy-селекторы `session-composer` / `session-new-composer` в `COMPOSER_SELECTORS` и `PAGE_DETECT_SELECTORS` станут мёртвым кодом — удалить отдельным PR (общая pending-задача с PR #29/#30).
+
+## Watch out
+
+- **`session-prompt-dock` остаётся в `COMPOSER_SELECTORS` как fallback.** НЕ удалять его — когда question-dock НЕ открыт, `session-prompt-dock` — валидный composer-target (например, пока `prompt-input-v2` ещё lazy-mount'ится внутри dock'а). Удаление селектора сломало бы раннее появление кнопки на свежих сессиях.
+- **Guard в `injectIntoComposer()` может задержать появление composer-кнопки на 1 кадр при закрытии question.** `injectIntoComposer` проверяет `question-custom-input` на момент вызова. Если question-prompt закрывается (textarea уже ушёл из DOM, но composer ещё не успел смонтироваться), `injectIntoComposer` вернёт `null` от `findComposer()` (composer ещё не готов), следующий retry-цикл MutationObserver поймает появление composer. На практике незаметно (1 кадр = ~16ms).
+- **Два guard'а независимы — не объединять.** Guard в `findComposer` (skip wrapper) и guard в `injectIntoComposer` (skip insert) защищают разные слои: первый предотвращает выбор неверного target, второй — неверную вставку даже если target валиден (edge-case: `prompt-input-v2` может быть в DOM одновременно с `question-custom-input` во время transition). Удаление любого из них оставляет race-condition window.
+- **Нет cleanup застрявших кнопок.** Если баг уже проявился (кнопка вставлена в `session-prompt-dock` во время question), `injectIntoElement` guard `if (existing) return null` не удаляет старую. После merge PR #31 новые вставки не происходят, но уже вставленные кнопки у пользователей останутся до hard-refresh. Можно добавить MutationObserver-cleanup в отдельном PR, но это усложнение без необходимости (после автообновления 1.0.2 проблема не воспроизводится).
+
+## Изменения
+
+### `src/ui.ts`
+
+`findComposer()` — добавлен skip-блок для `session-prompt-dock` с `session-question-dock` внутри:
+```ts
+function findComposer(): HTMLElement | null {
+ for (const selector of COMPOSER_SELECTORS) {
+ const el = document.querySelector(selector);
+ if (el) {
+ // session-prompt-dock — общий wrapper для question-dock и composer;
+ // если внутри question-dock, не использовать как composer-target
+ 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;
+}
+```
+
+`injectIntoComposer()` — добавлен ранний return при открытом question-textarea:
+```ts
+function injectIntoComposer(onToggle: (target: InsertTarget) => void, onCancel: () => void): void {
+ // Не вставлять composer-кнопку, если открыт question-prompt с textarea «Свой ответ»
+ 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");
+ }
+}
+```
+
+### `package.json`
+- `"version": "1.0.1"` → `"version": "1.0.2"`.
+
+### `vite.config.ts`
+- `userscript.version: "1.0.1"` → `"1.0.2"`.
+
+### Файлы вне спеки
+- `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 session-prompt-dock as composer target when question-dock open` — `src/ui.ts` (findComposer)
+2. `fix(ui): guard composer injection when question textarea open` — `src/ui.ts` (injectIntoComposer) + `package.json` + `vite.config.ts` (version bump)
+3. `docs(handoff): add handoff + ADR for PR` — этот коммит
+
+## ADR
+
+См. `docs/decisions/0003-pr-31-question-dock-duplicate-button.md`.
+
+## Источники
+
+- Предыдущий handoff: `docs/handoff/pr-30-v1-18-runtime-gate-debug.md` (родительский PR, gate + логирование)
+- Предыдущий ADR: `docs/decisions/0002-pr-30-broaden-page-detection-gate.md` (gate broaden, vite-plugin-monkey @version gotcha)
+- Память: `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` (`session-prompt-dock` → `SessionQuestionDock` условный рендер, `question-custom-input` lifecycle)
+- Live-верификация: opencode.slaid098.dev v1.18.3 (pending после merge)
\ No newline at end of file
diff --git a/package.json b/package.json
index 21b576e..1ae0e73 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "opencode-voice-dictation",
- "version": "1.0.1",
+ "version": "1.0.2",
"private": true,
"type": "module",
"engines": {
diff --git a/src/ui.ts b/src/ui.ts
index 190d1a0..c13dd57 100644
--- a/src/ui.ts
+++ b/src/ui.ts
@@ -251,6 +251,14 @@ function findComposer(): HTMLElement | null {
for (const selector of COMPOSER_SELECTORS) {
const el = document.querySelector(selector);
if (el) {
+ // session-prompt-dock — общий wrapper для question-dock и composer;
+ // если внутри question-dock, не использовать как composer-target
+ 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;
}
@@ -300,6 +308,10 @@ function injectIntoElement(
}
function injectIntoComposer(onToggle: (target: InsertTarget) => void, onCancel: () => void): void {
+ // Не вставлять composer-кнопку, если открыт question-prompt с textarea «Свой ответ»
+ if (document.querySelector('[data-slot="question-custom-input"]')) {
+ return;
+ }
const composer = findComposer();
if (composer) {
injectIntoElement(composer, onToggle, onCancel, "composer");
diff --git a/vite.config.ts b/vite.config.ts
index 572089f..bff385c 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -8,7 +8,7 @@ export default defineConfig({
userscript: {
name: "OpenCode Voice Dictation",
namespace: "https://github.com/slaid098/opencode-voice-dictation",
- version: "1.0.1",
+ version: "1.0.2",
description:
"Voice dictation for OpenCode web using Whisper (Groq API) - works on PC and mobile",
author: "slaid098",