diff --git a/.changeset/report-json-and-period-units.md b/.changeset/report-json-and-period-units.md new file mode 100644 index 0000000000..c039ce8ea5 --- /dev/null +++ b/.changeset/report-json-and-period-units.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/core": patch +"trigger.dev": patch +--- + +Reports can now be fetched as structured data, not just text: ask for the `json` format and you get the numbers and what they mean, typed. Report periods are also stricter — the shortest window is one minute (`30m`, `1h`, `7d`), because reports summarise data by the minute and anything shorter can't be answered honestly. diff --git a/.server-changes/ask-agent-replaces-ask-ai.md b/.server-changes/ask-agent-replaces-ask-ai.md new file mode 100644 index 0000000000..dadbeda8ba --- /dev/null +++ b/.server-changes/ask-agent-replaces-ask-ai.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Ask AI has been replaced by the dashboard agent everywhere it used to appear — the Help & Feedback menu, the deployment setup panels, and the link the CLI prints when a task errors. Chat answers now read as plain text instead of stacked cards, and on the Free plan you get 20 messages before an upgrade is needed (your past chats stay readable). diff --git a/.server-changes/dashboard-agent-first-turn-error.md b/.server-changes/dashboard-agent-first-turn-error.md new file mode 100644 index 0000000000..c3dea3a8f8 --- /dev/null +++ b/.server-changes/dashboard-agent-first-turn-error.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +When the assistant fails to start its very first reply, the chat now shows an error you can retry instead of sitting empty. diff --git a/.server-changes/dashboard-agent-investigate.md b/.server-changes/dashboard-agent-investigate.md new file mode 100644 index 0000000000..5fa5a45038 --- /dev/null +++ b/.server-changes/dashboard-agent-investigate.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +The dashboard agent can now investigate failures for you: click Investigate on a failed run, an error, or a backed-up queue and it gathers the evidence, tests a few hypotheses, and answers with what happened and how to fix it — every claim linked to the runs, errors, and deploys behind it. Available to organizations with the dashboard agent enabled. diff --git a/.server-changes/queue-metrics-api.md b/.server-changes/queue-metrics-api.md new file mode 100644 index 0000000000..72ac98360e --- /dev/null +++ b/.server-changes/queue-metrics-api.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +You can now read a single queue's wait times, peak depth, throughput, and how often it was throttled over a time window from the API. diff --git a/.server-changes/remove-header-docs-buttons.md b/.server-changes/remove-header-docs-buttons.md new file mode 100644 index 0000000000..74275b0f93 --- /dev/null +++ b/.server-changes/remove-header-docs-buttons.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +The Docs button has been removed from page headers to reduce clutter. Documentation links remain available in context where they're most useful. diff --git a/apps/webapp/.gitignore b/apps/webapp/.gitignore index 595ab180e1..3595adea8c 100644 --- a/apps/webapp/.gitignore +++ b/apps/webapp/.gitignore @@ -7,6 +7,9 @@ node_modules /cypress/screenshots /cypress/videos +# Output of `pnpm run agent-ui:screenshots` +/screenshots + /app/styles/tailwind.css # Ensure the .env symlink is not removed by accident @@ -20,4 +23,6 @@ storybook-static /prisma/seed.js /prisma/populate.js -.memory-snapshots \ No newline at end of file +.memory-snapshots +# Heartbeat story mode for seed-agent-examples (degraded | calm) +.agent-examples-heartbeat-mode diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx deleted file mode 100644 index 0d32265c25..0000000000 --- a/apps/webapp/app/components/AskAI.tsx +++ /dev/null @@ -1,614 +0,0 @@ -import { - ArrowPathIcon, - ArrowUpIcon, - HandThumbDownIcon, - HandThumbUpIcon, - StopIcon, -} from "@heroicons/react/20/solid"; -import { cn } from "~/utils/cn"; -import { type FeedbackComment, KapaProvider, type QA, useChat } from "@kapaai/react-sdk"; -import { useSearchParams } from "@remix-run/react"; -import DOMPurify from "dompurify"; -import { motion } from "framer-motion"; -import { marked } from "marked"; -import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; -import { useTypedRouteLoaderData } from "remix-typedjson"; -import { AISparkleIcon } from "~/assets/icons/AISparkleIcon"; -import { SparkleListIcon } from "~/assets/icons/SparkleListIcon"; -import { useFeatures } from "~/hooks/useFeatures"; -import { useShortcutKeys } from "~/hooks/useShortcutKeys"; -import { type loader } from "~/root"; -import { Button } from "./primitives/Buttons"; -import { Callout } from "./primitives/Callout"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./primitives/Dialog"; -import { Header2 } from "./primitives/Headers"; -import { Paragraph } from "./primitives/Paragraph"; -import { ShortcutKey } from "./primitives/ShortcutKey"; -import { Spinner } from "./primitives/Spinner"; -import { - SimpleTooltip, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "./primitives/Tooltip"; -import { ClientOnly } from "remix-utils/client-only"; - -function useKapaWebsiteId() { - const routeMatch = useTypedRouteLoaderData("root"); - return routeMatch?.kapa.websiteId; -} - -/** Open/close state for the Ask AI dialog, including the `?aiHelp=` deep-link handling. */ -function useAskAIState() { - const [isOpen, setIsOpen] = useState(false); - const [initialQuery, setInitialQuery] = useState(); - const [searchParams, setSearchParams] = useSearchParams(); - - const openAskAI = useCallback((question?: string) => { - if (question) { - setInitialQuery(question); - } else { - setInitialQuery(undefined); - } - setIsOpen(true); - }, []); - - const closeAskAI = useCallback(() => { - setIsOpen(false); - setInitialQuery(undefined); - }, []); - - // Handle URL param functionality - useEffect(() => { - const aiHelp = searchParams.get("aiHelp"); - if (aiHelp) { - // Delay to avoid hCaptcha bot detection - window.setTimeout(() => openAskAI(aiHelp), 1000); - - // Clone instead of mutating in place - const next = new URLSearchParams(searchParams); - next.delete("aiHelp"); - setSearchParams(next); - } - }, [searchParams, openAskAI]); - - return { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI }; -} - -/** - * Hosts Ask AI (Kapa provider, ⌘I shortcut, dialog) for a menu that renders its own trigger. Wrap - * it around the popover, not inside, so the dialog and shortcut survive the popover closing. - * `children` receives the open function, or undefined when Ask AI is unavailable (self-hosted, no - * Kapa website id, or SSR). - */ -export function AskAIRoot({ - children, -}: { - children: (openAskAI: (() => void) | undefined) => ReactNode; -}) { - const { isManagedCloud } = useFeatures(); - const websiteId = useKapaWebsiteId(); - - if (!isManagedCloud || !websiteId) { - return <>{children(undefined)}; - } - - return ( - {children(undefined)}}> - {() => {children}} - - ); -} - -function AskAIRootProvider({ - websiteId, - children, -}: { - websiteId: string; - children: (openAskAI: () => void) => ReactNode; -}) { - const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState(); - - useShortcutKeys({ - shortcut: { modifiers: ["mod"], key: "i", enabledOnInputElements: true }, - action: () => openAskAI(), - }); - - return ( - openAskAI(), - onAnswerGenerationCompleted: () => openAskAI(), - }, - }} - botProtectionMechanism="hcaptcha" - > - {children(() => openAskAI())} - - - ); -} - -export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) { - const { isManagedCloud } = useFeatures(); - const websiteId = useKapaWebsiteId(); - - if (!isManagedCloud || !websiteId) { - return null; - } - - return ( - - - - } - > - {() => } - - ); -} - -type AskAIProviderProps = { - websiteId: string; - isCollapsed?: boolean; -}; - -function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) { - const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState(); - - return ( - openAskAI(), - onAnswerGenerationCompleted: () => openAskAI(), - }, - }} - botProtectionMechanism="hcaptcha" - > - - - - - - - - - - Ask AI - - - - - - - - - - - ); -} - -type AskAIDialogProps = { - initialQuery?: string; - isOpen: boolean; - onOpenChange: (open: boolean) => void; - closeAskAI: () => void; -}; - -function AskAIDialog({ initialQuery, isOpen, onOpenChange, closeAskAI }: AskAIDialogProps) { - const handleOpenChange = (open: boolean) => { - if (!open) { - closeAskAI(); - } else { - onOpenChange(open); - } - }; - - return ( - - - -
- - Ask AI -
-
- -
-
- ); -} - -function ChatMessages({ - conversation, - isPreparingAnswer, - isGeneratingAnswer, - onReset, - onExampleClick, - error, - addFeedback, -}: { - conversation: QA[]; - isPreparingAnswer: boolean; - isGeneratingAnswer: boolean; - onReset: () => void; - onExampleClick: (question: string) => void; - error: string | null; - addFeedback: ( - questionAnswerId: string, - reaction: "upvote" | "downvote", - comment?: FeedbackComment - ) => void; -}) { - const [feedbackGivenForQAs, setFeedbackGivenForQAs] = useState>(new Set()); - - // Reset feedback state when conversation is reset - useEffect(() => { - if (conversation.length === 0) { - setFeedbackGivenForQAs(new Set()); - } - }, [conversation.length]); - - // Check if feedback has been given for the latest QA - const latestQA = conversation[conversation.length - 1]; - const hasFeedbackForLatestQA = latestQA?.id ? feedbackGivenForQAs.has(latestQA.id) : false; - - const exampleQuestions = [ - "How do I increase my concurrency limit?", - "How do I debug errors in my task?", - "How do I deploy my task?", - ]; - - return ( -
- {conversation.length === 0 ? ( - - - I'm trained on docs, examples, and other content. Ask me anything about Trigger.dev. - - {exampleQuestions.map((question, index) => ( - onExampleClick(question)} - variants={{ - hidden: { - opacity: 0, - x: 20, - }, - visible: { - opacity: 1, - x: 0, - transition: { - opacity: { - duration: 0.5, - ease: "linear", - }, - x: { - type: "spring", - stiffness: 300, - damping: 25, - }, - }, - }, - }} - > - - - {question} - - - ))} - - ) : ( - conversation.map((qa) => ( -
- {qa.question} -
-
- )) - )} - {conversation.length > 0 && - !isPreparingAnswer && - !isGeneratingAnswer && - !error && - !latestQA?.id && ( -
- - Answer generation was stopped - - -
- )} - {conversation.length > 0 && - !isPreparingAnswer && - !isGeneratingAnswer && - !error && - latestQA?.id && ( -
- {hasFeedbackForLatestQA ? ( - - - Thanks for your feedback! - - - ) : ( -
- - Was this helpful? - -
- - -
-
- )} - -
- )} - {isPreparingAnswer && ( -
- - Preparing answer… -
- )} - {error && ( -
- - Error generating answer: - - {error} If the problem persists after retrying, please contact support. - - -
- -
-
- )} -
- ); -} - -function ChatInterface({ initialQuery }: { initialQuery?: string }) { - const [message, setMessage] = useState(""); - const [isExpanded, setIsExpanded] = useState(false); - const hasSubmittedInitialQuery = useRef(false); - const { - conversation, - submitQuery, - isGeneratingAnswer, - isPreparingAnswer, - resetConversation, - stopGeneration, - error, - addFeedback, - } = useChat(); - - useEffect(() => { - if (initialQuery && !hasSubmittedInitialQuery.current) { - hasSubmittedInitialQuery.current = true; - setIsExpanded(true); - submitQuery(initialQuery); - } - }, [initialQuery, submitQuery]); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (message.trim()) { - setIsExpanded(true); - submitQuery(message); - setMessage(""); - } - }; - - const handleExampleClick = (question: string) => { - setIsExpanded(true); - submitQuery(question); - }; - - const handleReset = () => { - resetConversation(); - setIsExpanded(false); - }; - - return ( - - -
-
- setMessage(e.target.value)} - placeholder="Ask a question..." - disabled={isGeneratingAnswer} - autoFocus - className="flex-1 rounded-md border border-grid-bright bg-background-dimmed px-3 py-2 text-text-bright placeholder:text-text-dimmed focus-visible:focus-custom" - /> - {isGeneratingAnswer ? ( - stopGeneration()} - className="group relative z-10 flex size-10 min-w-10 cursor-pointer items-center justify-center" - > - - - - } - content="Stop generating" - /> - ) : isPreparingAnswer ? ( - - - - ) : ( -
-
-
- ); -} - -function GradientSpinnerBackground({ - children, - className, - hoverEffect = false, -}: { - children?: React.ReactNode; - className?: string; - hoverEffect?: boolean; -}) { - return ( -
-
- {children} -
-
- ); -} diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index 5fde85cc05..7888ce199e 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -33,7 +33,7 @@ import { v3NewProjectAlertPath, v3NewSchedulePath, } from "~/utils/pathBuilder"; -import { AskAI } from "./AskAI"; +import { AskAgentButton } from "./dashboard-agent/AskAgentButton"; import { CodeBlock } from "./code/CodeBlock"; import { InlineCode } from "./code/InlineCode"; import { environmentFullTitle, EnvironmentIcon } from "./environments/EnvironmentLabel"; @@ -61,6 +61,44 @@ import { import { StepContentContainer } from "./StepContentContainer"; import { V4Badge } from "./V4Badge"; +/** + * What the agent is asked when it's opened from a deployment setup panel. The + * panel is the docs answer; the agent is for the part the docs can't answer — + * this project, this environment. + */ +const ASK_AGENT_DEPLOY_PROMPT = + "I'm trying to deploy my tasks to this environment. Walk me through it and tell me if anything about this project or environment is going to get in the way."; + +/** The docs links the deployment panels offered before the agent did. */ +function DeployDocsLinks() { + return ( + <> + + } + content="Deploy docs" + /> + + } + content="Troubleshooting docs" + /> + + ); +} + export function HasNoTasksDev() { return ( @@ -269,29 +307,11 @@ export function DeploymentsNoneDev() { Deploy your tasks
- - } - content="Deploy docs" - /> - - } - content="Troubleshooting docs" - /> - + {/* One entry point instead of three: the docs and troubleshooting links + were a guess at which page you needed, and the agent can look at this + project and answer for it. Someone with no agent still gets the + links. */} + } />
@@ -657,29 +677,11 @@ function DeploymentOnboardingSteps() {
- - } - content="Deploy docs" - /> - - } - content="Troubleshooting docs" - /> - + {/* One entry point instead of three: the docs and troubleshooting links + were a guess at which page you needed, and the agent can look at this + project and answer for it. Someone with no agent still gets the + links. */} + } />
diff --git a/apps/webapp/app/components/Shortcuts.tsx b/apps/webapp/app/components/Shortcuts.tsx index c4ce2db6d0..202d4d8bcf 100644 --- a/apps/webapp/app/components/Shortcuts.tsx +++ b/apps/webapp/app/components/Shortcuts.tsx @@ -1,5 +1,8 @@ import { KeyboardIcon } from "~/assets/icons/KeyboardIcon"; import { useState } from "react"; +import { ASK_AGENT_LABEL } from "~/components/dashboard-agent/agent-identity"; +import { NEW_CHAT_SHORTCUT } from "~/components/dashboard-agent/DashboardAgentHeader"; +import { TOGGLE_PANEL_SHORTCUT } from "~/components/dashboard-agent/dashboardAgentLauncher"; import { useShortcutKeys } from "~/hooks/useShortcutKeys"; import { Header3 } from "./primitives/Headers"; import { SideMenuItemButton } from "./navigation/SideMenuItem"; @@ -62,9 +65,9 @@ function ShortcutContent() { - + - + @@ -94,6 +97,19 @@ function ShortcutContent() { +
+ Chat + + + + + + + +
Runs page diff --git a/apps/webapp/app/components/dashboard-agent/AgentChart.tsx b/apps/webapp/app/components/dashboard-agent/AgentChart.tsx index dbd1297c58..a14ddb9f2c 100644 --- a/apps/webapp/app/components/dashboard-agent/AgentChart.tsx +++ b/apps/webapp/app/components/dashboard-agent/AgentChart.tsx @@ -1,12 +1,20 @@ import type { OutputColumnMetadata } from "@internal/clickhouse"; import type { ChartBlock } from "@internal/dashboard-agent"; +import { + isTriggerUri, + type AgentIntent, + type ChartAction, +} from "@internal/dashboard-agent-contracts"; import { useEffect, useState } from "react"; import { QueryResultsChart } from "~/components/code/QueryResultsChart"; import type { ChartConfiguration } from "~/components/metrics/QueryWidget"; +import { Button } from "~/components/primitives/Buttons"; import { Spinner } from "~/components/primitives/Spinner"; import { useOptionalEnvironment } from "~/hooks/useEnvironment"; import { useOptionalOrganization } from "~/hooks/useOrganizations"; import { useOptionalProject } from "~/hooks/useProject"; +import { cn } from "~/utils/cn"; +import { ChatActionsRow } from "./chat-layout"; // Render an agent "chart" block by running its TRQL query through the dashboard's // own /resources/metric endpoint (session-authed, returns rows + real column @@ -25,6 +33,30 @@ type MetricResponse = }; }; +// The chart block's schema (`chartBlockBodySchema` in +// @internal/dashboard-agent-contracts) carries only `period` for the time window +// — no scope, no explicit from/to, no height. So these are fixed here rather +// than plumbed from the block. If the schema grows those fields, read them off +// `block` instead of using these. +const CHART_SCOPE = "environment"; // the panel is always open in one environment +const CHART_FROM = null; // `period` is the only window the agent can ask for +const CHART_TO = null; +// Fits the panel at its default width. `min-h-*` alongside the fixed height on +// purpose: the chart measures its own container and draws nothing at zero +// height, so a flex parent that squeezes the row (a card in a short turn, a +// narrow panel) must not be able to collapse it below this. +const CHART_HEIGHT_CLASS = "h-64 min-h-64"; +// Top padding inside the plot area. The chart's own top gridline/label sits +// right on the container edge, so without this it touches the card header. +const CHART_PADDING_CLASS = "px-2 pb-2 pt-4"; +/** The plot area's geometry, exported so the demo card can't drift from it. */ +export const AGENT_CHART_PLOT_CLASS = `w-full ${CHART_PADDING_CLASS} ${CHART_HEIGHT_CLASS}`; + +// Query errors come from ClickHouse via the metric endpoint and can carry SQL +// and schema detail, so the panel shows a fixed message and the real one goes to +// the console for whoever is debugging. +const CHART_ERROR_MESSAGE = "This chart's query couldn't run."; + type ChartState = | { status: "loading" } | { status: "error"; error: string } @@ -35,7 +67,53 @@ type ChartState = timeRange?: { from: string; to: string }; }; -export function AgentChart({ block }: { block: ChartBlock }) { +/** + * The buttons under a ranking chart: act on the item the chart put on top. + * + * A card never navigates or asks on its own — it hands the block's intent to the + * host, which submits an `ask` as the user's next message and resolves a + * `navigate` before moving. Without an `onIntent` there is nothing to hand it to, + * so the row isn't rendered rather than showing dead buttons. + */ +export function ChartActions({ + actions, + onIntent, +}: { + actions: ChartAction[]; + onIntent?: (intent: AgentIntent) => void; +}) { + // A chart action's navigate target is a plain string at the contract boundary + // (the model may hold no canonical URI) — only targets that really parse + // become buttons, so a hallucinated URI costs a button, never a dead click. + const renderable = actions.filter( + (action) => action.intent.kind !== "navigate" || isTriggerUri(action.intent.target) + ); + if (!onIntent || renderable.length === 0) return null; + return ( +
+ + {renderable.map((action, i) => ( + + ))} + +
+ ); +} + +export function AgentChart({ + block, + onIntent, +}: { + block: ChartBlock; + onIntent?: (intent: AgentIntent) => void; +}) { const organization = useOptionalOrganization(); const project = useOptionalProject(); const environment = useOptionalEnvironment(); @@ -63,10 +141,10 @@ export function AgentChart({ block }: { block: ChartBlock }) { organizationId, projectId, environmentId, - scope: "environment", + scope: CHART_SCOPE, period: block.period ?? null, - from: null, - to: null, + from: CHART_FROM, + to: CHART_TO, userAuthoredQuery: true, }), signal: controller.signal, @@ -75,7 +153,8 @@ export function AgentChart({ block }: { block: ChartBlock }) { .then((data) => { if (controller.signal.aborted) return; if (!data.success) { - setState({ status: "error", error: data.error }); + console.error("Dashboard agent chart query failed:", data.error); + setState({ status: "error", error: CHART_ERROR_MESSAGE }); } else { setState({ status: "ready", @@ -87,7 +166,8 @@ export function AgentChart({ block }: { block: ChartBlock }) { }) .catch((err) => { if (controller.signal.aborted) return; - setState({ status: "error", error: err?.message ?? "The query failed to run." }); + console.error("Dashboard agent chart request failed:", err); + setState({ status: "error", error: CHART_ERROR_MESSAGE }); }); return () => controller.abort(); }, [block.query, block.period, organizationId, projectId, environmentId]); @@ -110,7 +190,7 @@ export function AgentChart({ block }: { block: ChartBlock }) { {block.title}
) : null} -
+
{state.status === "loading" ? (
@@ -129,6 +209,7 @@ export function AgentChart({ block }: { block: ChartBlock }) { /> )}
+
); } diff --git a/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx b/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx new file mode 100644 index 0000000000..44534b83b8 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx @@ -0,0 +1,72 @@ +import { Link } from "@remix-run/react"; +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"; + +/** + * The Free plan's message cap, at the bottom of the panel. + * + * Two states, both sitting where the composer sits so the transcript above is + * untouched — the point of the cap is that the conversation you already had stays + * readable: + * + * - {@link AgentQuotaNotice} while there are messages left: one dimmed line and + * an upgrade link, under the composer. + * - {@link AgentUpgradeBlock} once the cap is reached: it replaces the composer, + * because a composer you can't send from is worse than no composer. + */ + +/** The composer's own 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, + /** The composer's context banner, so replacing the composer doesn't lose it. */ + context, +}: { + limit: number; + context?: React.ReactNode; +}) { + const organization = useOrganization(); + + return ( +
+ {context} +
+
+ + + Upgrade to unlock {ASK_AGENT_LABEL} + +
+

+ You've used all {limit} messages included on the Free plan. Your chats stay here to read. +

+ + Upgrade + +
+
+ ); +} + +export function AgentQuotaNotice({ remaining, limit }: { remaining: number; limit: number }) { + const organization = useOrganization(); + + return ( +
+ + {remaining} of {limit} free messages left + + · + + Upgrade + +
+ ); +} diff --git a/apps/webapp/app/components/dashboard-agent/AskAgentButton.tsx b/apps/webapp/app/components/dashboard-agent/AskAgentButton.tsx new file mode 100644 index 0000000000..3fc7ed07c6 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/AskAgentButton.tsx @@ -0,0 +1,53 @@ +import { Button } from "~/components/primitives/Buttons"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import { AgentIcon, AGENT_ICON_ACCENT_CLASS, ASK_AGENT_LABEL } from "./agent-identity"; +import { requestDashboardAgent, useDashboardAgentAvailable } from "./dashboardAgentOpenRequest"; + +/** + * "Ask {agent}" as a button, for the places that used to offer the docs chat: an + * empty state's header row, a setup panel. Opens the agent panel, optionally with + * a question already in play. + * + * Unlike `InvestigateButton` this goes through the open-request bridge rather than + * the provider context, so it works on a page above the environment layout too. + * It self-hides when the agent can't be opened (gated off, self-hosted without + * it), falling back to whatever the caller passes — so a setup panel that now + * offers the agent instead of a row of docs links still offers the docs links to + * someone who has no agent. + */ +export function AskAgentButton({ + prompt, + label = ASK_AGENT_LABEL, + /** Icon only, with the label as a tooltip — the empty-state header row idiom. */ + iconOnly = false, + variant = "small-menu-item", + className, + /** What to show instead when the agent can't be opened (a docs link, usually). */ + fallback = null, +}: { + prompt?: string; + label?: string; + iconOnly?: boolean; + variant?: "small-menu-item" | "secondary/small" | "primary/small"; + className?: string; + fallback?: React.ReactNode; +}) { + const available = useDashboardAgentAvailable(); + if (!available) return <>{fallback}; + + const button = ( + + ); + + return iconOnly ? : button; +} diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx index 2796c8516d..150d823e13 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx @@ -1,11 +1,14 @@ -import { useState } from "react"; +import type { SuggestedPrompt } from "@internal/dashboard-agent-contracts"; +import { useCallback, useMemo, useState } from "react"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "~/components/primitives/Resizable"; +import { useShortcutKeys } from "~/hooks/useShortcutKeys"; import { DashboardAgentPanel } from "./DashboardAgentPanel"; -import { DashboardAgentProvider } from "./dashboardAgentLauncher"; +import { DashboardAgentProvider, TOGGLE_PANEL_SHORTCUT } from "./dashboardAgentLauncher"; +import { useDashboardAgentOpenRequests } from "./dashboardAgentOpenRequest"; /** * Mounts the dashboard agent in the env layout. Renders the page content @@ -22,18 +25,62 @@ import { DashboardAgentProvider } from "./dashboardAgentLauncher"; export function DashboardAgent({ children, hasAccess = false, + promotedPrompt, }: { children: React.ReactNode; hasAccess?: boolean; + // The product-controlled promoted prompt chip, from the feature flag. + promotedPrompt?: SuggestedPrompt; }) { const [open, setOpen] = useState(false); + // A request from `openWith`, handed to the panel. `seq` makes repeat requests + // with the same text distinct, so the panel can tell them apart. + const [requestedMessage, setRequestedMessage] = useState< + { text: string; seq: number } | undefined + >(undefined); + + // Closing drops any pending request, so reopening the panel later doesn't + // replay text the user has moved on from. + const setPanelOpen = useCallback((next: boolean) => { + setOpen(next); + // The panel unmounts on close, so a stale request would re-apply on the next + // open instead of restoring the last chat. + if (!next) setRequestedMessage(undefined); + }, []); + + const openWith = useCallback((text: string) => { + const trimmed = text.trim(); + if (!trimmed) return; + setOpen(true); + setRequestedMessage((current) => ({ text: trimmed, seq: (current?.seq ?? 0) + 1 })); + }, []); + + // ⌘J toggles the panel. Opening mounts the composer, which focuses itself, so + // the shortcut lands you in the text field. Enabled inside inputs too, so the + // same keystroke closes the panel while you're typing in it. + useShortcutKeys({ + shortcut: TOGGLE_PANEL_SHORTCUT, + action: () => setPanelOpen(!open), + disabled: !hasAccess, + enabledOnInputElements: true, + }); + + // Entry points that sit ABOVE this provider — the side menu's "Ask {agent}" + // item — and the CLI's `?ask=` deep link, both handled in one place. See + // `dashboardAgentOpenRequest.ts`. + useDashboardAgentOpenRequests({ enabled: hasAccess, openWith, setOpen: setPanelOpen }); + + const context = useMemo( + () => ({ open, setOpen: setPanelOpen, openWith }), + [open, setPanelOpen, openWith] + ); if (!hasAccess) { return
{children}
; } return ( - + {open ? ( - setOpen(false)} /> + setPanelOpen(false)} + requestedMessage={requestedMessage} + promotedPrompt={promotedPrompt} + /> ) : ( diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx index e6662ca849..849394ae72 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx @@ -1,12 +1,21 @@ import { useChat } from "@ai-sdk/react"; import type { UIMessage } from "@ai-sdk/react"; import type { dashboardAgent } from "@internal/dashboard-agent"; +import type { AgentIntent, SuggestedPrompt } from "@internal/dashboard-agent-contracts"; +import { useNavigate } from "@remix-run/react"; import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useToast } from "~/components/primitives/Toast"; +import { AgentQuotaNotice, AgentUpgradeBlock } from "./AgentUpgradeGate"; import { DashboardAgentComposer } from "./DashboardAgentComposer"; import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner"; -import { DashboardAgentMessages } from "./DashboardAgentMessages"; +import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessages"; import { DashboardAgentSuggestedPrompts } from "./DashboardAgentSuggestedPrompts"; +import { createTranscriptOrder, orderTranscript } from "./message-order"; +import { appendRunFilters, pendingNavigateIntents } from "./navigate-target"; +import type { AgentPageContext } from "./page-context-types"; +import { useAgentMessageQuota } from "./useAgentMessageQuota"; +import { useTriggerUriResolver } from "./useTriggerUriResolver"; // The persisted session for a chat: the session-scoped token plus the stream // cursor. Resuming with `lastEventId` is what stops the agent's `.out` stream @@ -23,6 +32,10 @@ export type DashboardAgentClientData = { projectId?: string; environmentId?: string; currentPage?: string; + // What page the user is on, as facts rather than a path. Sent on create and + // on every turn, so the agent sees where the user is now — not where they + // were when the chat started. + pageContext?: AgentPageContext; }; /** @@ -44,7 +57,11 @@ export function DashboardAgentChat({ currentPage, pendingFirstMessage, streaming, + prefill, + promotedPrompt, + pagePaths, onTurnSettled, + onActivityChange, }: { chatId: string; initialMessages: UIMessage[]; @@ -54,6 +71,8 @@ export function DashboardAgentChat({ actionPath: string; projectSlug: string; environmentSlug: string; + // Human label for the current page, for the context banner. The path the agent + // sees travels separately, in `clientData.currentPage`. currentPage: string; // Cold start: send this first message through the transport once on mount to // trigger the turn. Undefined for head-started and resumed chats. @@ -62,9 +81,35 @@ export function DashboardAgentChat({ // streaming so the transport resumes `session.out` instead of treating it as // a settled session with nothing to reconnect to. streaming?: boolean; + // Text dropped into the composer from outside (the launcher's `openWith`). + // `seq` makes each request distinct so the same text can be sent twice. + prefill?: { text: string; seq: number }; + // The product-controlled promoted chip, from the feature flag. Only used for + // the suggested prompts on an empty chat. + promotedPrompt?: SuggestedPrompt; + /** Host-resolved dashboard paths for settings-page footer actions. */ + pagePaths?: Record; + /** A turn settled — tell the panel to refresh its history list. */ onTurnSettled: () => void; + /** + * Whether a turn is in flight, for the History list's row marker. Only this + * component knows — the turn status is `useChat`'s, with nothing server-side + * to read it back from. + */ + onActivityChange?: (chatId: string, activity: TurnActivity | null) => void; }) { const [input, setInput] = useState(""); + const navigate = useNavigate(); + const toast = useToast(); + + // Put requested text in the composer rather than sending it: a chat is already + // open, so the user gets to read and edit before it goes. + const prefilledSeq = useRef(undefined); + useEffect(() => { + if (!prefill || prefilledSeq.current === prefill.seq) return; + prefilledSeq.current = prefill.seq; + setInput(prefill.text); + }, [prefill]); const transport = useTriggerChatTransport({ task: "dashboard-agent", @@ -119,11 +164,12 @@ export function DashboardAgentChat({ }); const { - messages, + messages: rawMessages, sendMessage, status, stop: aiStop, error, + clearError, } = useChat({ id: chatId, messages: initialMessages, @@ -133,8 +179,25 @@ export function DashboardAgentChat({ resume: !!session && !pendingFirstMessage, }); + // The transcript in stable order. The store's copy is the base; live arrivals + // go after it, and a turn the stream replays goes back into its own slot — so + // a message sent right after a remount can't land between older turns. See + // `message-order.ts`. + const orderRef = useRef(createTranscriptOrder(initialMessages)); + const messages = orderTranscript(rawMessages, orderRef.current); + + // The Free plan's message cap. Read here rather than in the panel so it counts + // this chat's live transcript — including the turn just sent. `unlimited` on + // any paid plan, and on any plan we can't identify. + const quota = useAgentMessageQuota({ actionPath, chatId, messages }); + const atMessageCap = quota.kind === "reached"; + const isStreaming = status === "streaming"; - const isThinking = status === "submitted"; + // A turn is in flight from submit until it settles. Deriving the indicator + // from status (rather than from what the last part happens to be) keeps it up + // through long tool calls, where the agent is busy but silent. + const activity: TurnActivity | null = + status === "submitted" ? "thinking" : status === "streaming" ? "working" : null; // Cold start: trigger the first turn by sending the pending message once. const sentFirst = useRef(false); @@ -148,13 +211,95 @@ export function DashboardAgentChat({ const submit = useCallback( (text: string) => { const trimmed = text.trim(); - if (!trimmed || isStreaming) return; + // The composer is gone at the cap, but a suggested prompt or a card's + // action can still call this — so the cap is enforced here, not just in + // what's rendered. + if (!trimmed || isStreaming || atMessageCap) return; setInput(""); void sendMessage({ text: trimmed }); }, - [isStreaming, sendMessage] + [isStreaming, atMessageCap, sendMessage] + ); + + // Re-send the last thing the user asked. The failed turn produced nothing, so + // sending the same text again is the whole retry — no server-side state to + // unwind. + const retry = useCallback(() => { + const lastUserMessage = [...messages].reverse().find((m) => m.role === "user"); + const text = lastUserMessage?.parts + ?.filter((p): p is { type: "text"; text: string } => p.type === "text") + .map((p) => p.text) + .join("\n") + .trim(); + clearError(); + if (text) void sendMessage({ text }); + }, [messages, sendMessage, clearError]); + + // Take the user where a `navigate` intent points. The target is a `trigger://` + // URI, so the path comes from the server (the route's `resolve` intent, which + // owns the environment scope the resolver needs); the intent's runs-list + // filters are applied on top. Same-origin, so this is a client-side navigation + // — the panel lives in the env layout and survives it. + // Sync facade over the same `resolve` action — evidence and card links render + // as raw URIs on first paint and become links once the server answers. + const resolveUri = useTriggerUriResolver(actionPath); + + const goTo = useCallback( + async (intent: Extract) => { + const body = new FormData(); + body.set("intent", "resolve"); + body.set("uri", intent.target); + try { + const res = await fetch(actionPath, { method: "POST", body }); + const data = (await res.json()) as { path?: string }; + if (!res.ok || !data.path) throw new Error(`Resolve failed (${res.status})`); + navigate(appendRunFilters(data.path, intent.filters)); + } catch (error) { + console.error("Dashboard agent: failed to resolve a navigate target", error); + toast.error("Couldn't open that page."); + } + }, + [actionPath, navigate, toast] ); + // What a card's action does. An `ask` goes back into the conversation as the + // user's own question, so the click is visible in the transcript rather than + // happening silently. + // + // `propose_fix` is reserved and must never be executed. + const handleIntent = useCallback( + (intent: AgentIntent) => { + switch (intent.kind) { + case "ask": + submit(intent.prompt); + return; + case "navigate": + void goTo(intent); + return; + default: + console.warn(`Dashboard agent: unhandled intent "${intent.kind}"`); + } + }, + [submit, goTo] + ); + + // The `navigate_to` tool answers with an intent and the agent then narrates it + // in the past tense ("you're now on…"), so the panel has to actually move. + // Seeded with the loaded transcript before the first render, so opening a chat + // whose history contains a navigation never navigates on replay — only calls + // that land while this chat is open are honoured, once each. + const navigatedRef = useRef | null>(null); + if (navigatedRef.current === null) { + navigatedRef.current = new Set(); + pendingNavigateIntents(initialMessages, navigatedRef.current); + } + useEffect(() => { + const pending = pendingNavigateIntents(messages, navigatedRef.current!); + // Only the last one matters — the earlier destinations are already history. + const target = pending.at(-1); + if (target?.kind === "navigate") void goTo(target); + }, [messages, goTo]); + const stop = useCallback(() => { transport.stopGeneration(chatId); aiStop(); @@ -170,25 +315,74 @@ export function DashboardAgentChat({ prevStatus.current = status; }, [status, onTurnSettled]); + // Report the turn's activity up, so the History list can mark this chat while + // it's working. Not cleared on unmount: opening History unmounts this chat but + // the turn carries on server-side, and it reports again when it remounts. + useEffect(() => { + onActivityChange?.(chatId, activity); + }, [chatId, activity, onActivityChange]); + return ( <> - - {messages.length === 0 ? ( - + {/* A cold-start chat mounts with no messages and a first message about to + be sent, so the prompts would flash for a frame before the transcript + replaced them. Gate on that pending send. */} + {messages.length === 0 && !pendingFirstMessage ? ( + + ) : ( + + )} + {/* The Free plan's message cap occupies the composer slot: at the cap the + composer is replaced by the upgrade block (a composer you can't send + from is worse than none), and under it the composer is followed by the + remaining count. The transcript above is untouched either way — the + conversation you already had stays readable. */} + {quota.kind === "reached" ? ( + + } + /> ) : ( - + <> + submit(input)} + onStop={stop} + isStreaming={isStreaming} + focusKey={prefill?.seq} + context={ + + } + /> + {quota.kind === "within" && ( + + )} + )} - submit(input)} - onStop={stop} - isStreaming={isStreaming} - /> ); } diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx index 308a87ebab..6f9e61bcc2 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx @@ -1,5 +1,5 @@ -import { PaperAirplaneIcon, StopIcon } from "@heroicons/react/20/solid"; -import { useRef } from "react"; +import { ArrowUpIcon, StopIcon } from "@heroicons/react/20/solid"; +import { useEffect, useRef } from "react"; import { Button } from "~/components/primitives/Buttons"; import { cn } from "~/utils/cn"; @@ -9,21 +9,43 @@ export function DashboardAgentComposer({ onSubmit, onStop, isStreaming, + focusKey, + context, }: { value: string; onChange: (value: string) => void; onSubmit: () => void; onStop: () => void; isStreaming: boolean; + // Bump to move focus back to the textarea — e.g. text was just prefilled from + // outside the panel. Focus also happens on mount (panel open, chat switch). + focusKey?: string | number; + // The context chip. It describes the message about to be sent, so it belongs + // in the composer's footer rather than at the top of the panel. + context?: React.ReactNode; }) { const ref = useRef(null); + useEffect(() => { + const el = ref.current; + if (!el) return; + el.focus(); + // Caret after any prefilled text, so typing continues the sentence. + el.setSelectionRange(el.value.length, el.value.length); + }, [focusKey]); + return ( -
-
-
+ // No top border: the transcript scrolls behind the footer, which is what the + // gradient in `ChatTranscript`'s scroller edge is for. +
+ {context} +
+
+ {/* One text line tall at rest (matches the button height), grows with + content up to the cap. rows={1} + field-sizing-content do the work. */}