diff --git a/.changeset/report-json-and-period-units.md b/.changeset/report-json-and-period-units.md new file mode 100644 index 00000000000..c039ce8ea5e --- /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/api-actions-record-who.md b/.server-changes/api-actions-record-who.md new file mode 100644 index 00000000000..a9be717d1d6 --- /dev/null +++ b/.server-changes/api-actions-record-who.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Actions taken through the API, like resolving an error, now record which team member made them in every environment, not just development. 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 00000000000..dadbeda8bac --- /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 00000000000..c3dea3a8f8d --- /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-hero-and-fullscreen.md b/.server-changes/dashboard-agent-hero-and-fullscreen.md new file mode 100644 index 00000000000..5e37a7cb4a7 --- /dev/null +++ b/.server-changes/dashboard-agent-hero-and-fullscreen.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +A new chat with the agent now opens with a bigger prompt field and one-click suggestions for the page you're on. You can also expand the agent to fill the screen and collapse it back to the side panel, keeping your conversation. diff --git a/.server-changes/dashboard-agent-investigate.md b/.server-changes/dashboard-agent-investigate.md new file mode 100644 index 00000000000..fcfa772b1b9 --- /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, a backed-up queue, or a run that hasn't started yet 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/dashboard-agent-prompts-all-pages.md b/.server-changes/dashboard-agent-prompts-all-pages.md new file mode 100644 index 00000000000..3e196bdfffb --- /dev/null +++ b/.server-changes/dashboard-agent-prompts-all-pages.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +The dashboard agent's suggested questions now fit every page in an environment — tasks, schedules, batches, alerts, waitpoints, bulk actions, branches, limits, dashboards, agents, prompts, models, sessions and the rest — instead of falling back to generic ones. Pages where something is actually stuck offer a question about it: a scheduled task with no schedule attached, a paused queue, a batch whose runs failed, a wait token past its timeout, a prompt pinned to an override. diff --git a/.server-changes/dashboard-agent-prompts-more-pages.md b/.server-changes/dashboard-agent-prompts-more-pages.md new file mode 100644 index 00000000000..64b2776a887 --- /dev/null +++ b/.server-changes/dashboard-agent-prompts-more-pages.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +The dashboard agent's suggested questions now fit the page you're on across errors, queues and deployments, not just runs — an error group, a backed-up queue or a failed deploy each offer questions about that specific thing. diff --git a/.server-changes/dashboard-agent-stuck-investigations.md b/.server-changes/dashboard-agent-stuck-investigations.md new file mode 100644 index 00000000000..2a52cae73a9 --- /dev/null +++ b/.server-changes/dashboard-agent-stuck-investigations.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +An investigation card no longer spins forever when the agent doesn't come back to it. It now closes on its own as inconclusive, keeping everything that was checked. diff --git a/.server-changes/dashboard-agent-watch-alerts.md b/.server-changes/dashboard-agent-watch-alerts.md new file mode 100644 index 00000000000..29473e5722c --- /dev/null +++ b/.server-changes/dashboard-agent-watch-alerts.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Watches you set up with the dashboard agent can now alert you by email, Slack, or webhook when they fire. Pick the new "Dashboard agent watches" type on the Alerts page, and turn it off again from any alert email. diff --git a/.server-changes/dashboard-agent-watch-card.md b/.server-changes/dashboard-agent-watch-card.md new file mode 100644 index 00000000000..152e6bca719 --- /dev/null +++ b/.server-changes/dashboard-agent-watch-card.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +There's now a **Watch…** button on runs, queues, errors and the health report. It opens a short form with the right thing to wait for already filled in — a run finishing, a queue clearing, an error coming back, an environment recovering — so one click is enough. Open **Customize** first if you'd rather change how long it waits, how often it checks, or what it waits for, and you can ask for an email as well as the chat message. diff --git a/.server-changes/dashboard-agent-watch-investigates-itself.md b/.server-changes/dashboard-agent-watch-investigates-itself.md new file mode 100644 index 00000000000..1ed604c66c1 --- /dev/null +++ b/.server-changes/dashboard-agent-watch-investigates-itself.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +When you ask a watch to investigate bad news, it now does the whole thing on its own: the chat tells you the watch resolved, and the findings arrive right after in their own message, with the investigation card. You no longer have to come back and ask. diff --git a/.server-changes/dashboard-agent-watch-queue-conditions.md b/.server-changes/dashboard-agent-watch-queue-conditions.md new file mode 100644 index 00000000000..1fdb757a2a5 --- /dev/null +++ b/.server-changes/dashboard-agent-watch-queue-conditions.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +A queue watch can now wait for three more things under **Customize**: the queue coming back below a number you pick, the queue stopping moving at all, and runs waiting longer than a limit you set. On a queue where runs are already waiting too long, the **Watch…** button opens on that wait instead of on "until it clears". diff --git a/.server-changes/dashboard-agent-watches.md b/.server-changes/dashboard-agent-watches.md new file mode 100644 index 00000000000..d023193d8df --- /dev/null +++ b/.server-changes/dashboard-agent-watches.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Ask the dashboard agent to tell you when something happens — a run starting or finishing, a queue clearing or growing past a number you pick, an error coming back, an environment recovering — and it messages you in the chat once with the answer. It tells you either way: that the run finished, that it failed, or that the queue still hadn't cleared by the time it stopped looking. If the thing you asked about has already happened, it just says so instead of waiting. Each chat can wait on up to three things at a time, for up to 24 hours. You can also ask it to start looking into the cause if the news turns out to be bad, and it will — otherwise it just tells you and stops. diff --git a/.server-changes/queue-metrics-api.md b/.server-changes/queue-metrics-api.md new file mode 100644 index 00000000000..72ac98360e3 --- /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 00000000000..74275b0f93c --- /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 595ab180e15..f825411d640 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,4 @@ storybook-static /prisma/seed.js /prisma/populate.js -.memory-snapshots \ No newline at end of file +.memory-snapshots diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx deleted file mode 100644 index d61ea0055fa..00000000000 --- 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 53b552155be..b41586522b4 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -34,7 +34,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"; @@ -62,6 +62,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 ( @@ -270,29 +308,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. */} + } />
@@ -658,29 +678,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 c4ce2db6d0f..202d4d8bcf6 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/ActionsBlock.tsx b/apps/webapp/app/components/dashboard-agent/ActionsBlock.tsx new file mode 100644 index 00000000000..9f05a35d31e --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/ActionsBlock.tsx @@ -0,0 +1,43 @@ +/** + * The agent's offer, as buttons. + * + * When an answer ends in "want me to set up a watch?", this block is what makes + * that a click instead of a typed reply: the `watch` intent opens the watch + * configuration card pre-filled, `ask` sends the labelled question as the user's + * next message. The block only emits an intent — the host decides whether to + * honour it, so without `onIntent` there is nothing to render. + * + * PURE COMPONENT: props in, markup out. + */ +import type { + ActionsBlock as ActionsBlockPayload, + AgentIntent, +} from "@internal/dashboard-agent-contracts"; +import { Button } from "~/components/primitives/Buttons"; +import { ChatActionsRow } from "./chat-layout"; +import { renderableActions } from "./view-actions"; + +export function ActionsBlock({ + block, + onIntent, +}: { + block: ActionsBlockPayload; + onIntent?: (intent: AgentIntent) => void; +}) { + const renderable = renderableActions(block.actions); + if (!onIntent || renderable.length === 0) return null; + return ( + + {renderable.map((action, i) => ( + + ))} + + ); +} diff --git a/apps/webapp/app/components/dashboard-agent/AgentChart.tsx b/apps/webapp/app/components/dashboard-agent/AgentChart.tsx index dbd1297c58f..e09cca5769d 100644 --- a/apps/webapp/app/components/dashboard-agent/AgentChart.tsx +++ b/apps/webapp/app/components/dashboard-agent/AgentChart.tsx @@ -1,12 +1,17 @@ import type { OutputColumnMetadata } from "@internal/clickhouse"; import type { ChartBlock } from "@internal/dashboard-agent"; +import type { AgentIntent, 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 { Spinner } from "~/components/primitives/Spinner"; +import { Button } from "~/components/primitives/Buttons"; +import { AgentSpinner } 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"; +import { renderableActions } from "./view-actions"; // 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 +30,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 +64,50 @@ 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; +}) { + // Only navigate targets that really parse become buttons — see + // `renderableActions`, shared with the standalone `actions` block. + const renderable = renderableActions(actions); + 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 +135,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 +147,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 +160,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,10 +184,10 @@ export function AgentChart({ block }: { block: ChartBlock }) { {block.title}
) : null} -
+
{state.status === "loading" ? (
- + Running query…
) : state.status === "error" ? ( @@ -129,6 +203,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 00000000000..44534b83b8d --- /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 00000000000..3fc7ed07c6a --- /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 2796c8516df..4f11e34b5d9 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx @@ -1,11 +1,37 @@ -import { useState } from "react"; +import type { SuggestedPrompt, WatchSpec } from "@internal/dashboard-agent-contracts"; +import { useLocation } from "@remix-run/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "~/components/primitives/Resizable"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { useShortcutKeys } from "~/hooks/useShortcutKeys"; import { DashboardAgentPanel } from "./DashboardAgentPanel"; -import { DashboardAgentProvider } from "./dashboardAgentLauncher"; +import { DashboardAgentProvider, TOGGLE_PANEL_SHORTCUT } from "./dashboardAgentLauncher"; +import { useDashboardAgentOpenRequests } from "./dashboardAgentOpenRequest"; +import { + agentHiddenContentClassName, + agentTakeoverClassName, + readAgentFullscreen, + writeAgentFullscreen, +} from "./panel-layout"; +import { + showWatchWakesSummaryToast, + showWatchWakeToast, + WAKE_TOAST_MAX_INDIVIDUAL, + type WatchWake, +} from "./WatchWakeToast"; + +// How often the closed panel asks whether a watch woke a chat. A wake is worth +// noticing within a minute, and the count is one indexed query. +const UNREAD_POLL_INTERVAL_MS = 60_000; + +/** Wake ids already toasted, across reloads — see the dedupe note in the poll. */ +const TOASTED_WAKES_STORAGE_KEY = "tdev:dashboard-agent:toasted-wakes"; /** * Mounts the dashboard agent in the env layout. Renders the page content @@ -22,32 +48,274 @@ 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 organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const actionPath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboard-agent`; + const [open, setOpen] = useState(false); + const [unreadWakes, setUnreadWakes] = useState(0); + // Wakes already toasted this session. Session-scoped on purpose: a wake that + // arrived overnight deserves the toast on the first poll after a reload, but a + // wake the user has already been shown (and maybe dismissed) must not come + // back every 60s while the chat stays unread. + const toastedWakes = useRef(new Set()); + // The poll's toast source is "recent deliveries", not "unread" — so the dedupe + // must survive a reload, or every wake younger than the window re-toasts on + // every refresh. Persisted per watch id; the recency window caps growth, and a + // failed read (private mode) degrades to the in-memory set. + useEffect(() => { + try { + const raw = window.localStorage.getItem(TOASTED_WAKES_STORAGE_KEY); + if (raw) for (const id of JSON.parse(raw) as string[]) toastedWakes.current.add(id); + } catch { + // Storage unavailable — session-scoped dedupe still applies. + } + }, []); + const rememberToasted = useCallback((watchId: string) => { + toastedWakes.current.add(watchId); + try { + // Keep the newest ids only; the toast source window is 15 minutes, so a + // small tail is plenty and the key can't grow unbounded. + window.localStorage.setItem( + TOASTED_WAKES_STORAGE_KEY, + JSON.stringify([...toastedWakes.current].slice(-50)) + ); + } catch { + // Same degradation as the read. + } + }, []); + // The chat currently on screen (panel open). A wake there streams into the + // visible transcript, so it toasts but must not light the dot. + const visibleChat = useRef(null); + // The side panel is the default; someone who last worked fullscreen gets + // fullscreen back. Read lazily so SSR always renders the side panel. + const [fullscreen, setFullscreen] = useState(readAgentFullscreen); + + const toggleFullscreen = useCallback(() => { + setFullscreen((current) => { + writeAgentFullscreen(!current); + return !current; + }); + }, []); + + // Navigating to another page drops the takeover back to the side panel: the + // user asked for a page (a navbar click, an agent navigation), so the page + // must be what they see. Pathname only — filter and search-param changes stay + // on the page and keep fullscreen. + const { pathname } = useLocation(); + const previousPathname = useRef(pathname); + useEffect(() => { + if (previousPathname.current === pathname) return; + previousPathname.current = pathname; + setFullscreen((current) => { + if (current) writeAgentFullscreen(false); + return false; + }); + }, [pathname]); + // A request from `openWith`, handed to the panel. `seq` makes repeat requests + // with the same text distinct, so the panel can tell them apart. + // Bumped by contextual ⌘J while the panel is open; the panel starts a new + // chat when it changes. + const [newChatSeq, setNewChatSeq] = useState(0); + const [requestedMessage, setRequestedMessage] = useState< + { text: string; seq: number } | undefined + >(undefined); + // A specific chat to open, from a wake toast. `seq` so the same chat can be + // asked for twice (a second wake in a chat the user has already left). + const [openChatRequest, setOpenChatRequest] = useState< + { chatId: string; seq: number } | undefined + >(undefined); + // A watch card asked for by a `Watch…` entry (§2.1). A card is not a message, + // so it travels on its own channel: the panel opens it pre-filled, and nothing + // reaches the transcript unless the user submits it. + const [watchRequest, setWatchRequest] = useState<{ spec: WatchSpec; 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); + // Closing drops both pending requests: the panel unmounts, so a stale one + // would re-apply on the next open instead of restoring the last chat. + if (!next) { + // No chat is on screen any more — the dot counts every unread wake again. + visibleChat.current = null; + // Closing leaves fullscreen behind: reopening always starts as the side + // panel, whatever mode the panel was in when it was dismissed. + setFullscreen(false); + writeAgentFullscreen(false); + setRequestedMessage(undefined); + setOpenChatRequest(undefined); + // An abandoned card leaves no trace (§2.2) — including no pending request + // that would re-open it the next time the panel is. + setWatchRequest(undefined); + } + }, []); + + // Open the panel on the chat a wake happened in. Without the chat id the panel + // would just restore whatever it had open last, which is rarely the one the + // toast is about. + const openChat = useCallback((chatId: string) => { + setOpen(true); + setOpenChatRequest((current) => ({ chatId, seq: (current?.seq ?? 0) + 1 })); + }, []); + + const openWith = useCallback((text: string) => { + const trimmed = text.trim(); + if (!trimmed) return; + setOpen(true); + setRequestedMessage((current) => ({ text: trimmed, seq: (current?.seq ?? 0) + 1 })); + }, []); + + const openWithWatch = useCallback((spec: WatchSpec) => { + setOpen(true); + setWatchRequest((current) => ({ spec, seq: (current?.seq ?? 0) + 1 })); + }, []); + + // The dot's poll, and the toast's. Runs whether the panel is open or not: a + // wake in a chat that isn't on screen (another chat open, or none) must + // announce itself either way. `toastedWakes` keeps a wake that streamed into + // the visible transcript from toasting twice across polls. + useEffect(() => { + if (!hasAccess) return; + + let cancelled = false; + const load = async () => { + try { + const res = await fetch(`${actionPath}?unread=1`); + if (!res.ok) return; + const data = (await res.json()) as { unreadWakes?: number; wakes?: WatchWake[] }; + if (cancelled) return; + // The wakes list now carries READ ones too (they still toast, once) — + // only unread ones in the visible chat are subtracted from the dot. + const unreadInView = (data.wakes ?? []).filter( + (wake) => wake.unread && wake.chatId === visibleChat.current + ).length; + setUnreadWakes(Math.max(0, (data.unreadWakes ?? 0) - unreadInView)); + + const fresh = (data.wakes ?? []).filter((wake) => !toastedWakes.current.has(wake.watchId)); + for (const wake of fresh) rememberToasted(wake.watchId); + + // A burst gets one summary toast: a stack of persistent toasts is a wall, + // not a notification. + if (fresh.length > WAKE_TOAST_MAX_INDIVIDUAL) { + showWatchWakesSummaryToast(fresh.length, () => setPanelOpen(true)); + } else { + // Oldest first, so the newest wake ends up nearest the user. + for (const wake of [...fresh].reverse()) { + showWatchWakeToast(wake, openChat); + } + } + } catch { + // Offline or a hiccup — leave the dot as it is and try again next tick. + } + }; + + void load(); + const interval = window.setInterval(load, UNREAD_POLL_INTERVAL_MS); + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [hasAccess, actionPath, setPanelOpen, openChat]); + + // A chat the user is now looking at has no unread wakes. Zeroes the dot right + // away (the poll restores the truth on close if another chat still has one) and + // persists the read marker for the chat that's actually visible. + const markChatRead = useCallback( + async (chatId: string) => { + visibleChat.current = chatId; + setUnreadWakes(0); + const body = new FormData(); + body.set("intent", "read"); + body.set("chatId", chatId); + try { + await fetch(actionPath, { method: "POST", body }); + } catch { + // Not worth surfacing: the marker is caught up the next time the chat is + // opened. + } + }, + [actionPath] + ); + + // ⌘J is contextual: closed → open the panel (the composer focuses itself, so + // the keystroke lands you in the text field); open → start a new chat. + // Closing is Esc or the header's ×, never ⌘J. + useShortcutKeys({ + shortcut: TOGGLE_PANEL_SHORTCUT, + action: () => { + if (!open) { + setPanelOpen(true); + } else { + setNewChatSeq((seq) => seq + 1); + } + }, + 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, openWithWatch, unreadWakes }), + [open, setPanelOpen, openWith, openWithWatch, unreadWakes] + ); if (!hasAccess) { return
{children}
; } return ( - + {open ? ( - - -
{children}
-
- - - setOpen(false)} /> - -
+ // `relative` is the takeover's containing block: in fullscreen the panel + // is pinned over this box — everything right of the side nav — while the + // split, the page content and the panel itself all stay mounted. Toggling + // fullscreen is a class change, nothing more, so the open chat's + // transport and transcript survive it. See `panel-layout.tsx`. +
+ + +
{children}
+
+ + +
+ setPanelOpen(false)} + requestedMessage={requestedMessage} + openChatRequest={openChatRequest} + watchRequest={watchRequest} + newChatSeq={newChatSeq} + promotedPrompt={promotedPrompt} + onChatRead={markChatRead} + isFullscreen={fullscreen} + onToggleFullscreen={toggleFullscreen} + /> +
+
+
+
) : (
{children}
)} diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx index e6662ca8494..c51c46128a2 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx @@ -1,12 +1,23 @@ import { useChat } from "@ai-sdk/react"; import type { UIMessage } from "@ai-sdk/react"; import type { dashboardAgent } from "@internal/dashboard-agent"; +import type { AgentIntent, SuggestedPrompt, WatchSpec } 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 { DashboardAgentSuggestedPrompts } from "./DashboardAgentSuggestedPrompts"; +import { DashboardAgentHero } from "./DashboardAgentHero"; +import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessages"; +import { createTranscriptOrder, orderTranscript } from "./message-order"; +import { appendRunFilters } from "./navigate-target"; +import { pendingNavigateIntents, pendingWatchIntents } from "./pending-intents"; +import type { AgentPageContext } from "./page-context-types"; +import { useAgentMessageQuota } from "./useAgentMessageQuota"; +import { useTriggerUriResolver } from "./useTriggerUriResolver"; +import { WatchChips, type WatchChip } from "./WatchChips"; // 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 +34,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 +59,16 @@ export function DashboardAgentChat({ currentPage, pendingFirstMessage, streaming, + prefill, + promotedPrompt, + watches, + pagePaths, + watchCard, + appendedMessage, + onWatchIntent, + onCancelWatch, onTurnSettled, + onActivityChange, }: { chatId: string; initialMessages: UIMessage[]; @@ -54,6 +78,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 +88,53 @@ 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; + // This chat's active watches, from the panel's history load. + watches: WatchChip[]; + /** Host-resolved dashboard paths for settings-page footer actions. */ + pagePaths?: Record; + /** The ephemeral watch card, when one is open. Sits above the composer. */ + watchCard?: React.ReactNode; + /** + * A message the SERVER appended outside a turn — the watch card's confirmation + * or one-shot result. It is already durable in the store; this puts it in the + * live transcript now instead of on the next open. `seq` makes each append + * distinct, so the effect applies it exactly once. + */ + appendedMessage?: { message: UIMessage; seq: number }; + /** + * A card offered a watch. Every `watch` intent means the same thing — open the + * configuration card pre-filled with this spec — so the user reviews and + * submits it, and nothing is posted or persisted if they don't (§2.2). + */ + onWatchIntent?: (spec: WatchSpec) => void; + onCancelWatch: (watchId: string) => void; + /** A watch was created — tell the panel to re-read the chips. */ 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 +189,13 @@ export function DashboardAgentChat({ }); const { - messages, + messages: rawMessages, + setMessages, sendMessage, status, stop: aiStop, error, + clearError, } = useChat({ id: chatId, messages: initialMessages, @@ -133,8 +205,39 @@ 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; + + // A server-appended block (the watch card's outcome) joins the live transcript + // in place. Applied once per `seq`: the append is already persisted, so + // replaying it would show the same confirmation twice. + const appendedSeq = useRef(undefined); + useEffect(() => { + if (!appendedMessage || appendedSeq.current === appendedMessage.seq) return; + appendedSeq.current = appendedMessage.seq; + setMessages((current) => + current.some((message) => message.id === appendedMessage.message.id) + ? current + : [...current, appendedMessage.message] + ); + }, [appendedMessage, setMessages]); // Cold start: trigger the first turn by sending the pending message once. const sentFirst = useRef(false); @@ -148,13 +251,121 @@ 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. + // + // A `watch` does NOT: it opens the configuration card pre-filled with the spec + // the card offered. Every watch intent is treated this way, whatever offered it + // — so the user always sees what they are about to start, can change the window + // or the condition first, and an offer they walk away from leaves no trace. It + // used to post a visible "Watch this for me…" request and let the agent answer + // with schedule_watch; the card replaces that turn with 0 LLM. + // + // `propose_fix` is reserved and must never be executed. + const handleIntent = useCallback( + (intent: AgentIntent) => { + switch (intent.kind) { + case "ask": + submit(intent.prompt); + return; + case "watch": + onWatchIntent?.(intent.spec); + return; + case "navigate": + void goTo(intent); + return; + default: + console.warn(`Dashboard agent: unhandled intent "${intent.kind}"`); + } + }, + [submit, goTo, onWatchIntent] + ); + + // 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) void goTo(target); + }, [messages, goTo]); + + // `schedule_watch` proposes rather than creates: it answers with a watch intent + // and the panel opens the card pre-filled, so a free-text ask ("set up a watch + // then") is reviewed and confirmed like any other watch (§2.1 Path B). Seeded + // and deduped exactly like navigate, so reopening a chat whose history holds + // the call never reopens the card. + const watchProposedRef = useRef | null>(null); + if (watchProposedRef.current === null) { + watchProposedRef.current = new Set(); + pendingWatchIntents(initialMessages, watchProposedRef.current); + } + useEffect(() => { + const pending = pendingWatchIntents(messages, watchProposedRef.current!); + // One card at a time, so the newest proposal is the one to review. + const proposed = pending.at(-1); + if (proposed) onWatchIntent?.(proposed.spec); + }, [messages, onWatchIntent]); + const stop = useCallback(() => { transport.stopGeneration(chatId); aiStop(); @@ -170,25 +381,88 @@ 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 ( <> - watch.status === "active")} + onCancel={onCancelWatch} /> - {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 blank state, minus the composer: this chat already has one docked + // below. + ) : ( - + + )} + {/* Inset to line up with the docked composer below it. */} + {watchCard ?
{watchCard}
: null} + {/* 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 308a87ebab1..6a37056c4bd 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx @@ -1,29 +1,113 @@ -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"; +/** + * - `docked` — the composer at the bottom of the panel, one line tall at rest. + * - `hero` — the composer in the blank-state hero: a large field with the send + * button (and the context chip) on a row inside it. + */ +export type DashboardAgentComposerLayout = "docked" | "hero"; + export function DashboardAgentComposer({ value, onChange, onSubmit, onStop, isStreaming, + focusKey, + context, + layout = "docked", + autoFocus = true, + placeholderSuggestion, }: { 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; + // Where this composer is mounted. `docked` (the default) is the panel footer + // and renders exactly as it always has. + layout?: DashboardAgentComposerLayout; + // Off only where a composer isn't the thing the user came for — the storybook + // gallery renders several at once and must not steal the page's focus. + autoFocus?: boolean; + /** + * A suggested prompt shown as the placeholder while the field is empty. + * Tab accepts it into the field as editable text — it is never sent on its + * own. Once anything is typed, Tab goes back to being Tab. + */ + placeholderSuggestion?: string; }) { const ref = useRef(null); + useEffect(() => { + const el = ref.current; + if (!el || !autoFocus) return; + el.focus(); + // Caret after any prefilled text, so typing continues the sentence. + el.setSelectionRange(el.value.length, el.value.length); + }, [focusKey, autoFocus]); + + const isHero = layout === "hero"; + + const sendButton = isStreaming ? ( + // Grey, not red: stopping is a normal thing to do mid-answer, not a + // destructive action. +