Skip to content

Commit a70c71e

Browse files
committed
fix(webapp): don't tell paid plans their message allowance was the Free plan's
1 parent 4b307dd commit a70c71e

6 files changed

Lines changed: 41 additions & 14 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ const SLOT = "flex shrink-0 flex-col bg-background-bright px-3 pb-3 pt-1";
1111

1212
export function AgentUpgradeBlock({
1313
limit,
14+
planResolved,
1415
context,
1516
}: {
1617
limit: number;
18+
planResolved: boolean;
1719
context?: React.ReactNode;
1820
}) {
1921
const organization = useOrganization();
@@ -28,7 +30,7 @@ export function AgentUpgradeBlock({
2830
Upgrade to unlock {ASK_AGENT_LABEL}
2931
</span>
3032
</div>
31-
<p className="text-xs text-text-dimmed">{messageQuotaReachedCopy(limit)}</p>
33+
<p className="text-xs text-text-dimmed">{messageQuotaReachedCopy(limit, planResolved)}</p>
3234
<LinkButton variant="primary/small" to={v3BillingPath(organization)} fullWidth>
3335
Upgrade
3436
</LinkButton>

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,9 @@ export function DashboardAgentChat({
105105
const [input, setInput] = useState("");
106106
// Set when the server refuses a send over the cap, so the block shows at once rather than
107107
// waiting for the next quota poll.
108-
const [quotaReached, setQuotaReached] = useState<{ limit: number } | null>(null);
108+
const [quotaReached, setQuotaReached] = useState<{ limit: number; planResolved: boolean } | null>(
109+
null
110+
);
109111
const navigate = useNavigate();
110112
const location = useLocation();
111113
const toast = useToast();
@@ -207,6 +209,9 @@ export function DashboardAgentChat({
207209
const atMessageCap = quota.kind === "reached" || quotaReached !== null;
208210
const messageCapLimit =
209211
quotaReached?.limit ?? (quota.kind === "unlimited" ? FREE_PLAN_MESSAGE_LIMIT : quota.limit);
212+
// The poll only runs on the free plan, so its cap is the free-plan nudge; a refusal
213+
// carries the plan limit the server resolved.
214+
const messageCapPlanResolved = quotaReached?.planResolved ?? false;
210215

211216
const isStreaming = status === "streaming";
212217
// From status, not the last part: the indicator must stay up through silent tool calls.
@@ -438,6 +443,7 @@ export function DashboardAgentChat({
438443
{atMessageCap ? (
439444
<AgentUpgradeBlock
440445
limit={messageCapLimit}
446+
planResolved={messageCapPlanResolved}
441447
context={
442448
<DashboardAgentContextBanner
443449
projectSlug={projectSlug}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export function DashboardAgentDraft({
2626
pageContext?: AgentPageContext;
2727
promotedPrompt?: SuggestedPrompt;
2828
watchCard?: React.ReactNode;
29-
capReached?: { limit: number } | null;
29+
capReached?: { limit: number; planResolved: boolean } | null;
3030
}) {
3131
const [input, setInput] = useState("");
3232

@@ -67,6 +67,7 @@ export function DashboardAgentDraft({
6767
{watchCard}
6868
<AgentUpgradeBlock
6969
limit={capReached.limit}
70+
planResolved={capReached.planResolved}
7071
context={
7172
<DashboardAgentContextBanner
7273
projectSlug={projectSlug}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,9 @@ export function DashboardAgentPanel({
116116
const [chatsLoaded, setChatsLoaded] = useState(false);
117117
const [active, setActive] = useState<ActiveChat | null>(null);
118118
// A refused `create` over the cap: the draft shows the upgrade block instead of a raw toast.
119-
const [capReached, setCapReached] = useState<{ limit: number } | null>(null);
119+
const [capReached, setCapReached] = useState<{ limit: number; planResolved: boolean } | null>(
120+
null
121+
);
120122
// Starts true so an `openWith` request waits for the restore instead of racing it.
121123
const [loading, setLoading] = useState(
122124
() => readLastChat(storageKey)?.path === location.pathname

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,13 @@ describe("parseQuotaReachedResponse", () => {
9696
// Both the create path and the `in` transport refuse with this exact body.
9797
expect(
9898
parseQuotaReachedResponse(403, { error: MESSAGE_QUOTA_REACHED_ERROR, limit: 20 })
99-
).toEqual({ limit: 20 });
99+
).toEqual({ limit: 20, planResolved: true });
100100
});
101101

102102
it("falls back to the free limit when the body omits it", () => {
103103
expect(parseQuotaReachedResponse(403, { error: MESSAGE_QUOTA_REACHED_ERROR })).toEqual({
104104
limit: FREE_PLAN_MESSAGE_LIMIT,
105+
planResolved: false,
105106
});
106107
});
107108

@@ -113,13 +114,21 @@ describe("parseQuotaReachedResponse", () => {
113114
});
114115

115116
describe("messageQuotaReachedCopy", () => {
116-
it("is a friendly sentence naming the limit, never the raw code", () => {
117-
const copy = messageQuotaReachedCopy(20);
118-
expect(copy).toContain("all 20 messages");
117+
it("names the Free plan only for the client nudge", () => {
118+
const copy = messageQuotaReachedCopy(FREE_PLAN_MESSAGE_LIMIT, false);
119+
expect(copy).toContain(`all ${FREE_PLAN_MESSAGE_LIMIT} messages`);
119120
expect(copy).toContain("Free plan");
120121
// Control break: if the mapping leaked the server code, this fails.
121122
expect(copy).not.toContain(MESSAGE_QUOTA_REACHED_ERROR);
122123
});
124+
125+
it("stays plan-agnostic for a server-resolved limit, which paying orgs also hit", () => {
126+
const copy = messageQuotaReachedCopy(500, true);
127+
expect(copy).toContain("all 500 messages");
128+
expect(copy).toContain("your plan");
129+
expect(copy).not.toContain("Free plan");
130+
expect(copy).not.toContain(MESSAGE_QUOTA_REACHED_ERROR);
131+
});
123132
});
124133

125134
describe("countUserMessages", () => {

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

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,17 +57,24 @@ export const MESSAGE_QUOTA_REACHED_ERROR = "message_quota_reached";
5757
export function parseQuotaReachedResponse(
5858
status: number,
5959
data: { error?: string; limit?: number } | null | undefined
60-
): { limit: number } | null {
60+
): { limit: number; planResolved: boolean } | null {
6161
if (status === 403 && data?.error === MESSAGE_QUOTA_REACHED_ERROR) {
62-
return { limit: data.limit ?? FREE_PLAN_MESSAGE_LIMIT };
62+
return typeof data.limit === "number"
63+
? { limit: data.limit, planResolved: true }
64+
: { limit: FREE_PLAN_MESSAGE_LIMIT, planResolved: false };
6365
}
6466
return null;
6567
}
6668

67-
// The upgrade block's sentence. Pure so the copy is asserted directly, and so the raw
68-
// server code can never be what the user reads.
69-
export function messageQuotaReachedCopy(limit: number): string {
70-
return `You've used all ${limit} messages included on the Free plan. Your chats stay here to read.`;
69+
/**
70+
* The upgrade block's sentence. Pure so the copy is asserted directly, and so the raw
71+
* server code can never be what the user reads. Only the client's free-plan nudge may name
72+
* the Free plan — a server-resolved cap also lands on paying orgs, whose allowance isn't it.
73+
*/
74+
export function messageQuotaReachedCopy(limit: number, planResolved: boolean): string {
75+
return planResolved
76+
? `You've used all ${limit} messages included in your plan this month. Your chats stay here to read.`
77+
: `You've used all ${limit} messages included on the Free plan. Your chats stay here to read.`;
7178
}
7279

7380
// A watch's consent record is a user message the person never typed, so it is

0 commit comments

Comments
 (0)