Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ All notable changes to Agent Deck will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed
- **Mobile web terminal input**: mobile clients (`pointer: coarse`) no longer enforce an implicit read-only mode in the web UI. Keystrokes from phones/tablets now flow to the tmux session like any other client. To preserve the previous behavior, start the web server with `agent-deck web --read-only` — the server-side flag now owns read-only enforcement for all devices.

## [1.5.4] - 2026-04-16

### Added
Expand Down
42 changes: 11 additions & 31 deletions internal/web/static/app/TerminalPanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ export function TerminalPanel() {
const containerRef = useRef(null)
const ctxRef = useRef(null) // { terminal, fitAddon, ws, resizeObserver, controller, decoder, reconnectTimer, reconnectAttempt, wsReconnectEnabled, terminalAttached }
const sessionId = selectedIdSignal.value
const isMobile = isMobileDevice()

// Signal vanilla app.js to suppress its terminal path while TerminalPanel is mounted
useEffect(() => {
Expand All @@ -80,8 +79,8 @@ export function TerminalPanel() {

// Cleanup function: dispose terminal, close WS, remove observers.
// PERF-E: a single controller.abort() detaches every event listener
// registered inside the main useEffect (9 total: 4 touch, 1 window
// resize, 1 anonymous mobile touchstart, 4 ws).
// registered inside the main useEffect (8 total: 4 touch, 1 window
// resize, 4 ws).
Comment on lines 81 to +83
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment says there are 8 total event listeners in the main useEffect (4 touch + 1 window resize + 4 ws), but the effect also registers controller.signal.addEventListener('abort', ...) for the preload cleanup. Either update the count/description, or clarify that the count is only for DOM/WebSocket listeners that require { signal } teardown enforcement.

Copilot uses AI. Check for mistakes.
const cleanup = useCallback(() => {
const ctx = ctxRef.current
if (!ctx) return
Expand Down Expand Up @@ -112,7 +111,7 @@ export function TerminalPanel() {
const terminal = new Terminal({
convertEol: false,
cursorBlink: !mobile,
disableStdin: mobile,
disableStdin: false,
fontFamily: 'IBM Plex Mono, Menlo, Consolas, monospace',
fontSize: 13,
scrollback: 10000,
Expand Down Expand Up @@ -151,7 +150,7 @@ export function TerminalPanel() {
fitAddon.fit()

// PERF-E: single AbortController for every listener registered in this
// effect. Calling controller.abort() in the cleanup detaches all 9
// effect. Calling controller.abort() in the cleanup detaches all 8
// listeners in one call -- replaces the previously incomplete manual
// cleanup that only removed touchstart.
const controller = new AbortController()
Expand Down Expand Up @@ -226,22 +225,11 @@ export function TerminalPanel() {
// AbortController (PERF-E). No local dispose handle is needed.
installTouchScroll(container, terminal.element, controller)

// Keyboard input forwarding (desktop only)
let inputDisposable = null
if (!mobile) {
inputDisposable = terminal.onData((data) => {
if (!ctx.ws || ctx.ws.readyState !== WebSocket.OPEN || !ctx.terminalAttached || readOnlySignal.value) return
ctx.ws.send(JSON.stringify({ type: 'input', data }))
})
}

// Prevent mobile soft keyboard by blocking touch-focus on the hidden textarea
if (mobile) {
container.addEventListener('touchstart', (e) => { e.preventDefault() }, {
passive: false,
signal: controller.signal,
})
}
// Keyboard input forwarding (desktop + mobile; server gates on ReadOnly).
const inputDisposable = terminal.onData((data) => {
if (!ctx.ws || ctx.ws.readyState !== WebSocket.OPEN || !ctx.terminalAttached || readOnlySignal.value) return
ctx.ws.send(JSON.stringify({ type: 'input', data }))
})

terminal.writeln('Connecting to terminal...')

Expand Down Expand Up @@ -292,7 +280,7 @@ export function TerminalPanel() {
if (payload.type === 'status') {
if (payload.event === 'connected') {
readOnlySignal.value = !!payload.readOnly
if (terminal) terminal.options.disableStdin = !!payload.readOnly || mobile
if (terminal) terminal.options.disableStdin = !!payload.readOnly
wsStateSignal.value = 'connected'
} else if (payload.event === 'terminal_attached') {
ctx.terminalAttached = true
Expand Down Expand Up @@ -340,7 +328,7 @@ export function TerminalPanel() {

// Cleanup on unmount or sessionId change
return () => {
if (inputDisposable) inputDisposable.dispose()
inputDisposable.dispose()
clearTimeout(resizeTimer)
cleanup()
}
Expand All @@ -352,14 +340,6 @@ export function TerminalPanel() {

return html`
<div class="flex flex-col h-full">
${isMobile && html`
<div class="px-3 py-1.5 text-xs font-medium text-center
dark:bg-tn-yellow/20 dark:text-tn-yellow
bg-yellow-100 text-yellow-800
border-b dark:border-tn-muted/20 border-yellow-200">
READ-ONLY: terminal input is disabled on mobile
</div>
`}
<div class="flex-1 min-h-0 min-w-0 p-sp-16 overflow-hidden">
<div ref=${containerRef} class="h-full w-full overflow-hidden" />
</div>
Expand Down
24 changes: 22 additions & 2 deletions tests/e2e/visual/p1-bug6-terminal-padding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,34 @@ test.describe('BUG #6 / LAYT-03 — terminal panel has 16px padding on all edges
).toBe(true);
});

test('structural: mobile READ-ONLY banner is untouched', () => {
test('structural: mobile READ-ONLY banner is absent (mobile input enabled)', () => {
const src = readTerminalPanelSrc();
expect(
/READ-ONLY: terminal input is disabled on mobile/.test(src),
'TerminalPanel.js mobile READ-ONLY banner was removed — LAYT-03 must leave the mobile banner above the padded wrapper intact.',
'TerminalPanel.js must NOT render the legacy mobile READ-ONLY banner; mobile input is enabled and only the server --read-only flag disables input now.',
).toBe(false);
Comment on lines +80 to +85
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spec docstring still states "Keep the mobile READ-ONLY banner above the wrapper untouched", but the updated structural test below now requires that banner to be absent (mobile input enabled). Please update the docstring to reflect the new intended behavior so the test documentation matches the assertions.

Copilot uses AI. Check for mistakes.
});

test('structural: terminal.onData is not gated on !mobile', () => {
const src = readTerminalPanelSrc();
expect(
/if \(!mobile\)\s*\{\s*inputDisposable\s*=\s*terminal\.onData/.test(src),
'TerminalPanel.js must not gate terminal.onData behind !mobile; mobile input is enabled.',
).toBe(false);
expect(
/terminal\.onData\s*\(/.test(src),
'TerminalPanel.js must retain an unconditional terminal.onData(...) call to forward keystrokes to the tmux bridge.',
).toBe(true);
});

test('structural: disableStdin is not OR-ed with mobile on status messages', () => {
const src = readTerminalPanelSrc();
expect(
/disableStdin\s*=\s*!!payload\.readOnly\s*\|\|\s*mobile/.test(src),
'TerminalPanel.js must not OR mobile into disableStdin; only payload.readOnly should disable input.',
).toBe(false);
});

// RUNTIME — skips without a fixture session; measures real computed styles.
test('runtime: xterm wrapper has 16px padding on all four edges', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
Expand Down
15 changes: 8 additions & 7 deletions tests/e2e/visual/p8-perf-e-listener-cleanup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ import { join } from 'path';
* Phase 8 / Plan 02 / Task 1: PERF-E regression test (TerminalPanel listener leak).
*
* CURRENT STATE (verified by grep of TerminalPanel.js as of 2026-04-09):
* - 9 addEventListener sites inside the main useEffect:
* - 8 addEventListener sites inside the main useEffect:
* - 4 container touch listeners (touchstart, touchmove, touchend, touchcancel)
* - 1 window resize listener (already uses a LOCAL windowResizeController -- partially done)
* - 1 anonymous touchstart preventDefault on container (mobile only)
* - 4 ws.addEventListener (open, message, error, close)
* - The mobile-only anonymous touchstart preventDefault was removed when
* mobile console input was enabled; that dropped the site count from 9 to 8.
* - Only 1 of these currently uses controller.signal (the window resize block).
* - The existing cleanup at line 67 only manually removes the first touchstart; the
* remaining 8 listeners leak on every unmount / reconnect.
* remaining 7 listeners leak on every unmount / reconnect.
Comment on lines +9 to +17
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The header comment block is now inconsistent with TerminalPanel.js: it claims there are 8 addEventListener sites and that only 1 uses controller.signal, plus it describes an existing leak. In the current TerminalPanel.js, all touch/window/ws listeners already pass { signal: controller.signal } and cleanup calls controller.abort(). Please update/remove the outdated 'CURRENT STATE' commentary so the spec doesn’t mislead future maintenance.

Copilot uses AI. Check for mistakes.
*
* FIX TO ENFORCE (PERF-E):
* - ONE new AbortController() declared at the top of the useEffect.
Expand Down Expand Up @@ -53,13 +54,13 @@ test.describe('PERF-E -- TerminalPanel listener cleanup via AbortController', ()
).toBe(true);
});

test('structural: contains controller.signal at least 9 times (one per addEventListener site)', () => {
test('structural: contains controller.signal at least 8 times (one per addEventListener site)', () => {
const src = source();
const matches = src.match(/controller\.signal/g) || [];
expect(
matches.length,
`Expected controller.signal to appear on every addEventListener site (>=9), found ${matches.length}. Sites: 4 touch on container + 1 window resize + 1 anonymous touchstart + 4 ws.`,
).toBeGreaterThanOrEqual(9);
`Expected controller.signal to appear on every addEventListener site (>=8), found ${matches.length}. Sites: 4 touch on container + 1 window resize + 4 ws. (Mobile-only touchstart preventDefault was removed when mobile input was enabled.)`,
).toBeGreaterThanOrEqual(8);
Comment on lines +57 to +63
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PERF-E structural scanner no bare addEventListener call lacking a signal option will currently flag controller.signal.addEventListener('abort', ...) in TerminalPanel.js, because it matches addEventListener( but cannot (and should not) include an options object with signal:. This will make the test fail/flake unless the scanner excludes AbortSignal/EventTarget cases like *.signal.addEventListener(...) or is scoped to the DOM/WebSocket listeners you actually want to enforce.

Copilot uses AI. Check for mistakes.
Comment on lines +57 to +63
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The controller.signal count assertion is internally inconsistent: the message enumerates 4 touch + 1 window resize + 4 ws = 9 addEventListener sites, but the test only requires >= 8. With the mobile-only touchstart removed, the minimum should still be 9 (or 10 if you also count the controller.signal.addEventListener('abort', ...) handler). As written, this check could pass even if one listener site regresses and drops controller.signal.

Copilot uses AI. Check for mistakes.
});

test('structural: contains controller.abort() in the effect cleanup', () => {
Expand Down Expand Up @@ -91,7 +92,7 @@ test.describe('PERF-E -- TerminalPanel listener cleanup via AbortController', ()
).toEqual([]);
});

test('structural: no manual removeEventListener for the 9 migrated listeners', () => {
test('structural: no manual removeEventListener for the 8 migrated listeners', () => {
const src = source();
// The AbortController pattern replaces manual removeEventListener.
// If any removeEventListener for touch* events remains, the cleanup
Expand Down