Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
097bb5d
feat(webapp,dashboard-agent-db): server-side agent message quota
kathiekiwi Aug 10, 2026
af86a21
Merge remote-tracking branch 'origin/test/chat-agent-durability-tri-1…
kathiekiwi Aug 10, 2026
50ccb2a
fix(webapp): the quota limit shows a friendly upgrade prompt, not the…
kathiekiwi Aug 10, 2026
c9bbe89
merge: propagate review fixes from test/chat-agent-durability-tri-11166
kathiekiwi Aug 10, 2026
8341730
merge: propagate wave-2 review fixes from test/chat-agent-durability-…
kathiekiwi Aug 10, 2026
82035a7
merge: propagate org-purge best-effort from test/chat-agent-durabilit…
kathiekiwi Aug 10, 2026
12785db
fix(webapp): only charge agent message quota on a delivered send
kathiekiwi Aug 10, 2026
423d0a9
fix(webapp): guard capped agent send paths and settle the quota re-read
kathiekiwi Aug 10, 2026
bb381d3
merge: quota delivered-send charge review-comment fixes
kathiekiwi Aug 10, 2026
08f9d78
merge: propagate review-comment fixes from test/chat-agent-durability…
kathiekiwi Aug 10, 2026
ebc7e88
merge: propagate second-pass fixes from test/chat-agent-durability-tr…
kathiekiwi Aug 11, 2026
57a84f1
chore(server-changes): consolidate the agent message-quota notes into…
kathiekiwi Aug 11, 2026
19b49c1
fix(webapp): don't charge the message quota for a retry/regenerate
kathiekiwi Aug 11, 2026
4cc2f30
merge: consolidate message-quota notes 2 to 1
kathiekiwi Aug 11, 2026
83f9817
merge: don't charge quota for retry/regenerate
kathiekiwi Aug 11, 2026
7c59b8e
merge: propagate server-changes consolidation from test/chat-agent-du…
kathiekiwi Aug 11, 2026
ed572f3
merge: propagate changeset consolidation and note restoration from te…
kathiekiwi Aug 11, 2026
462360d
merge: propagate base UI relocation + drizzle attribution
kathiekiwi Aug 11, 2026
289fe1b
merge: propagate tsql linter test fix
kathiekiwi Aug 11, 2026
a9c15bf
merge: propagate card-test relocation
kathiekiwi Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/agent-message-quota.md
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.
11 changes: 5 additions & 6 deletions apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Link } from "@remix-run/react";
import { AgentMonoLogo } from "~/components/primitives/AgentDotMatrix";
import { LinkButton } from "~/components/primitives/Buttons";
import { useOrganization } from "~/hooks/useOrganizations";
import { cn } from "~/utils/cn";
import { v3BillingPath } from "~/utils/pathBuilder";
import { AgentIcon, AGENT_ICON_ACCENT_CLASS, ASK_AGENT_LABEL } from "./agent-identity";
import { ASK_AGENT_LABEL } from "./agent-identity";
import { messageQuotaReachedCopy } from "./message-quota";

// Matches the composer's outer geometry so the replacement lands in the same place.
const SLOT = "flex shrink-0 flex-col bg-background-bright px-3 pb-3 pt-1";
Expand All @@ -22,14 +23,12 @@ export function AgentUpgradeBlock({
{context}
<div className="mt-1.5 flex flex-col gap-2 rounded-md border border-border-bright bg-background-dimmed p-3">
<div className="flex items-center gap-1.5">
<AgentIcon className={cn("size-4 shrink-0", AGENT_ICON_ACCENT_CLASS)} />
<AgentMonoLogo size={16} decorative className="shrink-0" />
<span className="text-sm font-medium text-text-bright">
Upgrade to unlock {ASK_AGENT_LABEL}
</span>
</div>
<p className="text-xs text-text-dimmed">
You've used all {limit} messages included on the Free plan. Your chats stay here to read.
</p>
<p className="text-xs text-text-dimmed">{messageQuotaReachedCopy(limit)}</p>
<LinkButton variant="primary/small" to={v3BillingPath(organization)} fullWidth>
Upgrade
</LinkButton>
Expand Down
33 changes: 27 additions & 6 deletions apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand All @@ -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.");
}
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
return res;
},
clientData,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
// 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)))
Expand All @@ -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);

