Skip to content

Commit 00933ab

Browse files
committed
Merge remote-tracking branch 'origin/feat/agent-message-quota-tri-12863' into feat/agent-watch-limits-tri-12863
2 parents f9d674e + 50ccb2a commit 00933ab

6 files changed

Lines changed: 110 additions & 21 deletions

File tree

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

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Link } from "@remix-run/react";
2+
import { AgentMonoLogo } from "~/components/primitives/AgentDotMatrix";
23
import { LinkButton } from "~/components/primitives/Buttons";
34
import { useOrganization } from "~/hooks/useOrganizations";
4-
import { cn } from "~/utils/cn";
55
import { v3BillingPath } from "~/utils/pathBuilder";
6-
import { AgentIcon, AGENT_ICON_ACCENT_CLASS, ASK_AGENT_LABEL } from "./agent-identity";
6+
import { ASK_AGENT_LABEL } from "./agent-identity";
7+
import { messageQuotaReachedCopy } from "./message-quota";
78

89
// Matches the composer's outer geometry so the replacement lands in the same place.
910
const SLOT = "flex shrink-0 flex-col bg-background-bright px-3 pb-3 pt-1";
@@ -22,14 +23,12 @@ export function AgentUpgradeBlock({
2223
{context}
2324
<div className="mt-1.5 flex flex-col gap-2 rounded-md border border-border-bright bg-background-dimmed p-3">
2425
<div className="flex items-center gap-1.5">
25-
<AgentIcon className={cn("size-4 shrink-0", AGENT_ICON_ACCENT_CLASS)} />
26+
<AgentMonoLogo size={16} decorative className="shrink-0" />
2627
<span className="text-sm font-medium text-text-bright">
2728
Upgrade to unlock {ASK_AGENT_LABEL}
2829
</span>
2930
</div>
30-
<p className="text-xs text-text-dimmed">
31-
You've used all {limit} messages included on the Free plan. Your chats stay here to read.
32-
</p>
31+
<p className="text-xs text-text-dimmed">{messageQuotaReachedCopy(limit)}</p>
3332
<LinkButton variant="primary/small" to={v3BillingPath(organization)} fullWidth>
3433
Upgrade
3534
</LinkButton>

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
1717
import { DashboardAgentHero } from "./DashboardAgentHero";
1818
import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessages";
1919
import { MESSAGE_TOO_LARGE_ERROR } from "./message-limits";
20-
import { FREE_PLAN_MESSAGE_LIMIT } from "./message-quota";
20+
import { FREE_PLAN_MESSAGE_LIMIT, parseQuotaReachedResponse } from "./message-quota";
2121
import { createTranscriptOrder, orderTranscript } from "./message-order";
2222
import { navigateDestination } from "./navigate-target";
2323
import { pendingNavigateIntents, pendingWatchIntents } from "./pending-intents";
@@ -138,8 +138,9 @@ export function DashboardAgentChat({
138138
.clone()
139139
.json()
140140
.catch(() => null)) as { error?: string; limit?: number } | null;
141-
if (data?.error === "message_quota_reached") {
142-
setQuotaReached({ limit: data.limit ?? FREE_PLAN_MESSAGE_LIMIT });
141+
const reached = parseQuotaReachedResponse(res.status, data);
142+
if (reached) {
143+
setQuotaReached(reached);
143144
throw new Error("You've reached your message limit.");
144145
}
145146
}

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

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts";
22
import { useCallback, useMemo, useState } from "react";
3+
import { AgentUpgradeBlock } from "./AgentUpgradeGate";
34
import { DashboardAgentComposer } from "./DashboardAgentComposer";
45
import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
56
import { DashboardAgentHero } from "./DashboardAgentHero";
@@ -16,6 +17,7 @@ export function DashboardAgentDraft({
1617
pageContext,
1718
promotedPrompt,
1819
watchCard,
20+
capReached,
1921
}: {
2022
onSubmit: (text: string) => void;
2123
projectSlug: string;
@@ -24,6 +26,7 @@ export function DashboardAgentDraft({
2426
pageContext?: AgentPageContext;
2527
promotedPrompt?: SuggestedPrompt;
2628
watchCard?: React.ReactNode;
29+
capReached?: { limit: number } | null;
2730
}) {
2831
const [input, setInput] = useState("");
2932

@@ -57,16 +60,9 @@ export function DashboardAgentDraft({
5760
pageContext={pageContext}
5861
promoted={promotedPrompt}
5962
composer={
60-
<div className="flex w-full flex-col gap-3">
61-
{watchCard}
62-
<DashboardAgentComposer
63-
layout="hero"
64-
value={input}
65-
onChange={setInput}
66-
onSubmit={() => submit(input)}
67-
onStop={() => {}}
68-
isStreaming={false}
69-
placeholderSuggestion={watchCard ? undefined : placeholderSuggestion}
63+
capReached ? (
64+
<AgentUpgradeBlock
65+
limit={capReached.limit}
7066
context={
7167
<DashboardAgentContextBanner
7268
projectSlug={projectSlug}
@@ -75,7 +71,27 @@ export function DashboardAgentDraft({
7571
/>
7672
}
7773
/>
78-
</div>
74+
) : (
75+
<div className="flex w-full flex-col gap-3">
76+
{watchCard}
77+
<DashboardAgentComposer
78+
layout="hero"
79+
value={input}
80+
onChange={setInput}
81+
onSubmit={() => submit(input)}
82+
onStop={() => {}}
83+
isStreaming={false}
84+
placeholderSuggestion={watchCard ? undefined : placeholderSuggestion}
85+
context={
86+
<DashboardAgentContextBanner
87+
projectSlug={projectSlug}
88+
environmentSlug={environmentSlug}
89+
currentPage={currentPage}
90+
/>
91+
}
92+
/>
93+
</div>
94+
)
7995
}
8096
/>
8197
);

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
writeLastChat,
2525
} from "./last-chat-storage";
2626
import { DashboardAgentDraft } from "./DashboardAgentDraft";
27+
import { parseQuotaReachedResponse } from "./message-quota";
2728
import { WatchCard } from "./WatchCard";
2829
import { watchDraftFor } from "./watch-card";
2930
import { NO_WATCH_CARD, watchCardReducer } from "./watch-card-state";
@@ -108,6 +109,8 @@ export function DashboardAgentPanel({
108109
// Until the list has arrived, the page load's server count is the better answer.
109110
const [chatsLoaded, setChatsLoaded] = useState(false);
110111
const [active, setActive] = useState<ActiveChat | null>(null);
112+
// A refused `create` over the cap: the draft shows the upgrade block instead of a raw toast.
113+
const [capReached, setCapReached] = useState<{ limit: number } | null>(null);
111114
// Starts true so an `openWith` request waits for the restore instead of racing it.
112115
const [loading, setLoading] = useState(
113116
() => readLastChat(storageKey)?.path === location.pathname
@@ -238,14 +241,22 @@ export function DashboardAgentPanel({
238241
publicAccessToken?: string;
239242
headStarted?: boolean;
240243
error?: string;
244+
limit?: number;
241245
};
242246
if (seq !== openChatRequestSeq.current) return;
243247
if (!res.ok || !data.chatId || !data.publicAccessToken) {
248+
const reached = parseQuotaReachedResponse(res.status, data);
249+
if (reached) {
250+
setCapReached(reached);
251+
setActive(null);
252+
return;
253+
}
244254
console.error(`Dashboard agent: failed to create chat (${res.status})`, data.error);
245255
toast.error(data.error ?? "We couldn't start that chat. Try again in a moment.");
246256
setActive(null);
247257
return;
248258
}
259+
setCapReached(null);
249260
setActive({
250261
chatId: data.chatId,
251262
organizationId: organization.id,
@@ -287,6 +298,7 @@ export function DashboardAgentPanel({
287298
panelOrg.current = organization.id;
288299
claimChatSlot();
289300
setActive(null);
301+
setCapReached(null);
290302
setLoading(false);
291303
setChats([]);
292304
setChatsLoaded(false);
@@ -614,6 +626,7 @@ export function DashboardAgentPanel({
614626
pageContext={pageContext}
615627
promotedPrompt={promotedPrompt}
616628
watchCard={watchCardElement}
629+
capReached={capReached}
617630
/>
618631
)}
619632
</AgentPanelColumn>

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

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { describe, expect, it } from "vitest";
2-
import { countUserMessages, FREE_PLAN_MESSAGE_LIMIT, resolveMessageQuota } from "./message-quota";
2+
import {
3+
countUserMessages,
4+
FREE_PLAN_MESSAGE_LIMIT,
5+
MESSAGE_QUOTA_REACHED_ERROR,
6+
messageQuotaReachedCopy,
7+
parseQuotaReachedResponse,
8+
resolveMessageQuota,
9+
} from "./message-quota";
310

411
describe("resolveMessageQuota", () => {
512
it("caps a Free plan at the limit", () => {
@@ -42,6 +49,37 @@ describe("resolveMessageQuota", () => {
4249
});
4350
});
4451

52+
describe("parseQuotaReachedResponse", () => {
53+
it("maps a create/in 403 cap body to the limit", () => {
54+
// Both the create path and the `in` transport refuse with this exact body.
55+
expect(
56+
parseQuotaReachedResponse(403, { error: MESSAGE_QUOTA_REACHED_ERROR, limit: 20 })
57+
).toEqual({ limit: 20 });
58+
});
59+
60+
it("falls back to the free limit when the body omits it", () => {
61+
expect(parseQuotaReachedResponse(403, { error: MESSAGE_QUOTA_REACHED_ERROR })).toEqual({
62+
limit: FREE_PLAN_MESSAGE_LIMIT,
63+
});
64+
});
65+
66+
it("ignores other errors and non-403 statuses so they surface normally", () => {
67+
expect(parseQuotaReachedResponse(403, { error: "something_else" })).toBeNull();
68+
expect(parseQuotaReachedResponse(500, { error: MESSAGE_QUOTA_REACHED_ERROR })).toBeNull();
69+
expect(parseQuotaReachedResponse(403, null)).toBeNull();
70+
});
71+
});
72+
73+
describe("messageQuotaReachedCopy", () => {
74+
it("is a friendly sentence naming the limit, never the raw code", () => {
75+
const copy = messageQuotaReachedCopy(20);
76+
expect(copy).toContain("all 20 messages");
77+
expect(copy).toContain("Free plan");
78+
// Control break: if the mapping leaked the server code, this fails.
79+
expect(copy).not.toContain(MESSAGE_QUOTA_REACHED_ERROR);
80+
});
81+
});
82+
4583
describe("countUserMessages", () => {
4684
it("counts only what the user sent", () => {
4785
expect(

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,28 @@ export function resolveMessageQuota({
2727
: { kind: "within", used, limit, remaining };
2828
}
2929

30+
// The server code both the create and `in` paths refuse with. The client owns the copy,
31+
// so this code must never reach the UI as text.
32+
export const MESSAGE_QUOTA_REACHED_ERROR = "message_quota_reached";
33+
34+
// Maps a 403 refusal body to the cap signal, or null for any other error. Both paths use
35+
// this so a `message_quota_reached` code routes to the upgrade block, never a raw toast.
36+
export function parseQuotaReachedResponse(
37+
status: number,
38+
data: { error?: string; limit?: number } | null | undefined
39+
): { limit: number } | null {
40+
if (status === 403 && data?.error === MESSAGE_QUOTA_REACHED_ERROR) {
41+
return { limit: data.limit ?? FREE_PLAN_MESSAGE_LIMIT };
42+
}
43+
return null;
44+
}
45+
46+
// The upgrade block's sentence. Pure so the copy is asserted directly, and so the raw
47+
// server code can never be what the user reads.
48+
export function messageQuotaReachedCopy(limit: number): string {
49+
return `You've used all ${limit} messages included on the Free plan. Your chats stay here to read.`;
50+
}
51+
3052
// A watch's consent record is a user message the person never typed, so it is
3153
// excluded here exactly as the stored count excludes it.
3254
export function countUserMessages(messages: { role: string; id?: string }[]): number {

0 commit comments

Comments
 (0)