diff --git a/.changeset/dashboard-agent-chat-streams.md b/.changeset/dashboard-agent-chat-streams.md
new file mode 100644
index 000000000..a6dc7d188
--- /dev/null
+++ b/.changeset/dashboard-agent-chat-streams.md
@@ -0,0 +1,5 @@
+---
+"@trigger.dev/sdk": patch
+---
+
+Watch-mode chat streams now survive quiet windows and page reloads, and a reply cut off by a lost connection shows an error instead of appearing finished. Aborting a resumed subscription only closes your local stream — call `stopGeneration(chatId)` or pass `stopOnAbort: true` to stop the run. Also fixed a race where quickly restarting a stream could break stop and reconnect, and stopping a chat now hands it back to your other tabs instead of leaving them read-only.
diff --git a/.server-changes/agent-plan-limits.md b/.server-changes/agent-plan-limits.md
new file mode 100644
index 000000000..36e66bd02
--- /dev/null
+++ b/.server-changes/agent-plan-limits.md
@@ -0,0 +1,6 @@
+---
+area: webapp
+type: feature
+---
+
+The dashboard agent now has a monthly message allowance and plan-based limits on watches. Queries stay read-only with clearer errors when busy, and messages with unusual characters no longer fail to send.
diff --git a/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx b/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx
index 4b795edde..70a5da7b8 100644
--- a/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx
+++ b/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx
@@ -1,18 +1,21 @@
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";
export function AgentUpgradeBlock({
limit,
+ planResolved,
context,
}: {
limit: number;
+ planResolved: boolean;
context?: React.ReactNode;
}) {
const organization = useOrganization();
@@ -22,14 +25,12 @@ export function AgentUpgradeBlock({
{context}
-
+
Upgrade to unlock {ASK_AGENT_LABEL}
-
- You've used all {limit} messages included on the Free plan. Your chats stay here to read.
-
+
{messageQuotaReachedCopy(limit, planResolved)}
Upgrade
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx
index 19e65a570..bd1404e74 100644
--- a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx
+++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx
@@ -17,6 +17,12 @@ 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,
+ MESSAGE_QUOTA_REACHED_REASON,
+ parseQuotaReachedResponse,
+ type MessageQuota,
+} from "./message-quota";
import { createTranscriptOrder, orderTranscript } from "./message-order";
import { navigateDestination } from "./navigate-target";
import { pendingNavigateIntents, pendingWatchIntents } from "./pending-intents";
@@ -73,6 +79,7 @@ export function DashboardAgentChat({
onCancelWatch,
onTurnSettled,
onActivityChange,
+ onQuotaChange,
}: {
chatId: string;
initialMessages: UIMessage[];
@@ -100,8 +107,15 @@ export function DashboardAgentChat({
onCancelWatch: (watchId: string) => void;
onTurnSettled: () => void;
onActivityChange?: (chatId: string, activity: TurnActivity | null) => void;
+ /** The poll lives here, so this is where the panel learns the cap has lifted. */
+ onQuotaChange?: (quota: MessageQuota) => 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; planResolved: boolean } | null>(
+ null
+ );
const navigate = useNavigate();
const location = useLocation();
const toast = useToast();
@@ -128,6 +142,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 +211,20 @@ 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 });
+ useEffect(() => {
+ onQuotaChange?.(quota);
+ // The quota object is rebuilt every render; only its kind is acted on.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [quota.kind, onQuotaChange]);
+ // 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 === "unlimited" ? FREE_PLAN_MESSAGE_LIMIT : quota.limit);
+ // The poll only runs on the free plan, so its cap is the free-plan nudge; a refusal
+ // carries the plan limit the server resolved.
+ const messageCapPlanResolved = quotaReached?.planResolved ?? false;
const isStreaming = status === "streaming";
// From status, not the last part: the indicator must stay up through silent tool calls.
@@ -252,6 +289,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)))
@@ -264,7 +303,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);
@@ -399,6 +438,7 @@ export function DashboardAgentChat({
onSelect={submit}
pageContext={clientData.pageContext}
promoted={promotedPrompt}
+ promptsDisabledReason={atMessageCap ? MESSAGE_QUOTA_REACHED_REASON : undefined}
/>
) : (
)}
{watchCard ?
{watchCard}
: null}
- {quota.kind === "reached" ? (
+ {atMessageCap ? (
{
escapeGuardArmed.current = true;
- onChange(e.target.value.slice(0, MAX_MESSAGE_CHARS));
+ onChange(sliceWellFormed(e.target.value, MAX_MESSAGE_CHARS));
}}
onBlur={() => {
escapeGuardArmed.current = true;
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx
index 9698329ab..d7dcfabcc 100644
--- a/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx
+++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentDraft.tsx
@@ -1,8 +1,10 @@
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";
+import { MESSAGE_QUOTA_REACHED_REASON } from "./message-quota";
import type { AgentPageContext } from "./page-context-types";
import { readDismissedPromptIds, resolveSuggestedPromptsBySlot } from "./suggested-prompts";
@@ -16,6 +18,7 @@ export function DashboardAgentDraft({
pageContext,
promotedPrompt,
watchCard,
+ capReached,
}: {
onSubmit: (text: string) => void;
projectSlug: string;
@@ -24,6 +27,7 @@ export function DashboardAgentDraft({
pageContext?: AgentPageContext;
promotedPrompt?: SuggestedPrompt;
watchCard?: React.ReactNode;
+ capReached?: { limit: number; planResolved: boolean } | null;
}) {
const [input, setInput] = useState("");
@@ -43,12 +47,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 (
@@ -56,26 +62,44 @@ export function DashboardAgentDraft({
onSelect={submit}
pageContext={pageContext}
promoted={promotedPrompt}
+ promptsDisabledReason={capReached ? MESSAGE_QUOTA_REACHED_REASON : undefined}
composer={
-
+ )
}
/>
);
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentHero.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentHero.tsx
index 15d399b37..6695f3cda 100644
--- a/apps/webapp/app/components/dashboard-agent/DashboardAgentHero.tsx
+++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentHero.tsx
@@ -11,6 +11,7 @@ export function DashboardAgentHero({
promoted,
dismissedIds,
composer,
+ promptsDisabledReason,
}: {
/** Receives the prompt text to send, not the button label. */
onSelect: (prompt: string) => void;
@@ -18,6 +19,8 @@ export function DashboardAgentHero({
promoted?: SuggestedPrompt;
dismissedIds?: string[];
composer?: React.ReactNode;
+ /** Set to disable the suggestion chips and say why. */
+ promptsDisabledReason?: string;
}) {
// Centred by the child's `m-auto`, not by `justify-center`: auto margins give up their space
// once the content outgrows the panel, so the heading stays scrollable to.
@@ -40,6 +43,7 @@ export function DashboardAgentHero({
pageContext={pageContext}
promoted={promoted}
dismissedIds={dismissedIds}
+ disabledReason={promptsDisabledReason}
/>
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx
index 6ec77399a..ab8d9656e 100644
--- a/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx
+++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.tsx
@@ -35,6 +35,8 @@ export type DashboardAgentMessagesProps = {
activity: TurnActivity | null;
error?: Error;
onRetry?: () => void;
+ /** Set to disable the retry button and say why, e.g. over the message cap. */
+ retryDisabledReason?: string;
onDismissError?: () => void;
onIntent?: (intent: AgentIntent) => void;
resolveUri?: (uri: string) => ResolvedUri | null;
@@ -356,6 +358,7 @@ export function DashboardAgentTurns({
activity,
error,
onRetry,
+ retryDisabledReason,
onDismissError,
onIntent,
resolveUri,
@@ -402,7 +405,16 @@ export function DashboardAgentTurns({
(onRetry || onDismissError) && (
{onRetry && (
-
diff --git a/apps/webapp/app/components/dashboard-agent/demo/components/DemoChartCard.tsx b/apps/webapp/app/components/dashboard-agent/demo/components/DemoChartCard.tsx
new file mode 100644
index 000000000..bd45153d9
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/demo/components/DemoChartCard.tsx
@@ -0,0 +1,32 @@
+import type { AgentIntent, ChartAction } from "@internal/dashboard-agent-contracts";
+import { QueryResultsChart } from "~/components/code/QueryResultsChart";
+import { AGENT_CHART_PLOT_CLASS, ChartActions } from "../../AgentChart";
+import { AgentCard, AgentCardHeader } from "../../agent-card";
+import { demoChart } from "../fixtures/chart";
+
+export function DemoChartCard({
+ title = demoChart.title,
+ actions,
+ onIntent,
+}: {
+ title?: string;
+ actions?: ChartAction[];
+ onIntent?: (intent: AgentIntent) => void;
+}) {
+ return (
+
+ {title ? (
+ {title}
+ ) : null}
+
+
+
+
+
+ );
+}
diff --git a/apps/webapp/app/components/dashboard-agent/demo/components/DemoIntentBubble.tsx b/apps/webapp/app/components/dashboard-agent/demo/components/DemoIntentBubble.tsx
new file mode 100644
index 000000000..c2f182075
--- /dev/null
+++ b/apps/webapp/app/components/dashboard-agent/demo/components/DemoIntentBubble.tsx
@@ -0,0 +1,60 @@
+import {
+ ArrowTopRightOnSquareIcon,
+ CheckCircleIcon,
+ NoSymbolIcon,
+} from "@heroicons/react/20/solid";
+import { Button } from "~/components/primitives/Buttons";
+import { cn } from "~/utils/cn";
+import { AgentStatusIcon } from "../../agent-badges";
+import { ChatStatusLine } from "../../chat-layout";
+import type { DemoIntent } from "../fixtures/intents";
+
+export function DemoIntentBubble({
+ intent,
+ onIntercept,
+}: {
+ intent: DemoIntent;
+ onIntercept?: (message: string) => void;
+}) {
+ const rejected = !intent.executable;
+
+ return (
+
+
+
+ {meta.blurb} {sections.length} states, rendered in isolation at panel width (380px) from
+ the demo fixtures in{" "}
+ app/components/dashboard-agent/demo/fixtures.
+ The list lives in manifest.ts.
+
+
+ Run ids, queues, errors and reports are fabricated. Deep links resolve inside a project,
+ so here they render as plain text or navigate nowhere. The theme buttons flip{" "}
+ data-theme on the root element.
+
+
- Dashboard agent UI
-
- Blocks the dashboard agent renders via its render_view tool, shown through the same
- ViewBlocks registry the chat panel uses. The catalog has the diagnosis (failure) card,
- shown here, and a chart block that runs a TRQL query live (only renders inside a
- project/env, so it's not shown here). Run links resolve inside a project; here they render
- as plain text.
-
-