diff --git a/.changeset/report-json-and-period-units.md b/.changeset/report-json-and-period-units.md new file mode 100644 index 00000000000..5567d2a5b9b --- /dev/null +++ b/.changeset/report-json-and-period-units.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/core": patch +"trigger.dev": patch +--- + +Reports can be fetched as structured data with the `json` format. The shortest report period is now one minute (`30m`, `1h`, `7d`). diff --git a/.gitignore b/.gitignore index f540927e32b..b11dded2b02 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,5 @@ ailogger-output.log # observability-map CLI output artifact, not committed observability-map.json + +.claude/worktrees/ diff --git a/.server-changes/dashboard-agent.md b/.server-changes/dashboard-agent.md new file mode 100644 index 00000000000..7934298ca93 --- /dev/null +++ b/.server-changes/dashboard-agent.md @@ -0,0 +1,16 @@ +--- +area: webapp +type: feature +--- + +Meet the dashboard agent: a chat in every environment that answers questions about your runs, queues, errors and health with real data and links. It takes over from Ask AI everywhere that used to appear; on the Free plan you get 20 messages. + +**Investigate** on a failed run, an error, a backed-up queue or a run that hasn't started gets you a worked-through answer — what happened, why, and how to fix it, with every claim linked to the runs, errors and deploys behind it. + +**Watch…** on a run, queue, error or the health report tells you when things change: a run finishes, a queue clears or grows past a number you pick, an error comes back, an environment recovers. The answer arrives in the chat and, if you want, by email, Slack or webhook — and the agent can look into bad news on its own. + +The health report reads the same everywhere — dashboard, terminal, editor: same sections, same wording, and a marker instead of colour where colour isn't available. Its "stale data" mark is amber rather than blue, and its metric rows stay inside the panel when the chat is narrow. If a message to the agent fails, the chat says so and keeps saying so when you reopen it. A watch reaches you on any browser you sign in from, without opening the chat first — including one you started watching from another machine. A very long chat keeps working: the agent summarises the earlier part of the conversation and carries on, without losing an investigation or a watch that's still live. Messages to the agent now have a length limit, with a counter as you approach it. Setting up a watch leaves a record in the chat of exactly what you asked for, and submitting the same one twice never starts it twice. Unsubscribing from watch notifications takes effect immediately, and you can watch a run the moment you trigger it. An investigation that can't finish now closes itself in the chat instead of spinning forever, while the panel is still open and even if a write fails on the way. A watch says so when email couldn't be switched on, emails you once per result rather than twice, and tells you whether you will be emailed rather than whether anyone on the project will. The agent's replies no longer show images, and dashboard pages load images only from Trigger.dev and your sign-in provider; self-hosted deployments can name extra hosts. + +A sample of conversations is scored automatically so the agent keeps getting better. Only the score and a one-line summary are kept, never your messages, data or code, and we can switch it off for your organization on request. + +Separately: a queue's wait times, peak depth, throughput and throttling can now be read from the API, and the Docs button has been removed from page headers. 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..41901732207 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,38 @@ import { import { StepContentContainer } from "./StepContentContainer"; import { V4Badge } from "./V4Badge"; +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."; + +function DeployDocsLinks() { + return ( + <> + + } + content="Deploy docs" + /> + + } + content="Troubleshooting docs" + /> + + ); +} + export function HasNoTasksDev() { return ( @@ -270,29 +302,7 @@ export function DeploymentsNoneDev() { Deploy your tasks
- - } - content="Deploy docs" - /> - - } - content="Troubleshooting docs" - /> - + } />
@@ -658,29 +668,7 @@ function DeploymentOnboardingSteps() {
- - } - content="Deploy docs" - /> - - } - content="Troubleshooting docs" - /> - + } />
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..4ef3a276b74 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/ActionsBlock.tsx @@ -0,0 +1,31 @@ +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..dda01b7f192 100644 --- a/apps/webapp/app/components/dashboard-agent/AgentChart.tsx +++ b/apps/webapp/app/components/dashboard-agent/AgentChart.tsx @@ -1,18 +1,18 @@ 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"; - -// Render an agent "chart" block by running its TRQL query through the dashboard's -// own /resources/metric endpoint (session-authed, returns rows + real column -// metadata) and feeding the result into QueryResultsChart. So the chart is live -// and matches the Query page exactly: the agent only emits the query + chart -// config, never the rows. Runs against the project/env the panel is open in. +import { cn } from "~/utils/cn"; +import { AgentCard, AgentCardHeader } from "./agent-card"; +import { ChatActionsRow } from "./chat-layout"; +import { renderableActions } from "./view-actions"; type MetricResponse = | { success: false; error: string } @@ -25,6 +25,18 @@ type MetricResponse = }; }; +// `chartBlockBodySchema` carries only `period`, so scope and from/to are fixed here. +const CHART_SCOPE = "environment"; +const CHART_FROM = null; +const CHART_TO = null; +// `min-h` as well: the chart draws nothing at zero height if a flex parent collapses it. +const CHART_HEIGHT_CLASS = "h-64 min-h-64"; +const CHART_PADDING_CLASS = "px-2 pb-2 pt-4"; +export const AGENT_CHART_PLOT_CLASS = `w-full ${CHART_PADDING_CLASS} ${CHART_HEIGHT_CLASS}`; + +// Query errors can carry SQL and schema detail, so the real one only goes to the console. +const CHART_ERROR_MESSAGE = "This chart's query couldn't run."; + type ChartState = | { status: "loading" } | { status: "error"; error: string } @@ -35,7 +47,39 @@ type ChartState = timeRange?: { from: string; to: string }; }; -export function AgentChart({ block }: { block: ChartBlock }) { +export function ChartActions({ + actions, + onIntent, +}: { + actions: ChartAction[]; + onIntent?: (intent: AgentIntent) => void; +}) { + 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(); @@ -46,8 +90,7 @@ export function AgentChart({ block }: { block: ChartBlock }) { const environmentId = environment?.id; useEffect(() => { - // The block can render before its `query` has finished streaming in; wait - // for it rather than POST an empty query (which 400s). + // The block can render before `query` has streamed in; an empty query 400s. if (!block.query) return; if (!organizationId || !projectId || !environmentId) { setState({ status: "error", error: "No environment context to run the query." }); @@ -63,10 +106,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 +118,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 +131,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]); @@ -104,16 +149,16 @@ export function AgentChart({ block }: { block: ChartBlock }) { }; return ( -
+ {block.title ? ( -
+ {block.title} -
+ ) : null} -
+
{state.status === "loading" ? (
- + Running query…
) : state.status === "error" ? ( @@ -129,6 +174,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..4b795edde9e --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx @@ -0,0 +1,58 @@ +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"; + +// Matches the composer's outer geometry so the replacement lands in the same place. +const SLOT = "flex shrink-0 flex-col bg-background-bright px-3 pb-3 pt-1"; + +export function AgentUpgradeBlock({ + limit, + 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..f740758255e --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/AskAgentButton.tsx @@ -0,0 +1,41 @@ +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"; + +// Goes through the open-request bridge rather than the provider context, so it works +// on pages above the environment layout. +export function AskAgentButton({ + prompt, + label = ASK_AGENT_LABEL, + iconOnly = false, + variant = "small-menu-item", + className, + 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..9b58f5500a5 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx @@ -1,53 +1,294 @@ -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"; - -/** - * Mounts the dashboard agent in the env layout. Renders the page content - * (`children` = the route Outlet) and shares the open/close state via context so - * the page-header launcher (`DashboardAgentLauncher`) can toggle it. When open it - * splits the layout into a resizable content + agent panel, `autosaveId` persists - * the width. - * - * `hasAccess` is resolved server-side in the env layout loader - * (`canAccessDashboardAgent`); when false we render the content untouched and - * never expose the context, so the launcher stays hidden. The resource routes - * enforce the same check server-side. - */ +import { DashboardAgentProvider, TOGGLE_PANEL_SHORTCUT } from "./dashboardAgentLauncher"; +import { useDashboardAgentOpenRequests } from "./dashboardAgentOpenRequest"; +import { + agentHiddenContentClassName, + agentTakeoverClassName, + readAgentFullscreen, + writeAgentFullscreen, +} from "./panel-layout"; +import { startWakePolling } from "./wake-poll"; +import { shouldPollWakeFeed, subscribeWatchActivity } from "./watch-activity"; +import { + showWatchWakesSummaryToast, + showWatchWakeToast, + WAKE_TOAST_MAX_INDIVIDUAL, + type WatchWake, +} from "./WatchWakeToast"; + +const TOASTED_WAKES_STORAGE_KEY = "tdev:dashboard-agent:toasted-wakes"; + +// Shorter than the poll interval, so a stuck request is dropped before the next tick. +const UNREAD_REQUEST_TIMEOUT_MS = 30_000; + +/** `hasAccess` is a UI gate only; the resource routes enforce the same check server-side. */ export function DashboardAgent({ children, hasAccess = false, + promotedPrompt, + /** From the page load: unread wakes waiting for this user, whatever this browser remembers. */ + initialUnreadWakes = 0, + /** Also from the page load: a watch is running, so a wake can still arrive in this tab. */ + hasActiveWatches = false, }: { children: React.ReactNode; hasAccess?: boolean; + promotedPrompt?: SuggestedPrompt; + initialUnreadWakes?: number; + hasActiveWatches?: boolean; }) { + 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); + // Seeded from the page load, so the launcher dot is right before the first poll answers. + const [unreadWakes, setUnreadWakes] = useState(initialUnreadWakes); + const toastedWakes = useRef(new Set()); + // The toast source is recent deliveries, not unread, so the dedupe must survive a reload. + 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; the in-memory dedupe still applies. + } + }, []); + const rememberToasted = useCallback((watchId: string) => { + toastedWakes.current.add(watchId); + try { + // Newest ids only, so the key can't grow unbounded. + window.localStorage.setItem( + TOASTED_WAKES_STORAGE_KEY, + JSON.stringify([...toastedWakes.current].slice(-50)) + ); + } catch { + // Same as the read. + } + }, []); + // A wake in the on-screen chat toasts but must not light the dot. + const visibleChat = useRef(null); + // Read lazily so SSR always renders the side panel. + const [fullscreen, setFullscreen] = useState(readAgentFullscreen); + + const toggleFullscreen = useCallback(() => { + setFullscreen((current) => { + writeAgentFullscreen(!current); + return !current; + }); + }, []); + + // Pathname only: filter and search-param changes must 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]); + const [newChatSeq, setNewChatSeq] = useState(0); + const [requestedMessage, setRequestedMessage] = useState< + { text: string; seq: number } | undefined + >(undefined); + // `seq` so the same chat can be asked for twice. + const [openChatRequest, setOpenChatRequest] = useState< + { chatId: string; seq: number } | undefined + >(undefined); + const [watchRequest, setWatchRequest] = useState<{ spec: WatchSpec; seq: number } | undefined>( + undefined + ); + + const setPanelOpen = useCallback((next: boolean) => { + setOpen(next); + // Pending requests must be dropped or a stale one re-applies on the next open. + if (!next) { + visibleChat.current = null; + setFullscreen(false); + writeAgentFullscreen(false); + setRequestedMessage(undefined); + setOpenChatRequest(undefined); + setWatchRequest(undefined); + } + }, []); + + 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 })); + }, []); + + // Nothing to be woken about means nothing to poll for. The page load's unread count and + // active-watch flag are the ungated signals; the browser's own memory of a watch starts the + // poll without a reload. Once any says yes this tab keeps polling, so a wake reaches a tab + // that was open before the watch existed. + const [watching, setWatching] = useState(false); + useEffect(() => { + const sync = () => { + if ( + shouldPollWakeFeed({ + serverUnreadWakes: initialUnreadWakes, + serverHasActiveWatches: hasActiveWatches, + organizationId: organization.id, + }) + ) + setWatching(true); + }; + sync(); + return subscribeWatchActivity(sync); + }, [organization.id, initialUnreadWakes, hasActiveWatches]); + + useEffect(() => { + if (!hasAccess || !watching) return; + + let cancelled = false; + const load = async () => { + try { + // Bounded, so one stuck request can't hold the poll's in-flight guard. + const res = await fetch(`${actionPath}?unread=1`, { + signal: AbortSignal.timeout(UNREAD_REQUEST_TIMEOUT_MS), + }); + if (!res.ok) return; + const data = (await res.json()) as { unreadWakes?: number; wakes?: WatchWake[] }; + if (cancelled) return; + // The wakes list carries read ones too, so only unread ones are subtracted. + 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); + + if (fresh.length > WAKE_TOAST_MAX_INDIVIDUAL) { + showWatchWakesSummaryToast(fresh.length, () => setPanelOpen(true)); + } else { + for (const wake of [...fresh].reverse()) { + showWatchWakeToast(wake, openChat); + } + } + } catch { + // Try again next tick. + } + }; + + const stop = startWakePolling({ + load, + isHidden: () => document.hidden, + onVisibilityChange: (listener) => { + document.addEventListener("visibilitychange", listener); + return () => document.removeEventListener("visibilitychange", listener); + }, + }); + + return () => { + cancelled = true; + stop(); + }; + }, [hasAccess, watching, actionPath, setPanelOpen, openChat]); + + // Zeroes the dot right away; the poll restores the truth if another chat has one. + 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 { + // Catches up on the next open. + } + }, + [actionPath] + ); + + // ⌘J is contextual: closed opens the panel, open starts a new chat. It never closes. + useShortcutKeys({ + shortcut: TOGGLE_PANEL_SHORTCUT, + action: () => { + if (!open) { + setPanelOpen(true); + } else { + setNewChatSeq((seq) => seq + 1); + } + }, + disabled: !hasAccess, + enabledOnInputElements: true, + }); + + 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. +
+ + +
{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..11261222d5c 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx @@ -1,37 +1,52 @@ import { useChat } from "@ai-sdk/react"; import type { UIMessage } from "@ai-sdk/react"; import type { dashboardAgent } from "@internal/dashboard-agent"; +import { + isWatchRequestMessageId, + type AgentIntent, + type SuggestedPrompt, + type 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 { MESSAGE_TOO_LARGE_ERROR } from "./message-limits"; +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 { + fetchChatTranscript, + hasOpenInvestigation, + pollSettledTranscript, +} from "./settled-transcript"; +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 -// from replaying the previous turn. +// Resuming with `lastEventId` stops the `.out` stream replaying the previous turn. export type DashboardAgentSession = { publicAccessToken: string; lastEventId?: string; }; -// Per-turn context for the agent. Matches the agent's clientDataSchema input. +// Matches the agent's clientDataSchema input. export type DashboardAgentClientData = { userId: string; organizationId: string; projectId?: string; environmentId?: string; currentPage?: string; + pageContext?: AgentPageContext; }; -/** - * A single conversation. The panel mounts this with `key={chatId}`, so each - * chat gets its own transport constructed with its persisted session — the - * resume cursor flows in declaratively via the `sessions` option rather than - * an imperative setSession after the fact. A fresh chat passes no session and - * starts a new run on first send. - */ +/** Mounted with `key={chatId}`: the resume cursor arrives via `sessions`, not setSession. */ export function DashboardAgentChat({ chatId, initialMessages, @@ -44,7 +59,16 @@ export function DashboardAgentChat({ currentPage, pendingFirstMessage, streaming, + prefill, + promotedPrompt, + watches, + pagePaths, + watchCard, + appendedMessages, + onWatchIntent, + onCancelWatch, onTurnSettled, + onActivityChange, }: { chatId: string; initialMessages: UIMessage[]; @@ -54,31 +78,53 @@ export function DashboardAgentChat({ actionPath: string; projectSlug: string; environmentSlug: string; + // Display label only; the path the agent sees is `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. + // Undefined for head-started and resumed chats. pendingFirstMessage?: string; - // Head start: the turn is already in flight, so hydrate the session as - // streaming so the transport resumes `session.out` instead of treating it as - // a settled session with nothing to reconnect to. streaming?: boolean; + // `seq` makes each request distinct so the same text can be sent twice. + prefill?: { text: string; seq: number }; + promotedPrompt?: SuggestedPrompt; + watches: WatchChip[]; + pagePaths?: Record; + watchCard?: React.ReactNode; + appendedMessages?: { messages: UIMessage[]; seq: number }; + /** Nothing is persisted until the user submits the card. */ + onWatchIntent?: (spec: WatchSpec) => void; + onCancelWatch: (watchId: string) => void; onTurnSettled: () => void; + onActivityChange?: (chatId: string, activity: TurnActivity | null) => void; }) { const [input, setInput] = useState(""); + const navigate = useNavigate(); + const toast = useToast(); + + 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", baseURL: apiOrigin, - // New chats are created server-side (the `create` action owns the id and - // runs head start), so there's no client-driven head-start route here. - // Redirect only the `in`/append to the same-origin proxy, which mints + - // injects the delegated user token server-side. `baseURL` stays a string so - // `out` (the long-lived SSE) keeps the SDK's realtime-host routing — we - // never override it. The proxy forwards the same path on to the API. - fetch: (url, init, ctx) => { + // Only `in` goes through the same-origin proxy, which injects the delegated user + // token server-side. `baseURL` stays a string so `out` keeps the SDK's realtime routing. + fetch: async (url, init, ctx) => { if (ctx.endpoint !== "in") return globalThis.fetch(url, init); const { pathname, search } = new URL(url); - return globalThis.fetch(`${actionPath}/in${pathname}${search}`, init); + const res = await globalThis.fetch(`${actionPath}/in${pathname}${search}`, init); + // A refused message never succeeds on a retry, so it surfaces as the turn's error. + if (res.status === 413) { + const data = (await res + .clone() + .json() + .catch(() => null)) as { error?: string } | null; + throw new Error(data?.error ?? MESSAGE_TOO_LARGE_ERROR); + } + return res; }, clientData, sessions: session @@ -86,9 +132,7 @@ export function DashboardAgentChat({ [chatId]: { publicAccessToken: session.publicAccessToken, lastEventId: session.lastEventId, - // Head-started chats are mid-turn, so mark the session streaming to - // make the transport resume `session.out`. A settled session - // (history) stays false — its transcript loads from the store. + // Mid-turn chats must be marked streaming or the transport won't resume `session.out`. isStreaming: streaming ?? false, }, } @@ -119,24 +163,46 @@ export function DashboardAgentChat({ }); const { - messages, + messages: rawMessages, + setMessages, sendMessage, status, stop: aiStop, error, + clearError, } = useChat({ id: chatId, messages: initialMessages, transport, - // Resume an existing/head-started session's stream. A cold-start chat has a - // session but nothing to resume yet — it sends its first message instead. resume: !!session && !pendingFirstMessage, }); + const orderRef = useRef(createTranscriptOrder(initialMessages)); + const messages = orderTranscript(rawMessages, orderRef.current); + + // Counted here, not in the panel, so it includes the turn just sent. + const quota = useAgentMessageQuota({ actionPath, chatId, messages }); + const atMessageCap = quota.kind === "reached"; + const isStreaming = status === "streaming"; - const isThinking = status === "submitted"; + // From status, not the last part: the indicator must stay up through silent tool calls. + const activity: TurnActivity | null = + status === "submitted" ? "thinking" : status === "streaming" ? "working" : null; + + // Once per `seq`: the append is already persisted, so a replay would duplicate it. + // Ids are stable, so anything already in the transcript is skipped. + const appendedSeq = useRef(undefined); + useEffect(() => { + if (!appendedMessages || appendedSeq.current === appendedMessages.seq) return; + appendedSeq.current = appendedMessages.seq; + setMessages((current) => { + const missing = appendedMessages.messages.filter( + (message) => !current.some((existing) => existing.id === message.id) + ); + return missing.length === 0 ? current : [...current, ...missing]; + }); + }, [appendedMessages, setMessages]); - // Cold start: trigger the first turn by sending the pending message once. const sentFirst = useRef(false); useEffect(() => { if (pendingFirstMessage && !sentFirst.current) { @@ -148,47 +214,183 @@ export function DashboardAgentChat({ const submit = useCallback( (text: string) => { const trimmed = text.trim(); - if (!trimmed || isStreaming) return; + // Suggested prompts and card actions bypass the composer, so the cap is enforced here too. + if (!trimmed || isStreaming || atMessageCap) return; setInput(""); void sendMessage({ text: trimmed }); }, - [isStreaming, sendMessage] + [isStreaming, atMessageCap, sendMessage] ); + const retry = useCallback(() => { + // A watch's consent record is a user message nobody typed, so retry skips it. + const lastUserMessage = [...messages] + .reverse() + .find((m) => m.role === "user" && !isWatchRequestMessageId(m.id)); + 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]); + + const resolveUri = useTriggerUriResolver(actionPath); + + // `trigger://` targets resolve server-side: the server owns the environment scope. + 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] + ); + + // `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] + ); + + // Seeded from the loaded transcript before first render, so history never re-navigates. + const navigatedRef = useRef | null>(null); + if (navigatedRef.current === null) { + navigatedRef.current = new Set(); + pendingNavigateIntents(initialMessages, navigatedRef.current); + } + useEffect(() => { + const pending = pendingNavigateIntents(messages, navigatedRef.current!); + const target = pending.at(-1); + if (target) void goTo(target); + }, [messages, goTo]); + + const watchProposedRef = useRef | null>(null); + if (watchProposedRef.current === null) { + watchProposedRef.current = new Set(); + pendingWatchIntents(initialMessages, watchProposedRef.current); + } + useEffect(() => { + const pending = pendingWatchIntents(messages, watchProposedRef.current!); + const proposed = pending.at(-1); + if (proposed) onWatchIntent?.(proposed.spec); + }, [messages, onWatchIntent]); + const stop = useCallback(() => { transport.stopGeneration(chatId); aiStop(); }, [transport, chatId, aiStop]); - // Tell the panel to refresh its history list once a turn settles, so the new - // chat appears and titles/timestamps stay current. + // Read by the settle effect, which must not re-run when the transcript changes. + const messagesRef = useRef(messages); + messagesRef.current = messages; + const prevStatus = useRef(status); useEffect(() => { const wasInFlight = prevStatus.current === "streaming" || prevStatus.current === "submitted"; const nowSettled = status === "ready" || status === "error"; - if (wasInFlight && nowSettled) onTurnSettled(); prevStatus.current = status; - }, [status, onTurnSettled]); + if (!wasInFlight || !nowSettled) return; + + onTurnSettled(); + // The terminal card is written to the chat row after the stream closes, so this + // mounted panel would otherwise keep showing the last `in_progress` revision. + if (!hasOpenInvestigation(messagesRef.current)) return; + void pollSettledTranscript({ + fetchTranscript: () => fetchChatTranscript(actionPath, chatId), + apply: (merge) => setMessages((current) => merge(current)), + wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + }); + }, [status, onTurnSettled, actionPath, chatId, setMessages]); + + // Not cleared on unmount: the turn carries on server-side and reports again on remount. + useEffect(() => { + onActivityChange?.(chatId, activity); + }, [chatId, activity, onActivityChange]); return ( <> - watch.status === "active")} + onCancel={onCancelWatch} /> - {messages.length === 0 ? ( - + {messages.length === 0 && !pendingFirstMessage ? ( + ) : ( - + + )} + {watchCard ?
{watchCard}
: null} + {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..5b238d8ffd4 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx @@ -1,7 +1,10 @@ -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"; +import { MAX_MESSAGE_CHARS, MESSAGE_CHARS_WARN_AT } from "./message-limits"; + +export type DashboardAgentComposerLayout = "docked" | "hero"; export function DashboardAgentComposer({ value, @@ -9,50 +12,124 @@ export function DashboardAgentComposer({ 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. + focusKey?: string | number; + context?: React.ReactNode; + layout?: DashboardAgentComposerLayout; + autoFocus?: boolean; + // Shown as the placeholder while the field is empty. Tab accepts it as editable + // text; it is never sent on its own. + placeholderSuggestion?: string; }) { const ref = useRef(null); + useEffect(() => { + const el = ref.current; + if (!el || !autoFocus) return; + el.focus(); + el.setSelectionRange(el.value.length, el.value.length); + }, [focusKey, autoFocus]); + + const isHero = layout === "hero"; + + const sendButton = isStreaming ? ( +