-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(webapp): server-side agent message quota #4552
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4efe0d1
34996fd
097bb5d
c42002b
af86a21
b9610f8
50ccb2a
b1463c2
c9bbe89
6db1133
8341730
d7321e5
82035a7
12785db
423d0a9
bb381d3
f06235c
08f9d78
074da82
ebc7e88
57a84f1
19b49c1
4cc2f30
83f9817
c93380e
7c59b8e
08ee567
ed572f3
112b837
462360d
a036345
289fe1b
873cebc
a9c15bf
7428874
d9cfca9
a95e926
bc3e5d0
3556dff
06ca8d1
4dbb314
c7e8a4b
656d607
78bf74f
18c3f25
8b40a62
2c0a0b8
037cd9f
36920a9
cab27fd
1057049
d23718c
4f4336b
ce7b6e6
3c94fa7
315c426
a3ed278
378e00e
6593f88
c7bbd1d
c7c7245
e449743
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| area: webapp | ||
| type: feature | ||
| --- | ||
|
|
||
| The dashboard agent now comes with a monthly message allowance. A message that fails to send doesn't count against it. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| area: webapp | ||
| type: feature | ||
| --- | ||
|
|
||
| Watches now respect your plan's limits: free plans can run a limited number of watches at once and for a shorter window, with a prompt to upgrade for more. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,7 @@ import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner"; | |
| import { DashboardAgentHero } from "./DashboardAgentHero"; | ||
| import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessages"; | ||
| import { MESSAGE_TOO_LARGE_ERROR } from "./message-limits"; | ||
| import { FREE_PLAN_MESSAGE_LIMIT, parseQuotaReachedResponse } from "./message-quota"; | ||
| import { createTranscriptOrder, orderTranscript } from "./message-order"; | ||
| import { navigateDestination } from "./navigate-target"; | ||
| import { pendingNavigateIntents, pendingWatchIntents } from "./pending-intents"; | ||
|
|
@@ -102,6 +103,9 @@ export function DashboardAgentChat({ | |
| onActivityChange?: (chatId: string, activity: TurnActivity | null) => void; | ||
| }) { | ||
| const [input, setInput] = useState(""); | ||
| // Set when the server refuses a send over the cap, so the block shows at once rather than | ||
| // waiting for the next quota poll. | ||
| const [quotaReached, setQuotaReached] = useState<{ limit: number } | null>(null); | ||
| const navigate = useNavigate(); | ||
| const location = useLocation(); | ||
| const toast = useToast(); | ||
|
|
@@ -128,6 +132,18 @@ export function DashboardAgentChat({ | |
| .catch(() => null)) as { error?: string } | null; | ||
| throw new Error(data?.error ?? MESSAGE_TOO_LARGE_ERROR); | ||
| } | ||
| // Over the message cap: show the upgrade block instead of a generic turn error. | ||
| if (res.status === 403) { | ||
| const data = (await res | ||
| .clone() | ||
| .json() | ||
| .catch(() => null)) as { error?: string; limit?: number } | null; | ||
| const reached = parseQuotaReachedResponse(res.status, data); | ||
| if (reached) { | ||
| setQuotaReached(reached); | ||
| throw new Error("You've reached your message limit."); | ||
| } | ||
| } | ||
| return res; | ||
| }, | ||
| clientData, | ||
|
|
@@ -185,9 +201,12 @@ export function DashboardAgentChat({ | |
| const orderRef = useRef(createTranscriptOrder(initialMessages)); | ||
| const messages = orderTranscript(rawMessages, orderRef.current); | ||
|
|
||
| // Counted here, not in the panel, so it includes the turn just sent. | ||
| const quota = useAgentMessageQuota({ actionPath, chatId, messages }); | ||
| const atMessageCap = quota.kind === "reached"; | ||
| // Read here, not in the panel, so it re-reads as each turn settles. | ||
| const quota = useAgentMessageQuota({ actionPath, chatId, status }); | ||
| // Either the poll saw the cap, or a send was just refused over it. | ||
| const atMessageCap = quota.kind === "reached" || quotaReached !== null; | ||
| const messageCapLimit = | ||
| quotaReached?.limit ?? (quota.kind === "reached" ? quota.limit : FREE_PLAN_MESSAGE_LIMIT); | ||
|
|
||
| const isStreaming = status === "streaming"; | ||
| // From status, not the last part: the indicator must stay up through silent tool calls. | ||
|
|
@@ -252,6 +271,8 @@ export function DashboardAgentChat({ | |
| }, [sendRequest, submit, canSend]); | ||
|
|
||
| const retry = useCallback(() => { | ||
| // Over the cap, a retry only earns another 403 — same guard as `submit`. | ||
| if (atMessageCap) return; | ||
|
Comment on lines
+274
to
+275
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Retry button silently does nothing once the allowance is exhausted The retry action is turned into a no-op whenever the allowance is exhausted ( Regenerate is not charged server-side, but is blocked client-side
Meanwhile the error banner with its Retry button is still rendered at the cap ( Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| // A watch's consent record is a user message nobody typed, so retry never treats it as one. | ||
| const action = retryAction( | ||
| messages.filter((m) => !(m.role === "user" && isWatchRequestMessageId(m.id))) | ||
|
|
@@ -264,7 +285,7 @@ export function DashboardAgentChat({ | |
| return; | ||
| } | ||
| void sendMessage({ text: action.text, messageId: action.messageId }); | ||
| }, [messages, sendMessage, regenerate, clearError]); | ||
| }, [messages, sendMessage, regenerate, clearError, atMessageCap]); | ||
|
|
||
| const resolveUri = useTriggerUriResolver(actionPath); | ||
|
|
||
|
|
@@ -414,9 +435,9 @@ export function DashboardAgentChat({ | |
| /> | ||
| )} | ||
| {watchCard ? <div className="px-3 pb-2">{watchCard}</div> : null} | ||
| {quota.kind === "reached" ? ( | ||
| {atMessageCap ? ( | ||
| <AgentUpgradeBlock | ||
| limit={quota.limit} | ||
| limit={messageCapLimit} | ||
| context={ | ||
| <DashboardAgentContextBanner | ||
| projectSlug={projectSlug} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts"; | ||
| import { useCallback, useMemo, useState } from "react"; | ||
| import { AgentUpgradeBlock } from "./AgentUpgradeGate"; | ||
| import { DashboardAgentComposer } from "./DashboardAgentComposer"; | ||
| import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner"; | ||
| import { DashboardAgentHero } from "./DashboardAgentHero"; | ||
|
|
@@ -16,6 +17,7 @@ export function DashboardAgentDraft({ | |
| pageContext, | ||
| promotedPrompt, | ||
| watchCard, | ||
| capReached, | ||
| }: { | ||
| onSubmit: (text: string) => void; | ||
| projectSlug: string; | ||
|
|
@@ -24,6 +26,7 @@ export function DashboardAgentDraft({ | |
| pageContext?: AgentPageContext; | ||
| promotedPrompt?: SuggestedPrompt; | ||
| watchCard?: React.ReactNode; | ||
| capReached?: { limit: number } | null; | ||
| }) { | ||
| const [input, setInput] = useState(""); | ||
|
|
||
|
|
@@ -43,12 +46,14 @@ export function DashboardAgentDraft({ | |
|
|
||
| const submit = useCallback( | ||
| (text: string) => { | ||
| // Suggested prompts reach here via the hero, bypassing the composer's cap guard. | ||
| if (capReached) return; | ||
|
Comment on lines
+49
to
+50
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggested prompt buttons do nothing once the message allowance is used up The suggested-prompt buttons on the empty-chat screen stay clickable but their click is silently discarded ( Hero prompts remain rendered while the composer is replacedWhen Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
| const trimmed = text.trim(); | ||
| if (!trimmed) return; | ||
| setInput(""); | ||
| onSubmit(trimmed); | ||
| }, | ||
| [onSubmit] | ||
| [onSubmit, capReached] | ||
| ); | ||
|
|
||
| return ( | ||
|
|
@@ -57,25 +62,41 @@ export function DashboardAgentDraft({ | |
| pageContext={pageContext} | ||
| promoted={promotedPrompt} | ||
| composer={ | ||
| <div className="flex w-full flex-col gap-3"> | ||
| {watchCard} | ||
| <DashboardAgentComposer | ||
| layout="hero" | ||
| value={input} | ||
| onChange={setInput} | ||
| onSubmit={() => submit(input)} | ||
| onStop={() => {}} | ||
| isStreaming={false} | ||
| placeholderSuggestion={watchCard ? undefined : placeholderSuggestion} | ||
| context={ | ||
| <DashboardAgentContextBanner | ||
| projectSlug={projectSlug} | ||
| environmentSlug={environmentSlug} | ||
| currentPage={currentPage} | ||
| /> | ||
| } | ||
| /> | ||
| </div> | ||
| capReached ? ( | ||
| <div className="flex w-full flex-col gap-3"> | ||
| {watchCard} | ||
| <AgentUpgradeBlock | ||
| limit={capReached.limit} | ||
| context={ | ||
| <DashboardAgentContextBanner | ||
| projectSlug={projectSlug} | ||
| environmentSlug={environmentSlug} | ||
| currentPage={currentPage} | ||
| /> | ||
| } | ||
| /> | ||
| </div> | ||
| ) : ( | ||
| <div className="flex w-full flex-col gap-3"> | ||
| {watchCard} | ||
| <DashboardAgentComposer | ||
| layout="hero" | ||
| value={input} | ||
| onChange={setInput} | ||
| onSubmit={() => submit(input)} | ||
| onStop={() => {}} | ||
| isStreaming={false} | ||
| placeholderSuggestion={watchCard ? undefined : placeholderSuggestion} | ||
| context={ | ||
| <DashboardAgentContextBanner | ||
| projectSlug={projectSlug} | ||
| environmentSlug={environmentSlug} | ||
| currentPage={currentPage} | ||
| /> | ||
| } | ||
| /> | ||
| </div> | ||
| ) | ||
| } | ||
| /> | ||
| ); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,28 @@ export function resolveMessageQuota({ | |
| : { kind: "within", used, limit, remaining }; | ||
| } | ||
|
|
||
| // The server code both the create and `in` paths refuse with. The client owns the copy, | ||
| // so this code must never reach the UI as text. | ||
| export const MESSAGE_QUOTA_REACHED_ERROR = "message_quota_reached"; | ||
|
|
||
| // Maps a 403 refusal body to the cap signal, or null for any other error. Both paths use | ||
| // this so a `message_quota_reached` code routes to the upgrade block, never a raw toast. | ||
| export function parseQuotaReachedResponse( | ||
| status: number, | ||
| data: { error?: string; limit?: number } | null | undefined | ||
| ): { limit: number } | null { | ||
| if (status === 403 && data?.error === MESSAGE_QUOTA_REACHED_ERROR) { | ||
| return { limit: data.limit ?? FREE_PLAN_MESSAGE_LIMIT }; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| // The upgrade block's sentence. Pure so the copy is asserted directly, and so the raw | ||
| // server code can never be what the user reads. | ||
| export function messageQuotaReachedCopy(limit: number): string { | ||
| return `You've used all ${limit} messages included on the Free plan. Your chats stay here to read.`; | ||
| } | ||
|
Comment on lines
+46
to
+50
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Upgrade copy hard-codes "Free plan" but the server refuses any org over its plan limit
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| // A watch's consent record is a user message the person never typed, so it is | ||
| // excluded here exactly as the stored count excludes it. | ||
| export function countUserMessages(messages: { role: string; id?: string }[]): number { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.