mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-01 13:09:42 +02:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf3183abf7 | ||
|
|
15a43894f9 | ||
|
|
e20aa1d4d8 | ||
|
|
aa28ef048c | ||
|
|
67f6ed3168 | ||
|
|
2d4616f059 | ||
|
|
35f8f9d19f | ||
|
|
c992784681 | ||
|
|
3a7be356ae | ||
|
|
a6a572e635 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user