fix(voice): dictation with the tile grid open reaches the focused tile

With the tile grid open the main terminal is parked (display: none), but
local echo stays on, so direct-mode dictation for the focused tile's
session (which is activeSessionId) was appended to the main terminal's
hidden local-echo overlay. Nothing appeared in the tile, Enter in the tile
submitted without the dictated text, and the stranded text was later
flushed into whichever tile had focus when the grid closed, or dropped.

- _insertText: skip the overlay while _tilesOwnTerminal() is true, so the
  text goes through _sendToTarget to the session itself.
- The post-insert refocus gives the keyboard to the focused tile (only if
  it is still the dictation target) instead of the parked main terminal.
  Outside the grid it still focuses the main terminal, so split view keeps
  its behaviour even when Pane B took focus mid-dictation.
- Green send button: with tiles open, send only Enter to the target and
  leave the parked overlay and main-terminal predictions alone.

The gate is _tilesOwnTerminal(), not _focusedPane().isPrimary: in split
view a target equal to activeSessionId is Pane A with a visible overlay,
and focus read at transcript time could otherwise push Pane A's dictation
past its own unflushed overlay text.

Tests: tile-grid dictation and green-send cases (both fail without the
fix) plus a split-view pin in test/voice-input-target.test.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 08:51:08 +02:00
parent 18c8b5c280
commit 37ddcbe2f0
2 changed files with 106 additions and 9 deletions
+22 -6
View File
@@ -1007,15 +1007,30 @@ const VoiceInput = {
} else {
// Direct mode: inject into local echo overlay if available, else send to PTY.
// The overlay belongs to the ACTIVE session's terminal, so text dictated
// for any other session must not be typed into it.
// for any other session must not be typed into it. It also belongs to the
// MAIN terminal, which the tile grid parks (display: none): with tiles
// open the text would sit in an invisible overlay the focused tile never
// sees, so it goes straight to the session instead.
const isActive = target === app.activeSessionId;
if (isActive && app._localEchoEnabled && app._localEchoOverlay) {
const tilesOpen = !!app._tilesOwnTerminal?.();
if (isActive && !tilesOpen && app._localEchoEnabled && app._localEchoOverlay) {
app._localEchoOverlay.appendText(trimmed);
} else {
this._sendToTarget(target, trimmed).catch(() => {});
}
this._showVoiceSendBtn();
setTimeout(() => { if (isActive && app.terminal) app.terminal.focus(); }, 150);
setTimeout(() => {
if (!isActive) return;
// With the grid open the keyboard belongs to the focused tile; the
// parked main terminal cannot take focus. Split view keeps the main
// terminal, even if Pane B took focus meanwhile (it is not the target).
if (app._tilesOwnTerminal?.()) {
const pane = app._focusedPane?.();
if (pane?.sessionId === target) pane.terminal?.focus();
} else if (app.terminal) {
app.terminal.focus();
}
}, 150);
}
},
@@ -1045,9 +1060,10 @@ const VoiceInput = {
if (!target) return;
// Simulate Enter key: if local echo is active, flush its buffer + send \r;
// otherwise just send \r directly to the PTY. Both the overlay and the
// predictions belong to the ACTIVE session's terminal, so a dictation
// for another session just sends its Enter there.
if (target !== app.activeSessionId) {
// predictions belong to the ACTIVE session's MAIN terminal, so a
// dictation for another session, or for a tile while the grid has the
// main terminal parked, just sends its Enter there.
if (target !== app.activeSessionId || app._tilesOwnTerminal?.()) {
this._sendToTarget(target, '\r').catch(() => {});
} else if (app._localEchoEnabled && app._localEchoOverlay) {
const text = app._localEchoOverlay.pendingText || '';
+84 -3
View File
@@ -8,6 +8,12 @@
* session. The target is now captured in `start()` (through `_focusedPane()`,
* so a second terminal pane can claim it later) and every send path uses it.
*
* With the tile grid open the main terminal (and with it the local-echo
* overlay) is parked with `display: none`, so direct-mode dictation used to be
* typed into an invisible overlay and never reached the focused tile. While
* `_tilesOwnTerminal()` is true the overlay is skipped and the keyboard goes
* back to the focused tile, not the parked main terminal.
*
* Loaded via `vm` with a stubbed `app` (no jsdom).
*/
import { readFileSync } from 'node:fs';
@@ -27,10 +33,13 @@ type Voice = {
_targetSessionId: string | null;
};
function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string } = {}) {
type Pane = { sessionId: string; isPrimary?: boolean; terminal?: { focus: () => void } };
function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string; tiles?: boolean; pane?: Pane } = {}) {
const sendInput = vi.fn(async () => {});
const sendInputAsync = vi.fn();
const appendText = vi.fn();
const overlayClear = vi.fn();
const showToast = vi.fn();
const gear = {
classList: { contains: () => false, add: vi.fn(), remove: vi.fn() },
@@ -53,9 +62,19 @@ function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string
showToast,
terminal: { focus: vi.fn() },
_localEchoEnabled: !!opts.localEcho,
_localEchoOverlay: opts.localEcho ? { appendText, pendingText: '', clear: vi.fn() } : null,
_localEchoOverlay: opts.localEcho
? { appendText, pendingText: '', clear: overlayClear, suppressBufferDetection: vi.fn() }
: null,
_predictiveEcho: { clearPredictions: vi.fn() },
};
if (opts.focused) app._focusedPane = () => ({ sessionId: opts.focused });
// A full pane record, as terminal-ui.js _focusedPane() returns it. Reassign
// app._focusedPane in a test to move focus between panes.
if (opts.pane) {
const pane = opts.pane;
app._focusedPane = () => pane;
}
if (opts.tiles) app._tilesOwnTerminal = () => true;
const context = vm.createContext({
console,
setTimeout: (fn: () => void) => fn(),
@@ -81,7 +100,12 @@ function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string
// Recording itself is out of scope: start() only has to pick the target.
voice._resolveProvider = () => 'webspeech';
voice._startWebSpeech = vi.fn();
return { voice, app, sendInput, sendInputAsync, appendText, showToast, gear };
return { voice, app, sendInput, sendInputAsync, appendText, overlayClear, showToast, gear };
}
function clickGreenSend(gear: { addEventListener: { mock: { calls: unknown[][] } } }) {
const handler = gear.addEventListener.mock.calls.find((c: unknown[]) => c[0] === 'click')?.[1] as () => void;
handler();
}
describe('dictation target', () => {
@@ -163,6 +187,63 @@ describe('dictation target', () => {
expect(showToast).toHaveBeenCalledWith('That session has closed; dictation not sent', 'warning');
});
it('with the tile grid open, dictation goes to the focused tile, not the parked overlay', () => {
const tileTerminal = { focus: vi.fn() };
const { voice, app, appendText, sendInput, sendInputAsync } = load({
localEcho: true,
tiles: true,
pane: { sessionId: 'session-a', isPrimary: false, terminal: tileTerminal },
});
voice.start();
voice._insertText('into the tile');
expect(appendText).not.toHaveBeenCalled();
expect(sendInput).toHaveBeenCalledWith('into the tile');
expect(sendInputAsync).not.toHaveBeenCalled();
// The keyboard goes back to the tile; the main terminal is display: none.
expect(tileTerminal.focus).toHaveBeenCalled();
expect((app.terminal as { focus: ReturnType<typeof vi.fn> }).focus).not.toHaveBeenCalled();
});
it('with the tile grid open, the green send button sends only Enter to the tile session', () => {
const tileTerminal = { focus: vi.fn() };
const { voice, app, gear, sendInput, overlayClear } = load({
localEcho: true,
tiles: true,
pane: { sessionId: 'session-a', isPrimary: false, terminal: tileTerminal },
});
voice.start();
voice._insertText('ship it');
// Something stale in the parked main overlay must not ride along.
(app._localEchoOverlay as { pendingText: string }).pendingText = 'stale main-terminal text';
clickGreenSend(gear);
expect(overlayClear).not.toHaveBeenCalled();
expect(sendInput).not.toHaveBeenCalledWith('stale main-terminal text');
expect(sendInput).toHaveBeenLastCalledWith('\r');
// The parked main terminal's predictions are not this pane's.
expect(
(app._predictiveEcho as { clearPredictions: ReturnType<typeof vi.fn> }).clearPredictions
).not.toHaveBeenCalled();
});
it('split view is unchanged: a Pane A dictation keeps the overlay and refocuses the main terminal', () => {
const paneB = { focus: vi.fn() };
const { voice, app, appendText, sendInput } = load({ localEcho: true, focused: 'session-a' });
voice.start();
// The user clicked into Pane B while speaking.
app._focusedPane = () => ({ sessionId: 'session-b', isPrimary: false, terminal: paneB });
voice._insertText('for pane a');
expect(appendText).toHaveBeenCalledWith('for pane a');
expect(sendInput).not.toHaveBeenCalled();
expect((app.terminal as { focus: ReturnType<typeof vi.fn> }).focus).toHaveBeenCalled();
expect(paneB.focus).not.toHaveBeenCalled();
});
it('refuses to start with no session at all', () => {
const { voice, app, showToast } = load();
app.activeSessionId = null;