Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
0edbb22
feat(webapp): pin the query boundary end-to-end and cap the query retry
kathiekiwi Aug 10, 2026
3fa21ef
Merge remote-tracking branch 'origin/fix/watch-mode-keepalive-tri-130…
kathiekiwi Aug 10, 2026
acd54e0
Merge remote-tracking branch 'origin/fix/watch-mode-keepalive-tri-130…
kathiekiwi Aug 10, 2026
25fe809
merge: propagate review fixes from fix/watch-mode-keepalive-tri-13065
kathiekiwi Aug 10, 2026
2361556
merge: propagate wave-2 review fixes from fix/watch-mode-keepalive-tr…
kathiekiwi Aug 10, 2026
4ed6b99
merge: propagate org-purge best-effort from fix/watch-mode-keepalive-…
kathiekiwi Aug 10, 2026
614a7a9
fix(dashboard-agent): only count SQL errors toward the query-failure cap
kathiekiwi Aug 10, 2026
24444be
merge: query-failure cap SQL errors only review-comment fixes
kathiekiwi Aug 10, 2026
9a78579
merge: propagate review-comment fixes from fix/watch-mode-keepalive-t…
kathiekiwi Aug 10, 2026
50df6c3
merge: propagate second-pass fixes from fix/watch-mode-keepalive-tri-…
kathiekiwi Aug 11, 2026
2026a4e
merge: propagate server-changes consolidation from fix/watch-mode-kee…
kathiekiwi Aug 11, 2026
ed2c5c2
merge: propagate changeset consolidation and note restoration from fi…
kathiekiwi Aug 11, 2026
dbd216b
merge: propagate base UI relocation + drizzle attribution
kathiekiwi Aug 11, 2026
cf7fa9a
merge: propagate the tsql linter test fix from fix/watch-mode-keepali…
kathiekiwi Aug 11, 2026
dca2118
merge: propagate card-test relocation
kathiekiwi Aug 11, 2026
bf6a051
chore: merge fix/watch-mode-keepalive-tri-13065 (main sync)
kathiekiwi Aug 11, 2026
dd22919
chore: merge fix/watch-mode-keepalive-tri-13065 (review fixes)
kathiekiwi Aug 11, 2026
76560bb
chore: merge fix/watch-mode-keepalive-tri-13065 (review fixes round 2)
kathiekiwi Aug 11, 2026
14ea61c
chore: merge fix/watch-mode-keepalive-tri-13065 (review fixes round 3)
kathiekiwi Aug 11, 2026
66b6dfa
chore: merge fix/watch-mode-keepalive-tri-13065 (review fixes round 3)
kathiekiwi Aug 11, 2026
d329967
chore: merge fix/watch-mode-keepalive-tri-13065 (composer escape foll…
kathiekiwi Aug 11, 2026
f3d492d
chore: merge fix/watch-mode-keepalive-tri-13065 (main sync post-#4529)
kathiekiwi Aug 12, 2026
8e36f12
chore: merge fix/watch-mode-keepalive-tri-13065 (review fixes)
kathiekiwi Aug 12, 2026
d1e12b7
chore: merge fix/watch-mode-keepalive-tri-13065 (prompt re-send guard)
kathiekiwi Aug 12, 2026
cfaf934
chore: merge fix/watch-mode-keepalive-tri-13065 (main sync post-#4525)
kathiekiwi Aug 12, 2026
a5b1482
test(webapp): chat.agent durability regression suite (#4550)
kathiekiwi Aug 12, 2026
bd608b7
chore: merge fix/watch-mode-keepalive-tri-13065 (demo line-0 guard)
kathiekiwi Aug 12, 2026
54f101b
fix(dashboard-agent): don't count busy-rejections toward the query fa…
kathiekiwi Aug 12, 2026
683bd87
chore: merge review-fix packet (busy rejections exempt from query cap)
kathiekiwi Aug 12, 2026
9f67130
feat(webapp): server-side agent message quota (#4552)
kathiekiwi Aug 12, 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.
6 changes: 6 additions & 0 deletions .server-changes/agent-watch-plan-limits.md
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.
6 changes: 6 additions & 0 deletions .server-changes/query-boundary-and-retry-cap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---

Queries stay read-only, and the agent now stops after a few failed queries in a row and answers with what it found instead of spending the whole reply retrying.
6 changes: 6 additions & 0 deletions .server-changes/query-busy-retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

When queries are queued up and one is turned away, you now get a clear "try again shortly" instead of an error that looks like a problem with the query itself.
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.");
}
}
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 @@ -114,6 +115,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 @@ -260,14 +263,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;
}
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 @@ -309,6 +320,7 @@ export function DashboardAgentPanel({
panelOrg.current = organization.id;
claimChatSlot();
setActive(null);
setCapReached(null);
setLoading(false);
setChats([]);
setChatsLoaded(false);
Expand Down Expand Up @@ -634,6 +646,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