Expand Down Expand Up @@ -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}
Expand Down
61 changes: 41 additions & 20 deletions apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx
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";
Expand All @@ -16,6 +17,7 @@ export function DashboardAgentDraft({
pageContext,
promotedPrompt,
watchCard,
capReached,
}: {
onSubmit: (text: string) => void;
projectSlug: string;
Expand All @@ -24,6 +26,7 @@ export function DashboardAgentDraft({
pageContext?: AgentPageContext;
promotedPrompt?: SuggestedPrompt;
watchCard?: React.ReactNode;
capReached?: { limit: number } | null;
}) {
const [input, setInput] = useState("");

Expand All @@ -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;
const trimmed = text.trim();
if (!trimmed) return;
setInput("");
onSubmit(trimmed);
},
[onSubmit]
[onSubmit, capReached]
);

return (
Expand All @@ -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>
)
}
/>
);
Expand Down
13 changes: 13 additions & 0 deletions apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
writeLastChat,
} from "./last-chat-storage";
import { DashboardAgentDraft } from "./DashboardAgentDraft";
import { parseQuotaReachedResponse } from "./message-quota";
import { WatchCard } from "./WatchCard";
import { watchDraftFor } from "./watch-card";
import { NO_WATCH_CARD, watchCardReducer } from "./watch-card-state";
Expand Down Expand Up @@ -108,6 +109,8 @@ export function DashboardAgentPanel({
// Until the list has arrived, the page load's server count is the better answer.
const [chatsLoaded, setChatsLoaded] = useState(false);
const [active, setActive] = useState<ActiveChat | null>(null);
// A refused `create` over the cap: the draft shows the upgrade block instead of a raw toast.
const [capReached, setCapReached] = useState<{ limit: number } | null>(null);
// Starts true so an `openWith` request waits for the restore instead of racing it.
const [loading, setLoading] = useState(
() => readLastChat(storageKey)?.path === location.pathname
Expand Down Expand Up @@ -241,14 +244,22 @@ export function DashboardAgentPanel({
publicAccessToken?: string;
headStarted?: boolean;
error?: string;
limit?: number;
};
if (seq !== openChatRequestSeq.current) return;
if (!res.ok || !data.chatId || !data.publicAccessToken) {
const reached = parseQuotaReachedResponse(res.status, data);
if (reached) {
setCapReached(reached);
setActive(null);
return;
}
Comment thread
kathiekiwi marked this conversation as resolved.
console.error(`Dashboard agent: failed to create chat (${res.status})`, data.error);
toast.error(data.error ?? "We couldn't start that chat. Try again in a moment.");
setActive(null);
return;
}
setCapReached(null);
setActive({
chatId: data.chatId,
organizationId: organization.id,
Expand Down Expand Up @@ -290,6 +301,7 @@ export function DashboardAgentPanel({
panelOrg.current = organization.id;
claimChatSlot();
setActive(null);
setCapReached(null);
setLoading(false);
setChats([]);
setChatsLoaded(false);
Expand Down Expand Up @@ -617,6 +629,7 @@ export function DashboardAgentPanel({
pageContext={pageContext}
promotedPrompt={promotedPrompt}
watchCard={watchCardElement}
capReached={capReached}
/>
)}
</AgentPanelColumn>
Expand Down
40 changes: 39 additions & 1 deletion apps/webapp/app/components/dashboard-agent/message-quota.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
import { countUserMessages, FREE_PLAN_MESSAGE_LIMIT, resolveMessageQuota } from "./message-quota";
import {
countUserMessages,
FREE_PLAN_MESSAGE_LIMIT,
MESSAGE_QUOTA_REACHED_ERROR,
messageQuotaReachedCopy,
parseQuotaReachedResponse,
resolveMessageQuota,
} from "./message-quota";

describe("resolveMessageQuota", () => {
it("caps a Free plan at the limit", () => {
Expand Down Expand Up @@ -42,6 +49,37 @@ describe("resolveMessageQuota", () => {
});
});

describe("parseQuotaReachedResponse", () => {
it("maps a create/in 403 cap body to the limit", () => {
// Both the create path and the `in` transport refuse with this exact body.
expect(
parseQuotaReachedResponse(403, { error: MESSAGE_QUOTA_REACHED_ERROR, limit: 20 })
).toEqual({ limit: 20 });
});

it("falls back to the free limit when the body omits it", () => {
expect(parseQuotaReachedResponse(403, { error: MESSAGE_QUOTA_REACHED_ERROR })).toEqual({
limit: FREE_PLAN_MESSAGE_LIMIT,
});
});

it("ignores other errors and non-403 statuses so they surface normally", () => {
expect(parseQuotaReachedResponse(403, { error: "something_else" })).toBeNull();
expect(parseQuotaReachedResponse(500, { error: MESSAGE_QUOTA_REACHED_ERROR })).toBeNull();
expect(parseQuotaReachedResponse(403, null)).toBeNull();
});
});

describe("messageQuotaReachedCopy", () => {
it("is a friendly sentence naming the limit, never the raw code", () => {
const copy = messageQuotaReachedCopy(20);
expect(copy).toContain("all 20 messages");
expect(copy).toContain("Free plan");
// Control break: if the mapping leaked the server code, this fails.
expect(copy).not.toContain(MESSAGE_QUOTA_REACHED_ERROR);
});
});

describe("countUserMessages", () => {
it("counts only what the user sent", () => {
expect(
Expand Down
22 changes: 22 additions & 0 deletions apps/webapp/app/components/dashboard-agent/message-quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`;
}

// 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 {
Expand Down
Loading
Loading