Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/project-loop.js
Original file line number Diff line number Diff line change
Expand Up @@ -1441,6 +1441,10 @@ function attachLoop(ctx) {
for (var di = 0; di < sessionIds.length; di++) {
sm.deleteSessionQuiet(sessionIds[di]);
}
// lr-0827ba (PEACHES follow-up): notify clients of exactly which
// sessions were deleted so per-session client state (rate-limit-state.js)
// can be pruned instead of leaking a background timer forever.
if (sessionIds.length > 0) sm.broadcastSessionDeleted(sessionIds);
loopRegistry.remove(loopIdToDel);
sm.broadcastSessionList();
return true;
Expand Down
5 changes: 3 additions & 2 deletions lib/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,7 @@ function createProjectContext(opts) {
text: text,
resetsAt: sendsAt,
scheduledAt: Date.now(),
localId: session.localId,
};
sm.sendAndRecord(session, schedEntry);
session.scheduledMessage = {
Expand All @@ -698,7 +699,7 @@ function createProjectContext(opts) {
session.scheduledMessage = null;
if (session.destroying) return;
console.log("[project] Scheduled message firing for session " + session.localId);
sm.sendAndRecord(session, { type: "scheduled_message_sent" });
sm.sendAndRecord(session, { type: "scheduled_message_sent", localId: session.localId });
var schedUserMsg = { type: "user_message", text: text, _ts: Date.now() };
// lr-2ea2a7: routes through the shared history-cap helper (see grep-guard test).
sm.recordHistoryEntry(session, schedUserMsg, true);
Expand All @@ -719,7 +720,7 @@ function createProjectContext(opts) {
clearTimeout(session.scheduledMessage.timer);
session.scheduledMessage = null;
session.rateLimitAutoContinuePending = false;
sm.sendAndRecord(session, { type: "scheduled_message_cancelled" });
sm.sendAndRecord(session, { type: "scheduled_message_cancelled", localId: session.localId });
}
}

Expand Down
37 changes: 29 additions & 8 deletions lib/public/modules/app-messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import { updateProjectList, resetClientState, showUpdateAvailable, handleRemoveP
import { updateHistorySentinel, prependOlderHistory } from './app-header.js';
import { hideHomeHub, handleHubSchedules, handleHubRecentSessions } from './app-home-hub.js';
import { openDm, enterDmMode, exitDmMode, appendDmMessage, showDmTypingIndicator } from './app-dm.js';
import { handleRateLimitEvent, updateRateLimitUsage, addScheduledMessageBubble, removeScheduledMessageBubble, handleFastModeState } from './app-rate-limit.js';
import { handleRateLimitEvent, updateRateLimitUsage, addScheduledMessageBubble, removeScheduledMessageBubble, clearScheduledMessage, handleFastModeState, restoreRateLimitStateForSession, forgetSessionRateLimitState } from './app-rate-limit.js';
import { handleRemoteCursorMove, handleRemoteCursorLeave, handleRemoteSelection, clearRemoteCursors } from './app-cursors.js';
import { showLoopBanner, updateLoopBanner, updateLoopInputVisibility, updateLoopPendingBanner, showRalphApprovalBar, updateRalphApprovalStatus, openRalphPreviewModal, showExecModal, updateExecModalStatus } from './app-loop-ui.js';
import { handleSkillInstallWs } from './app-skills-install.js';
Expand Down Expand Up @@ -435,6 +435,18 @@ export function processMessage(msg) {
if (!store.get('dmMode')) handleInputSync(msg.text);
break;

case "session_deleted":
// lr-0827ba (PEACHES follow-up): a session was actually deleted (not
// merely switched away from) — prune its per-session rate-limit /
// scheduled-message arming state, canceling any background reset
// timer, instead of leaking it forever. session_list alone never
// told the client WHICH id(s) disappeared, so nothing could target
// this cleanup before this message existed.
(msg.ids || []).forEach(function (deletedId) {
forgetSessionRateLimitState(deletedId);
});
break;

case "session_list":
renderSessionList(msg.sessions || []);
handlePaletteSessionSwitch();
Expand Down Expand Up @@ -558,6 +570,11 @@ export function processMessage(msg) {
// Session presence is now tracked server-side (user-presence.json)
clearRemoteCursors();
resetClientState();
// lr-0827ba: redraw the incoming session's own armed rate-limit /
// scheduled-message state (arming happens per-session in the
// background regardless of focus — see rate-limit-state.js) instead
// of relying on chat-history replay to happen to repaint it.
restoreRateLimitStateForSession(store.get('activeSessionId'));

updateLoopInputVisibility(msg.loop);
// Restore input area visibility (may have been hidden by auth_required)
Expand Down Expand Up @@ -964,19 +981,23 @@ export function processMessage(msg) {
break;

case "scheduled_message_queued":
addScheduledMessageBubble(msg.text, msg.resetsAt);
setScheduleBtnDisabled(true);
// lr-0827ba: msg.localId identifies the session this schedule
// actually belongs to — may not be the currently focused session.
addScheduledMessageBubble(msg.text, msg.resetsAt, msg.localId);
if (msg.localId == null || msg.localId === store.get('activeSessionId')) setScheduleBtnDisabled(true);
break;

case "scheduled_message_sent":
removeScheduledMessageBubble();
setScheduleBtnDisabled(false);
setStatus("processing");
clearScheduledMessage(msg.localId);
if (msg.localId == null || msg.localId === store.get('activeSessionId')) {
setScheduleBtnDisabled(false);
setStatus("processing");
}
break;

case "scheduled_message_cancelled":
removeScheduledMessageBubble();
setScheduleBtnDisabled(false);
clearScheduledMessage(msg.localId);
if (msg.localId == null || msg.localId === store.get('activeSessionId')) setScheduleBtnDisabled(false);
break;

case "auto_continue_scheduled":
Expand Down
155 changes: 128 additions & 27 deletions lib/public/modules/app-rate-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,37 @@ import { store } from './store.js';
import { getWs } from './ws-ref.js';
import { addToMessages, scrollToBottom } from './app-rendering.js';
import { userAvatarUrl } from './avatar.js';
import { setScheduleDelayMs, clearScheduleDelay } from './input.js';
import { clearScheduleDelay, setScheduleDelayForSession, clearScheduleDelayForSession, restoreScheduleDelayUi } from './input.js';
import { isSonnetModel, isOpusModel } from './settings-defaults.js';
import {
getRateLimitResetsAt, setRateLimitResetsAt,
getRateLimitResetTimer, setRateLimitResetTimer,
getRateLimitResetState,
setScheduledMsg, clearScheduledMsg, getScheduledMsg,
clearSession as clearSessionRateLimitState,
} from './rate-limit-state.js';

// --- Module-owned state ---
// lr-0827ba: rateLimitResetsAt / rateLimitResetTimer / rateLimitResetState /
// the scheduled-message bubble text are now owned per-session by
// rate-limit-state.js (see imports above) — arming one project's rate-limit
// auto-schedule no longer clobbers another project's armed state. The
// variables below remain module-scoped because they are references to the
// single visible DOM element/timer for whichever session is currently
// focused; they get rebuilt/rebound whenever the focused session changes.
var rateLimitCountdownTimer = null;
var rateLimitIndicatorEl = null;
var rateLimitResetsAt = null;
var rateLimitResetTimer = null;
var rateLimitUsageEl = null;
var rateLimitResetState = {};
var rateLimitTickTimer = null;
var _rateLimitUsageLastHtml = null; // guard against redundant innerHTML + refreshIcons()
var scheduledMsgEl = null;
var scheduledCountdownTimer = null;
var fastModeIndicatorEl = null;

function currentSessionId() {
return store.get('activeSessionId');
}

// --- Internal helpers ---

function getVendorUsageMeta(vendor) {
Expand Down Expand Up @@ -158,11 +173,12 @@ function tickRateLimitUsage() {
if (!rateLimitUsageEl) return;
var parts = [];
var types = ["five_hour", "seven_day", "seven_day_opus", "seven_day_sonnet"];
var resetState = getRateLimitResetState(currentSessionId());
for (var i = 0; i < types.length; i++) {
var entry = rateLimitResetState[types[i]];
var entry = resetState[types[i]];
if (!entry || !entry.resetsAt) continue;
var timeStr = formatResetTime(entry.resetsAt);
if (!timeStr) { delete rateLimitResetState[types[i]]; continue; }
if (!timeStr) { delete resetState[types[i]]; continue; }
parts.push(rateLimitTypeShortLabel(types[i]) + " resets " + timeStr);
}
var newHtml;
Expand Down Expand Up @@ -201,18 +217,31 @@ export function handleRateLimitEvent(msg) {
var typeLabel = rateLimitTypeLabel(msg.rateLimitType);
var popoverText = "";

// lr-0827ba: the event's own session (server-stamped localId) is the one
// that gets its schedule armed, NOT necessarily the currently-focused
// session — a rate limit hit in a background session must still arm that
// session's auto-schedule and keep its reset timer running, exactly like
// server-side session.scheduledMessage already does. Fall back to the
// active session for events from servers/replays that predate the localId
// stamp (older recorded history entries).
var eventSessionId = msg.localId != null ? msg.localId : currentSessionId();
var isActiveSession = eventSessionId === currentSessionId();

if (isRejected && msg.resetsAt) {
// Check if already expired (history replay) — skip popover
if (msg.resetsAt < Date.now()) {
updateRateLimitIndicator(msg);
if (isActiveSession) updateRateLimitIndicator(msg);
return;
}
popoverText = typeLabel + " limit exceeded";
updateRateLimitIndicator(msg);
startRateLimitCountdown(null, msg.resetsAt, null);
// Track rate limit reset time
rateLimitResetsAt = msg.resetsAt;
if (rateLimitResetTimer) clearTimeout(rateLimitResetTimer);
if (isActiveSession) {
popoverText = typeLabel + " limit exceeded";
updateRateLimitIndicator(msg);
startRateLimitCountdown(null, msg.resetsAt, null);
}
// Track rate limit reset time for the event's own session.
setRateLimitResetsAt(eventSessionId, msg.resetsAt);
var existingTimer = getRateLimitResetTimer(eventSessionId);
if (existingTimer) clearTimeout(existingTimer);
// Auto-switch input to schedule mode: any message typed will be queued for after reset.
// Model-aware: a seven_day_sonnet rejection should not schedule when the user is on Opus
// (and vice-versa), since those messages would send fine against the other model's quota.
Expand All @@ -235,20 +264,21 @@ export function handleRateLimitEvent(msg) {
}
}
if (shouldSchedule) {
setScheduleDelayMs(delayUntilReset + 60000); // +1min buffer after reset
setScheduleDelayForSession(eventSessionId, delayUntilReset + 60000); // +1min buffer after reset
}
}
// Only arm the reset timer when we actually entered schedule mode; otherwise we'd
// clear unrelated scheduled state on reset.
if (shouldSchedule) {
rateLimitResetTimer = setTimeout(function () {
rateLimitResetsAt = null;
rateLimitResetTimer = null;
// Clear schedule mode when rate limit resets
clearScheduleDelay();
}, msg.resetsAt - Date.now() + 1000);
setRateLimitResetTimer(eventSessionId, setTimeout(function () {
setRateLimitResetsAt(eventSessionId, null);
setRateLimitResetTimer(eventSessionId, null);
// Clear schedule mode when rate limit resets — for the event's own
// session, whether or not it's the one currently focused.
clearScheduleDelayForSession(eventSessionId);
}, msg.resetsAt - Date.now() + 1000));
}
} else {
} else if (isActiveSession) {
var pct = msg.utilization ? Math.round(msg.utilization * 100) : null;
popoverText = typeLabel + " warning" + (pct ? " (" + pct + "% used)" : "");
updateRateLimitIndicator(msg);
Expand All @@ -258,9 +288,19 @@ export function handleRateLimitEvent(msg) {
}

export function updateRateLimitUsage(msg) {
// lr-0827ba: rate_limit_usage is a project-wide broadcast (account-wide
// usage, not session content), but only repaint the top-bar widget when
// the event belongs to the session currently in view, so a background
// session's usage tick doesn't visibly flicker the widget for whatever
// session the user is actually looking at. Reset-time bookkeeping itself
// is still tracked per-session (via rate-limit-state.js) so switching back
// into that session redraws with its own latest data.
var eventSessionId = msg.localId != null ? msg.localId : currentSessionId();
var resetState = getRateLimitResetState(eventSessionId);
if (msg.rateLimitType && msg.resetsAt) {
rateLimitResetState[msg.rateLimitType] = { resetsAt: msg.resetsAt, status: msg.status };
resetState[msg.rateLimitType] = { resetsAt: msg.resetsAt, status: msg.status };
}
if (eventSessionId !== currentSessionId()) return;

var topBarActions = document.querySelector("#top-bar .top-bar-actions");
if (!topBarActions) return;
Expand All @@ -279,7 +319,7 @@ export function updateRateLimitUsage(msg) {
var parts = [];
var types = ["five_hour", "seven_day", "seven_day_opus", "seven_day_sonnet"];
for (var i = 0; i < types.length; i++) {
var entry = rateLimitResetState[types[i]];
var entry = resetState[types[i]];
if (!entry || !entry.resetsAt) continue;
var timeStr = formatResetTime(entry.resetsAt);
if (!timeStr) continue;
Expand Down Expand Up @@ -313,7 +353,17 @@ export function updateRateLimitUsage(msg) {
}
}

export function addScheduledMessageBubble(text, resetsAt) {
// lr-0827ba: msg carries the server-stamped localId of the session the
// scheduled message actually belongs to. Persist the queued text/resetsAt
// for that session regardless of focus (so switching back in later can
// redraw it), but only render the visible bubble into the DOM when the
// event's session is the one currently in view — otherwise a background
// project's scheduled message would render into whatever session's message
// list happens to be on screen.
export function addScheduledMessageBubble(text, resetsAt, sessionId) {
var targetSessionId = sessionId != null ? sessionId : currentSessionId();
setScheduledMsg(targetSessionId, text, resetsAt);
if (targetSessionId !== currentSessionId()) return;
removeScheduledMessageBubble();
var isChannel = document.body.classList.contains("wide-view");
var wrap = document.createElement("div");
Expand Down Expand Up @@ -475,6 +525,10 @@ export function addScheduledMessageBubble(text, resetsAt) {
scheduledCountdownTimer = setInterval(updateCountdown, 1000);
}

// Clears the visible DOM bubble/countdown only — does not touch the
// per-session persisted state. Used internally when re-rendering the bubble
// for the same session (addScheduledMessageBubble) and by
// clearScheduledMessage() below.
export function removeScheduledMessageBubble() {
if (scheduledMsgEl) {
scheduledMsgEl.remove();
Expand All @@ -486,6 +540,16 @@ export function removeScheduledMessageBubble() {
}
}

// lr-0827ba: clears both the persisted per-session scheduled-message state
// and (if that session is currently focused) the visible bubble. sessionId
// defaults to the currently active session for existing call sites that
// don't yet carry a server-stamped localId (older history entries).
export function clearScheduledMessage(sessionId) {
var targetSessionId = sessionId != null ? sessionId : currentSessionId();
clearScheduledMsg(targetSessionId);
if (targetSessionId === currentSessionId()) removeScheduledMessageBubble();
}

export function handleFastModeState(state) {
var statusArea = document.querySelector(".title-bar-content .status");
if (!statusArea) return;
Expand Down Expand Up @@ -515,15 +579,52 @@ export function handleFastModeState(state) {

export function getScheduledMsgEl() { return scheduledMsgEl; }

// lr-0827ba: this used to also null out the bare module-scoped
// rateLimitResetsAt/rateLimitResetTimer/rateLimitResetState variables —
// which meant switching projects silently canceled the OUTGOING session's
// rate-limit reset timer and armed schedule, exactly the bug this task
// fixes. Reset timers and armed state are per-session now (rate-limit-state.js)
// and must keep running in the background regardless of focus, mirroring
// server-side session.scheduledMessage. This function now only tears down
// the visible DOM/UI-timer for whichever session was previously focused —
// it does not touch any session's persisted arming state. Callers switching
// into a different session must call restoreRateLimitStateForSession() for
// the new session afterward to redraw its own state (see app-messages.js
// session_switched handler).
export function resetRateLimitState() {
clearRateLimitIndicator();
if (rateLimitCountdownTimer) { clearInterval(rateLimitCountdownTimer); rateLimitCountdownTimer = null; }
if (rateLimitTickTimer) { clearInterval(rateLimitTickTimer); rateLimitTickTimer = null; }
if (rateLimitResetTimer) { clearTimeout(rateLimitResetTimer); rateLimitResetTimer = null; }
rateLimitResetsAt = null;
rateLimitResetState = {};
// Remove the usage link element entirely so it is rebuilt fresh for the new
// project (correct vendor href, correct reset times).
if (rateLimitUsageEl) { rateLimitUsageEl.remove(); rateLimitUsageEl = null; }
if (fastModeIndicatorEl) { fastModeIndicatorEl.remove(); fastModeIndicatorEl = null; }
removeScheduledMessageBubble();
}

// Called when a session is actually destroyed/removed (not merely
// unfocused) so its background reset timer is canceled and its entry is
// dropped instead of leaking forever. Wired from app-messages.js's
// session_deleted handler (server-stamped ids from lib/sessions.js'
// broadcastSessionDeleted, lr-0827ba PEACHES follow-up).
export function forgetSessionRateLimitState(sessionId) {
clearSessionRateLimitState(sessionId);
}

// lr-0827ba: "redraw on switch-in" — restores the newly-focused session's
// already-armed indicator/schedule-button/scheduled-message-bubble state
// instead of relying on the unreliable chat-history-replay side effect that
// happened to repaint these before. Call after resetRateLimitState() once
// the new session is focused (store.activeSessionId already updated).
export function restoreRateLimitStateForSession(sessionId) {
restoreScheduleDelayUi();
var resetsAt = getRateLimitResetsAt(sessionId);
if (resetsAt && resetsAt > Date.now()) {
updateRateLimitIndicator({ status: "rejected", resetsAt: resetsAt });
startRateLimitCountdown(null, resetsAt, null);
}
var queued = getScheduledMsg(sessionId);
if (queued && queued.resetsAt > Date.now()) {
addScheduledMessageBubble(queued.text, queued.resetsAt, sessionId);
}
}
Loading
Loading