Compare commits

...
Author SHA1 Message Date
Codeman maintainer cf3183abf7 chore: version packages
Release 1.16.6: phone overview started/idle stamps, plus fixes for the
selection-dialog keyboard lockout, the accessory bar arrows bypassing the
local-echo overlay, and recovered sessions being restamped as newly created
on every server restart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 23:14:57 +02:00
Codeman maintainer 15a43894f9 chore: version packages 2026-08-11 19:36:19 +02:00
Codeman maintainer e20aa1d4d8 style(settings): pair Save and Close into one tray in the phone sheet header
Below 860px Save moves into the header (a bottom action bar would cost 60px
of a phone sheet), which left the two ways OUT of the sheet sitting side by
side in mismatched shapes: a fat accent pill next to a bare 1.5rem glyph
with no box at all. They are the same decision (save-and-close vs
discard-and-close), hit in the same corner with the same thumb, so they now
share a recessed tray and matching pill geometry and read as one cluster.

- 36px on both, so the tray comes out at 44px including its 3px padding and
  1px border — the same height as the phone header it sits in.
- `.modal-close` gets a real box (36x36, radius 9) only inside the tray; its
  bare-glyph form is still right in a plain modal header.
- Tray colors come from skin tokens (--border/--bg-input). A hardcoded black
  alpha would render as a grey slab on the four light skins, the same trap
  the layout preview frame hit.
- `:has(.set-head-save)` keeps the tray off the sheets that carry a lone x:
  Session Options and Add Case save from inside their own forms.
- The shared focus ring offsets OUTWARD, which inside the tray would draw on
  top of the tray border, so it is inset to ring the button instead.

DOM order stays close-then-save so the focus trap still lands on Close;
row-reverse paints Save to its left.

