Skip to content

Commit f15ad15

Browse files
committed
fix(webapp): release the upgrade panel when the message allowance frees up
1 parent c56e3e5 commit f15ad15

4 files changed

Lines changed: 62 additions & 1 deletion

File tree

apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
FREE_PLAN_MESSAGE_LIMIT,
2222
MESSAGE_QUOTA_REACHED_REASON,
2323
parseQuotaReachedResponse,
24+
type MessageQuota,
2425
} from "./message-quota";
2526
import { createTranscriptOrder, orderTranscript } from "./message-order";
2627
import { navigateDestination } from "./navigate-target";
@@ -78,6 +79,7 @@ export function DashboardAgentChat({
7879
onCancelWatch,
7980
onTurnSettled,
8081
onActivityChange,
82+
onQuotaChange,
8183
}: {
8284
chatId: string;
8385
initialMessages: UIMessage[];
@@ -105,6 +107,8 @@ export function DashboardAgentChat({
105107
onCancelWatch: (watchId: string) => void;
106108
onTurnSettled: () => void;
107109
onActivityChange?: (chatId: string, activity: TurnActivity | null) => void;
110+
/** The poll lives here, so this is where the panel learns the cap has lifted. */
111+
onQuotaChange?: (quota: MessageQuota) => void;
108112
}) {
109113
const [input, setInput] = useState("");
110114
// Set when the server refuses a send over the cap, so the block shows at once rather than
@@ -209,6 +213,11 @@ export function DashboardAgentChat({
209213

210214
// Read here, not in the panel, so it re-reads as each turn settles.
211215
const quota = useAgentMessageQuota({ actionPath, chatId, status });
216+
useEffect(() => {
217+
onQuotaChange?.(quota);
218+
// The quota object is rebuilt every render; only its kind is acted on.
219+
// eslint-disable-next-line react-hooks/exhaustive-deps
220+
}, [quota.kind, onQuotaChange]);
212221
// Either the poll saw the cap, or a send was just refused over it.
213222
const atMessageCap = quota.kind === "reached" || quotaReached !== null;
214223
const messageCapLimit =

apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ import {
2424
writeLastChat,
2525
} from "./last-chat-storage";
2626
import { DashboardAgentDraft } from "./DashboardAgentDraft";
27-
import { parseQuotaReachedResponse } from "./message-quota";
27+
import {
28+
parseQuotaReachedResponse,
29+
shouldClearCapReached,
30+
type MessageQuota,
31+
} from "./message-quota";
2832
import { WatchCard } from "./WatchCard";
2933
import { watchDraftFor } from "./watch-card";
3034
import { NO_WATCH_CARD, watchCardReducer } from "./watch-card-state";
@@ -205,6 +209,8 @@ export function DashboardAgentPanel({
205209
// half-configured watch card, which would otherwise be submitted against the new chat.
206210
const claimChatSlot = useCallback(() => {
207211
dispatchWatchCard({ type: "chat-changed" });
212+
// A new attempt goes back to the server, which re-refuses if the cap still stands.
213+
setCapReached(null);
208214
// A request belongs to the chat it was made in: the remounting chat has a fresh guard ref,
209215
// so a kept request would be sent a second time.
210216
setSendRequest(undefined);
@@ -491,6 +497,11 @@ export function DashboardAgentPanel({
491497
setActive(null);
492498
}, [claimChatSlot]);
493499

500+
// Released only by a read that proves capacity: an unknown quota keeps the block.
501+
const handleQuotaChange = useCallback((quota: MessageQuota) => {
502+
if (shouldClearCapReached(quota)) setCapReached(null);
503+
}, []);
504+
494505
const switchChat = useCallback(
495506
(id: string) => {
496507
void openChat(id);
@@ -638,6 +649,7 @@ export function DashboardAgentPanel({
638649
// The generated chat name is written before the turn-complete chunk lands.
639650
onTurnSettled={loadHistory}
640651
onActivityChange={handleActivityChange}
652+
onQuotaChange={handleQuotaChange}
641653
/>
642654
) : (
643655
<DashboardAgentDraft

apps/webapp/app/components/dashboard-agent/message-quota.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
quotaResponseUpdate,
99
resolveMessageLimit,
1010
resolveMessageQuota,
11+
shouldClearCapReached,
1112
} from "./message-quota";
1213

1314
describe("quotaResponseUpdate", () => {
@@ -113,6 +114,36 @@ describe("parseQuotaReachedResponse", () => {
113114
});
114115
});
115116

117+
describe("shouldClearCapReached", () => {
118+
const readQuota = (data: { used?: number; limit?: number | null } | null) => {
119+
const update = quotaResponseUpdate(data);
120+
return resolveMessageQuota({
121+
isFreePlan: true,
122+
used: update?.used,
123+
limit: resolveMessageLimit(update?.limit),
124+
});
125+
};
126+
127+
it("releases the block once a read shows capacity", () => {
128+
// Refused at 20/20, then the allowance resets or the plan's cap grows.
129+
expect(shouldClearCapReached(readQuota({ used: 20, limit: 20 }))).toBe(false);
130+
expect(shouldClearCapReached(readQuota({ used: 0, limit: 20 }))).toBe(true);
131+
expect(shouldClearCapReached(readQuota({ used: 20, limit: 500 }))).toBe(true);
132+
});
133+
134+
it("keeps the block when the read is degraded, so the composer can't flash", () => {
135+
expect(shouldClearCapReached(readQuota({ used: 20, limit: 20 }))).toBe(false);
136+
expect(shouldClearCapReached(readQuota(null))).toBe(false);
137+
expect(shouldClearCapReached(readQuota({ limit: 20 }))).toBe(false);
138+
});
139+
140+
it("keeps the block while the plan hasn't resolved", () => {
141+
expect(shouldClearCapReached(resolveMessageQuota({ isFreePlan: undefined, used: 0 }))).toBe(
142+
false
143+
);
144+
});
145+
});
146+
116147
describe("messageQuotaReachedCopy", () => {
117148
it("names the Free plan only for the client nudge", () => {
118149
const copy = messageQuotaReachedCopy(FREE_PLAN_MESSAGE_LIMIT, false);

apps/webapp/app/components/dashboard-agent/message-quota.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ export function resolveMessageQuota({
4848
: { kind: "within", used, limit, remaining };
4949
}
5050

51+
/**
52+
* Whether a refusal-set cap can be released: only a read that proves capacity is back. An
53+
* unknown quota (degraded read, plan not resolved) keeps the block, so the composer never
54+
* flashes back for someone the server is about to refuse again.
55+
*/
56+
export function shouldClearCapReached(quota: MessageQuota): boolean {
57+
return quota.kind === "within";
58+
}
59+
5160
// The server code both the create and `in` paths refuse with. The client owns the copy,
5261
// so this code must never reach the UI as text.
5362
export const MESSAGE_QUOTA_REACHED_ERROR = "message_quota_reached";

0 commit comments

Comments
 (0)