## Что сделано - `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
| pr_number | branch | parent_pr | title | status | created |
|---|---|---|---|---|---|
| 31 | fix/ui/question-dock-duplicate-button | 30 | prevent duplicate mic button in question-dock | open | 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.tsfindComposer(): добавлен guard —session-prompt-dockskip'ается в цикле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.tsinjectIntoComposer(): добавлен ранний return, если в DOM есть[data-slot="question-custom-input"](textarea «Свой ответ» открыта). Бамп@version1.0.1 → 1.0.2 вpackage.jsonиvite.config.ts(синхронно) — разблокировать автообновление userscript-менеджеров.
Третий коммит — docs (этот handoff + ADR 0003).
Почему
session-prompt-dock— общий wrapper. Вsst/opencodev1.18.3 (session-composer-region.tsx)session-prompt-dockdiv рендерится сразу (стр.26), внутри<Show when={controller.state.questionRequest()}>—SessionQuestionDock, и отдельно<Show when={controller.showComposer()}>—PromptInput/PromptInputV2Composer. КогдаquestionRequesttruthy, composer скрывается (blocked=true), ноsession-prompt-dockостаётся.findComposer()(вторая итерация цикла после неудачи сprompt-input-v2) находил wrapper и возвращал его как composer-target.- Кнопка вставлялась в верх блока.
injectIntoElementделает targetposition: relativeи append'ит контейнер с кнопкой. Наsession-prompt-dock(широкий wrapper) кнопка сposition: absolute; top: 8px; right: 8pxсадится в правый-верхний угол всего блока вопросов, надSessionQuestionDock. ОдновременноinjectIntoQuestionPromptsвставлял вторую кнопку вquestion-option-mainspan (узкий контейнер textarea). - Застревание после закрытия. Когда question-prompt закрывается (
questionRequestстановится falsy),SessionQuestionDockразмонтируется, textarea исчезает — ноsession-prompt-dockостаётся (теперь с composer внутри). Кнопка, вставленная вsession-prompt-dockво время question, не размонтируется автоматически (нет MutationObserver-cleanup), иinjectIntoElementимеет guardif (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),injectIntoElementguardif (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 внутри:
function findComposer(): HTMLElement | null {
for (const selector of COMPOSER_SELECTORS) {
const el = document.querySelector<HTMLElement>(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:
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— без изменений.
Коммиты
fix(ui): skip session-prompt-dock as composer target when question-dock open—src/ui.ts(findComposer)fix(ui): guard composer injection when question textarea open—src/ui.ts(injectIntoComposer) +package.json+vite.config.ts(version bump)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-inputlifecycle) - Live-верификация: opencode.slaid098.dev v1.18.3 (pending после merge)