diff --git a/.changeset/chat-agent-preview-branch.md b/.changeset/chat-agent-preview-branch.md new file mode 100644 index 00000000000..0d5407b7dea --- /dev/null +++ b/.changeset/chat-agent-preview-branch.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Fix `chat.agent` / `AgentChat` when the agent is deployed to a Trigger.dev preview branch. The realtime message-append and stream-subscribe calls now send the `x-trigger-branch` header (sourced from the same resolver `sessions.start` uses), so messaging a preview-branch chat agent no longer fails with `x-trigger-branch header required for preview env`. diff --git a/.changeset/chat-head-start-prepare-messages.md b/.changeset/chat-head-start-prepare-messages.md new file mode 100644 index 00000000000..d8473cb5df8 --- /dev/null +++ b/.changeset/chat-head-start-prepare-messages.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Fix Head Start handovers breaking when a `chat.agent` also defines a `prepareMessages` hook. A handover hands the first turn's pending tool call to the agent as a tool-approval round whose trailing tool message must reach the model untouched. A `prepareMessages` hook that rewrites the last message (for example the recommended prompt-caching breakpoint) could disturb it, so the turn failed with "tool_use ids were found without tool_result". The agent now preserves that approval tail across `prepareMessages`, so caching and Head Start compose cleanly. diff --git a/.changeset/chat-headstart-api-client.md b/.changeset/chat-headstart-api-client.md new file mode 100644 index 00000000000..4bdce864bf9 --- /dev/null +++ b/.changeset/chat-headstart-api-client.md @@ -0,0 +1,14 @@ +--- +"@trigger.dev/sdk": patch +--- + +`chat.headStart` now accepts an `apiClient` option (base URL + access token), so the head-start route can create the session and trigger the agent run against a different project/environment than the warm server's ambient Trigger config. Useful when your `chat.agent` lives in a separate project from the app serving the route. Mirrors the `apiClient` option on `chat.createStartSessionAction`; your LLM provider keys stay in the `run` callback and are unaffected. + +```ts +export const POST = chat.headStart({ + agentId: "my-agent", + apiClient: { baseURL, accessToken }, + run: async ({ chat }) => + streamText({ ...chat.toStreamTextOptions({ tools }), model: anthropic("claude-sonnet-4-6") }), +}); +``` diff --git a/.changeset/chat-start-session-api-client.md b/.changeset/chat-start-session-api-client.md new file mode 100644 index 00000000000..b5c7d7d11dc --- /dev/null +++ b/.changeset/chat-start-session-api-client.md @@ -0,0 +1,13 @@ +--- +"@trigger.dev/sdk": patch +--- + +`chat.createStartSessionAction` now accepts an `apiClient` option, so you can scope a chat session start to a specific environment's API config (`baseURL` / `accessToken`) without setting a global `TRIGGER_SECRET_KEY`. Useful when one server starts chats across more than one environment. + +```ts +const startSession = chat.createStartSessionAction("my-chat", { + apiClient: { baseURL, accessToken }, +}); + +await startSession({ chatId, clientData }); +``` diff --git a/.claude/skills/drizzle/SKILL.md b/.claude/skills/drizzle/SKILL.md new file mode 100644 index 00000000000..ee32d5fdd19 --- /dev/null +++ b/.claude/skills/drizzle/SKILL.md @@ -0,0 +1,191 @@ +--- +name: drizzle +description: Use this skill when writing or modifying Drizzle ORM schemas, queries, or migrations in this repo — specifically the `@internal/dashboard-agent-db` package (the dashboard agent's conversation datastore). Covers pg-core schema definition, the postgres-js driver, drizzle-kit migrations, and this repo's conventions: a dedicated Postgres schema, foreign-key-free cross-database design, pooler-safe connections, and the access-pattern query layer. Drizzle is NOT the main database — that's Prisma. +allowed-tools: Read, Write, Edit, Glob, Grep, Bash +--- + +# Drizzle ORM (this repo) + +Drizzle is used in exactly one place: **`internal-packages/dashboard-agent-db`** (`@internal/dashboard-agent-db`), the in-dashboard agent's conversation store. Everything else in the monorepo is **Prisma** (`@trigger.dev/database`). Keep them separate. + +Pinned versions: **`drizzle-orm` ^0.45**, **`drizzle-kit` ^0.31** (dev), **`postgres` ^3.4** (postgres.js driver). drizzle-orm and drizzle-kit are intentionally on different version lines — 0.31.x is the correct companion for 0.45.x, there is no peer dependency between them. + +## Critical rules + +1. **Drizzle is only the agent's own datastore.** The agent (and its task bundle) must have **no access to the main Prisma database or ClickHouse**. Never import the Prisma client into the agent task or into `@internal/dashboard-agent-db`. Main data is reached via the API, not Drizzle. +2. **Foreign-key-free.** In cloud this DB is a *separate* PlanetScale database, so it can't FK into the main DB. Reference main entities (`organizationId`, `userId`, …) **by id only — never `.references()`**. Joins happen in app code; tenant scoping is enforced in the query layer. +3. **One dedicated Postgres schema.** All tables live under `pgSchema("trigger_dashboard_agent")` so they're schema-qualified and isolated from Prisma's `public` schema (this is what makes the OSS single-database fallback safe). +4. **Pooler-safe connections.** Connections go through a transaction-mode pooler (PlanetScale / PgBouncer-style), so postgres.js must run with **`prepare: false`** — prepared statements don't survive a connection being handed to another client between checkouts. +5. **Node16 module resolution.** Relative imports need explicit **`.js`** extensions (`import { chats } from "./schema.js"`), even though the source is `.ts`. +6. **Scope every user query.** All queries that touch user data go through `src/queries.ts` and are scoped by `organizationId` / `userId`, so callers can't forget the `where`. Don't write ad-hoc cross-tenant queries elsewhere. + +## Package layout + +``` +internal-packages/dashboard-agent-db/ + drizzle.config.ts # drizzle-kit config (schema path, out dir, schemaFilter) + drizzle/ # generated migrations (committed) + src/ + schema.ts # pgSchema + table definitions + client.ts # createDashboardAgentDb() — postgres.js + drizzle + queries.ts # the access-pattern layer (org/user-scoped) + index.ts # barrel: re-exports schema, client, queries +``` + +`package.json` points `main`/`types` at `./src/index.ts` (consumed as source, no build step) — same as other simple internal packages. + +## Schema (pg-core) + +Use `pgSchema(...).table(...)`, not the bare `pgTable`, so tables land in the dedicated schema. ([schemas](https://orm.drizzle.team/docs/schemas), [pg column types](https://orm.drizzle.team/docs/column-types/pg), [indexes](https://orm.drizzle.team/docs/indexes-constraints)) + +```ts +import { sql } from "drizzle-orm"; +import { index, jsonb, pgSchema, text, timestamp } from "drizzle-orm/pg-core"; + +export const dashboardAgentSchema = pgSchema("trigger_dashboard_agent"); + +export const chats = dashboardAgentSchema.table( + "chats", + { + id: text("id").primaryKey(), + organizationId: text("organization_id").notNull(), // FK-free: id only, no .references() + userId: text("user_id").notNull(), + title: text("title").notNull().default("New chat"), + // JSONB with a typed view; .default([]) / .default({}) emit '[]'::jsonb / '{}'::jsonb + messages: jsonb("messages").$type().notNull().default([]), + metadata: jsonb("metadata").$type>().notNull().default({}), + deletedAt: timestamp("deleted_at", { withTimezone: true }), // soft delete + lastMessageAt: timestamp("last_message_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + // Extra config returns an ARRAY in drizzle-orm 0.36+ (not an object). + (t) => [ + // Partial + ordered composite index. `.desc()` on the column, `.where(sql`...`)` for partial. + index("chats_org_user_last_msg_idx") + .on(t.organizationId, t.userId, t.lastMessageAt.desc()) + .where(sql`${t.deletedAt} is null`), + ] +); + +// Inferred row types for the query layer + consumers. +export type Chat = typeof chats.$inferSelect; +export type NewChat = typeof chats.$inferInsert; +``` + +Notes: +- `timestamp(..., { withTimezone: true })` → `timestamp with time zone`. Use `.defaultNow()` for `DEFAULT now()`. +- For a "newest first, nulls last" sort the partial index uses `.desc()`; the *query* uses raw `sql` for `NULLS LAST` (see below). +- Don't add `.references()` — see critical rule 2. + +## Client (postgres.js + drizzle) + +([connect overview](https://orm.drizzle.team/docs/connect-overview)) One small pool, `prepare: false`. In the agent task create it once in `onBoot` (per-process); in the webapp wrap it in the `singleton(...)` helper. + +```ts +import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import postgres, { type Sql } from "postgres"; +import * as schema from "./schema.js"; + +export type DashboardAgentDb = PostgresJsDatabase; + +export function createDashboardAgentDb(connectionString: string, opts: { max?: number } = {}) { + const sql: Sql = postgres(connectionString, { + max: opts.max ?? 5, // small — the pooler does the real pooling + idle_timeout: 20, // release conns when an agent run suspends + prepare: false, // REQUIRED for transaction-mode poolers + }); + return { db: drizzle(sql, { schema }), sql, close: () => sql.end() }; +} +``` + +## Queries (the access-pattern layer) + +([select](https://orm.drizzle.team/docs/select), [insert](https://orm.drizzle.team/docs/insert), [operators](https://orm.drizzle.team/docs/operators), [transactions](https://orm.drizzle.team/docs/transactions), [joins](https://orm.drizzle.team/docs/joins)) + +```ts +import { and, desc, eq, isNull, sql } from "drizzle-orm"; + +// Select EXPLICIT columns for list views — never select a large blob (messages) +// or a secret (tokens) you don't need. `NULLS LAST` needs raw sql in orderBy. +await db + .select({ id: chats.id, title: chats.title, lastMessageAt: chats.lastMessageAt }) + .from(chats) + .where(and(eq(chats.organizationId, orgId), eq(chats.userId, userId), isNull(chats.deletedAt))) + .orderBy(sql`${chats.pinnedAt} desc nulls last`, desc(chats.lastMessageAt)) + .limit(50); + +// Idempotent create (avoids a duplicate-key race between two writers). +await db.insert(chats).values({ id, organizationId: orgId, userId }).onConflictDoNothing(); + +// Upsert. +await db + .insert(chatSessions) + .values({ chatId, publicAccessToken }) + .onConflictDoUpdate({ target: chatSessions.chatId, set: { publicAccessToken, updatedAt: sql`now()` } }); + +// Owner-scope a join (this DB is FK-free, so enforce ownership in the query). +await db + .select({ /* session cols */ }) + .from(chatSessions) + .innerJoin(chats, eq(chats.id, chatSessions.chatId)) + .where(and(eq(chatSessions.chatId, chatId), eq(chats.userId, userId))); + +// Multi-write that must be consistent on the next read → one transaction. +await db.transaction(async (tx) => { + await tx.update(chats).set({ messages, updatedAt: sql`now()` }).where(eq(chats.id, chatId)); + await tx.insert(chatSessions).values({ /* ... */ }).onConflictDoUpdate({ /* ... */ }); +}); +``` + +Use `sql\`now()\`` for DB-side timestamps in updates. + +## Migrations (drizzle-kit) + +([kit overview](https://orm.drizzle.team/docs/kit-overview), [generate](https://orm.drizzle.team/docs/drizzle-kit-generate), [migrate](https://orm.drizzle.team/docs/drizzle-kit-migrate)) + +`drizzle.config.ts` must set **`schemaFilter`** so drizzle-kit only ever manages our schema — never Prisma's `public` (critical in the OSS single-DB fallback): + +```ts +import { defineConfig } from "drizzle-kit"; +export default defineConfig({ + schema: "./src/schema.ts", + out: "./drizzle", + dialect: "postgresql", + schemaFilter: ["trigger_dashboard_agent"], + dbCredentials: { url: process.env.DASHBOARD_AGENT_DATABASE_URL ?? process.env.DATABASE_URL ?? "postgres://placeholder" }, +}); +``` + +Workflow: + +```bash +cd internal-packages/dashboard-agent-db +pnpm run db:generate # diff schema.ts → emit SQL into drizzle/. OFFLINE (no DB needed). +# review the generated drizzle/000N_*.sql before committing +pnpm run db:migrate # apply pending migrations. Needs a real DATABASE URL. +``` + +- `db:generate` is **offline** — it only reads `schema.ts`, so you can verify a schema change compiles to valid DDL with no database. Use it as a fast check. +- drizzle-kit names migration files with a **random suffix** (`0000_magenta_lilandra.sql`). Don't regenerate a committed migration just to "refresh" it — that churns the filename. After the first migration is committed, schema changes produce a **new** `000N_*.sql`; commit that. +- Generated DDL for a new schema is one `CREATE SCHEMA` + schema-qualified `CREATE TABLE`s + indexes, **no foreign keys** (by design here). + +## Common gotchas + +- **`prepare: false`** is not optional with a pooler — without it you'll get prepared-statement errors under load. +- **Missing `.js` extension** on a relative import → TS2835 under Node16 resolution. +- **Extra-config callback returns an array** `(t) => [ ... ]` in drizzle-orm 0.36+. The old object form `(t) => ({ ... })` is deprecated. +- **`NULLS LAST` / `NULLS FIRST`** aren't on the `desc()` helper — use raw `sql\`col desc nulls last\`` in `orderBy`. +- **Don't `SELECT *` into list views** — explicitly pick columns so you never ship a megabyte `messages` blob or a session token to a list query. +- **Adding a dependency**: edit `package.json`, then `pnpm i` from the repo root (never `pnpm add`). Mind the repo's `minimumReleaseAge` (3 days) — pin with a caret range and let pnpm resolve an old-enough version. + +## Reference (official docs) + +- Schema declaration — https://orm.drizzle.team/docs/sql-schema-declaration +- PostgreSQL column types — https://orm.drizzle.team/docs/column-types/pg +- Schemas (`pgSchema`) — https://orm.drizzle.team/docs/schemas +- Indexes & constraints — https://orm.drizzle.team/docs/indexes-constraints +- Connect (postgres-js) — https://orm.drizzle.team/docs/connect-overview +- Select / Insert / Update / Delete — https://orm.drizzle.team/docs/select · /insert · /update · /delete +- Joins / Operators — https://orm.drizzle.team/docs/joins · /operators +- Transactions — https://orm.drizzle.team/docs/transactions +- drizzle-kit (generate / migrate / push) — https://orm.drizzle.team/docs/kit-overview diff --git a/.gitignore b/.gitignore index d5f0c945ad1..c1fe3103332 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ apps/**/public/build **/.claude/settings.local.json .claude/architecture/ .claude/docs-plans/ +.claude/plans/ .claude/review-guides/ .claude/scheduled_tasks.lock .mcp.log diff --git a/apps/webapp/app/components/dashboard-agent/AgentChart.tsx b/apps/webapp/app/components/dashboard-agent/AgentChart.tsx new file mode 100644 index 00000000000..c9810071f70 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/AgentChart.tsx @@ -0,0 +1,133 @@ +import type { OutputColumnMetadata } from "@internal/clickhouse"; +import type { ChartBlock } from "@internal/dashboard-agent"; +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 { 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. + +type MetricResponse = + | { success: false; error: string } + | { + success: true; + data: { + rows: Record[]; + columns: OutputColumnMetadata[]; + timeRange: { from: string; to: string }; + }; + }; + +type ChartState = + | { status: "loading" } + | { status: "error"; error: string } + | { + status: "ready"; + rows: Record[]; + columns: OutputColumnMetadata[]; + timeRange?: { from: string; to: string }; + }; + +export function AgentChart({ block }: { block: ChartBlock }) { + const organization = useOptionalOrganization(); + const project = useOptionalProject(); + const environment = useOptionalEnvironment(); + const [state, setState] = useState({ status: "loading" }); + + const organizationId = organization?.id; + const projectId = project?.id; + 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). + if (!block.query) return; + if (!organizationId || !projectId || !environmentId) { + setState({ status: "error", error: "No environment context to run the query." }); + return; + } + const controller = new AbortController(); + setState({ status: "loading" }); + fetch("/resources/metric", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: block.query, + organizationId, + projectId, + environmentId, + scope: "environment", + period: block.period ?? null, + from: null, + to: null, + }), + signal: controller.signal, + }) + .then(async (res) => (await res.json()) as MetricResponse) + .then((data) => { + if (controller.signal.aborted) return; + if (!data.success) { + setState({ status: "error", error: data.error }); + } else { + setState({ + status: "ready", + rows: data.data.rows, + columns: data.data.columns, + timeRange: data.data.timeRange, + }); + } + }) + .catch((err) => { + if (controller.signal.aborted) return; + setState({ status: "error", error: err?.message ?? "The query failed to run." }); + }); + return () => controller.abort(); + }, [block.query, block.period, organizationId, projectId, environmentId]); + + const config: ChartConfiguration = { + chartType: block.chartType, + xAxisColumn: block.xAxisColumn, + yAxisColumns: block.yAxisColumns ?? [], + groupByColumn: block.groupByColumn ?? null, + stacked: block.stacked ?? false, + sortByColumn: null, + sortDirection: "desc", + aggregation: block.aggregation ?? "sum", + }; + + return ( +
+ {block.title ? ( +
+ {block.title} +
+ ) : null} +
+ {state.status === "loading" ? ( +
+ + Running query… +
+ ) : state.status === "error" ? ( +
+ {state.error} +
+ ) : ( + + )} +
+
+ ); +} diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx new file mode 100644 index 00000000000..2ea1df3fabf --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx @@ -0,0 +1,68 @@ +import { SparklesIcon } from "@heroicons/react/20/solid"; +import { useState } from "react"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "~/components/primitives/Resizable"; +import { DashboardAgentPanel } from "./DashboardAgentPanel"; + +/** + * Mounts the dashboard agent in the env layout. Renders the page content + * (`children` = the route Outlet); when the agent is open it splits the layout + * into a resizable content + agent panel using the shared Resizable primitive, + * with `autosaveId` persisting the width. When closed it's a floating launcher. + * + * `hasAccess` is resolved server-side in the env layout loader (via + * `canAccessDashboardAgent`: global env, admins/impersonators, then the + * global/per-org feature flag, default off), so the launcher is hidden unless + * the agent is enabled. The resource routes enforce the same check server-side. + */ +export function DashboardAgent({ + children, + hasAccess = false, + headStartEnabled = false, +}: { + children: React.ReactNode; + hasAccess?: boolean; + headStartEnabled?: boolean; +}) { + const [open, setOpen] = useState(false); + + if (!hasAccess) { + return
{children}
; + } + + if (!open) { + return ( +
+
{children}
+ +
+ ); + } + + return ( + + +
{children}
+
+ + + setOpen(false)} headStartEnabled={headStartEnabled} /> + +
+ ); +} diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx new file mode 100644 index 00000000000..ea0f90afdf2 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx @@ -0,0 +1,174 @@ +import { useChat } from "@ai-sdk/react"; +import type { UIMessage } from "@ai-sdk/react"; +import type { dashboardAgent } from "@internal/dashboard-agent"; +import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { DashboardAgentComposer } from "./DashboardAgentComposer"; +import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner"; +import { DashboardAgentMessages } from "./DashboardAgentMessages"; +import { DashboardAgentSuggestedPrompts } from "./DashboardAgentSuggestedPrompts"; + +// 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. +export type DashboardAgentSession = { + publicAccessToken: string; + lastEventId?: string; +}; + +// Per-turn context for the agent. Matches the agent's clientDataSchema input. +export type DashboardAgentClientData = { + userId: string; + organizationId: string; + projectId?: string; + environmentId?: string; + currentPage?: string; +}; + +/** + * 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. + */ +export function DashboardAgentChat({ + chatId, + initialMessages, + session, + clientData, + apiOrigin, + actionPath, + projectSlug, + environmentSlug, + currentPage, + headStartEnabled, + onTurnSettled, +}: { + chatId: string; + initialMessages: UIMessage[]; + session: DashboardAgentSession | null; + clientData: DashboardAgentClientData; + apiOrigin: string; + actionPath: string; + projectSlug: string; + environmentSlug: string; + currentPage: string; + headStartEnabled: boolean; + onTurnSettled: () => void; +}) { + const [input, setInput] = useState(""); + + const transport = useTriggerChatTransport({ + task: "dashboard-agent", + baseURL: apiOrigin, + // First turn of a brand-new chat streams step 1 from the same-origin + // head-start route (which mints + injects the delegated token server-side + // and boots the agent in parallel). Only when the server is head-start + // capable; otherwise the first turn takes the normal cold-start path. + headStart: headStartEnabled ? `${actionPath}/headstart` : undefined, + // 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) => { + if (ctx.endpoint !== "in") return globalThis.fetch(url, init); + const { pathname, search } = new URL(url); + return globalThis.fetch(`${actionPath}/in${pathname}${search}`, init); + }, + clientData, + sessions: session + ? { + [chatId]: { + publicAccessToken: session.publicAccessToken, + lastEventId: session.lastEventId, + isStreaming: false, + }, + } + : undefined, + startSession: async ({ chatId }) => { + const body = new FormData(); + body.set("intent", "start"); + body.set("chatId", chatId); + body.set("clientData", JSON.stringify(clientData)); + const res = await fetch(actionPath, { method: "POST", body }); + const data = (await res.json()) as { publicAccessToken?: string; error?: string }; + if (!res.ok || !data.publicAccessToken) { + throw new Error(data.error ?? "The dashboard agent couldn't start."); + } + return { publicAccessToken: data.publicAccessToken }; + }, + accessToken: async ({ chatId }) => { + const body = new FormData(); + body.set("intent", "token"); + body.set("chatId", chatId); + const res = await fetch(actionPath, { method: "POST", body }); + const data = (await res.json()) as { token?: string; error?: string }; + if (!res.ok || !data.token) { + throw new Error(data.error ?? "Couldn't refresh the dashboard agent token."); + } + return data.token; + }, + }); + + const { + messages, + sendMessage, + status, + stop: aiStop, + error, + } = useChat({ + id: chatId, + messages: initialMessages, + transport, + resume: !!session, + }); + + const isStreaming = status === "streaming"; + const isThinking = status === "submitted"; + + const submit = useCallback( + (text: string) => { + const trimmed = text.trim(); + if (!trimmed || isStreaming) return; + setInput(""); + void sendMessage({ text: trimmed }); + }, + [isStreaming, sendMessage] + ); + + 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. + const prevStatus = useRef(status); + useEffect(() => { + if (prevStatus.current === "streaming" && status === "ready") onTurnSettled(); + prevStatus.current = status; + }, [status, onTurnSettled]); + + return ( + <> + + {messages.length === 0 ? ( + + ) : ( + + )} + 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 new file mode 100644 index 00000000000..345f05c7b44 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx @@ -0,0 +1,58 @@ +import { PaperAirplaneIcon, StopIcon } from "@heroicons/react/20/solid"; +import { useRef } from "react"; +import { Button } from "~/components/primitives/Buttons"; +import { cn } from "~/utils/cn"; + +export function DashboardAgentComposer({ + value, + onChange, + onSubmit, + onStop, + isStreaming, +}: { + value: string; + onChange: (value: string) => void; + onSubmit: () => void; + onStop: () => void; + isStreaming: boolean; +}) { + const ref = useRef(null); + + return ( +
+
+
+