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>
This commit is contained in:
Codeman maintainer
2026-08-11 23:14:57 +02:00
parent 15a43894f9
commit cf3183abf7
13 changed files with 446 additions and 27 deletions
+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();
});
});
+31
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';