fix(tiles): send a tile's click report ephemeral, review follow-up

The tile's hand-encoded click report went through _handleDesktopTerminalClick
and _sendSyntheticSgrTap to _sendInputAsync, so it took a seq, was persisted
and would be redelivered after a reload. The documented TerminalTile rule
(CLAUDE.md, Split-pane sessions) is that only typed input enters that queue
and focus/mouse reports go out ephemeral, and the tile's own _onTerminalData
says the same. Before #555 an opencode tile's click went through xterm's
encoder and that ephemeral path. A click still unacknowledged when the page
reloads, or sent during a server restart, could be replayed onto a later
screen, where a press+release can pick a dialog option.

_sendSyntheticSgrTap now takes an opt-in `ephemeral` field on its target and
sends through _sendInputEphemeral when it is set; _handleDesktopTerminalClick
passes the target through unchanged, and TerminalTile._installClickListener
sets it. Without the flag nothing changes, so the primary pane's own click
and touch tap reports stay on _sendInputAsync exactly as before (whether the
primary pane should also go ephemeral is a separate question, out of scope
here).

Tests: the tile case now requires a frame with no seq and nothing pending in
the reliable queue, and the targeted-click case in terminal-touch-tap spies on
both send paths: a target with the flag goes ephemeral, an untargeted click
and an untargeted tap stay durable. Dropping `ephemeral: true` from the tile,
or the branch in _sendSyntheticSgrTap, turns the matching test red.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 08:42:03 +02:00
parent 24a73ecd81
commit a96a94fb7e
5 changed files with 50 additions and 15 deletions
+5 -1
View File
@@ -28,7 +28,8 @@
* wheel-down from those overflow rows is xterm's, and brings it home).
* - The desktop click report: a plain left-click hand-encoded as SGR while
* the session's CLI has mouse tracking on (cliMouseTracking), for the modes
* whose mouse DECSETs the server strips (_installClickListener).
* whose mouse DECSETs the server strips (_installClickListener), sent
* ephemeral, like every mouse report from this pane (_onTerminalData).
* - The soft-keyboard controller (terminal-keycode229-recovery.js), one per
* pane, on this pane's own textarea and composition helper and sending to
* this pane's session (_createKeyCode229Recovery): it forwards an
@@ -860,6 +861,9 @@
terminal: this.terminal,
sessionId: this.sessionId,
linkHovered: this._linkHovered,
// Like every mouse report from this pane (_onTerminalData): once,
// never persisted, so a reload cannot replay it onto a later screen.
ephemeral: true,
});
};
this.mountEl.addEventListener('click', this._onClick);
+16 -7
View File
@@ -5465,8 +5465,13 @@ Object.assign(CodemanApp.prototype, {
// Encode a tap as an SGR mouse report (press + release at button 0) and send it
// to the PTY directly, bypassing xterm's mouse encoder. `target` ({ terminal,
// sessionId }) aims it at a TerminalTile instead of the primary pane; either
// field left out means the primary pane's.
// sessionId, ephemeral }) aims it at a TerminalTile instead of the primary
// pane; either of the first two left out means the primary pane's.
// `ephemeral: true` sends it through _sendInputEphemeral instead of the
// persisted exactly-once queue: a TerminalTile's mouse reports never enter
// that queue (CLAUDE.md, Split-pane sessions), or a reload would replay one
// onto a later screen. Left out, the report stays on _sendInputAsync, as the
// primary pane always sent it.
_sendSyntheticSgrTap(clientX, clientY, target = {}) {
const sessionId = target.sessionId || this.activeSessionId;
const terminal = target.terminal || this.terminal;
@@ -5474,7 +5479,9 @@ Object.assign(CodemanApp.prototype, {
if (!this._terminalViewportAtBottom(terminal)) return; // scrollback click → misfire, do nothing
const pos = this._clientPointToCell(clientX, clientY, terminal);
if (!pos) return;
this._sendInputAsync(sessionId, `\x1b[<0;${pos.col};${pos.row}M\x1b[<0;${pos.col};${pos.row}m`);
const report = `\x1b[<0;${pos.col};${pos.row}M\x1b[<0;${pos.col};${pos.row}m`;
if (target.ephemeral) this._sendInputEphemeral(sessionId, report);
else this._sendInputAsync(sessionId, report);
},
// True when a parsed CLI version string ('2.1.187' — banner-parsed on the
@@ -5758,10 +5765,12 @@ Object.assign(CodemanApp.prototype, {
// clicks outside the cell grid, and sessions where xterm's own encoder is
// live (it reported the click itself — a second report would double-move).
//
// `target` ({ terminal, sessionId, linkHovered }) runs the same skips for a
// TerminalTile's click: its own terminal, its own session's tracking flag and
// its own link hover (the primary pane's _linkHovered belongs to its terminal
// alone). Every field left out means the primary pane's.
// `target` ({ terminal, sessionId, linkHovered, ephemeral }) runs the same
// skips for a TerminalTile's click: its own terminal, its own session's
// tracking flag and its own link hover (the primary pane's _linkHovered
// belongs to its terminal alone). `ephemeral` reaches _sendSyntheticSgrTap,
// so a TerminalTile's mouse report never enters the persisted input queue.
// Every field left out means the primary pane's.
_handleDesktopTerminalClick(ev, target = {}) {
const terminal = target.terminal || this.terminal;
if (!terminal || !ev?.isTrusted) return;