Skip to content

Commit 480bede

Browse files
authored
feat(webapp,sdk): dashboard agent plan enforcement, component gallery — and fixes (#4516)
Plan enforcement for the dashboard agent — message quota and watch limits — plus the component gallery, fixes and test hardening from the same stack (#4548, #4549, #4550, #4552, #4556 merged here). ## Plan enforcement ([TRI-12863](https://linear.app/triggerdotdev/issue/TRI-12863)) **Agent message quota.** The Free-plan allowance becomes a real server-side limit with a durable counter. New `agent_message_usage` table keyed `(organization_id, period)` — deliberately not joined to chats, so deleting a chat can't free quota within the period. Both send paths count one user message (wakes never count) and refuse at the cap with `403 message_quota_reached`, which the client renders as an upgrade panel, never a silent drop. The refusal code is a single shared constant on both sides. **Watch limits.** A watch whose window exceeds the plan's `agentWatchMaxHours`, or that would push the org past its `agentWatchers` count, is refused with `watch_limit_reached` (409 on the API, an upgrade hint on the card). Plan limits only tighten the existing code ceilings (`min(plan, 24h)`, per-chat cap of 3 still applies). A plan limit of zero means zero, not unlimited. Questions answerable instantly are answered before any plan refusal — a one-shot consumes no slot and never sees an upgrade nag. **Fails open by design.** Cloud ships the actual per-plan numbers separately (TRI-12863 P0). Until then absent limits resolve to the unlimited sentinel and the upgrade UI is gated on billing presence — self-hosted sees no cap, no upsell, with tests proving the fallback. Both quotas are nudges, not security boundaries: a failing limit read never blocks a send. ## Component gallery An admin-only gallery of every agent card state: five `storybook.agent-*` pages (chat UI, view blocks, report, investigation, watch) with their shared shell and manifest, demo fixtures, two demo-only cards, toast examples, and the screenshot script. No LLM and no data — every state renders from fixtures under `dashboard-agent/demo/`, never reachable from a production path. Designers and reviewers can look at every state, including the report states, without seeding anything. ## And fixes **SDK: watch-mode chat subscriptions survive quiet windows** (TRI-13065, TRI-13070) — watch mode keeps reconnecting across empty long-poll windows and only stops on abort or a settled session; a passive subscriber can no longer stop a turn it doesn't own (`stopOnAbort` is explicit, default off). Review findings fixed alongside: a superseded stream's async teardown no longer removes the live successor's abort controller or multi-tab claim, and stopping a generation hands the chat back to the user's other tabs. **Query boundary pinned end-to-end** ([TRI-11165](https://linear.app/triggerdotdev/issue/TRI-11165)) — a route-level test drives `api.v1.query` with a real signed environment JWT (writes refused before ClickHouse, a read passes); `readonly=1` made non-overridable; a per-turn cap stops the model burning a turn rewriting a query it can't fix (deterministic SQL errors only — busy/transport rejections don't count). **chat.agent durability regression suite** ([TRI-11166](https://linear.app/triggerdotdev/issue/TRI-11166)) — testcontainers-backed coverage of the two audit criticals (cross-tenant isolation, no duplicate mid-stream turn, both control-broken) plus crash-resume, cursor-based refresh, clean rollback of a mid-write turn failure (torn by a real constraint violation), and OOM-restart replay. **Investigation sweep backoff** — stale investigations get an attempt counter and backoff so a poison row can't pin the sweep queue head (migration `0005`: `sweep_attempts`, `last_sweep_attempt_at`). ## Screenshots <img width="1440" height="791" alt="Screenshot 2026-08-06 at 00 36 19" src="https://github.com/user-attachments/assets/6a68cd42-8580-469d-afe7-e28d1eef18e1" /> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent ed1bb72 commit 480bede

84 files changed

Lines changed: 9782 additions & 239 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
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.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
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.

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
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";
1011

1112
export function AgentUpgradeBlock({
1213
limit,
14+
planResolved,
1315
context,
1416
}: {
1517
limit: number;
18+
planResolved: boolean;
1619
context?: React.ReactNode;
1720
}) {
1821
const organization = useOrganization();
@@ -22,14 +25,12 @@ export function AgentUpgradeBlock({
2225
{context}
2326
<div className="mt-1.5 flex flex-col gap-2 rounded-md border border-border-bright bg-background-dimmed p-3">
2427
<div className="flex items-center gap-1.5">
25-
<AgentIcon className={cn("size-4 shrink-0", AGENT_ICON_ACCENT_CLASS)} />
28+
<AgentMonoLogo size={16} decorative className="shrink-0" />
2629
<span className="text-sm font-medium text-text-bright">
2730
Upgrade to unlock {ASK_AGENT_LABEL}
2831
</span>
2932
</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>
33+
<p className="text-xs text-text-dimmed">{messageQuotaReachedCopy(limit, planResolved)}</p>
3334
<LinkButton variant="primary/small" to={v3BillingPath(organization)} fullWidth>
3435
Upgrade
3536
</LinkButton>

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

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ 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 {
21+
FREE_PLAN_MESSAGE_LIMIT,
22+
MESSAGE_QUOTA_REACHED_REASON,
23+
parseQuotaReachedResponse,
24+
type MessageQuota,
25+
} from "./message-quota";
2026
import { createTranscriptOrder, orderTranscript } from "./message-order";
2127
import { navigateDestination } from "./navigate-target";
2228
import { pendingNavigateIntents, pendingWatchIntents } from "./pending-intents";
@@ -73,6 +79,7 @@ export function DashboardAgentChat({
7379
onCancelWatch,
7480
onTurnSettled,
7581
onActivityChange,
82+
onQuotaChange,
7683
}: {
7784
chatId: string;
7885
initialMessages: UIMessage[];
@@ -100,8 +107,15 @@ export function DashboardAgentChat({
100107
onCancelWatch: (watchId: string) => void;
101108
onTurnSettled: () => void;
102109
onActivityChange?: (chatId: string, activity: TurnActivity | null) => void;
110+
/** The poll lives here, so this is where the panel learns the cap has lifted. */
111+
onQuotaChange?: (quota: MessageQuota) => void;
103112
}) {
104113
const [input, setInput] = useState("");
114+
// Set when the server refuses a send over the cap, so the block shows at once rather than
115+
// waiting for the next quota poll.
116+
const [quotaReached, setQuotaReached] = useState<{ limit: number; planResolved: boolean } | null>(
117+
null
118+
);
105119
const navigate = useNavigate();
106120
const location = useLocation();
107121
const toast = useToast();
@@ -128,6 +142,18 @@ export function DashboardAgentChat({
128142
.catch(() => null)) as { error?: string } | null;
129143
throw new Error(data?.error ?? MESSAGE_TOO_LARGE_ERROR);
130144
}
145+
// Over the message cap: show the upgrade block instead of a generic turn error.
146+
if (res.status === 403) {
147+
const data = (await res
148+
.clone()
149+
.json()
150+
.catch(() => null)) as { error?: string; limit?: number } | null;
151+
const reached = parseQuotaReachedResponse(res.status, data);
152+
if (reached) {
153+
setQuotaReached(reached);
154+
throw new Error("You've reached your message limit.");
155+
}
156+
}
131157
return res;
132158
},
133159
clientData,
@@ -185,9 +211,20 @@ export function DashboardAgentChat({
185211
const orderRef = useRef(createTranscriptOrder(initialMessages));
186212
const messages = orderTranscript(rawMessages, orderRef.current);
187213

188-
// Counted here, not in the panel, so it includes the turn just sent.
189-
const quota = useAgentMessageQuota({ actionPath, chatId, messages });
190-
const atMessageCap = quota.kind === "reached";
214+
// Read here, not in the panel, so it re-reads as each turn settles.
215+
const quota = useAgentMessageQuota({ actionPath, chatId, status });
216+
useEffect(() => {
217+
onQuotaChange?.(quota);
218+
// The quota object is rebuilt every render; only its kind is acted on.
219+
// eslint-disable-next-line react-hooks/exhaustive-deps
220+
}, [quota.kind, onQuotaChange]);
221+
// Either the poll saw the cap, or a send was just refused over it.
222+
const atMessageCap = quota.kind === "reached" || quotaReached !== null;
223+
const messageCapLimit =
224+
quotaReached?.limit ?? (quota.kind === "unlimited" ? FREE_PLAN_MESSAGE_LIMIT : quota.limit);
225+
// The poll only runs on the free plan, so its cap is the free-plan nudge; a refusal
226+
// carries the plan limit the server resolved.
227+
const messageCapPlanResolved = quotaReached?.planResolved ?? false;
191228

192229
const isStreaming = status === "streaming";
193230
// From status, not the last part: the indicator must stay up through silent tool calls.
@@ -252,6 +289,8 @@ export function DashboardAgentChat({
252289
}, [sendRequest, submit, canSend]);
253290

254291
const retry = useCallback(() => {
292+
// Over the cap, a retry only earns another 403 — same guard as `submit`.
293+
if (atMessageCap) return;
255294
// A watch's consent record is a user message nobody typed, so retry never treats it as one.
256295
const action = retryAction(
257296
messages.filter((m) => !(m.role === "user" && isWatchRequestMessageId(m.id)))
@@ -264,7 +303,7 @@ export function DashboardAgentChat({
264303
return;
265304
}
266305
void sendMessage({ text: action.text, messageId: action.messageId });
267-
}, [messages, sendMessage, regenerate, clearError]);
306+
}, [messages, sendMessage, regenerate, clearError, atMessageCap]);
268307

269308
const resolveUri = useTriggerUriResolver(actionPath);
270309

@@ -399,13 +438,15 @@ export function DashboardAgentChat({
399438
onSelect={submit}
400439
pageContext={clientData.pageContext}
401440
promoted={promotedPrompt}
441+
promptsDisabledReason={atMessageCap ? MESSAGE_QUOTA_REACHED_REASON : undefined}
402442
/>
403443
) : (
404444
<DashboardAgentMessages
405445
messages={messages}
406446
activity={activity}
407447
error={error}
408448
onRetry={retry}
449+
retryDisabledReason={atMessageCap ? MESSAGE_QUOTA_REACHED_REASON : undefined}
409450
onDismissError={clearError}
410451
onIntent={handleIntent}
411452
pagePaths={pagePaths}
@@ -414,9 +455,10 @@ export function DashboardAgentChat({
414455
/>
415456
)}
416457
{watchCard ? <div className="px-3 pb-2">{watchCard}</div> : null}
417-
{quota.kind === "reached" ? (
458+
{atMessageCap ? (
418459
<AgentUpgradeBlock
419-
limit={quota.limit}
460+
limit={messageCapLimit}
461+
planResolved={messageCapPlanResolved}
420462
context={
421463
<DashboardAgentContextBanner
422464
projectSlug={projectSlug}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ArrowUpIcon, StopIcon } from "@heroicons/react/20/solid";
2+
import { sliceWellFormed } from "@internal/dashboard-agent-contracts";
23
import { useEffect, useRef } from "react";
34
import { Button } from "~/components/primitives/Buttons";
45
import { cn } from "~/utils/cn";
@@ -94,7 +95,7 @@ export function DashboardAgentComposer({
9495
maxLength={MAX_MESSAGE_CHARS}
9596
onChange={(e) => {
9697
escapeGuardArmed.current = true;
97-
onChange(e.target.value.slice(0, MAX_MESSAGE_CHARS));
98+
onChange(sliceWellFormed(e.target.value, MAX_MESSAGE_CHARS));
9899
}}
99100
onBlur={() => {
100101
escapeGuardArmed.current = true;

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

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
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";
7+
import { MESSAGE_QUOTA_REACHED_REASON } from "./message-quota";
68
import type { AgentPageContext } from "./page-context-types";
79
import { readDismissedPromptIds, resolveSuggestedPromptsBySlot } from "./suggested-prompts";
810

@@ -16,6 +18,7 @@ export function DashboardAgentDraft({
1618
pageContext,
1719
promotedPrompt,
1820
watchCard,
21+
capReached,
1922
}: {
2023
onSubmit: (text: string) => void;
2124
projectSlug: string;
@@ -24,6 +27,7 @@ export function DashboardAgentDraft({
2427
pageContext?: AgentPageContext;
2528
promotedPrompt?: SuggestedPrompt;
2629
watchCard?: React.ReactNode;
30+
capReached?: { limit: number; planResolved: boolean } | null;
2731
}) {
2832
const [input, setInput] = useState("");
2933

@@ -43,39 +47,59 @@ export function DashboardAgentDraft({
4347

4448
const submit = useCallback(
4549
(text: string) => {
50+
// Suggested prompts reach here via the hero, bypassing the composer's cap guard.
51+
if (capReached) return;
4652
const trimmed = text.trim();
4753
if (!trimmed) return;
4854
setInput("");
4955
onSubmit(trimmed);
5056
},
51-
[onSubmit]
57+
[onSubmit, capReached]
5258
);
5359

5460
return (
5561
<DashboardAgentHero
5662
onSelect={submit}
5763
pageContext={pageContext}
5864
promoted={promotedPrompt}
65+
promptsDisabledReason={capReached ? MESSAGE_QUOTA_REACHED_REASON : undefined}
5966
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}
70-
context={
71-
<DashboardAgentContextBanner
72-
projectSlug={projectSlug}
73-
environmentSlug={environmentSlug}
74-
currentPage={currentPage}
75-
/>
76-
}
77-
/>
78-
</div>
67+
capReached ? (
68+
<div className="flex w-full flex-col gap-3">
69+
{watchCard}
70+
<AgentUpgradeBlock
71+
limit={capReached.limit}
72+
planResolved={capReached.planResolved}
73+
context={
74+
<DashboardAgentContextBanner
75+
projectSlug={projectSlug}
76+
environmentSlug={environmentSlug}
77+
currentPage={currentPage}
78+
/>
79+
}
80+
/>
81+
</div>
82+
) : (
83+
<div className="flex w-full flex-col gap-3">
84+
{watchCard}
85+
<DashboardAgentComposer
86+
layout="hero"
87+
value={input}
88+
onChange={setInput}
89+
onSubmit={() => submit(input)}
90+
onStop={() => {}}
91+
isStreaming={false}
92+
placeholderSuggestion={watchCard ? undefined : placeholderSuggestion}
93+
context={
94+
<DashboardAgentContextBanner
95+
projectSlug={projectSlug}
96+
environmentSlug={environmentSlug}
97+
currentPage={currentPage}
98+
/>
99+
}
100+
/>
101+
</div>
102+
)
79103
}
80104
/>
81105
);

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@ export function DashboardAgentHero({
1111
promoted,
1212
dismissedIds,
1313
composer,
14+
promptsDisabledReason,
1415
}: {
1516
/** Receives the prompt text to send, not the button label. */
1617
onSelect: (prompt: string) => void;
1718
pageContext?: AgentPageContext;
1819
promoted?: SuggestedPrompt;
1920
dismissedIds?: string[];
2021
composer?: React.ReactNode;
22+
/** Set to disable the suggestion chips and say why. */
23+
promptsDisabledReason?: string;
2124
}) {
2225
// Centred by the child's `m-auto`, not by `justify-center`: auto margins give up their space
2326
// once the content outgrows the panel, so the heading stays scrollable to.
@@ -40,6 +43,7 @@ export function DashboardAgentHero({
4043
pageContext={pageContext}
4144
promoted={promoted}
4245
dismissedIds={dismissedIds}
46+
disabledReason={promptsDisabledReason}
4347
/>
4448
</div>
4549
</div>

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export type DashboardAgentMessagesProps = {
3535
activity: TurnActivity | null;
3636
error?: Error;
3737
onRetry?: () => void;
38+
/** Set to disable the retry button and say why, e.g. over the message cap. */
39+
retryDisabledReason?: string;
3840
onDismissError?: () => void;
3941
onIntent?: (intent: AgentIntent) => void;
4042
resolveUri?: (uri: string) => ResolvedUri | null;
@@ -356,6 +358,7 @@ export function DashboardAgentTurns({
356358
activity,
357359
error,
358360
onRetry,
361+
retryDisabledReason,
359362
onDismissError,
360363
onIntent,
361364
resolveUri,
@@ -402,7 +405,16 @@ export function DashboardAgentTurns({
402405
(onRetry || onDismissError) && (
403406
<ChatActionsRow>
404407
{onRetry && (
405-
<Button variant="primary/small" LeadingIcon={ArrowPathIcon} onClick={onRetry}>
408+
<Button
409+
variant="primary/small"
410+
LeadingIcon={ArrowPathIcon}
411+
onClick={onRetry}
412+
disabled={!!retryDisabledReason}
413+
tooltip={retryDisabledReason}
414+
aria-label={
415+
retryDisabledReason ? `Try again — ${retryDisabledReason}` : undefined
416+
}
417+
>
406418
Try again
407419
</Button>
408420
)}

0 commit comments

Comments
 (0)