mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09: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:
+68
-2
@@ -153,6 +153,9 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.SESSION_IDLE, '_onSessionIdle'],
|
||||
[SSE_EVENTS.SESSION_WORKING, '_onSessionWorking'],
|
||||
[SSE_EVENTS.SESSION_AUTO_CLEAR, '_onSessionAutoClear'],
|
||||
[SSE_EVENTS.SESSION_LIMIT_PAUSE_SCHEDULED, '_onSessionLimitPauseScheduled'],
|
||||
[SSE_EVENTS.SESSION_LIMIT_RESUME, '_onSessionLimitResume'],
|
||||
[SSE_EVENTS.SESSION_LIMIT_RESUME_CANCELLED, '_onSessionLimitResumeCancelled'],
|
||||
[SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'],
|
||||
|
||||
// Scheduled runs
|
||||
@@ -576,7 +579,6 @@ class CodemanApp {
|
||||
// Apply keyboard bar mode from settings
|
||||
const _kbSettings = this.loadAppSettingsFromStorage();
|
||||
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
|
||||
this.bindMobileHeaderUtilityToggle?.();
|
||||
this.applyHeaderVisibilitySettings();
|
||||
this.applyTabWrapSettings();
|
||||
this.applyMonitorVisibility();
|
||||
@@ -1768,6 +1770,33 @@ class CodemanApp {
|
||||
this._notifySession(data.sessionId, 'info', 'auto-clear', 'Auto-Cleared', `Context reset at ${(data.tokens || 0).toLocaleString()} tokens`);
|
||||
}
|
||||
|
||||
_onSessionLimitPauseScheduled(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
if (session) session.autoResumeAt = data.resumeAt;
|
||||
const at = new Date(data.resumeAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
if (data.sessionId === this.activeSessionId) {
|
||||
this.showToast(`Usage limit reached — auto-resume at ${at}`, 'warning');
|
||||
}
|
||||
this._notifySession(data.sessionId, 'warning', 'limit-pause', 'Usage Limit Reached', `Auto-resume scheduled for ${at}`);
|
||||
this.updateAutoResumeStatus(data.sessionId);
|
||||
}
|
||||
|
||||
_onSessionLimitResume(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
if (session) session.autoResumeAt = undefined;
|
||||
if (data.sessionId === this.activeSessionId) {
|
||||
this.showToast('Usage limit reset — work resumed automatically', 'success');
|
||||
}
|
||||
this._notifySession(data.sessionId, 'info', 'limit-resume', 'Auto-Resumed', 'Usage limit reset — continuing work');
|
||||
this.updateAutoResumeStatus(data.sessionId);
|
||||
}
|
||||
|
||||
_onSessionLimitResumeCancelled(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
if (session) session.autoResumeAt = undefined;
|
||||
this.updateAutoResumeStatus(data.sessionId);
|
||||
}
|
||||
|
||||
_onSessionCliInfo(data) {
|
||||
const session = this.sessions.get(data.sessionId);
|
||||
if (session) {
|
||||
@@ -1843,6 +1872,7 @@ class CodemanApp {
|
||||
// selectSession's earlier resizes ran before this WS existed, so they
|
||||
// went over HTTP, which never claims (see ws-routes sizingToken).
|
||||
this.sendResize(sessionId)?.catch?.(() => {});
|
||||
this._startMobileResizeRetry(sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1868,6 +1898,7 @@ class CodemanApp {
|
||||
this._ws = null;
|
||||
this._wsSessionId = null;
|
||||
this._wsReady = false;
|
||||
this._stopMobileResizeRetry();
|
||||
|
||||
// Reconnect on unexpected close (server restart, network blip, ping timeout).
|
||||
// Don't reconnect if we intentionally disconnected (_disconnectWs nulls onclose)
|
||||
@@ -1893,6 +1924,7 @@ class CodemanApp {
|
||||
_disconnectWs() {
|
||||
this._clearTimer('_wsReconnectTimer');
|
||||
this._wsReconnectAttempts = 0;
|
||||
this._stopMobileResizeRetry();
|
||||
if (this._ws) {
|
||||
this._ws.onclose = null; // Prevent re-entrant cleanup
|
||||
this._ws.close();
|
||||
@@ -1902,6 +1934,41 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small-viewport claim-idle retry. While a desktop sizing claim is "hot",
|
||||
* the server ignores this device's resize (Session.DESKTOP_CLAIM_IDLE_MS),
|
||||
* and the single resize sent on attach is deduped client-side — without a
|
||||
* retry, a phone that attached under an active desktop would render a
|
||||
* desktop-width stream forever. Re-send the current dims periodically (a
|
||||
* server-side no-op once the pane already matches) so the pane reflows to
|
||||
* this device shortly after the desktop goes idle. Visible-tab only: a
|
||||
* phone in a pocket must not steal the pane from an active desktop.
|
||||
*/
|
||||
_startMobileResizeRetry(sessionId) {
|
||||
this._stopMobileResizeRetry();
|
||||
const type =
|
||||
typeof MobileDetection !== 'undefined' && MobileDetection.getDeviceType
|
||||
? MobileDetection.getDeviceType()
|
||||
: 'desktop';
|
||||
if (type === 'desktop') return;
|
||||
this._mobileResizeRetryTimer = setInterval(() => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
if (!this._wsReady || this._wsSessionId !== sessionId) return;
|
||||
// Same guard as throttledResize: while the virtual keyboard is up, a
|
||||
// fit()+SIGWINCH at the shrunken row count makes Ink re-render garbage
|
||||
// and shifts the accessory toolbar mid-typing. Retry after it closes.
|
||||
if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) return;
|
||||
this.sendResize(sessionId)?.catch?.(() => {});
|
||||
}, MOBILE_RESIZE_RETRY_MS);
|
||||
}
|
||||
|
||||
_stopMobileResizeRetry() {
|
||||
if (this._mobileResizeRetryTimer) {
|
||||
clearInterval(this._mobileResizeRetryTimer);
|
||||
this._mobileResizeRetryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send input to server without blocking the keystroke flush cycle.
|
||||
* Uses a sequential promise chain to preserve character ordering
|
||||
@@ -2093,7 +2160,6 @@ class CodemanApp {
|
||||
KeyboardHandler.cleanup();
|
||||
MobileDetection.init();
|
||||
KeyboardHandler.init();
|
||||
this.bindMobileHeaderUtilityToggle?.();
|
||||
// Clear tab alerts
|
||||
this.tabAlerts.clear();
|
||||
// Clear shown completions (used for duplicate notification prevention)
|
||||
|
||||
@@ -51,6 +51,7 @@ const GROUPING_TIMEOUT_MS = 5000; // 5 seconds - notification grouping
|
||||
const NOTIFICATION_LIST_CAP = 100; // Max notifications in list
|
||||
const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate
|
||||
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
|
||||
const MOBILE_RESIZE_RETRY_MS = 30000; // Small-viewport resize re-send while a desktop sizing claim is hot
|
||||
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
|
||||
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
|
||||
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
|
||||
@@ -243,6 +244,9 @@ const SSE_EVENTS = {
|
||||
SESSION_WORKING: 'session:working',
|
||||
SESSION_AUTO_CLEAR: 'session:autoClear',
|
||||
SESSION_AUTO_COMPACT: 'session:autoCompact',
|
||||
SESSION_LIMIT_PAUSE_SCHEDULED: 'session:limitPauseScheduled',
|
||||
SESSION_LIMIT_RESUME: 'session:limitResume',
|
||||
SESSION_LIMIT_RESUME_CANCELLED: 'session:limitResumeCancelled',
|
||||
SESSION_CLI_INFO: 'session:cliInfo',
|
||||
SESSION_MESSAGE: 'session:message',
|
||||
SESSION_INTERACTIVE: 'session:interactive',
|
||||
|
||||
+17
-17
@@ -69,10 +69,6 @@
|
||||
<span class="logo" onclick="app.goHome()" title="Go to main page">Codeman</span>
|
||||
</div>
|
||||
|
||||
<button class="mobile-header-utility-toggle" id="mobileHeaderUtilityToggle" type="button" aria-label="Toggle header utilities" aria-controls="headerRight" aria-expanded="false" title="Header utilities">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Session Tabs -->
|
||||
<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs">
|
||||
</div>
|
||||
@@ -619,6 +615,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auto-resume-box">
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" id="modalAutoResumeEnabled" onchange="app.autoSaveAutoResume()">
|
||||
<span>Auto-resume when usage limit resets</span>
|
||||
</label>
|
||||
<span class="auto-resume-status" id="autoResumeStatus"></span>
|
||||
<span class="form-hint">If Claude pauses on a usage limit ("limit reached · resets 3pm"), Codeman waits for the reset time and automatically continues the work. Independent of the respawn loop below.</span>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Duration</label>
|
||||
<div class="duration-presets">
|
||||
@@ -658,7 +663,7 @@
|
||||
<div class="form-section-header">Respawn Cycle</div>
|
||||
<div class="form-row">
|
||||
<label>1. Update Prompt</label>
|
||||
<textarea id="modalRespawnPrompt" rows="3" placeholder="Prompt to send when idle" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 60px;">update all the docs and CLAUDE.md</textarea>
|
||||
<textarea id="modalRespawnPrompt" rows="1" placeholder="Prompt to send when idle" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 30px;">update all the docs and CLAUDE.md</textarea>
|
||||
</div>
|
||||
|
||||
<div class="respawn-options-row" style="margin: 8px 0;">
|
||||
@@ -670,22 +675,17 @@
|
||||
<input type="checkbox" id="modalRespawnSendInit" checked onchange="app.autoSaveRespawnConfig()">
|
||||
<span>3. Send /init</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>4. Kickstart Prompt</label>
|
||||
<textarea id="modalRespawnKickstart" rows="2" placeholder="Optional: prompt if /init doesn't trigger work" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 40px;"></textarea>
|
||||
<span class="form-hint">Sent only when /init completes but Claude stays idle</span>
|
||||
</div>
|
||||
|
||||
<div class="form-section-header">Behavior</div>
|
||||
<div class="respawn-options-row">
|
||||
<label class="checkbox-inline">
|
||||
<label class="checkbox-inline" title="Presses Enter for plan approvals and default question options">
|
||||
<input type="checkbox" id="modalRespawnAutoAccept" checked onchange="app.autoSaveRespawnConfig()">
|
||||
<span>Auto-accept prompts</span>
|
||||
</label>
|
||||
</div>
|
||||
<span class="form-hint">Auto-accept presses Enter for plan approvals and default question options</span>
|
||||
|
||||
<div class="form-row">
|
||||
<label>4. Kickstart Prompt</label>
|
||||
<textarea id="modalRespawnKickstart" rows="1" placeholder="Optional: prompt if /init doesn't trigger work" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 30px;"></textarea>
|
||||
<span class="form-hint">Sent only when /init completes but Claude stays idle · Auto-accept presses Enter for plan approvals and default options</span>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- End respawn-tab -->
|
||||
|
||||
|
||||
@@ -304,8 +304,16 @@ const KeyboardHandler = {
|
||||
// translate up so it sits at the bottom of the visual viewport.
|
||||
// This formula accounts for iOS scrolling the visual viewport (offsetTop)
|
||||
// when the user types in xterm's hidden textarea.
|
||||
const appEl = document.querySelector('.app');
|
||||
const layoutHeight = appEl?.getBoundingClientRect().bottom || window.innerHeight;
|
||||
//
|
||||
// MUST measure against the LAYOUT viewport (window.innerHeight): the
|
||||
// bars are position:fixed, which anchors to the layout viewport — on
|
||||
// iOS that keeps its full height while the keyboard is open. Measuring
|
||||
// the shrunken .app instead (its height tracks --app-height = visual
|
||||
// viewport) made the offset compute to 0 on iOS, leaving the toolbar
|
||||
// and accessory bar behind the OS keyboard (0.9.8 regression). On
|
||||
// Android the layout viewport itself shrinks with the keyboard, so
|
||||
// innerHeight === visualBottom and the offset is naturally 0 there.
|
||||
const layoutHeight = window.innerHeight;
|
||||
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
|
||||
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
|
||||
|
||||
|
||||
@@ -94,30 +94,6 @@ html.mobile-init .file-browser-panel {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.mobile-header-utility-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
margin: -4px 0.2rem -4px 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
order: -1;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-header-utility-toggle.active,
|
||||
.mobile-header-utility-toggle:active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
position: fixed;
|
||||
top: calc(52px + var(--safe-area-top));
|
||||
@@ -474,25 +450,6 @@ html.mobile-init .file-browser-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-header-utility-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
margin: -6px 0.15rem -6px 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: var(--text-muted);
|
||||
order: -1;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
/* Smaller header buttons on mobile */
|
||||
.btn-icon-header {
|
||||
width: 26px;
|
||||
|
||||
@@ -690,6 +690,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('modalAutoCompactPrompt').value = session.autoCompactPrompt ?? '';
|
||||
document.getElementById('modalAutoClearEnabled').checked = session.autoClearEnabled ?? false;
|
||||
document.getElementById('modalAutoClearThreshold').value = session.autoClearThreshold ?? 140000;
|
||||
|
||||
// Populate auto-resume on usage limit (token pause control)
|
||||
document.getElementById('modalAutoResumeEnabled').checked = session.autoResumeEnabled ?? false;
|
||||
this.updateAutoResumeStatus(sessionId);
|
||||
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
|
||||
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
|
||||
|
||||
@@ -790,6 +794,39 @@ Object.assign(CodemanApp.prototype, {
|
||||
} catch { /* silent */ }
|
||||
},
|
||||
|
||||
async autoSaveAutoResume() {
|
||||
if (!this.editingSessionId) return;
|
||||
const enabled = document.getElementById('modalAutoResumeEnabled').checked;
|
||||
try {
|
||||
await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-resume`, { enabled });
|
||||
const session = this.sessions.get(this.editingSessionId);
|
||||
if (session) {
|
||||
session.autoResumeEnabled = enabled;
|
||||
if (!enabled) session.autoResumeAt = undefined;
|
||||
}
|
||||
this.updateAutoResumeStatus(this.editingSessionId);
|
||||
this.showToast(`Auto-resume on usage limit ${enabled ? 'enabled' : 'disabled'}`, 'success');
|
||||
} catch (err) {
|
||||
this.showToast('Failed to toggle auto-resume: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// Show "resumes at HH:MM" in the session options modal while a usage-limit
|
||||
// pause is armed for the session being edited
|
||||
updateAutoResumeStatus(sessionId) {
|
||||
const el = document.getElementById('autoResumeStatus');
|
||||
if (!el || this.editingSessionId !== sessionId) return;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session?.autoResumeAt && session.autoResumeAt > Date.now()) {
|
||||
const at = new Date(session.autoResumeAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
el.textContent = `Usage limit pause active — resumes at ${at}`;
|
||||
el.classList.add('active');
|
||||
} else {
|
||||
el.textContent = '';
|
||||
el.classList.remove('active');
|
||||
}
|
||||
},
|
||||
|
||||
async toggleSessionImageWatcher() {
|
||||
if (!this.editingSessionId) return;
|
||||
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
|
||||
|
||||
@@ -1711,70 +1711,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
toggleMobileHeaderUtilities() {
|
||||
const tray = document.getElementById('headerRight');
|
||||
const toggle = document.getElementById('mobileHeaderUtilityToggle');
|
||||
if (!tray) return;
|
||||
|
||||
const expanded = tray.classList.toggle('mobile-collapsed') === false;
|
||||
if (toggle) {
|
||||
toggle.classList.toggle('active', expanded);
|
||||
toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||
}
|
||||
},
|
||||
|
||||
handleMobileHeaderUtilityToggle(event) {
|
||||
if (event) {
|
||||
event.preventDefault?.();
|
||||
event.stopPropagation?.();
|
||||
|
||||
const now = Date.now();
|
||||
if ((event.type === 'click' || event.type === 'touchend') && this._lastMobileHeaderUtilityPointerAt) {
|
||||
if (now - this._lastMobileHeaderUtilityPointerAt < 500) return;
|
||||
}
|
||||
if (event.type === 'click' && this._lastMobileHeaderUtilityTouchAt) {
|
||||
if (now - this._lastMobileHeaderUtilityTouchAt < 500) return;
|
||||
}
|
||||
if (event.type === 'touchend') {
|
||||
this._lastMobileHeaderUtilityTouchAt = now;
|
||||
}
|
||||
if (event.type === 'pointerup') {
|
||||
this._lastMobileHeaderUtilityPointerAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
this.toggleMobileHeaderUtilities();
|
||||
},
|
||||
|
||||
bindMobileHeaderUtilityToggle() {
|
||||
const toggle = document.getElementById('mobileHeaderUtilityToggle');
|
||||
if (!toggle || this._mobileHeaderUtilityToggleEl === toggle) return;
|
||||
|
||||
if (this._mobileHeaderUtilityToggleEl && this._mobileHeaderUtilityToggleHandler) {
|
||||
this._mobileHeaderUtilityToggleEl.removeEventListener('click', this._mobileHeaderUtilityToggleHandler);
|
||||
this._mobileHeaderUtilityToggleEl.removeEventListener('touchend', this._mobileHeaderUtilityToggleHandler);
|
||||
this._mobileHeaderUtilityToggleEl.removeEventListener('pointerup', this._mobileHeaderUtilityToggleHandler);
|
||||
}
|
||||
|
||||
this._mobileHeaderUtilityToggleEl = toggle;
|
||||
this._mobileHeaderUtilityToggleHandler = (event) => this.handleMobileHeaderUtilityToggle(event);
|
||||
toggle.addEventListener('click', this._mobileHeaderUtilityToggleHandler);
|
||||
toggle.addEventListener('touchend', this._mobileHeaderUtilityToggleHandler, { passive: false });
|
||||
toggle.addEventListener('pointerup', this._mobileHeaderUtilityToggleHandler);
|
||||
},
|
||||
|
||||
closeMobileHeaderUtilities() {
|
||||
const tray = document.getElementById('headerRight');
|
||||
const toggle = document.getElementById('mobileHeaderUtilityToggle');
|
||||
if (!tray || tray.classList.contains('mobile-collapsed')) return;
|
||||
|
||||
tray.classList.add('mobile-collapsed');
|
||||
if (toggle) {
|
||||
toggle.classList.remove('active');
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
},
|
||||
|
||||
applyTabWrapSettings() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
|
||||
@@ -190,12 +190,6 @@ body {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* Mobile-only header utility toggle — hidden by default; the mobile/tablet
|
||||
media queries in mobile.css (max-width: 768px) re-enable it as flex. */
|
||||
.mobile-header-utility-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Session Tabs */
|
||||
.session-tabs {
|
||||
display: flex;
|
||||
@@ -4676,6 +4670,39 @@ body.solo-mode .btn-lifecycle-log {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Auto-resume on usage limit (token pause control) */
|
||||
.auto-resume-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin: 0.75rem 0;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: rgba(59, 130, 246, 0.07);
|
||||
border: 1px solid rgba(59, 130, 246, 0.35);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.auto-resume-box .checkbox-inline {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.auto-resume-box .form-hint {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.auto-resume-status {
|
||||
display: none;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.auto-resume-status.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Respawn actions in modal */
|
||||
.respawn-actions {
|
||||
display: flex;
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
ResizeSchema,
|
||||
AutoClearSchema,
|
||||
AutoCompactSchema,
|
||||
AutoResumeSchema,
|
||||
ImageWatcherSchema,
|
||||
FlickerFilterSchema,
|
||||
QuickRunSchema,
|
||||
@@ -1003,6 +1004,27 @@ export function registerSessionRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ========== Auto-Resume (usage-limit pause) ==========
|
||||
|
||||
app.post('/api/sessions/:id/auto-resume', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(AutoResumeSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id);
|
||||
|
||||
session.setAutoResume(body.enabled);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
autoResume: {
|
||||
enabled: session.autoResumeEnabled,
|
||||
resumeAt: session.autoResumeAt ?? undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ========== Image Watcher ==========
|
||||
|
||||
app.post('/api/sessions/:id/image-watcher', async (req) => {
|
||||
|
||||
@@ -114,6 +114,7 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
// can ignore small-viewport resizes only while a desktop is actually
|
||||
// connected (see Session._desktopSizeClaims).
|
||||
const sizingToken = Symbol('ws-desktop-sizing');
|
||||
let holdsDesktopClaim = false;
|
||||
|
||||
// Attach message handler synchronously BEFORE any async work
|
||||
// (@fastify/websocket requirement to avoid dropped messages).
|
||||
@@ -122,6 +123,9 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
const msg = JSON.parse(String(raw));
|
||||
if (msg.t === 'i' && typeof msg.d === 'string') {
|
||||
if (msg.d.length > MAX_INPUT_LENGTH) return;
|
||||
// Typed input from a claim-holding desktop keeps the claim "hot"
|
||||
// and re-asserts the desktop layout after a mobile override.
|
||||
if (holdsDesktopClaim) session.noteDesktopActivity();
|
||||
session.write(msg.d);
|
||||
} else if (
|
||||
msg.t === 'z' &&
|
||||
@@ -135,10 +139,12 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
const viewportType = msg.v === 'mobile' || msg.v === 'tablet' || msg.v === 'desktop' ? msg.v : undefined;
|
||||
if (viewportType === 'desktop') {
|
||||
session.claimDesktopSizing(sizingToken);
|
||||
holdsDesktopClaim = true;
|
||||
} else if (viewportType) {
|
||||
// The connection's viewport can change (e.g. browser window
|
||||
// narrowed past the tablet breakpoint) — drop a stale claim.
|
||||
session.releaseDesktopSizing(sizingToken);
|
||||
holdsDesktopClaim = false;
|
||||
}
|
||||
if (viewportType) {
|
||||
session.resize(msg.c, msg.r, { viewportType });
|
||||
|
||||
@@ -450,6 +450,11 @@ export const AutoCompactSchema = z.object({
|
||||
prompt: z.string().max(10000).optional(),
|
||||
});
|
||||
|
||||
/** POST /api/sessions/:id/auto-resume */
|
||||
export const AutoResumeSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
/** POST /api/sessions/:id/image-watcher */
|
||||
export const ImageWatcherSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
|
||||
@@ -1993,6 +1993,12 @@ export class WebServer extends EventEmitter {
|
||||
if (savedState.autoClearEnabled !== undefined || savedState.autoClearThreshold !== undefined) {
|
||||
session.setAutoClear(savedState.autoClearEnabled ?? false, savedState.autoClearThreshold);
|
||||
}
|
||||
// Auto-resume on usage limit (re-arms a pending schedule; an
|
||||
// overdue one fires shortly after boot — the limit footer won't
|
||||
// reprint on its own, so the pause would otherwise stall)
|
||||
if (savedState.autoResumeEnabled) {
|
||||
session.restoreAutoResume(true, savedState.autoResumeAt);
|
||||
}
|
||||
// Token tracking
|
||||
if (
|
||||
savedState.inputTokens !== undefined ||
|
||||
|
||||
@@ -45,6 +45,9 @@ export interface SessionListenerRefs {
|
||||
taskFailed: (task: BackgroundTask, error: string) => void;
|
||||
autoClear: (data: { tokens: number; threshold: number }) => void;
|
||||
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
|
||||
limitPauseScheduled: (data: { resetAt: number; resumeAt: number; matched: string }) => void;
|
||||
limitResume: (data: { attempt: number }) => void;
|
||||
limitResumeCancelled: (data: { reason: string }) => void;
|
||||
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => void;
|
||||
ralphLoopUpdate: (state: RalphTrackerState) => void;
|
||||
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
|
||||
@@ -243,6 +246,28 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
if (tracker) tracker.recordAutoCompact(data.tokens, data.threshold);
|
||||
},
|
||||
|
||||
/** Broadcasts `session:limitPauseScheduled` — usage-limit pause detected, auto-resume armed.
|
||||
* Persisted so a pending schedule survives a Codeman restart. */
|
||||
limitPauseScheduled: (data: { resetAt: number; resumeAt: number; matched: string }) => {
|
||||
deps.broadcast(SseEvent.SessionLimitPauseScheduled, { sessionId: session.id, ...data });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
deps.persistSessionState(session);
|
||||
},
|
||||
|
||||
/** Broadcasts `session:limitResume` — auto-resume prompt sent after limit reset */
|
||||
limitResume: (data: { attempt: number }) => {
|
||||
deps.broadcast(SseEvent.SessionLimitResume, { sessionId: session.id, ...data });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
deps.persistSessionState(session);
|
||||
},
|
||||
|
||||
/** Broadcasts `session:limitResumeCancelled` — pending auto-resume no longer needed */
|
||||
limitResumeCancelled: (data: { reason: string }) => {
|
||||
deps.broadcast(SseEvent.SessionLimitResumeCancelled, { sessionId: session.id, ...data });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
deps.persistSessionState(session);
|
||||
},
|
||||
|
||||
// ─── CLI Info ────────────────────────────────────────────
|
||||
|
||||
/** Broadcasts `session:cliInfo` — Claude Code version, model, account type parsed from terminal */
|
||||
@@ -350,6 +375,9 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.on('taskFailed', refs.taskFailed);
|
||||
session.on('autoClear', refs.autoClear);
|
||||
session.on('autoCompact', refs.autoCompact);
|
||||
session.on('limitPauseScheduled', refs.limitPauseScheduled);
|
||||
session.on('limitResume', refs.limitResume);
|
||||
session.on('limitResumeCancelled', refs.limitResumeCancelled);
|
||||
session.on('cliInfoUpdated', refs.cliInfoUpdated);
|
||||
session.on('ralphLoopUpdate', refs.ralphLoopUpdate);
|
||||
session.on('ralphTodoUpdate', refs.ralphTodoUpdate);
|
||||
@@ -379,6 +407,9 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.off('taskFailed', refs.taskFailed);
|
||||
session.off('autoClear', refs.autoClear);
|
||||
session.off('autoCompact', refs.autoCompact);
|
||||
session.off('limitPauseScheduled', refs.limitPauseScheduled);
|
||||
session.off('limitResume', refs.limitResume);
|
||||
session.off('limitResumeCancelled', refs.limitResumeCancelled);
|
||||
session.off('cliInfoUpdated', refs.cliInfoUpdated);
|
||||
session.off('ralphLoopUpdate', refs.ralphLoopUpdate);
|
||||
session.off('ralphTodoUpdate', refs.ralphTodoUpdate);
|
||||
|
||||
@@ -73,6 +73,12 @@ export const SessionWorking = 'session:working' as const;
|
||||
export const SessionAutoClear = 'session:autoClear' as const;
|
||||
/** Auto-compact triggered for the session. */
|
||||
export const SessionAutoCompact = 'session:autoCompact' as const;
|
||||
/** Usage-limit pause detected; auto-resume scheduled. */
|
||||
export const SessionLimitPauseScheduled = 'session:limitPauseScheduled' as const;
|
||||
/** Auto-resume prompt sent after a usage-limit reset. */
|
||||
export const SessionLimitResume = 'session:limitResume' as const;
|
||||
/** Pending usage-limit auto-resume cancelled (session resumed or feature disabled). */
|
||||
export const SessionLimitResumeCancelled = 'session:limitResumeCancelled' as const;
|
||||
/** CLI version/model info detected from session output. */
|
||||
export const SessionCliInfo = 'session:cliInfo' as const;
|
||||
/** General session message (e.g. status text). */
|
||||
@@ -361,6 +367,9 @@ export const SseEvent = {
|
||||
SessionWorking,
|
||||
SessionAutoClear,
|
||||
SessionAutoCompact,
|
||||
SessionLimitPauseScheduled,
|
||||
SessionLimitResume,
|
||||
SessionLimitResumeCancelled,
|
||||
SessionCliInfo,
|
||||
SessionMessage,
|
||||
SessionInteractive,
|
||||
|
||||
Reference in New Issue
Block a user