Skip to content

Commit 423d0a9

Browse files
committed
fix(webapp): guard capped agent send paths and settle the quota re-read
Draft submit and chat retry now bail when the message cap is reached, so a suggested prompt or retry over the cap no longer fires a silent 403. The capped draft keeps any open watch card. The quota re-reads when a turn settles instead of on optimistic append, so the count and cap no longer lag by one message.
1 parent 12785db commit 423d0a9

3 files changed

Lines changed: 39 additions & 23 deletions

File tree

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,8 @@ export function DashboardAgentChat({
201201
const orderRef = useRef(createTranscriptOrder(initialMessages));
202202
const messages = orderTranscript(rawMessages, orderRef.current);
203203

204-
// Counted here, not in the panel, so it includes the turn just sent.
205-
const quota = useAgentMessageQuota({ actionPath, chatId, messages });
204+
// Read here, not in the panel, so it re-reads as each turn settles.
205+
const quota = useAgentMessageQuota({ actionPath, chatId, status });
206206
// Either the poll saw the cap, or a send was just refused over it.
207207
const atMessageCap = quota.kind === "reached" || quotaReached !== null;
208208
const messageCapLimit =
@@ -271,6 +271,8 @@ export function DashboardAgentChat({
271271
}, [sendRequest, submit, canSend]);
272272

273273
const retry = useCallback(() => {
274+
// Over the cap, a retry only earns another 403 — same guard as `submit`.
275+
if (atMessageCap) return;
274276
// A watch's consent record is a user message nobody typed, so retry never treats it as one.
275277
const action = retryAction(
276278
messages.filter((m) => !(m.role === "user" && isWatchRequestMessageId(m.id)))
@@ -283,7 +285,7 @@ export function DashboardAgentChat({
283285
return;
284286
}
285287
void sendMessage({ text: action.text, messageId: action.messageId });
286-
}, [messages, sendMessage, regenerate, clearError]);
288+
}, [messages, sendMessage, regenerate, clearError, atMessageCap]);
287289

288290
const resolveUri = useTriggerUriResolver(actionPath);
289291

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

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,14 @@ export function DashboardAgentDraft({
4646

4747
const submit = useCallback(
4848
(text: string) => {
49+
// Suggested prompts reach here via the hero, bypassing the composer's cap guard.
50+
if (capReached) return;
4951
const trimmed = text.trim();
5052
if (!trimmed) return;
5153
setInput("");
5254
onSubmit(trimmed);
5355
},
54-
[onSubmit]
56+
[onSubmit, capReached]
5557
);
5658

5759
return (
@@ -61,16 +63,19 @@ export function DashboardAgentDraft({
6163
promoted={promotedPrompt}
6264
composer={
6365
capReached ? (
64-
<AgentUpgradeBlock
65-
limit={capReached.limit}
66-
context={
67-
<DashboardAgentContextBanner
68-
projectSlug={projectSlug}
69-
environmentSlug={environmentSlug}
70-
currentPage={currentPage}
71-
/>
72-
}
73-
/>
66+
<div className="flex w-full flex-col gap-3">
67+
{watchCard}
68+
<AgentUpgradeBlock
69+
limit={capReached.limit}
70+
context={
71+
<DashboardAgentContextBanner
72+
projectSlug={projectSlug}
73+
environmentSlug={environmentSlug}
74+
currentPage={currentPage}
75+
/>
76+
}
77+
/>
78+
</div>
7479
) : (
7580
<div className="flex w-full flex-col gap-3">
7681
{watchCard}

apps/webapp/app/components/dashboard-agent/useAgentMessageQuota.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import type { UIMessage } from "@ai-sdk/react";
2-
import { useEffect, useState } from "react";
1+
import { useEffect, useRef, useState } from "react";
32
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
4-
import { countUserMessages, resolveMessageQuota, type MessageQuota } from "./message-quota";
3+
import { resolveMessageQuota, type MessageQuota } from "./message-quota";
54

65
// Gated on billing PRESENCE, not the plan value: no subscription means billing isn't wired
76
// up (self-hosted), so there is no cap and no upgrade UI. A wired-up, non-paying plan is free.
@@ -11,20 +10,30 @@ function useIsFreePlan(): boolean | undefined {
1110
return subscription.isPaying === false;
1211
}
1312

14-
// `used` is the server's per-period count for the org. Re-read whenever the user sends, so
15-
// the running total tracks the message just sent without counting the transcript twice.
13+
// `used` is the server's per-period count for the org. Re-read once a turn settles — the
14+
// server increment happens mid-turn in the `.in` proxy, so reading on optimistic append
15+
// would lag the count by one message and show the cap a message late.
1616
export function useAgentMessageQuota({
1717
actionPath,
1818
chatId,
19-
messages,
19+
status,
2020
}: {
2121
actionPath: string;
2222
chatId: string;
23-
messages: UIMessage[];
23+
status: string;
2424
}): MessageQuota {
2525
const isFreePlan = useIsFreePlan();
2626
const [used, setUsed] = useState<number | undefined>(undefined);
27-
const sentCount = countUserMessages(messages);
27+
28+
// Bumped each time the status leaves streaming/submitted, which drives the re-read.
29+
const [settleTick, setSettleTick] = useState(0);
30+
const prevStatus = useRef(status);
31+
useEffect(() => {
32+
const wasInFlight = prevStatus.current === "streaming" || prevStatus.current === "submitted";
33+
const nowSettled = status === "ready" || status === "error";
34+
prevStatus.current = status;
35+
if (wasInFlight && nowSettled) setSettleTick((tick) => tick + 1);
36+
}, [status]);
2837

2938
useEffect(() => {
3039
if (isFreePlan !== true) return;
@@ -40,7 +49,7 @@ export function useAgentMessageQuota({
4049
}
4150
})();
4251
return () => controller.abort();
43-
}, [isFreePlan, actionPath, chatId, sentCount]);
52+
}, [isFreePlan, actionPath, chatId, settleTick]);
4453

4554
return resolveMessageQuota({ isFreePlan, used });
4655
}

0 commit comments

Comments
 (0)