mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
feat(session,mobile): auto-resume on usage limit + mobile view fixes
Auto-resume on usage limit ("token pause" control, opt-in checkbox at the
top of the Respawn tab, off by default):
- usage-limit-patterns.ts (new, pure): detects all Claude Code limit
messages (1.0.x-2.1.x eras incl. "5-hour limit reached - resets 8pm",
"You've hit your limit - resets 1:40pm (TZ)", weekly date forms, raw
"usage limit reached|<epoch>") and parses the reset time. Conservative:
no parseable future reset time, no action.
- SessionAutoOps: arms a timer at reset+2min, sends Esc (dismisses the
rate-limit dialog) + "continue"; dedups footer redraws, retries every
5min on stale times, cancels when Claude starts working, persists and
re-arms across Codeman restarts (SessionState.autoResumeEnabled/At).
- Respawn guard: cycles are blocked while limit-paused so /clear cannot
wipe the paused conversation (respawnBlocked reason 'usage_limit').
- POST /api/sessions/:id/auto-resume; SSE session:limitPauseScheduled/
limitResume/limitResumeCancelled; toasts + status line in the modal.
- Respawn tab tidied: single-row prompt fields, merged behavior row.
Mobile fixes (0.9.8 regressions, user-reported):
- Resize arbitration is now activity-based: a desktop sizing claim only
blocks phone resizes while the desktop typed within 90s
(Session.DESKTOP_CLAIM_IDLE_MS). Idle desktop -> phone takes the pane;
next desktop keystroke re-asserts the desktop layout server-side
(noteDesktopActivity via ws-routes input). Phones re-send dims every
30s (visible tab only, skipped while the keyboard is open) so attaching
under a hot claim self-corrects. Fixes the desktop-width-stream-in-
narrow-xterm soup (mid-word wraps, tmux dot fill, Ink overdraw).
- Cross-device reflows (takeover/re-assert) emit a debounced needsRefresh
so all clients reload the buffer instead of stacking ghost Ink frames.
- Keyboard accessory/toolbar lift restored: measure keyboardOffset
against window.innerHeight (layout viewport), not the shrunken .app -
on iOS the offset computed to 0, leaving both bars hidden behind the
OS keyboard with a dead gap above.
- Removed the mobile header utility ("three dots") toggle entirely;
the headerRight tray stays collapsed on small viewports.
Tests: usage-limit-patterns (36), session-auto-resume (21), resize
arbitration (+6), session routes (+4), respawn guard (+2); MockSession
auto-resume/sizing stubs; mobile tabs test updated for toggle removal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
|
||||
/** Must exceed Session.DESKTOP_CLAIM_IDLE_MS (90s) */
|
||||
const PAST_IDLE_MS = 91_000;
|
||||
|
||||
type ResizeableSessionInternals = {
|
||||
ptyProcess: { resize: (cols: number, rows: number) => void };
|
||||
_ptyCols: number;
|
||||
@@ -109,4 +112,100 @@ describe('Session resize arbitration', () => {
|
||||
|
||||
expect(resize).toHaveBeenCalledWith(100, 30);
|
||||
});
|
||||
|
||||
describe('idle-desktop override (whoever is active wins)', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('lets a mobile client take the pane once the desktop claim goes idle', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
const resize = attachFakePty(session, 160, 48);
|
||||
|
||||
session.claimDesktopSizing(Symbol('desktop-conn'));
|
||||
session.resize(48, 28, { viewportType: 'mobile' });
|
||||
expect(resize).not.toHaveBeenCalled(); // fresh claim → ignored
|
||||
|
||||
vi.advanceTimersByTime(PAST_IDLE_MS);
|
||||
session.resize(48, 28, { viewportType: 'mobile' });
|
||||
expect(resize).toHaveBeenCalledWith(48, 28); // idle desktop → applied
|
||||
});
|
||||
|
||||
it('keeps blocking mobile while the desktop stays active via typed input', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
const resize = attachFakePty(session, 160, 48);
|
||||
|
||||
session.claimDesktopSizing(Symbol('desktop-conn'));
|
||||
vi.advanceTimersByTime(PAST_IDLE_MS - 10_000);
|
||||
session.noteDesktopActivity(); // user typed on desktop
|
||||
vi.advanceTimersByTime(20_000); // idle since claim, but not since input
|
||||
|
||||
session.resize(48, 28, { viewportType: 'mobile' });
|
||||
expect(resize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-asserts the desktop layout on desktop input after a mobile override', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
const resize = attachFakePty(session, 160, 48);
|
||||
|
||||
session.resize(208, 45, { viewportType: 'desktop' }); // desktop sizes the pane
|
||||
session.claimDesktopSizing(Symbol('desktop-conn'));
|
||||
vi.advanceTimersByTime(PAST_IDLE_MS);
|
||||
|
||||
session.resize(48, 28, { viewportType: 'mobile' }); // phone takes over
|
||||
expect(resize).toHaveBeenLastCalledWith(48, 28);
|
||||
|
||||
session.noteDesktopActivity(); // desktop user types again
|
||||
expect(resize).toHaveBeenLastCalledWith(208, 45); // layout restored
|
||||
});
|
||||
|
||||
it('does not re-assert when no mobile override happened', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
const resize = attachFakePty(session, 160, 48);
|
||||
|
||||
session.resize(208, 45, { viewportType: 'desktop' });
|
||||
resize.mockClear();
|
||||
session.noteDesktopActivity();
|
||||
expect(resize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits needsRefresh after a mobile takeover and after a desktop re-assert', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
attachFakePty(session, 160, 48);
|
||||
const refresh = vi.fn();
|
||||
session.on('needsRefresh', refresh);
|
||||
|
||||
session.resize(208, 45, { viewportType: 'desktop' });
|
||||
session.claimDesktopSizing(Symbol('desktop-conn'));
|
||||
vi.advanceTimersByTime(800);
|
||||
expect(refresh).not.toHaveBeenCalled(); // plain desktop resize: no refresh
|
||||
|
||||
vi.advanceTimersByTime(PAST_IDLE_MS);
|
||||
session.resize(48, 28, { viewportType: 'mobile' }); // takeover
|
||||
vi.advanceTimersByTime(800);
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
|
||||
session.noteDesktopActivity(); // re-assert to 208x45
|
||||
vi.advanceTimersByTime(800);
|
||||
expect(refresh).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not emit needsRefresh for ordinary single-device resizes', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
attachFakePty(session, 160, 48);
|
||||
const refresh = vi.fn();
|
||||
session.on('needsRefresh', refresh);
|
||||
|
||||
session.resize(48, 28, { viewportType: 'mobile' }); // mobile-only, no claims
|
||||
session.resize(208, 45, { viewportType: 'desktop' });
|
||||
session.resize(100, 30); // untyped
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user