Verified at 390x844: tray 44px tall, Save 36px, Close 36x36, both radius 9
inside a 12-radius tray. PostCSS-parsed (prettier does not catch an unclosed
CSS block, and styles.css is prettier-ignored by design).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:34:21 +02:00
Codeman maintainer aa28ef048c fix(mobile): reconcile the two keyboard-dismiss paths (#279 + #280)
#279 and #280 auto-merge cleanly, but the merged result was red: neither
branch could see the other, and CI cannot see either, because the only test
covering #279 lives in test/mobile/** which test:ci excludes.

Two problems, both in #279's test:

1. The in-terminal case tapped the terminal's top-left corner, i.e. an inert
   transcript row, and asserted focus was retained. That is precisely the
   gesture #280 redefines, so #280 turned it red. Aim it at the PROMPT row
   instead: the one in-terminal tap whose outcome neither PR claims, so it
   still proves the #terminalContainer exemption without asserting the
   toggle's behaviour.

2. The "a real control is exempt" case was VACUOUS. It picked the first
   button measuring >8px, which is .welcome-ralph-link inside the welcome
   overlay hideWelcome() had already hidden: the rect still measures, but
   elementFromPoint at that point returns .xterm-screen, so the case tapped
   the TERMINAL and passed for the wrong reason. It only surfaced because
   #280 changed what a terminal tap does. Require the sampled point to
   actually resolve to the button, and fail loudly when no control is
   usable rather than silently asserting nothing.

Mutation-checked: removing the install, the #terminalContainer exemption,
the control exemption or the `if (moved) return` scroll guard each turns
the test red on its own. The control exemption had no coverage before.

Also fold the duplicated tap slop into one constant: initTerminal's
TAP_THRESHOLD now reads MOBILE_KEYBOARD_DISMISS_TAP_SLOP instead of
re-declaring 8, since a drift between them is exactly the bug the second
#279 commit fixed. And restore the comment the slop constant was inserted
into the middle of, which left "Regions where a tap must NOT dismiss"
sitting above the slop rather than the selector it documents.

test/mobile/keyboard.test.ts: 5 failed | 47 passed (52). Master is
5 failed | 46 passed (51) — the same five pre-existing failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:34:00 +02:00
Ark0N 67f6ed3168 Merge pull request #280 from Lint111/feat/mobile-tap-toggles-keyboard 2026-08-11 19:33:41 +02:00
Ark0N 2d4616f059 Merge pull request #279 from Lint111/feat/mobile-keyboard-dismiss 2026-08-11 19:33:33 +02:00
liorandClaude Opus 5 35f8f9d19f fix(mobile): let a second tap on inert transcript close the keyboard
Every terminal tap re-focuses the hidden textarea, so once the on-screen keyboard
is open the only way to close it is the accessory bar's dismiss chevron. Tapping
the transcript to get the screen back is the obvious gesture and it did nothing.

A tap on INERT content with the keyboard already up now dismisses it. Nothing
else claims that gesture: an inert row has no action to trigger, so by that point
the tap has already done its only other job (the mouse report).

Scoped to 'content' ON PURPOSE. The prompt row ('input') keeps
focus-then-position, so a second tap there still places the caret — that is real
capability and trading it away would be a worse deal than the bug. A separate
test pins it rather than leaving it to the reader.

Actionable rows are unchanged: readbacks, "esc to interrupt" status rows and menu
selections still blur via _isActionableMobileTerminalTap, which runs first.

`keeps the hidden keyboard input focused after an inert Claude transcript tap`
asserted the OLD behaviour and is renamed and inverted, since revising that
behaviour is the point of this change. Its setup already focused the terminal
before tapping, so it was always exercising the second-tap case.

test/terminal-touch-tap.test.ts: 28 tests. The two new ones fail on master —
`closes the keyboard on a second tap of INERT transcript content` behaviourally,
by asserting blur where master re-focuses.

test/mobile/keyboard.test.ts: 51 tests, 5 failed | 46 passed — the same five
pre-existing failures as master, untouched here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 20:31:38 +03:00
liorandClaude Opus 5 c992784681 test(mobile): cover the scroll case in the keyboard-dismiss test
The dismiss handler fired on any touchend, so a scroll closed the keyboard too —
a regression the original test could not see, because it only ever dispatched a
stationary tap.

The helper now takes an optional travel distance and emits touchmove steps, and
the test asserts a 120px scroll leaves the terminal input focused. Removing the
`if (moved) return` guard fails this assertion, so it genuinely pins the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:31:09 +03:00
liorandClaude Opus 5 3a7be356ae fix(mobile): do not dismiss the keyboard when a scroll ends
Regression from the dismiss handler in #279: it fired on any touchend,
and a scroll ends in touchend too. Scrolling to read something while composing
closed the keyboard and dropped the composer — worse than the bug it fixed.

Track finger travel from touchstart and only treat a near-stationary gesture as
a tap, using the same 8px TAP_THRESHOLD the terminal's own touch handling uses
so both agree on tap-vs-scroll. Multi-touch is never a dismissing tap.

All three listeners stay passive; nothing calls preventDefault.

Measured on a Pixel-class viewport with a Firefox UA:
  tap                -> dismissed
  scroll (120px)     -> keyboard kept
  micro-drift (4px)  -> dismissed, so an imprecise tap still works

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:21:05 +03:00
liorandClaude Opus 5 a6a572e635 fix(mobile): close the on-screen keyboard when tapping outside the terminal
On a phone the terminal holds focus on a hidden textarea, and nothing ever
released it. Once the keyboard was up, tapping the header, the tab strip or any
empty page chrome left it up — covering roughly half the screen with no in-app
way to dismiss it.

Repro, iPhone-class viewport (390x844), claude-mode session, focus the terminal
then tap the header logo:

| | document.activeElement after the tap |
| --- | --- |
| master | textarea.xterm-helper-textarea (keyboard stays up) |
| this branch | body (keyboard closes) |

A document-level touchend handler blurs the terminal input, deliberately scoped
so focus is never stolen from something that wants it:

- only when the terminal input actually holds focus;
- never inside #terminalContainer — _handleMobileTerminalTap already classifies
  and routes those taps and owns that decision;
- never on a control. Anything focusable or clickable is about to take focus
  itself, and the keyboard accessory bar exists to be used WHILE the keyboard is
  open, so dismissing there would fight the user.

Bound to touchend rather than click: a tap meant to dismiss usually is not meant
to activate what sits underneath, and touchend fires before the synthesized
click so the blur lands first. The listener is passive — it never calls
preventDefault.

Test: `dismisses the on-screen keyboard when a tap lands outside the terminal`
in test/mobile/keyboard.test.ts. It fails on master with a BEHAVIOURAL assertion
(`expected 'xterm-helper-textarea' not to contain 'xterm-helper-textarea'`),
not a TypeError, and passes here. It drives real dispatched touch events rather
than calling the helper, because the handler is bound on document and a direct
call would bypass the routing under test.

test/mobile/keyboard.test.ts: 52 tests, 5 failed | 47 passed. Master is 51 tests,
5 failed | 46 passed — the same five pre-existing failures (stale layout and
accessory-bar expectations, a CJK timeout), untouched here.

Full suite: 4944 passed | 12 skipped, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:37:27 +03:00
15 changed files with 838 additions and 34 deletions
+52
View File
@@ -1,5 +1,57 @@
# aicodeman
## 1.16.6
### Patch Changes
- Phone home screen now shows session ages, plus three mobile input fixes.
**Phone overview: started / how long stamps.** Every live session row on the "C" home screen carries a third line: when the session first started, and how long it has been in the state it is in ("started 3d ago · idle 12m"). Idle, waiting, error and ended states measure from the pane's last output, which for a Claude pane sitting at its composer is exactly when the turn ended; a WORKING session measures from its last Enter instead, because a running pane repaints about once a second and would otherwise report every turn as 0m. A 20s clock rewrites the values in place rather than re-rendering, so no row's blink or pulse restarts.
**Fix: a recovered session was restamped as new on every restart.** Boot recovery never passed `createdAt`, so each server start reset it to `Date.now()` and a week-old pane reported "created 2m ago" (and sorted as the newest thing in the unified session list). It now comes from the tmux session's own birth time, which mux-sessions.json already carried. The desktop home rail's "created" stamp is fixed by the same change.
**Fix: a selection dialog locked the on-screen keyboard out of the terminal (regression in 1.16.5).** The check that decides whether a tap belongs to the TUI scanned the whole viewport for a numbered menu, so while a Claude question or permission dialog was on screen EVERY tap in the terminal counted as actionable and blurred the input. The keyboard could not be opened at all until the dialog was answered, which left tapping an option, the one gesture that commits an answer, as the only interaction a phone had. The menu test is now row-local: the dialog's own rows still report the tap and keep the keyboard down, while the question title, the transcript and blank space summon the keyboard so a digit can be typed at the dialog instead of aimed at it.
**Fix: the accessory bar's arrow keys bypassed the local-echo overlay.** On a phone the text you type is buffered in the browser and has never reached the PTY, so an arrow tapped on the bar arrived at a composer the CLI still considered empty: Up recalled a history entry into it while the overlay went on painting the draft over the same row and still believed it was pending, and the next Enter submitted the two mixed together. The four arrows now flush the draft first and hand the session to plain PTY echo, the same contract a nav key typed on a hardware keyboard has had since #218. The CLI stashes the flushed draft, so Down brings it back. Tab now shares that one flush helper instead of its own copy.
## 1.16.5
### Patch Changes
- Mobile keyboard dismissal, and a tidier Save/Close pair in the phone settings sheet.
**The on-screen keyboard can finally be closed from inside the app.** The terminal
keeps focus on a hidden textarea and nothing ever released it, so once the keyboard
was up it covered roughly half the screen with no way out but the OS back gesture.
Two gestures now dismiss it:
- **A tap outside the terminal** (header, tab strip, empty page chrome). Deliberately
narrow: it only fires while the terminal input actually holds focus, never inside
the terminal (tap classification owns that decision), and never on a control, since
anything focusable is about to take focus itself and the keyboard accessory bar
exists to be used _while_ the keyboard is open. A scroll ends in `touchend` too, so
finger travel is tracked from `touchstart` and only a near-stationary gesture counts
as a tap, sharing the terminal's own 8px threshold so both agree on tap-vs-scroll.
Scrolling to read something mid-compose no longer drops the composer.
- **A second tap on inert transcript content.** Every terminal tap used to re-focus,
which left the accessory bar's chevron as the only way out. Scoped to inert rows on
purpose: the prompt row keeps focus-then-position, so a second tap there still
places the caret, and actionable rows (readbacks, `esc to interrupt` status rows,
menu selections) still blur as before.
**Settings sheet header on phones.** Below 860px Save moves into the header, which
left the two ways out of the sheet as a fat accent pill beside a bare glyph. Save and
Close now share a recessed tray with matching 36px pill geometry, reading as one
44px cluster the height of the phone header. Tray colors come from skin tokens, so
the light skins keep their look, and the tray stays off the sheets that carry a lone
close button.
Also fixes a test that could never have caught a regression: the case asserting that
tapping a control does _not_ dismiss the keyboard was picking a button from the
hidden welcome overlay, whose rect still measures while the hit-test lands on the
terminal underneath, so it passed for the wrong reason and stayed green even with the
exemption deleted. All four guards in the dismiss handler are now individually
pinned.
## 1.16.4
### Patch Changes
+3 -1
View File
@@ -74,7 +74,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 1.16.4 (must match `package.json`)
**Version**: 1.16.6 (must match `package.json`)
## Project Overview
@@ -280,6 +280,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Shell keyboard accessory bar + one-shot Ctrl** (issue #262, `keyboard-accessory.js`): a **shell**-mode session automatically swaps the mobile accessory bar for terminal controls (Ctrl, Esc, Tab, four arrows, paste, dismiss); every other mode keeps the agent bar. `setMode()` now records the user's `extendedKeyboardBar` preference as the **base** layout and `refreshForActiveSession()` (called from `selectSession`) resolves base-vs-shell, so a settings save during a shell session cannot yank the bar away and switching back restores the user's choice. ⚠️ **Ctrl is a ONE-SHOT modifier applied in `terminal.onData`, not in a keydown handler**: a virtual keyboard emits no usable key events, so the character only exists as onData text. The hook sits AFTER `shouldSuppressTerminalQueryResponse` (xterm answers DA/CPR through onData too, and one of those would silently spend the modifier) and BEFORE every send path, so the control byte follows the normal control-char route. ⚠️ **Not every onData chunk is a keystroke**, and the query filter is not enough on its own: xterm ALSO emits mouse and focus reports on its own initiative, so the hook skips them via `isTerminalFocusOrMouseReport()` (they still reach the PTY, they just don't count as the next key). The mouse half is live — a shell session keeps the NARROW strip, so mouse DECSETs reach the browser and one tap while vim/htop runs spent the armed modifier silently (measured). The focus half is defense in depth: `FOCUS_ESCAPE_FILTER` in `session.ts` strips `\x1b[?1004h` from every PTY read, so `sendFocusMode` never turns on today; if it ever did, the bar's own post-key refocus would emit `\x1b[I` and eat the modifier before the user typed. ⚠️ It must disarm on ALL of: use, second tap, any other accessory key, session switch, keyboard dismissal, and a layout swap; a modifier left armed turns the next innocent keystroke into a control byte. ⚠️ **onData is not the only input path** — with `cjkInputEnabled` on, the CJK textarea owns the keyboard (onData returns early for everything it swallows, and the focus router sends `terminal.focus()` there, which is where the bar refocuses after every key), so `_handleCjkInput()` applies the modifier too. It is that module's single choke point to the PTY, so one call covers typed characters, IME flushes, Enter, backspace and arrows. Without it an armed modifier could neither fire NOR be spent, and survived to a later keystroke. Mapping is `ctrlByteFor()` (`code & 0x1f` over @A-Z[\]^_ and a-z, plus Ctrl+Space=NUL / Ctrl+?=DEL); characters with no control equivalent pass through unchanged, like a hardware keyboard. ⚠️ The armed style is `.accessory-btn.accessory-btn-ctrl.armed` (0,3,0) in BOTH stylesheets, and it cannot outrank mobile.css's light-skin repaint at **(0,3,1)** (`:is()` inherits its most specific argument, and that list holds `.btn-toolbar.btn-shell`) — so that rule excludes the state by hand as `.accessory-btn:not(.armed)`. Without the exclusion the armed button renders identically to a resting one on all four light skins, which is worse than no armed style at all.
**Dismissing the on-screen keyboard** (PRs #279/#280, `terminal-ui.js`): the terminal parks focus on a hidden textarea that nothing used to release, so TWO gestures now blur it, and they own different regions. **(1)** `_installMobileKeyboardDismiss()` — a document-level `touchend` that fires only while the terminal input actually holds focus, **never inside `#terminalContainer`** (tap classification owns that) and **never on a control** (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, matched with `closest()` so an icon inside a button counts). Session tabs are covered by the selector's `[tabindex]:not([tabindex="-1"])` arm, which is what stops a tab tap from blurring and then being re-focused by `selectSession()`. **(2)** In `_handleMobileTerminalTap`, a second tap on **inert `content`** (`startedWithTerminalFocus`) blurs instead of re-focusing. ⚠️ Scoped to `content` on purpose: the prompt row (`input`) keeps focus-then-position so a second tap still places the caret, and actionable rows blur earlier via `_isActionableMobileTerminalTap`. ⚠️ **A scroll ends in `touchend` too** — dismissing there closes the keyboard and drops the composer mid-read, so travel is tracked from `touchstart` and multi-touch is never a tap. Both classifiers MUST share one threshold: `initTerminal`'s `TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`, since a gesture the terminal calls a scroll and the dismiss handler calls a tap is exactly that bug. ⚠️ **`test:ci` excludes `test/mobile/**`, so CI cannot see the only test covering (1)** — run `npm test -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. That blind spot is why merging the two PRs, which conflicted semantically but not textually, produced a red suite with two green CI checks.
**Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 430px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
⚠️ **`sendEnterKey()` MUST go through `terminal._core.coreService.triggerDataEvent('\r', true)`** — not `sendInput()`, and never a raw POST to `/api/sessions/:id/input`. `localEchoEnabled` defaults to `MobileDetection.isTouchDevice()`, so on every phone the characters you type are buffered in the `LocalEchoOverlay` and have **never reached the PTY**; the `onData` Enter branch in terminal-ui.js is what flushes `pendingText` first and only then sends `\r` (after an 80ms delay so text lands first). Sending a bare `\r` submits an empty line and strands the typed text on screen, so the button looks dead. Replaying the keypress reuses the overlay flush, the flushed-offset cleanup and the ordering instead of reimplementing them. `KeyboardAccessory.sendKey()` is for escape sequences (arrows/Esc) and is the WRONG template to copy for input.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.16.4",
"version": "1.16.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.16.4",
"version": "1.16.6",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.16.4",
"version": "1.16.6",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+6 -1
View File
@@ -591,7 +591,12 @@ export class Session extends EventEmitter {
this.mode = config.mode || 'claude';
this._name = config.name || '';
this._resumeSessionId = config.resumeSessionId;
this._lastActivityAt = this.createdAt;
// NOW, not `createdAt`: recovery passes the ORIGINAL creation time of a
// days-old tmux session, and seeding last-activity from it would report a
// freshly re-attached pane as having been silent for days, which the idle
// confirmation reads as "already quiet" and the home screens print as its
// idle duration. For a genuinely new session the two are the same instant.
this._lastActivityAt = Date.now();
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
this._claudeSessionId = config.resumeSessionId || this.id;
// Restored from state.json on boot recovery. start() resets _claudeSessionId
+52 -21
View File
@@ -729,16 +729,16 @@ const KeyboardAccessoryBar = {
this.toggleCtrl();
break;
case 'scroll-up':
this.sendKey('\x1b[A');
this.sendNavKey('\x1b[A');
break;
case 'scroll-down':
this.sendKey('\x1b[B');
this.sendNavKey('\x1b[B');
break;
case 'arrow-left':
this.sendKey('\x1b[D');
this.sendNavKey('\x1b[D');
break;
case 'arrow-right':
this.sendKey('\x1b[C');
this.sendNavKey('\x1b[C');
break;
case 'esc':
this.sendKey('\x1b');
@@ -746,26 +746,12 @@ const KeyboardAccessoryBar = {
case 'opt-enter':
this.sendKey('\x1b\r');
break;
case 'tab': {
case 'tab':
// Tab means "complete what I just typed", but with local echo the typed
// text is still buffered in the overlay and has never reached the PTY —
// a bare \t would ask the CLI to complete an empty composer. Flush the
// pending text first (same steps as the Shift+Enter branch in
// terminal-ui.js), then send \t after the sendCommand settle delay.
const overlay = app._localEchoOverlay;
const pending = (app._localEchoEnabled && overlay?.pendingText) || '';
if (pending) {
overlay.clear();
overlay.suppressBufferDetection?.();
app._flushedOffsets?.delete(app.activeSessionId);
app._flushedTexts?.delete(app.activeSessionId);
app.sendInput(pending);
setTimeout(() => this.sendKey('\t'), 120);
} else {
this.sendKey('\t');
}
// a bare \t would ask the CLI to complete an empty composer.
this.flushPendingThen(() => this.sendKey('\t'));
break;
}
case 'shift-tab':
this.sendKey('\x1b[Z');
break;
@@ -860,6 +846,51 @@ const KeyboardAccessoryBar = {
setTimeout(() => app.sendInput('\r'), 120);
},
/**
* Flush whatever the local-echo overlay is still holding, THEN run `after()`.
*
* On a phone the characters you type sit in the overlay and have never
* reached the PTY, so any key that acts on "what I just typed" has to push
* that text out first or the CLI acts on an empty composer. Mirrors the
* flush the typed path performs in terminal-ui.js's onData; the 120ms is the
* same settle delay sendCommand uses, so the text lands before the key.
*/
flushPendingThen(after) {
const overlay = app._localEchoOverlay;
const pending = (app._localEchoEnabled && overlay?.pendingText) || '';
if (!pending) {
after();
return;
}
overlay.clear();
overlay.suppressBufferDetection?.();
app._flushedOffsets?.delete(app.activeSessionId);
app._flushedTexts?.delete(app.activeSessionId);
app.sendInput(pending);
setTimeout(after, 120);
},
/**
* A composer nav key (the four arrows) from the bar, under the SAME contract
* as pressing one on a hardware keyboard (the `isComposerNavKey` branch of
* terminal-ui.js's onData): flush the unsent draft so the key edits the real
* composer, then hand the session to plain PTY echo until Enter or Ctrl+C,
* because after a nav key the cursor can sit mid-text where the overlay's
* append-only buffering cannot track edits (issue #218).
*
* Without the flush the arrow reached a composer the CLI still saw as EMPTY:
* Up recalled a history entry into it while the overlay went on painting the
* draft over the same row and still believed it was pending. The draft was
* then submitted on top of the recalled text, and history recall looked
* broken because what came back was never what the row showed.
*/
sendNavKey(sequence) {
if (!app.activeSessionId) return;
if (!app._echoPassthroughSessions) app._echoPassthroughSessions = new Set();
app._echoPassthroughSessions.add(app.activeSessionId);
this.flushPendingThen(() => this.sendKey(sequence));
},
/** Send a special key (arrow, escape, etc.) directly to the PTY.
* Bypasses tmux send-keys -l (literal mode) since escape sequences
* must be written raw to be interpreted as key presses by Ink. */
+160
View File
@@ -14,12 +14,19 @@
* only this module removes it, so desktop (which never loads mobile.css) cannot
* render an unstyled overview even if a class rule leaked.
*
* Each live row also carries when the session FIRST started and how long it has
* been in the state it is in ("started 3d ago · idle 12m"). Both go stale on
* their own (a sitting session emits no event), so a slow clock rewrites them
* IN PLACE from the epoch ms parked on the elements, never by re-rendering,
* which would restart every row's blink and pulse.
*
* Everything renders from state the page already holds (`this.sessions`,
* `this.cases`, `this.pendingHooks`) — no endpoint, no SSE event, no schema.
* `buildMobileOverviewModel()` is pure and unit-tested (test/mobile-overview.test.ts).
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession, run)
* @dependency ralph-panel.js (formatRelativeTime, the app's one relative-time formatter)
* @dependency mobile-handlers.js (MobileDetection)
* @dependency session-ui.js (selectQuickStartCase for "New session here")
* @loadorder 12.55 of 16, after webview-tabs.js, before entrance-animations.js
@@ -64,6 +71,23 @@ const MOBILE_OVERVIEW_PILL_LABEL = {
done: 'done',
};
/**
* Label for the "how long has it been like this" stamp, per state. The pill
* already names the state, so this word is there to say what the duration next
* to it is measuring.
*/
const MOBILE_OVERVIEW_SINCE_LABEL = {
needs: 'waiting',
waiting: 'waiting',
error: 'failed',
working: 'working',
idle: 'idle',
done: 'ended',
};
/** How often the age stamps are rewritten in place while the home screen is up. */
const MOBILE_OVERVIEW_CLOCK_MS = 20000;
Object.assign(CodemanApp.prototype, {
// ═══════════════════════════════════════════════════════════════
// Model (pure)
@@ -87,6 +111,29 @@ Object.assign(CodemanApp.prototype, {
return 'idle';
},
/**
* Anchor + label for the row's second stamp: how long the session has been in
* the state it is in.
*
* For everything that is NOT working that anchor is `lastActivityAt`, the last
* byte the pane printed: a Claude pane sitting at its composer prints nothing,
* so the end of the last turn is exactly when the session went quiet.
*
* A WORKING pane is the opposite: it repaints about once a second, so its
* last-activity stamp is always "now" and would report every running turn as
* 0m. The turn's own start is the pane's last Enter (`lastSubmitAt`), which is
* persisted server-side and therefore survives a Codeman restart. A session
* that has never submitted has no anchor at all, and gets no stamp rather than
* a made-up one.
*
* @returns {{key: string, at: number}|null}
*/
_mobileOverviewSince(state, session) {
const at = state === 'working' ? Number(session.lastSubmitAt) || 0 : Number(session.lastActivityAt) || 0;
if (!at) return null;
return { key: MOBILE_OVERVIEW_SINCE_LABEL[state] || state, at };
},
/**
* Longest-prefix match of a workingDir against the case list, so a session
* started in a subdirectory still belongs to its case. Mirrors the matching in
@@ -137,6 +184,10 @@ Object.assign(CodemanApp.prototype, {
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
state,
pill: MOBILE_OVERVIEW_PILL_LABEL[state] || state,
// Epoch ms, straight off the session payload; formatting happens at
// render time so the clock can redo it without a re-render.
createdAt: Number(session.createdAt) || 0,
since: this._mobileOverviewSince(state, session),
orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex,
};
});
@@ -224,6 +275,7 @@ Object.assign(CodemanApp.prototype, {
const el = document.getElementById('mobileOverview');
if (!el) return;
this._closeMobileOverviewRunMenu();
this._stopMobileOverviewClock();
el.classList.remove('visible');
el.hidden = true;
},
@@ -380,6 +432,8 @@ Object.assign(CodemanApp.prototype, {
this._mobileOverviewHistory ? 'No past conversations yet' : 'Loading…'
)
);
this._startMobileOverviewClock();
},
/**
@@ -623,6 +677,8 @@ Object.assign(CodemanApp.prototype, {
line2.textContent = row.mode + (row.dir ? ' · ' + row.dir : '');
body.appendChild(line2);
body.appendChild(this._buildMobileOverviewMeta(row));
item.appendChild(body);
const pill = document.createElement('span');
@@ -654,6 +710,110 @@ Object.assign(CodemanApp.prototype, {
return item;
},
// ═══════════════════════════════════════════════════════════════
// Age stamps: started / how long in this state
// ═══════════════════════════════════════════════════════════════
/**
* The "started 3d ago · idle 12m" line under a session row. Both stamps keep
* their raw epoch ms on the element (`data-mo-ts`) so `_tickMobileOverviewTimes()`
* can rewrite the text without rebuilding the row (a re-render would restart
* the blink on every waiting row and the pulse on every working one).
*/
_buildMobileOverviewMeta(row) {
const meta = document.createElement('span');
meta.className = 'mobile-overview-row-meta';
// Relative times are generated text, and "started"/"idle" here are the same
// generic words that mean something else on other surfaces.
meta.setAttribute('data-i18n-skip', '');
meta.appendChild(this._buildMobileOverviewStamp('started', row.createdAt, 'ago', 'mobile-overview-meta-started'));
if (row.since) {
const sep = document.createElement('span');
sep.className = 'mobile-overview-meta-sep';
sep.setAttribute('aria-hidden', 'true');
sep.textContent = '·';
meta.appendChild(sep);
meta.appendChild(
this._buildMobileOverviewStamp(row.since.key, row.since.at, 'for', 'mobile-overview-meta-since')
);
}
return meta;
},
/** One labelled stamp: a dim key, the value, the full date in the title. */
_buildMobileOverviewStamp(key, timestamp, format, className) {
const wrap = document.createElement('span');
wrap.className = 'mobile-overview-meta-item ' + className;
const label = document.createElement('span');
label.className = 'mobile-overview-meta-key';
label.textContent = key;
wrap.appendChild(label);
const value = document.createElement('span');
value.dataset.moTs = String(timestamp || 0);
value.dataset.moFmt = format;
value.textContent = this._mobileOverviewStampText(timestamp, format);
wrap.appendChild(value);
if (timestamp) wrap.title = `${key}: ${new Date(timestamp).toLocaleString()}`;
return wrap;
},
/**
* 'ago' points at a moment ("3d ago", the app's one relative formatter);
* 'for' measures a span from it to now ("12m"), which is what a duration
* beside a state word wants to read as.
*/
_mobileOverviewStampText(timestamp, format) {
if (!timestamp) return '—';
if (format === 'ago') {
return (this.formatRelativeTime && this.formatRelativeTime(timestamp)) || '—';
}
const ms = Date.now() - timestamp;
if (ms < 60000) return '<1m';
const mins = Math.floor(ms / 60000);
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return mins % 60 ? `${hours}h ${mins % 60}m` : `${hours}h`;
const days = Math.floor(hours / 24);
return hours % 24 ? `${days}d ${hours % 24}h` : `${days}d`;
},
/**
* Rewrites the stamps in place every `MOBILE_OVERVIEW_CLOCK_MS`. A sitting
* session emits nothing, so without this its "idle 2m" would still read 2m an
* hour later, the one number on the screen that has to move on its own.
*/
_startMobileOverviewClock() {
if (this._mobileOverviewClock) return;
this._mobileOverviewClock = setInterval(() => {
if (!this.isMobileOverviewVisible()) {
this._stopMobileOverviewClock();
return;
}
this._tickMobileOverviewTimes();
}, MOBILE_OVERVIEW_CLOCK_MS);
},
_stopMobileOverviewClock() {
if (!this._mobileOverviewClock) return;
clearInterval(this._mobileOverviewClock);
this._mobileOverviewClock = null;
},
_tickMobileOverviewTimes() {
const el = document.getElementById('mobileOverview');
if (!el) return;
for (const node of el.querySelectorAll('[data-mo-ts]')) {
const text = this._mobileOverviewStampText(Number(node.dataset.moTs) || 0, node.dataset.moFmt);
if (node.textContent !== text) node.textContent = text;
}
},
/** The session's pending approval, when the strip should render (dialogs only). */
_pendingApprovalForSession(sessionId) {
if (!this.approvals || !this.approvalsInboxEnabled || !this.approvalsInboxEnabled()) return null;
+68 -1
View File
@@ -2720,6 +2720,46 @@ html.mobile-init .file-browser-panel {
text-overflow: ellipsis;
}
/* Third line of the row body: "STARTED 3d ago · IDLE 12m". Monospace so the
numbers stay put as the clock rewrites them every 20s, and dimmer than the
path above it: it answers a question you only ask on purpose. */
.mobile-overview-row-meta {
display: flex;
align-items: center;
gap: 0.3rem;
min-width: 0;
font-family: var(--font-mono, monospace);
font-size: 0.63rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
}
.mobile-overview-meta-item {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-overview-meta-key {
margin-right: 0.3rem;
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.mobile-overview-meta-sep {
opacity: 0.45;
}
/* The freshest signal on the row: while a session is actually running, how
long the current turn has been going is the number the eye should land on.
Same treatment the desktop rail gives its "active" stamp. */
.mobile-overview-row--working .mobile-overview-meta-since {
color: var(--green);
opacity: 0.95;
}
.mobile-overview-dot {
flex-shrink: 0;
width: 9px;
@@ -3157,9 +3197,36 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
border-radius: 10px;
}
/* Save moves into the header; the bottom action bar would cost 60px */
/* Save + Close are the two ways out of the sheet (save-and-close vs
discard-and-close), hit in the same corner with the same thumb, so here —
and only here, since Save is header-only below 860px — they share a
recessed tray and matching pill geometry instead of reading as a fat
accent pill parked beside a stray × glyph. Tray colors come from skin
tokens, never a hardcoded black alpha, or the light skins get a grey slab.
`:has()` keeps the tray off the two sheets that carry a lone × (Session
Options and Add Case save from inside their own forms). */
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions:has(.set-head-save) {
padding: 3px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-input);
}
/* Save moves into the header; the bottom action bar would cost 60px. Both
buttons grow to a thumb-sized target and keep identical heights so the
pair reads as one cluster — 36 + the tray's 3px padding and 1px border on
each side is a 44px block, the same height as the phone header. */
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-save {
display: inline-flex;
height: 36px;
padding: 0 16px;
font-size: 0.86rem;
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions .modal-close {
width: 36px;
height: 36px;
font-size: 1.35rem;
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-foot {
+55 -3
View File
@@ -14759,11 +14759,14 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
flex-shrink: 0;
}
/* DOM order stays close-then-save so the focus trap lands on Close;
row-reverse paints Save to the left of it. Below 860px, where Save joins the
header, the two become ONE control cluster inside a tray (mobile.css). */
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions {
display: flex;
flex-direction: row-reverse;
align-items: center;
gap: 6px;
gap: 3px;
margin-left: auto;
}
@@ -14771,16 +14774,65 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
instead; on desktop the footer owns Save, so this stays hidden. */
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-save {
display: none;
align-items: center;
justify-content: center;
height: 30px;
font: inherit;
font-size: 0.82rem;
font-weight: 620;
letter-spacing: -0.01em;
padding: 8px 15px;
padding: 0 14px;
border: 0;
border-radius: 10px;
border-radius: 9px;
cursor: pointer;
background: linear-gradient(180deg, var(--accent-grad-a), var(--accent-grad-b));
color: var(--accent-ink);
transition:
filter 0.15s ease,
transform 0.1s ease;
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-save:hover {
filter: brightness(1.08);
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-save:active {
transform: scale(0.97);
}
/* `.modal-close` ships as a bare 1.5rem glyph with no box — fine in a plain
modal header, but inside the tray it needs the same footprint as Save. */
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions .modal-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border-radius: 9px;
font-size: 1.2rem;
color: var(--text-dim);
background: transparent;
transition:
background 0.15s ease,
color 0.15s ease,
transform 0.1s ease;
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions .modal-close:hover {
background: var(--bg-hover);
color: var(--text);
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions .modal-close:active {
transform: scale(0.94);
}
/* The shared rule offsets the ring OUTWARD, which inside the tray draws it on
top of the tray border. Inset it so the focused button is ringed, not the
cluster. */
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions .modal-close:focus-visible {
outline-offset: -2px;
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-body {
+122 -2
View File
@@ -32,6 +32,28 @@
// short window, only the app's synthetic tap-to-position mouse event should
// reach xterm.
const TOUCH_COMPAT_MOUSE_SUPPRESS_MS = 450;
// Finger travel (px) still counted as a tap rather than a scroll. Shared by
// the terminal's own touch handling (TAP_THRESHOLD, initTerminal) and the
// keyboard-dismiss handler (_installMobileKeyboardDismiss), which MUST agree:
// a gesture the terminal treats as a scroll but the dismiss handler treats as
// a tap would close the keyboard mid-scroll and drop the composer.
const MOBILE_KEYBOARD_DISMISS_TAP_SLOP = 8;
// Regions where a tap must NOT dismiss the on-screen keyboard
// (_installMobileKeyboardDismiss). Two groups: anything that is about to take
// focus itself, and the accessory bar, which is built to be used while the
// keyboard is open.
const MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR = [
'input',
'textarea',
'select',
'button',
'a[href]',
'[contenteditable=""]',
'[contenteditable="true"]',
'[tabindex]:not([tabindex="-1"])',
'.keyboard-accessory-bar',
'.path-picker-overlay',
].join(',');
// Escape sequences occupy no terminal cells, so they must come out before a
// captured line's WIDTH can be measured (_estimateReplayRows). Covers OSC,
// CSI, charset designators and the short escapes tmux emits; deliberately
@@ -182,6 +204,8 @@
PAGE_KEY_SCREEN_FRACTION,
PAGE_KEY_MAX_PER_BATCH,
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
MOBILE_KEYBOARD_DISMISS_TAP_SLOP,
};
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
global.codemanCurrentXtermTheme = currentXtermTheme;
@@ -654,7 +678,9 @@ Object.assign(CodemanApp.prototype, {
let touchStartY = 0;
let tapStartedWithTerminalFocus = false;
let tapStartIntentCache = null;
const TAP_THRESHOLD = 8; // px — ignore micro-drift to distinguish tap from scroll
// px — ignore micro-drift to distinguish tap from scroll. Shared with the
// keyboard-dismiss handler so both classify the same gesture the same way.
const TAP_THRESHOLD = window.CodemanTerminalInput.MOBILE_KEYBOARD_DISMISS_TAP_SLOP;
container.addEventListener(
'touchstart',
(ev) => {
@@ -786,6 +812,8 @@ Object.assign(CodemanApp.prototype, {
// Hand-encode the SGR report for plain left-clicks on those sessions.
container.addEventListener('click', (ev) => this._handleDesktopTerminalClick(ev));
this._installMobileKeyboardDismiss();
// Welcome message
this.showWelcome();
@@ -3491,6 +3519,73 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Tapping outside the terminal closes the on-screen keyboard.
*
* The terminal keeps focus on a hidden textarea, and nothing ever released it:
* once the keyboard was up, every tap on the header, the tab strip or empty
* page chrome left it up, covering half a phone screen with no way to dismiss
* it but the OS back gesture.
*
* Deliberately narrow, because focus is not ours to steal:
*
* - only when the terminal input actually holds focus;
* - never for a tap inside the terminal — those are classified and routed by
* `_handleMobileTerminalTap`, which owns that decision;
* - never for a tap on another control. Anything focusable or clickable is
* about to take focus itself, and the accessory bar in particular exists to
* be used WHILE the keyboard is open, so dismissing there would fight the
* user. `closest()` covers taps landing on a child (an icon inside a button).
*
* Bound to `touchend` rather than `click`: a tap that dismisses the keyboard
* usually is not meant to activate whatever is underneath, and touchend fires
* before the synthesized click, so the blur lands first.
*/
_installMobileKeyboardDismiss() {
if (this._mobileKeyboardDismissHandler) return;
// A SCROLL also ends in touchend, and dismissing there is wrong: scrolling
// to read something while composing must not close the keyboard and lose
// the composer. Track how far the finger travelled and only treat a
// near-stationary gesture as a tap — the same TAP_THRESHOLD the terminal's
// own touch handling uses, so both agree on what a tap is.
let startX = 0;
let startY = 0;
let moved = false;
this._mobileKeyboardDismissStart = (ev) => {
if (ev.touches.length !== 1) {
moved = true; // a multi-touch gesture is never a dismissing tap
return;
}
startX = ev.touches[0].clientX;
startY = ev.touches[0].clientY;
moved = false;
};
this._mobileKeyboardDismissMove = (ev) => {
if (moved || !ev.touches.length) return;
const dx = ev.touches[0].clientX - startX;
const dy = ev.touches[0].clientY - startY;
const slop = window.CodemanTerminalInput.MOBILE_KEYBOARD_DISMISS_TAP_SLOP;
if (Math.abs(dx) > slop || Math.abs(dy) > slop) {
moved = true;
}
};
this._mobileKeyboardDismissHandler = (ev) => {
if (moved) return;
if (!this._isMobileTerminalInputFocused()) return;
const target = ev.target;
if (!target || typeof target.closest !== 'function') return;
if (target.closest('#terminalContainer')) return;
if (target.closest(window.CodemanTerminalInput.MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR)) return;
this._blurMobileTerminalInput();
};
// Passive throughout: this never calls preventDefault, so it must not make
// the page feel less responsive to scrolling.
document.addEventListener('touchstart', this._mobileKeyboardDismissStart, { passive: true });
document.addEventListener('touchmove', this._mobileKeyboardDismissMove, { passive: true });
document.addEventListener('touchend', this._mobileKeyboardDismissHandler, { passive: true });
},
/**
* Which 'content' taps should DISMISS the mobile keyboard. Expandable
* readbacks, tool results and decision rows are TUI-owned: tapping them acts
@@ -3546,9 +3641,20 @@ Object.assign(CodemanApp.prototype, {
// the affordance above; there is deliberately no verb literal here, because
// the verb is randomised per build.
// A visible selection dialog makes its OWN rows actionable, not the whole
// screen. Two viewport-wide `some()` tests used to be the entire answer, so
// while a Claude question or permission dialog was up EVERY tap in the
// terminal (inert transcript, the question title, blank rows) came back
// actionable, and the caller blurred on each one. The on-screen keyboard
// could then not be opened at all until the dialog was answered, which left
// tapping an option row as the only interaction available: the one that
// commits an answer. Requiring the TAPPED line to be a numbered row keeps
// the dialog's own rows behaving as before (report the tap, keep the
// keyboard down) while any other row can still summon the keyboard, which
// is how a digit gets typed at a dialog instead of aimed at it.
const hasMenuPrompt = lines.some((line) => /^\s*[❯›]\s+\d+[.)]\s/.test(line));
const hasMenuChoice = lines.some((line) => /^\s+\d+[.)]\s/.test(line));
return hasMenuPrompt && hasMenuChoice;
return hasMenuPrompt && hasMenuChoice && /^\s*(?:[❯›]\s*)?\d+[.)]\s/.test(tappedLine);
},
_focusMobileTerminalInput() {
@@ -3595,6 +3701,20 @@ Object.assign(CodemanApp.prototype, {
// A synthetic xterm click can focus its helper textarea. Blur after the
// report so collapsing a readback never opens or retains the keyboard.
this._blurMobileTerminalInput();
} else if (intent === 'content' && startedWithTerminalFocus) {
// Tapping INERT transcript with the keyboard already up closes it.
//
// Every terminal tap re-focuses, so once the keyboard is open the only way
// to close it is the accessory bar's dismiss chevron. Tapping the
// transcript to get the screen back is the obvious gesture, and nothing
// else claims it: an inert row has no action to trigger, so by this point
// the tap has already done its only other job (the mouse report above).
//
// Scoped to 'content' ON PURPOSE. The prompt row ('input') keeps
// focus-then-position, so a second tap there still places the caret —
// pinned by "keeps the first prompt tap focus-only so it cannot activate a
// CLI row". Toggling there would trade away real capability.
this._blurMobileTerminalInput();
} else {
this._focusMobileTerminalInput();
}
+6
View File
@@ -2619,6 +2619,12 @@ export class WebServer extends EventEmitter {
workingDir: muxSession.workingDir,
mode: muxSession.mode,
name: sessionName,
// When the session FIRST started, not when this server booted.
// Without it every recovered session was restamped `Date.now()` on
// each restart, so a week-old pane read as "created 2m ago" on the
// home screens (and sorted as the newest thing in the unified list).
// mux-sessions.json carries the tmux session's own birth time.
createdAt: muxSession.createdAt || savedState?.createdAt,
mux: this.mux,
useMux: true,
muxSession: muxSession, // Pass the existing session so startInteractive() can attach to it
+54
View File
@@ -211,6 +211,60 @@ describe('mobile overview model', () => {
expect(empty).toMatchObject({ needsYou: [], current: [], past: [], sessionCount: 0 });
});
it('anchors the "how long" stamp on last activity, and on the last Enter while working', () => {
const app = loadOverviewApp();
const now = Date.now();
const model = app.buildMobileOverviewModel({
sessions: [
// A working pane repaints about once a second, so lastActivityAt is
// always "now" and would report every running turn as 0m. The turn's
// own start is the last Enter.
session({
id: 'w',
status: 'busy',
createdAt: now - 7200_000,
lastActivityAt: now,
lastSubmitAt: now - 300_000,
}),
// A quiet pane prints nothing, so its last byte IS when it went idle.
session({ id: 'i', status: 'idle', createdAt: now - 7200_000, lastActivityAt: now - 900_000 }),
],
cases: CASES,
});
const rows = Object.fromEntries(model.current.map((r: any) => [r.id, r]));
expect(rows.w.since).toEqual({ key: 'working', at: now - 300_000 });
expect(rows.i.since).toEqual({ key: 'idle', at: now - 900_000 });
expect(rows.i.createdAt).toBe(now - 7200_000);
});
it('leaves the stamp off rather than inventing an anchor', () => {
const app = loadOverviewApp();
const model = app.buildMobileOverviewModel({
// A session that has never submitted has no turn start to measure from.
sessions: [session({ id: 'w', status: 'busy', lastActivityAt: Date.now() })],
cases: CASES,
});
expect(model.current[0].since).toBeNull();
expect(model.current[0].createdAt).toBe(0);
});
it('formats a moment as "ago" and a span as a bare duration', () => {
const app = loadOverviewApp();
app.formatRelativeTime = () => '3d ago';
const now = Date.now();
expect(app._mobileOverviewStampText(now - 86_400_000, 'ago')).toBe('3d ago');
expect(app._mobileOverviewStampText(now - 20_000, 'for')).toBe('<1m');
expect(app._mobileOverviewStampText(now - 12 * 60_000, 'for')).toBe('12m');
expect(app._mobileOverviewStampText(now - 125 * 60_000, 'for')).toBe('2h 5m');
expect(app._mobileOverviewStampText(now - 3 * 3600_000, 'for')).toBe('3h');
expect(app._mobileOverviewStampText(now - 50 * 3600_000, 'for')).toBe('2d 2h');
// No anchor renders as a dash, never as "56 years ago" off epoch 0.
expect(app._mobileOverviewStampText(0, 'for')).toBe('—');
expect(app._mobileOverviewStampText(0, 'ago')).toBe('—');
});
it('no longer builds a spaces section', () => {
const app = loadOverviewApp();
const model = app.buildMobileOverviewModel({ sessions: [session({ id: 'a' })], cases: CASES });
+67
View File
@@ -573,3 +573,70 @@ describe('armed styling survives the light-skin overrides', () => {
);
});
});
describe('composer nav keys from the bar', () => {
/** loadBar()'s app stub plus the local-echo state the flush path reads. */
function barWithDraft(draft: string) {
const loaded = loadBar('claude');
const overlay = {
pendingText: draft,
clear: vi.fn(() => {
overlay.pendingText = '';
}),
suppressBufferDetection: vi.fn(),
};
const app = loaded.app as unknown as Record<string, unknown>;
app._localEchoEnabled = true;
app._localEchoOverlay = overlay;
app.sendInput = vi.fn();
app._flushedOffsets = new Map([['session-1', 3]]);
app._flushedTexts = new Map([['session-1', 'dra']]);
return { ...loaded, app, overlay };
}
const sentKeys = (fetchMock: { mock: { calls: unknown[][] } }) =>
fetchMock.mock.calls.map((call) => JSON.parse((call[1] as { body: string }).body).input);
it('flushes the unsent draft before sending the arrow', () => {
// On a phone the typed text lives in the overlay and has NEVER reached the
// PTY, so an arrow sent on its own arrives at a composer the CLI still
// considers empty: Up recalls a history entry into it while the overlay
// goes on painting the draft over the same row and still believes it is
// pending. Flushing first is also what makes the draft recoverable: the
// CLI stashes the live composer and hands it back on Down.
const { bar, app, overlay, fetchMock } = barWithDraft('draft I typed');
bar.handleAction('scroll-up');
expect(app.sendInput).toHaveBeenCalledWith('draft I typed');
expect(sentKeys(fetchMock)).toEqual(['\x1b[A']);
expect(overlay.pendingText).toBe('');
expect(overlay.suppressBufferDetection).toHaveBeenCalled();
// The overlay's bookkeeping for this session has to go with it.
expect((app._flushedOffsets as Map<string, number>).has('session-1')).toBe(false);
expect((app._flushedTexts as Map<string, string>).has('session-1')).toBe(false);
});
it('hands the session to plain PTY echo, like a typed nav key does', () => {
// After a nav key the real cursor can sit mid-text, where the overlay's
// append-only buffering cannot track edits (issue #218). terminal-ui.js's
// onData branch does exactly this for a nav key typed on a keyboard.
const { bar, app } = barWithDraft('');
bar.handleAction('arrow-left');
expect([...(app._echoPassthroughSessions as Set<string>)]).toEqual(['session-1']);
});
it('sends all four arrows and skips the flush when there is no draft', () => {
const { bar, app, fetchMock } = barWithDraft('');
bar.handleAction('scroll-up');
bar.handleAction('scroll-down');
bar.handleAction('arrow-left');
bar.handleAction('arrow-right');
expect(sentKeys(fetchMock)).toEqual(['\x1b[A', '\x1b[B', '\x1b[D', '\x1b[C']);
expect(app.sendInput).not.toHaveBeenCalled();
});
});
+122 -2
View File
@@ -624,6 +624,122 @@ describe('Virtual Keyboard', () => {
expect(Number(styles?.zIndex)).toBeGreaterThanOrEqual(0);
});
it('dismisses the on-screen keyboard when a tap lands outside the terminal', async () => {
// The terminal holds focus on a hidden textarea and nothing released it,
// so once the keyboard was up every tap on the header or page chrome left
// it up — covering half a phone screen with no in-app way to close it.
//
// Driven as a real dispatched gesture: the handler is bound to touchend on
// document, and calling the internal helper would bypass the routing this
// test exists to check.
const result = await page.evaluate(async () => {
const sampleX = (rect: DOMRect) => Math.max(2, rect.left + Math.min(6, rect.width / 2));
const sampleY = (rect: DOMRect) => Math.max(2, rect.top + Math.min(6, rect.height / 2));
const tap = async (el: Element, travel = 0, point?: { x: number; y: number }) => {
const rect = el.getBoundingClientRect();
const x = point ? point.x : sampleX(rect);
const y = point ? point.y : sampleY(rect);
const target = document.elementFromPoint(x, y) || el;
const at = (cy: number) => new Touch({ identifier: 21, target, clientX: x, clientY: cy });
target.dispatchEvent(
new TouchEvent('touchstart', {
touches: [at(y)],
targetTouches: [at(y)],
changedTouches: [at(y)],
bubbles: true,
cancelable: true,
})
);
for (const step of travel ? [travel / 3, (travel * 2) / 3, travel] : []) {
target.dispatchEvent(
new TouchEvent('touchmove', {
touches: [at(y + step)],
targetTouches: [at(y + step)],
changedTouches: [at(y + step)],
bubbles: true,
cancelable: true,
})
);
await new Promise((resolve) => setTimeout(resolve, 15));
}
await new Promise((resolve) => setTimeout(resolve, 25));
target.dispatchEvent(
new TouchEvent('touchend', {
touches: [],
changedTouches: [at(y + travel)],
bubbles: true,
cancelable: true,
})
);
await new Promise((resolve) => setTimeout(resolve, 250));
return document.activeElement?.className ?? '';
};
app.hideWelcome();
app.terminal.reset();
await new Promise<void>((resolve) => app.terminal.write('transcript\r\n\r\n> ', resolve));
// Inert page chrome: the keyboard must close.
app._focusMobileTerminalInput();
const focusedBefore = document.activeElement?.className ?? '';
const afterOutside = await tap(document.querySelector('.logo, .header-brand, header') ?? document.body);
// A real control: it takes focus itself, so we must NOT interfere.
app._focusMobileTerminalInput();
const button = Array.from(document.querySelectorAll('button:not([disabled])')).find((candidate) => {
const rect = candidate.getBoundingClientRect();
if (rect.width <= 8 || rect.height <= 8) return false;
// A rect is not enough. The welcome overlay is hidden by hideWelcome()
// above but its buttons still MEASURE, so a rect-only pick sampled a
// point the terminal actually owns — elementFromPoint returned
// .xterm-screen and this case tapped the terminal instead of a
// control, passing for the wrong reason. Require the sampled point to
// really resolve to this button.
const hit = document.elementFromPoint(sampleX(rect), sampleY(rect));
return !!hit && candidate.contains(hit);
});
const afterButton = button ? await tap(button) : 'no-visible-button';
// Inside the terminal, tap classification owns the decision, so this
// handler must keep its hands off. Aimed at the PROMPT row: that is the
// one in-terminal tap whose outcome belongs to nobody else, since an
// inert transcript row is claimed by the in-terminal dismiss toggle
// (`toggles the keyboard shut on a second inert Claude transcript tap`)
// and asserting focus there would be asserting that toggle's behaviour
// rather than this exemption. The guard still bites: the container's own
// touchend listener runs first and refocuses, so a missing
// #terminalContainer exemption would blur right back over it.
app._focusMobileTerminalInput();
const screen = app.terminal.element?.querySelector('.xterm-screen');
const cell = app.terminal._core?._renderService?.dimensions?.css?.cell;
const screenRect = screen?.getBoundingClientRect();
const promptPoint =
screenRect && cell?.width && cell?.height
? {
x: screenRect.left + cell.width * 2,
y: screenRect.top + cell.height * (app.terminal.buffer.active.cursorY + 0.5),
}
: undefined;
const afterTerminal = await tap(document.querySelector('#terminalContainer')!, 0, promptPoint);
// A SCROLL also ends in touchend. Scrolling to read something while
// composing must not close the keyboard and drop the composer.
app._focusMobileTerminalInput();
const afterScroll = await tap(document.querySelector('.logo, .header-brand, header') ?? document.body, 120);
return { focusedBefore, afterOutside, afterButton, afterTerminal, afterScroll };
});
expect(result.focusedBefore).toContain('xterm-helper-textarea');
// Red on master: the textarea keeps focus and the keyboard stays up.
expect(result.afterOutside).not.toContain('xterm-helper-textarea');
expect(result.afterButton).not.toBe('no-visible-button');
expect(result.afterButton).toContain('xterm-helper-textarea');
expect(result.afterTerminal).toContain('xterm-helper-textarea');
// A scroll ends in touchend too, and must NOT close the keyboard.
expect(result.afterScroll).toContain('xterm-helper-textarea');
});
it('routes CJK textarea typing through local echo on Enter', async () => {
await page.evaluate(() => {
window.__sentInputs = [];
@@ -890,7 +1006,7 @@ describe('Virtual Keyboard', () => {
expect(state.sentInputs[0]).toMatch(/^\x1b\[<0;\d+;1M\x1b\[<0;\d+;1m$/);
});
it('keeps the hidden keyboard input focused after an inert Claude transcript tap', async () => {
it('toggles the keyboard shut on a second inert Claude transcript tap', async () => {
const point = await page.evaluate(async () => {
window.__sentInputs = [];
app.activeSessionId = 'mobile-claude-transcript-tap-test';
@@ -941,8 +1057,12 @@ describe('Virtual Keyboard', () => {
await page.touchscreen.tap(point!.x, point!.y);
// The setup above leaves the terminal focused, so this tap is the SECOND
// one on an inert row — the case that now closes the keyboard. Previously
// it re-focused, which left the accessory bar's chevron as the only way to
// dismiss. The prompt row is unaffected and still positions the caret.
const activeClass = await page.evaluate(() => document.activeElement?.className);
expect(activeClass).toContain('xterm-helper-textarea');
expect(activeClass).not.toContain('xterm-helper-textarea');
});
it('prevents Claude subagent status taps from opening the hidden keyboard input', async () => {
+68
View File
@@ -166,6 +166,37 @@ describe('terminal touch tap mouse guard', () => {
expect(app._classifyMobileTerminalTap(9, 49)).toBe('content');
});
it('keeps the keyboard reachable while a selection dialog is on screen', () => {
// The lock this pins: a visible dialog used to make EVERY row of the
// terminal "actionable" (both menu tests scanned the whole viewport), so
// every tap blurred and the on-screen keyboard could not be opened until
// the dialog was answered, leaving tapping an option (the one gesture that
// commits an answer) as the only thing a phone could do.
const { app, setActiveElement } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app.terminal = createTerminalGrid(
['Do you want to proceed?', '', '❯ 1. Yes', ' 2. No, tell Claude what to do', '', ''],
2
);
app._sendInputAsync = vi.fn();
setActiveElement(null);
// The dialog's own rows stay TUI-owned: report the tap, keep the keyboard down.
expect(app._isActionableMobileTerminalTap(9, 33)).toBe(true); // ❯ 1. Yes
expect(app._isActionableMobileTerminalTap(9, 49)).toBe(true); // 2. No, …
// Everything else is inert, and must still be able to summon the keyboard.
expect(app._isActionableMobileTerminalTap(9, 1)).toBe(false); // question title
expect(app._isActionableMobileTerminalTap(9, 65)).toBe(false); // blank row
app._handleMobileTerminalTap({ clientX: 9, clientY: 1 }, false);
expect(app.terminal.focus).toHaveBeenCalledOnce();
app.terminal.focus.mockClear();
app._handleMobileTerminalTap({ clientX: 9, clientY: 33 }, false);
expect(app.terminal.focus).not.toHaveBeenCalled();
});
it('collapses TUI readback content without opening or retaining the keyboard', () => {
const { app, setActiveElement } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
@@ -199,6 +230,43 @@ describe('terminal touch tap mouse guard', () => {
expect(app.terminal.focus).toHaveBeenCalledOnce();
});
it('closes the keyboard on a second tap of INERT transcript content', () => {
const { app, setActiveElement } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app.terminal = createTerminalGrid(['transcript line', '', '', '', '❯ ', ''], 4);
app._sendInputAsync = vi.fn();
// Keyboard DOWN: the tap opens it.
setActiveElement(null);
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 1 }, false)).toBe('content');
expect(app.terminal.focus).toHaveBeenCalledOnce();
expect(app.terminal.textarea.blur).not.toHaveBeenCalled();
// Keyboard UP on the same inert row: the tap closes it.
app.terminal.focus.mockClear();
setActiveElement(app.terminal.textarea);
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 1 }, true)).toBe('content');
expect(app.terminal.textarea.blur).toHaveBeenCalledOnce();
expect(app.terminal.focus).not.toHaveBeenCalled();
});
it('keeps the prompt row focusing rather than toggling, so the caret can still be placed', () => {
// The toggle is scoped to 'content' on purpose: a second tap on the PROMPT
// must still position the cursor. This is the guarantee that makes the
// change safe to make, so it is pinned separately.
const { app, setActiveElement } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app.terminal = createTerminalGrid(['transcript line', '', '', '', '❯ ask', ''], 4);
app._sendInputAsync = vi.fn();
setActiveElement(app.terminal.textarea);
expect(app._handleMobileTerminalTap({ clientX: 9, clientY: 65 }, true)).toBe('input');
expect(app.terminal.textarea.blur).not.toHaveBeenCalled();
expect(app.terminal.focus).toHaveBeenCalledOnce();
});
it('suppresses browser trusted compatibility mouse events during the tap window', () => {
const { app } = loadTerminalUiHarness();
const { element, dispatch } = createElementHarness();