From 94af0df33e2f4d739df4b8a408570084e5efb6bc Mon Sep 17 00:00:00 2001 From: Dan <51248046+danton267@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:29:53 +0100 Subject: [PATCH 01/12] feat(ai-assistant): add AiChat/AiChatSession persistence tables Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migration.sql | 26 +++++++++++++++++ .../database/prisma/schema.prisma | 28 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 internal-packages/database/prisma/migrations/20260602103345_add_ai_chat_tables/migration.sql diff --git a/internal-packages/database/prisma/migrations/20260602103345_add_ai_chat_tables/migration.sql b/internal-packages/database/prisma/migrations/20260602103345_add_ai_chat_tables/migration.sql new file mode 100644 index 00000000000..ed24494e90f --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260602103345_add_ai_chat_tables/migration.sql @@ -0,0 +1,26 @@ +-- CreateTable +CREATE TABLE "public"."AiChat" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "title" TEXT NOT NULL DEFAULT 'New chat', + "messages" JSONB NOT NULL DEFAULT '[]', + "model" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AiChat_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."AiChatSession" ( + "id" TEXT NOT NULL, + "publicAccessToken" TEXT NOT NULL, + "lastEventId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AiChatSession_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AiChat_userId_updatedAt_idx" ON "public"."AiChat"("userId", "updatedAt" DESC); diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 91a4d34bcf9..fb4a193d180 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -3172,3 +3172,31 @@ model OrganizationDataStore { @@index([kind]) } + +// ==================================================== +// AI Assistant Chat Persistence +// ==================================================== + +/// Stores chat conversations for the dashboard AI assistant. +/// The id is the same as the chatId / session externalId. +model AiChat { + id String @id + userId String + title String @default("New chat") + messages Json @default("[]") + model String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId, updatedAt(sort: Desc)]) +} + +/// Stores session state for AI assistant chat sessions. +/// Used for SSE resume and token refresh. +model AiChatSession { + id String @id + publicAccessToken String + lastEventId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} From 3e42603e03c3fa586ee13a57190d8697d09d4bc5 Mon Sep 17 00:00:00 2001 From: Dan <51248046+danton267@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:30:00 +0100 Subject: [PATCH 02/12] feat(ai-assistant): dashboard agent task, tools, and session routes Co-Authored-By: Claude Opus 4.8 (1M context) --- .server-changes/ai-assistant-onpreload-fix.md | 16 + .../app/lib/ai-assistant/tool-schemas.ts | 49 ++ .../resources.ai-assistant.chat.$chatId.ts | 46 ++ .../routes/resources.ai-assistant.history.ts | 21 + .../app/routes/resources.ai-assistant.ts | 74 +++ .../webapp/app/services/aiAssistant.server.ts | 54 +++ .../ai-assistant-tools/docs/search-docs.ts | 71 +++ .../app/trigger/ai-assistant-tools/index.ts | 18 + .../navigation/get-current-context.ts | 18 + .../navigation/navigate-to-page.ts | 25 + .../navigation/page-matcher.ts | 53 ++ .../navigation/page-registry.ts | 152 ++++++ .../navigation/search-pages.ts | 21 + .../app/trigger/ai-assistant-tools/types.ts | 26 + apps/webapp/app/trigger/ai-assistant.ts | 165 +++++++ apps/webapp/app/trigger/db.ts | 8 + apps/webapp/package.json | 7 +- apps/webapp/trigger.config.ts | 8 + apps/webapp/tsconfig.json | 8 +- package.json | 3 +- packages/trigger-sdk/package.json | 1 + pnpm-lock.yaml | 456 +++++------------- 22 files changed, 954 insertions(+), 346 deletions(-) create mode 100644 .server-changes/ai-assistant-onpreload-fix.md create mode 100644 apps/webapp/app/lib/ai-assistant/tool-schemas.ts create mode 100644 apps/webapp/app/routes/resources.ai-assistant.chat.$chatId.ts create mode 100644 apps/webapp/app/routes/resources.ai-assistant.history.ts create mode 100644 apps/webapp/app/routes/resources.ai-assistant.ts create mode 100644 apps/webapp/app/services/aiAssistant.server.ts create mode 100644 apps/webapp/app/trigger/ai-assistant-tools/docs/search-docs.ts create mode 100644 apps/webapp/app/trigger/ai-assistant-tools/index.ts create mode 100644 apps/webapp/app/trigger/ai-assistant-tools/navigation/get-current-context.ts create mode 100644 apps/webapp/app/trigger/ai-assistant-tools/navigation/navigate-to-page.ts create mode 100644 apps/webapp/app/trigger/ai-assistant-tools/navigation/page-matcher.ts create mode 100644 apps/webapp/app/trigger/ai-assistant-tools/navigation/page-registry.ts create mode 100644 apps/webapp/app/trigger/ai-assistant-tools/navigation/search-pages.ts create mode 100644 apps/webapp/app/trigger/ai-assistant-tools/types.ts create mode 100644 apps/webapp/app/trigger/ai-assistant.ts create mode 100644 apps/webapp/app/trigger/db.ts create mode 100644 apps/webapp/trigger.config.ts diff --git a/.server-changes/ai-assistant-onpreload-fix.md b/.server-changes/ai-assistant-onpreload-fix.md new file mode 100644 index 00000000000..4b4f5613695 --- /dev/null +++ b/.server-changes/ai-assistant-onpreload-fix.md @@ -0,0 +1,16 @@ +--- +area: webapp +type: fix +--- + +Fix the dashboard AI assistant (`dashboard-assistant` chat.agent) silently not +responding to messages. The session is started via `chat.createStartSessionAction`, +which triggers the first run with `trigger: "preload"`, so every chat boots +preloaded. The agent only created its `AiChat`/`AiChatSession` rows in +`onChatStart`, which early-returns on preloaded runs — so the rows were never +created and `onTurnStart`'s `aiChat.update(...)` threw before `run()` streamed. + +Adds an `onPreload` hook that creates the rows (with `onChatStart` kept as the +non-preloaded fallback), and declares `tools` on the agent config (function form) +read back from the `run()` payload so the SDK re-applies each tool's +`toModelOutput` when re-converting history on later turns. diff --git a/apps/webapp/app/lib/ai-assistant/tool-schemas.ts b/apps/webapp/app/lib/ai-assistant/tool-schemas.ts new file mode 100644 index 00000000000..8341b15cba8 --- /dev/null +++ b/apps/webapp/app/lib/ai-assistant/tool-schemas.ts @@ -0,0 +1,49 @@ +// Schema-only tool definitions. Execute functions live in +// app/trigger/ai-assistant-tools/ and spread these in, so descriptions are +// single-sourced. Keep this file dependency-light (only `ai` and `zod`) — no +// SDK runtime, Prisma, or Node built-ins. +import { tool } from "ai"; +import { z } from "zod"; + +export const searchDocs = tool({ + description: + "Search Trigger.dev documentation for guides, API reference, configuration, " + + "troubleshooting, and help articles. Use when the user asks how a feature works, " + + "how to configure something, or needs help with an error.", + inputSchema: z.object({ + query: z.string().describe("Search query about Trigger.dev features or APIs"), + }), +}); + +export const navigateToPage = tool({ + description: + "Navigate the user to a specific dashboard page. Use when the user asks " + + "'where do I find X', 'take me to Y', 'show me the Z page', or 'go to settings'. " + + "Returns a URL that the frontend renders as a clickable link.", + inputSchema: z.object({ + destination: z + .string() + .describe( + "Where the user wants to go, e.g. 'runs page', 'environment variables', " + + "'deployment settings', 'error alerts', 'concurrency configuration'" + ), + }), +}); + +export const getCurrentContext = tool({ + description: + "Get information about what the user is currently viewing in the dashboard. " + + "Returns the current project, environment, page, and any active parameters. " + + "Use to ground your answers in the user's current context.", + inputSchema: z.object({}), +}); + +export const searchPages = tool({ + description: + "Search for available dashboard pages by description. Returns matching pages " + + "with descriptions and URLs. Use when the user's destination is ambiguous or " + + "you want to suggest relevant pages.", + inputSchema: z.object({ + query: z.string().describe("Description of what the user is looking for"), + }), +}); \ No newline at end of file diff --git a/apps/webapp/app/routes/resources.ai-assistant.chat.$chatId.ts b/apps/webapp/app/routes/resources.ai-assistant.chat.$chatId.ts new file mode 100644 index 00000000000..6d7a376ef76 --- /dev/null +++ b/apps/webapp/app/routes/resources.ai-assistant.chat.$chatId.ts @@ -0,0 +1,46 @@ +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { prisma } from "~/db.server"; +import { requireUserId } from "~/services/session.server"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { chatId } = params; + + if (!chatId) { + return json({ error: "Missing chatId" }, { status: 400 }); + } + + const [chat, session] = await Promise.all([ + prisma.aiChat.findFirst({ + where: { id: chatId, userId }, + select: { + id: true, + title: true, + messages: true, + model: true, + }, + }), + prisma.aiChatSession.findFirst({ + where: { id: chatId }, + select: { + publicAccessToken: true, + lastEventId: true, + }, + }), + ]); + + if (!chat) { + return json({ error: "Chat not found" }, { status: 404 }); + } + + return json({ + chat, + session: session + ? { + publicAccessToken: session.publicAccessToken, + lastEventId: session.lastEventId, + } + : null, + }); +}; \ No newline at end of file diff --git a/apps/webapp/app/routes/resources.ai-assistant.history.ts b/apps/webapp/app/routes/resources.ai-assistant.history.ts new file mode 100644 index 00000000000..1cec88a0763 --- /dev/null +++ b/apps/webapp/app/routes/resources.ai-assistant.history.ts @@ -0,0 +1,21 @@ +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { prisma } from "~/db.server"; +import { requireUserId } from "~/services/session.server"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + + const chats = await prisma.aiChat.findMany({ + where: { userId }, + select: { + id: true, + title: true, + updatedAt: true, + }, + orderBy: { updatedAt: "desc" }, + take: 50, + }); + + return json({ chats }); +}; \ No newline at end of file diff --git a/apps/webapp/app/routes/resources.ai-assistant.ts b/apps/webapp/app/routes/resources.ai-assistant.ts new file mode 100644 index 00000000000..772f91c1024 --- /dev/null +++ b/apps/webapp/app/routes/resources.ai-assistant.ts @@ -0,0 +1,74 @@ +import type { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { auth } from "@trigger.dev/sdk"; +import { chat } from "@trigger.dev/sdk/ai"; +import { requireUserId } from "~/services/session.server"; +import { withAssistantAuth, type AssistantEnvContext } from "~/services/aiAssistant.server"; + +const startDashboardAssistant = chat.createStartSessionAction("dashboard-assistant"); + +// Auth context from the server-trusted userId + the slugs the browser sends. +// The userId is never trusted from the browser — membership is re-checked +// against it in `withAssistantAuth`. +function envContext( + userId: string, + clientData: Record | undefined +): AssistantEnvContext { + return { + userId, + organizationSlug: String(clientData?.organizationSlug ?? ""), + projectSlug: String(clientData?.projectSlug ?? ""), + environmentSlug: String(clientData?.environmentSlug ?? ""), + }; +} + +export const action = async ({ request }: ActionFunctionArgs) => { + const userId = await requireUserId(request); + const body = (await request.json()) as { + intent?: string; + chatId?: string; + clientData?: Record; + }; + + if (!body.chatId) { + return json({ error: "Missing chatId" }, { status: 400 }); + } + const chatId = body.chatId; + + if (body.intent === "createSession") { + const { clientData } = body; + const ctx = envContext(userId, clientData); + + const result = await withAssistantAuth(ctx, () => + startDashboardAssistant({ + chatId, + // Override the browser-claimed userId with the server-trusted one. + clientData: { ...clientData, userId }, + }) + ); + + return json({ + sessionId: result.sessionId, + publicAccessToken: result.publicAccessToken, + }); + } + + if (body.intent === "refreshToken") { + const { chatId, clientData } = body; + const ctx = envContext(userId, clientData); + + // Pure mint — no session create, no run trigger. Scoped to this chat. + const publicAccessToken = await withAssistantAuth(ctx, () => + auth.createPublicToken({ + scopes: { + read: { sessions: chatId }, + write: { sessions: chatId }, + }, + }) + ); + + return json({ publicAccessToken }); + } + + return json({ error: "Unknown intent" }, { status: 400 }); +}; diff --git a/apps/webapp/app/services/aiAssistant.server.ts b/apps/webapp/app/services/aiAssistant.server.ts new file mode 100644 index 00000000000..b3a00222d1c --- /dev/null +++ b/apps/webapp/app/services/aiAssistant.server.ts @@ -0,0 +1,54 @@ +import { auth } from "@trigger.dev/sdk"; +import { env } from "~/env.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { findProjectBySlug } from "~/models/project.server"; + +// The assistant runs as a Trigger.dev task on this same platform. Rather than +// stashing a secret, we authenticate SDK calls with the apiKey of the +// environment the user is currently viewing, read from the DB and scoped via +// `auth.withAuth`. Moving the assistant to a dedicated project would only +// change `resolveAssistantApiKey`. + +export type AssistantEnvContext = { + userId: string; + organizationSlug: string; + projectSlug: string; + environmentSlug: string; +}; + +class AssistantAuthError extends Error {} + +async function resolveAssistantApiKey(ctx: AssistantEnvContext): Promise { + const project = await findProjectBySlug(ctx.organizationSlug, ctx.projectSlug, ctx.userId); + if (!project) { + throw new AssistantAuthError( + `AI assistant: no project "${ctx.projectSlug}" in org "${ctx.organizationSlug}" for this user` + ); + } + + const environment = await findEnvironmentBySlug(project.id, ctx.environmentSlug, ctx.userId); + if (!environment) { + throw new AssistantAuthError( + `AI assistant: no environment "${ctx.environmentSlug}" in project "${ctx.projectSlug}"` + ); + } + + return environment.apiKey; +} + +// Run `fn` with the SDK API client scoped to the current environment's key and +// this instance's own origin, so session/token calls hit the local platform. +export async function withAssistantAuth( + ctx: AssistantEnvContext, + fn: () => Promise +): Promise { + const apiKey = await resolveAssistantApiKey(ctx); + + return auth.withAuth( + { + baseURL: env.API_ORIGIN ?? env.APP_ORIGIN, + accessToken: apiKey, + }, + fn + ); +} diff --git a/apps/webapp/app/trigger/ai-assistant-tools/docs/search-docs.ts b/apps/webapp/app/trigger/ai-assistant-tools/docs/search-docs.ts new file mode 100644 index 00000000000..841c1276e8e --- /dev/null +++ b/apps/webapp/app/trigger/ai-assistant-tools/docs/search-docs.ts @@ -0,0 +1,71 @@ +import { tool } from "ai"; +import { searchDocs as searchDocsSchema } from "~/lib/ai-assistant/tool-schemas"; + +const MINTLIFY_MCP_URL = "https://trigger.dev/docs/mcp"; +const MCP_PROTOCOL_VERSION = "2025-06-18"; + +export function createSearchDocsTool() { + return tool({ + ...searchDocsSchema, + execute: async ({ query }) => { + try { + const body = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "search_trigger_dev", arguments: { query } }, + }; + + const response = await fetch(MINTLIFY_MCP_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "MCP-Protocol-Version": MCP_PROTOCOL_VERSION, + }, + body: JSON.stringify(body), + }); + + const data: any = await parseResponse(response); + return { success: true, results: data?.result ?? data }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }, + }); +} + +// --- Mintlify response parsing (handles both SSE and JSON) --- +// Adapted from packages/cli-v3/src/mcp/mintlifyClient.ts + +async function parseResponse(response: Response) { + if (response.headers.get("content-type")?.includes("text/event-stream")) { + return parseSSEResponse(response); + } + return response.json(); +} + +async function parseSSEResponse(response: Response) { + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + if (!reader) throw new Error("No reader found"); + + let buffer = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) throw new Error("SSE stream closed before data arrived"); + buffer += decoder.decode(value, { stream: true }); + const events = buffer.split("\n\n"); + buffer = events.pop()!; + for (const evt of events) { + for (const line of evt.split("\n")) { + if (line.startsWith("data:")) { + return JSON.parse(line.slice(5).trim()); + } + } + } + } +} \ No newline at end of file diff --git a/apps/webapp/app/trigger/ai-assistant-tools/index.ts b/apps/webapp/app/trigger/ai-assistant-tools/index.ts new file mode 100644 index 00000000000..71bd5c35386 --- /dev/null +++ b/apps/webapp/app/trigger/ai-assistant-tools/index.ts @@ -0,0 +1,18 @@ +import type { ClientData } from "./types"; +import { buildToolContext } from "./types"; +import { createSearchDocsTool } from "./docs/search-docs"; +import { createNavigateToPageTool } from "./navigation/navigate-to-page"; +import { createSearchPagesTool } from "./navigation/search-pages"; +import { createGetCurrentContextTool } from "./navigation/get-current-context"; + +// Builds the tool set for a client context. Called from the agent's run() per turn. +export function buildAssistantTools(clientData: ClientData) { + const ctx = buildToolContext(clientData); + + return { + searchDocs: createSearchDocsTool(), + navigateToPage: createNavigateToPageTool(ctx), + searchPages: createSearchPagesTool(ctx), + getCurrentContext: createGetCurrentContextTool(ctx), + }; +} \ No newline at end of file diff --git a/apps/webapp/app/trigger/ai-assistant-tools/navigation/get-current-context.ts b/apps/webapp/app/trigger/ai-assistant-tools/navigation/get-current-context.ts new file mode 100644 index 00000000000..55d77250378 --- /dev/null +++ b/apps/webapp/app/trigger/ai-assistant-tools/navigation/get-current-context.ts @@ -0,0 +1,18 @@ +import { tool } from "ai"; +import { getCurrentContext as contextSchema } from "~/lib/ai-assistant/tool-schemas"; +import type { ToolContext } from "../types"; + +export function createGetCurrentContextTool(ctx: ToolContext) { + return tool({ + ...contextSchema, + execute: async () => { + return { + project: ctx.clientData.projectSlug, + environment: ctx.clientData.environmentSlug, + currentPage: ctx.clientData.currentPage, + currentParams: ctx.clientData.currentParams ?? {}, + description: `The user is viewing the ${ctx.clientData.currentPage} page in project "${ctx.clientData.projectSlug}" (${ctx.clientData.environmentSlug} environment).`, + }; + }, + }); +} \ No newline at end of file diff --git a/apps/webapp/app/trigger/ai-assistant-tools/navigation/navigate-to-page.ts b/apps/webapp/app/trigger/ai-assistant-tools/navigation/navigate-to-page.ts new file mode 100644 index 00000000000..e193c4f9c74 --- /dev/null +++ b/apps/webapp/app/trigger/ai-assistant-tools/navigation/navigate-to-page.ts @@ -0,0 +1,25 @@ +import { tool } from "ai"; +import { navigateToPage as navigateSchema } from "~/lib/ai-assistant/tool-schemas"; +import type { ToolContext } from "../types"; +import { findBestMatch } from "./page-matcher"; + +export function createNavigateToPageTool(ctx: ToolContext) { + return tool({ + ...navigateSchema, + execute: async ({ destination }) => { + const match = findBestMatch(destination); + if (!match) { + return { + found: false, + message: "I couldn't find that page. Try asking me to search for available pages.", + }; + } + return { + found: true, + pageName: match.id, + description: match.description, + url: match.pathFn(ctx.org, ctx.project, ctx.env), + }; + }, + }); +} \ No newline at end of file diff --git a/apps/webapp/app/trigger/ai-assistant-tools/navigation/page-matcher.ts b/apps/webapp/app/trigger/ai-assistant-tools/navigation/page-matcher.ts new file mode 100644 index 00000000000..19579cc2ef2 --- /dev/null +++ b/apps/webapp/app/trigger/ai-assistant-tools/navigation/page-matcher.ts @@ -0,0 +1,53 @@ +import type { PageEntry } from "./page-registry"; +import { PAGE_REGISTRY } from "./page-registry"; + +/** + * Score a page entry against a search query. Higher = better match. + * Uses keyword overlap and substring matching — intentionally simple. + */ +function scoreMatch(entry: PageEntry, query: string): number { + const lower = query.toLowerCase(); + let score = 0; + + // Exact ID match + if (lower === entry.id) return 100; + + // ID substring + if (lower.includes(entry.id) || entry.id.includes(lower)) score += 10; + + // Keyword matches + for (const keyword of entry.keywords) { + if (lower.includes(keyword)) score += 5; + if (keyword.includes(lower)) score += 3; + } + + // Description substring + if (entry.description.toLowerCase().includes(lower)) score += 2; + + return score; +} + +/** Find the best matching page for a destination string */ +export function findBestMatch(query: string): PageEntry | null { + let best: PageEntry | null = null; + let bestScore = 0; + + for (const entry of PAGE_REGISTRY) { + const score = scoreMatch(entry, query); + if (score > bestScore) { + bestScore = score; + best = entry; + } + } + + return bestScore > 0 ? best : null; +} + +/** Find top N matching pages for a search query */ +export function findMatches(query: string, limit = 5): PageEntry[] { + return PAGE_REGISTRY.map((entry) => ({ entry, score: scoreMatch(entry, query) })) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(({ entry }) => entry); +} \ No newline at end of file diff --git a/apps/webapp/app/trigger/ai-assistant-tools/navigation/page-registry.ts b/apps/webapp/app/trigger/ai-assistant-tools/navigation/page-registry.ts new file mode 100644 index 00000000000..90ef9d69ee1 --- /dev/null +++ b/apps/webapp/app/trigger/ai-assistant-tools/navigation/page-registry.ts @@ -0,0 +1,152 @@ +import { + v3RunsPath, + v3ErrorsPath, + v3DeploymentsPath, + v3BatchesPath, + v3SchedulesPath, + v3EnvironmentVariablesPath, + v3ApiKeysPath, + v3QueuesPath, + v3TestPath, + v3LogsPath, + v3SessionsPath, + v3AgentsPath, + v3ModelsPath, + v3PromptsPath, + v3ProjectAlertsPath, + v3ProjectSettingsPath, + branchesPath, + concurrencyPath, + regionsPath, + queryPath, +} from "~/utils/pathBuilder"; + +export interface PageEntry { + id: string; + keywords: string[]; + description: string; + pathFn: (org: { slug: string }, project: { slug: string }, env: { slug: string }) => string; +} + +export const PAGE_REGISTRY: PageEntry[] = [ + { + id: "runs", + keywords: ["runs", "task runs", "executions", "jobs", "run list"], + description: "Task runs list — view, filter, and manage all task runs", + pathFn: (org, project, env) => v3RunsPath(org, project, env), + }, + { + id: "errors", + keywords: ["errors", "error groups", "failures", "exceptions", "bugs"], + description: "Error groups — see grouped errors across tasks with counts and trends", + pathFn: (org, project, env) => v3ErrorsPath(org, project, env), + }, + { + id: "deployments", + keywords: ["deployments", "deploys", "versions", "releases"], + description: "Deployments — view deployment history, promote, and rollback versions", + pathFn: (org, project, env) => v3DeploymentsPath(org, project, env), + }, + { + id: "batches", + keywords: ["batches", "batch runs", "batch triggers"], + description: "Batches — view and monitor batch trigger operations", + pathFn: (org, project, env) => v3BatchesPath(org, project, env), + }, + { + id: "schedules", + keywords: ["schedules", "cron", "scheduled tasks", "recurring"], + description: "Schedules — create, edit, and manage scheduled task triggers", + pathFn: (org, project, env) => v3SchedulesPath(org, project, env), + }, + { + id: "environment-variables", + keywords: ["env vars", "environment variables", "secrets", "config", "configuration"], + description: "Environment variables — configure secrets and config values per environment", + pathFn: (org, project, env) => v3EnvironmentVariablesPath(org, project, env), + }, + { + id: "api-keys", + keywords: ["api keys", "tokens", "authentication", "secret keys"], + description: "API keys — manage server and public API keys for each environment", + pathFn: (org, project, env) => v3ApiKeysPath(org, project, env), + }, + { + id: "queues", + keywords: ["queues", "concurrency", "queue management"], + description: "Queues — view queue status, set concurrency limits, pause queues", + pathFn: (org, project, env) => v3QueuesPath(org, project, env), + }, + { + id: "test", + keywords: ["test", "testing", "test tasks", "trigger test", "playground"], + description: "Test — trigger test runs for your tasks with custom payloads", + pathFn: (org, project, env) => v3TestPath(org, project, env), + }, + { + id: "logs", + keywords: ["logs", "log viewer", "logging", "log lines"], + description: "Logs — search and filter log output from task runs", + pathFn: (org, project, env) => v3LogsPath(org, project, env), + }, + { + id: "sessions", + keywords: ["sessions", "chat sessions", "agent sessions"], + description: "Sessions — view active and past chat agent sessions", + pathFn: (org, project, env) => v3SessionsPath(org, project, env), + }, + { + id: "agents", + keywords: ["agents", "ai agents", "chat agents"], + description: "Agents — view registered chat agents and their status", + pathFn: (org, project, env) => v3AgentsPath(org, project, env), + }, + { + id: "models", + keywords: ["models", "ai models", "llm", "model registry"], + description: "Models — view LLM model usage, costs, and performance", + pathFn: (org, project, env) => v3ModelsPath(org, project, env), + }, + { + id: "prompts", + keywords: ["prompts", "prompt management", "prompt versions"], + description: "Prompts — manage versioned prompts, create overrides, promote versions", + pathFn: (org, project, env) => v3PromptsPath(org, project, env), + }, + { + id: "alerts", + keywords: ["alerts", "notifications", "alert rules"], + description: "Alerts — configure alert rules for task failures and performance", + pathFn: (org, project, env) => v3ProjectAlertsPath(org, project, env), + }, + { + id: "settings", + keywords: ["settings", "project settings", "general settings"], + description: "Settings — general project configuration and integrations", + pathFn: (org, project, env) => v3ProjectSettingsPath(org, project, env), + }, + { + id: "branches", + keywords: ["branches", "preview branches", "git branches"], + description: "Branches — manage preview branch environments", + pathFn: (org, project, env) => branchesPath(org, project, env), + }, + { + id: "concurrency", + keywords: ["concurrency", "concurrency limits", "parallel"], + description: "Concurrency — view and configure task concurrency limits", + pathFn: (org, project, env) => concurrencyPath(org, project, env), + }, + { + id: "regions", + keywords: ["regions", "deployment regions", "geography"], + description: "Regions — configure deployment regions for task execution", + pathFn: (org, project, env) => regionsPath(org, project, env), + }, + { + id: "query", + keywords: ["query", "trql", "query editor", "search runs"], + description: "Query — write and execute TRQL queries against your task data", + pathFn: (org, project, env) => queryPath(org, project, env), + }, +]; \ No newline at end of file diff --git a/apps/webapp/app/trigger/ai-assistant-tools/navigation/search-pages.ts b/apps/webapp/app/trigger/ai-assistant-tools/navigation/search-pages.ts new file mode 100644 index 00000000000..5516114a79b --- /dev/null +++ b/apps/webapp/app/trigger/ai-assistant-tools/navigation/search-pages.ts @@ -0,0 +1,21 @@ +import { tool } from "ai"; +import { searchPages as searchPagesSchema } from "~/lib/ai-assistant/tool-schemas"; +import type { ToolContext } from "../types"; +import { findMatches } from "./page-matcher"; + +export function createSearchPagesTool(ctx: ToolContext) { + return tool({ + ...searchPagesSchema, + execute: async ({ query }) => { + const matches = findMatches(query, 5); + return { + matches: matches.map((m) => ({ + pageName: m.id, + description: m.description, + url: m.pathFn(ctx.org, ctx.project, ctx.env), + })), + total: matches.length, + }; + }, + }); +} \ No newline at end of file diff --git a/apps/webapp/app/trigger/ai-assistant-tools/types.ts b/apps/webapp/app/trigger/ai-assistant-tools/types.ts new file mode 100644 index 00000000000..dc08dc1b02d --- /dev/null +++ b/apps/webapp/app/trigger/ai-assistant-tools/types.ts @@ -0,0 +1,26 @@ +// Matches the `withClientData` schema on the chat.agent definition. +export interface ClientData { + userId: string; + organizationSlug: string; + projectSlug: string; + environmentSlug: string; + currentPage: string; + currentParams?: Record; +} + +export interface ToolContext { + clientData: ClientData; + // Pre-built path objects for pathBuilder functions. + org: { slug: string }; + project: { slug: string }; + env: { slug: string }; +} + +export function buildToolContext(clientData: ClientData): ToolContext { + return { + clientData, + org: { slug: clientData.organizationSlug }, + project: { slug: clientData.projectSlug }, + env: { slug: clientData.environmentSlug }, + }; +} \ No newline at end of file diff --git a/apps/webapp/app/trigger/ai-assistant.ts b/apps/webapp/app/trigger/ai-assistant.ts new file mode 100644 index 00000000000..d8930f46310 --- /dev/null +++ b/apps/webapp/app/trigger/ai-assistant.ts @@ -0,0 +1,165 @@ +import { chat } from "@trigger.dev/sdk/ai"; +import { logger, prompts } from "@trigger.dev/sdk"; +import { streamText, stepCountIs } from "ai"; +import { openai } from "@ai-sdk/openai"; +import { z } from "zod"; +import { prisma } from "./db"; +import { buildAssistantTools } from "./ai-assistant-tools"; + +type ChatMessagesForWrite = NonNullable< + Parameters[0]["data"] +>["messages"]; + +// Idempotently create the chat + session rows before onTurnStart's update +// runs. Must happen in onPreload (every chat boots preloaded) with onChatStart +// as the non-preloaded fallback. +async function ensureChatRows(args: { + chatId: string; + chatAccessToken: string; + userId: string; +}) { + await prisma.aiChat.upsert({ + where: { id: args.chatId }, + create: { + id: args.chatId, + title: "New chat", + userId: args.userId, + model: "gpt-4.1-mini", + }, + update: {}, + }); + await prisma.aiChatSession.upsert({ + where: { id: args.chatId }, + create: { id: args.chatId, publicAccessToken: args.chatAccessToken }, + update: { publicAccessToken: args.chatAccessToken }, + }); +} + +const systemPrompt = prompts.define({ + id: "dashboard-assistant-system", + model: "openai:gpt-4.1-mini", + config: { temperature: 0.7 }, + variables: z.object({ + projectSlug: z.string(), + environmentSlug: z.string(), + currentPage: z.string(), + }), + content: `You are the Trigger.dev AI assistant, embedded in the dashboard. + +## Your role +Help the user navigate the dashboard, find documentation, and understand Trigger.dev features. + +## Current context +The user is viewing: project "{{projectSlug}}" / {{environmentSlug}} environment / {{currentPage}} page. + +## Guidelines +- Be concise and friendly. Prefer short, direct answers unless the user asks for detail. +- When the user asks how something works, ALWAYS search documentation first. +- When the user asks "where do I find X" or "take me to Y", use navigateToPage. +- Use getCurrentContext to ground answers in what the user is viewing. +- Use markdown formatting for code blocks, lists, and structured output. +- If you don't know something, say so — don't make things up. +- When you use a tool, briefly explain what you're doing. + +## What you CAN do (V1A) +- Search and read Trigger.dev documentation +- Navigate the user to any dashboard page +- Explain Trigger.dev features, configuration, and APIs +- Help with common questions about retries, concurrency, deployments, env vars, etc. + +## What you CANNOT do yet +- Inspect specific runs, errors, or logs (coming soon) +- Modify settings or trigger actions (coming soon) +- Access the user's code (coming soon)`, +}); + +export const dashboardAssistant = chat + .withClientData({ + schema: z.object({ + userId: z.string(), + organizationSlug: z.string(), + projectSlug: z.string(), + environmentSlug: z.string(), + currentPage: z.string(), + currentParams: z.record(z.string()).optional(), + }), + }) + .agent({ + id: "dashboard-assistant", + idleTimeoutInSeconds: 60, + chatAccessTokenTTL: "1h", + + // Declared here (not just on streamText) so the SDK re-applies each tool's + // `toModelOutput` when re-converting prior-turn history. run() reads them + // back via `tools`. + tools: ({ clientData }) => buildAssistantTools(clientData!), + + uiMessageStreamOptions: { + onError: (error: unknown) => { + logger.error("Stream error", { error }); + if (error instanceof Error && error.message.includes("rate limit")) { + return "Rate limited — please wait a moment and try again."; + } + return "Something went wrong. Please try again."; + }, + }, + + onBoot: async ({ clientData }) => { + if (!clientData) return; + const resolved = await systemPrompt.resolve({ + projectSlug: clientData.projectSlug, + environmentSlug: clientData.environmentSlug, + currentPage: clientData.currentPage, + }); + chat.prompt.set(resolved); + }, + + onPreload: async ({ chatId, chatAccessToken, clientData }) => { + if (!clientData) return; + await ensureChatRows({ chatId, chatAccessToken, userId: clientData.userId }); + }, + + // Fallback for non-preloaded runs; onPreload already created the rows. + onChatStart: async ({ chatId, chatAccessToken, clientData, preloaded }) => { + if (preloaded) return; + if (!clientData) return; + await ensureChatRows({ chatId, chatAccessToken, userId: clientData.userId }); + }, + + // Await the write (not chat.defer): a deferred write loses the user + // message on a mid-stream page refresh. + onTurnStart: async ({ chatId, uiMessages }) => { + await prisma.aiChat.update({ + where: { id: chatId }, + data: { messages: uiMessages as unknown as ChatMessagesForWrite }, + }); + }, + + // Atomic write of messages + session state; a non-atomic write races the + // resume-replay. + onTurnComplete: async ({ chatId, uiMessages, chatAccessToken, lastEventId }) => { + await prisma.$transaction([ + prisma.aiChat.update({ + where: { id: chatId }, + data: { messages: uiMessages as unknown as ChatMessagesForWrite }, + }), + prisma.aiChatSession.upsert({ + where: { id: chatId }, + create: { id: chatId, publicAccessToken: chatAccessToken, lastEventId }, + update: { publicAccessToken: chatAccessToken, lastEventId }, + }), + ]); + }, + + // chat.toStreamTextOptions() must be spread first. `tools` comes from the + // run payload so streamText and the history re-converter see the same set. + run: async ({ messages, tools, stopSignal }) => { + return streamText({ + ...chat.toStreamTextOptions({ tools }), + model: openai("gpt-4.1-mini"), + messages, + abortSignal: stopSignal, + stopWhen: stepCountIs(5), + }); + }, + }); \ No newline at end of file diff --git a/apps/webapp/app/trigger/db.ts b/apps/webapp/app/trigger/db.ts new file mode 100644 index 00000000000..c2d7225f08b --- /dev/null +++ b/apps/webapp/app/trigger/db.ts @@ -0,0 +1,8 @@ +import { PrismaClient } from "@trigger.dev/database"; + +// Dedicated client for trigger tasks. Importing the webapp's `~/db.server` +// pulls in `~/v3/tracer.server`, whose module-load OTel registration collides +// with the worker's own ("Attempted duplicate registration of API: trace"). +export const prisma = new PrismaClient({ + datasourceUrl: process.env.DATABASE_URL, +}); diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 683c775efbb..cbcab896a58 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -56,7 +56,6 @@ "@electric-sql/react": "^0.3.5", "@headlessui/react": "^1.7.8", "@heroicons/react": "^2.0.12", - "@jsonhero/schema-infer": "^0.1.5", "@internal/cache": "workspace:*", "@internal/compute": "workspace:*", "@internal/llm-model-catalog": "workspace:*", @@ -67,6 +66,7 @@ "@internal/tsql": "workspace:*", "@internal/zod-worker": "workspace:*", "@internationalized/date": "^3.5.1", + "@jsonhero/schema-infer": "^0.1.5", "@kapaai/react-sdk": "^0.1.3", "@lezer/highlight": "^1.1.6", "@opentelemetry/api": "1.9.0", @@ -116,6 +116,7 @@ "@sentry/remix": "9.46.0", "@slack/web-api": "7.16.0", "@socket.io/redis-adapter": "^8.3.0", + "@streamdown/code": "^1.1.1", "@tabler/icons-react": "^3.36.1", "@tailwindcss/container-queries": "^0.1.1", "@tanstack/match-sorter-utils": "^8.19.4", @@ -125,9 +126,9 @@ "@trigger.dev/companyicons": "^1.5.35", "@trigger.dev/core": "workspace:*", "@trigger.dev/database": "workspace:*", - "@trigger.dev/rbac": "workspace:*", "@trigger.dev/otlp-importer": "workspace:*", "@trigger.dev/platform": "1.0.27", + "@trigger.dev/rbac": "workspace:*", "@trigger.dev/redis-worker": "workspace:*", "@trigger.dev/sdk": "workspace:*", "@types/pg": "8.6.6", @@ -220,7 +221,6 @@ "sonner": "^1.0.3", "sql-formatter": "^15.4.10", "sqs-consumer": "^7.4.0", - "@streamdown/code": "^1.1.1", "streamdown": "^2.5.0", "superjson": "^2.2.1", "tailwind-merge": "^1.12.0", @@ -249,6 +249,7 @@ "@tailwindcss/forms": "^0.5.3", "@tailwindcss/typography": "^0.5.9", "@total-typescript/ts-reset": "^0.4.2", + "@trigger.dev/build": "4.4.6", "@types/bcryptjs": "^2.4.2", "@types/compression": "^1.7.2", "@types/cookie": "^0.6.0", diff --git a/apps/webapp/trigger.config.ts b/apps/webapp/trigger.config.ts new file mode 100644 index 00000000000..3d893638c00 --- /dev/null +++ b/apps/webapp/trigger.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "@trigger.dev/sdk"; + +export default defineConfig({ + project: process.env.TRIGGER_PROJECT_REF!, + dirs: ["./app/trigger"], + maxDuration: 3600, + runtime: "node-22", +}); diff --git a/apps/webapp/tsconfig.json b/apps/webapp/tsconfig.json index 36944e395b6..8adf6dfd6ad 100644 --- a/apps/webapp/tsconfig.json +++ b/apps/webapp/tsconfig.json @@ -1,6 +1,12 @@ { "exclude": ["./cypress", "./cypress.config.ts"], - "include": ["remix.env.d.ts", "global.d.ts", "**/*.ts", "**/*.tsx"], + "include": [ + "remix.env.d.ts", + "global.d.ts", + "**/*.ts", + "**/*.tsx", + "trigger.config.ts" + ], "compilerOptions": { "types": ["vitest/globals", "node"], "lib": ["DOM", "DOM.Iterable", "DOM.AsyncIterable", "ES2020"], diff --git a/package.json b/package.json index cea11cf5293..4d3b67cecb2 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,8 @@ "postcss@>=8 <8.5.10": "^8.5.10", "yaml@>=2 <2.8.3": "^2.8.3", "semver@>=5 <5.7.2": "^5.7.2", - "defu@>=6 <6.1.5": "^6.1.5" + "defu@>=6 <6.1.5": "^6.1.5", + "ai": "6.0.116" }, "onlyBuiltDependencies": [ "@depot/cli", diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index 59150386739..1e5bf27b47e 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -92,6 +92,7 @@ "@types/ws": "^8.5.3", "ai": "^6.0.116", "encoding": "^0.1.13", + "react": "18.2.0", "rimraf": "^6.0.1", "tshy": "^3.0.2", "tsx": "4.17.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2f19b74659..0ebb0b5bc95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,7 @@ overrides: yaml@>=2 <2.8.3: ^2.8.3 semver@>=5 <5.7.2: ^5.7.2 defu@>=6 <6.1.5: ^6.1.5 + ai: 6.0.116 patchedDependencies: '@changesets/assemble-release-plan@5.2.4': @@ -578,8 +579,8 @@ importers: specifier: 1.1.3 version: 1.1.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) ai: - specifier: ^6.0.116 - version: 6.0.168(zod@3.25.76) + specifier: 6.0.116 + version: 6.0.116(zod@3.25.76) assert-never: specifier: ^1.2.1 version: 1.2.1 @@ -902,6 +903,9 @@ importers: '@total-typescript/ts-reset': specifier: ^0.4.2 version: 0.4.2 + '@trigger.dev/build': + specifier: 4.4.6 + version: 4.4.6(bufferutil@4.0.9)(typescript@5.5.4) '@types/bcryptjs': specifier: ^2.4.2 version: 2.4.2 @@ -1015,7 +1019,7 @@ importers: version: 2.0.5(eslint@8.31.0) evalite: specifier: 1.0.0-beta.16 - version: 1.0.0-beta.16(ai@6.0.168(zod@3.25.76))(better-sqlite3@11.10.0)(bufferutil@4.0.9) + version: 1.0.0-beta.16(ai@6.0.116(zod@3.25.76))(better-sqlite3@11.10.0)(bufferutil@4.0.9) npm-run-all: specifier: ^4.1.5 version: 4.1.5 @@ -1894,8 +1898,8 @@ importers: specifier: ^4.0.14 version: 4.0.14 ai: - specifier: ^6.0.0 - version: 6.0.3(zod@3.25.76) + specifier: 6.0.116 + version: 6.0.116(zod@3.25.76) defu: specifier: ^6.1.5 version: 6.1.7 @@ -2166,9 +2170,6 @@ importers: evt: specifier: ^2.4.13 version: 2.4.13 - react: - specifier: ^18.0 || ^19.0 - version: 18.3.1 slug: specifier: ^6.0.0 version: 6.1.0 @@ -2201,11 +2202,14 @@ importers: specifier: ^8.5.3 version: 8.5.4 ai: - specifier: ^6.0.116 + specifier: 6.0.116 version: 6.0.116(zod@3.25.76) encoding: specifier: ^0.1.13 version: 0.1.13 + react: + specifier: 18.2.0 + version: 18.2.0 rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -2259,7 +2263,7 @@ importers: specifier: workspace:* version: link:../../packages/trigger-sdk ai: - specifier: ^6.0.0 + specifier: 6.0.116 version: 6.0.116(zod@3.25.76) next: specifier: 15.3.3 @@ -2393,8 +2397,8 @@ importers: specifier: ^0.10.0 version: 0.10.0 ai: - specifier: 5.0.14 - version: 5.0.14(zod@3.25.76) + specifier: 6.0.116 + version: 6.0.116(zod@3.25.76) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2484,8 +2488,8 @@ importers: specifier: ^0.10.0 version: 0.10.0 ai: - specifier: 4.2.5 - version: 4.2.5(react@19.0.0)(zod@3.25.76) + specifier: 6.0.116 + version: 6.0.116(zod@3.25.76) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2582,7 +2586,7 @@ importers: specifier: workspace:* version: link:../../packages/trigger-sdk ai: - specifier: ^6.0.116 + specifier: 6.0.116 version: 6.0.116(zod@3.25.76) arktype: specifier: ^2.0.0 @@ -2699,8 +2703,8 @@ importers: specifier: ^7.0.3 version: 7.0.3(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(react@18.3.1)(uploadthing@7.1.0(express@5.2.1)(fastify@5.8.5)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1)) ai: - specifier: ^4.0.0 - version: 4.0.0(react@18.3.1)(zod@3.25.76) + specifier: 6.0.116 + version: 6.0.116(zod@3.25.76) class-variance-authority: specifier: ^0.7.0 version: 0.7.0 @@ -2966,8 +2970,8 @@ importers: specifier: workspace:* version: link:../../packages/trigger-sdk ai: - specifier: ^5.0.76 - version: 5.0.76(zod@3.25.76) + specifier: 6.0.116 + version: 6.0.116(zod@3.25.76) next: specifier: 15.5.6 version: 15.5.6(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -3114,30 +3118,6 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/gateway@1.0.6': - resolution: {integrity: sha512-JuSj1MtTr4vw2VBBth4wlbciQnQIV0o1YV9qGLFA+r85nR5H+cJp3jaYE0nprqfzC9rYG8w9c6XGHB3SDKgcgA==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4 - - '@ai-sdk/gateway@2.0.0': - resolution: {integrity: sha512-Gj0PuawK7NkZuyYgO/h5kDK/l6hFOjhLdTq3/Lli1FTl47iGmwhH1IZQpAL3Z09BeFYWakcwUmn02ovIm2wy9g==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/gateway@3.0.104': - resolution: {integrity: sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - '@ai-sdk/gateway@3.0.2': - resolution: {integrity: sha512-giJEg9ob45htbu3iautK+2kvplY2JnTj7ir4wZzYSQWvqGatWfBBfDuNCU5wSJt9BCGjymM5ZS9ziD42JGCZBw==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/gateway@3.0.66': resolution: {integrity: sha512-SIQ0YY0iMuv+07HLsZ+bB990zUJ6S4ujORAh+Jv1V2KGNn73qQKnGO0JBk+w+Res8YqOFSycwDoWcFlQrVxS4A==} engines: {node: '>=18'} @@ -3210,12 +3190,6 @@ packages: peerDependencies: zod: ^3.25.76 || ^4 - '@ai-sdk/provider-utils@4.0.1': - resolution: {integrity: sha512-de2v8gH9zj47tRI38oSxhQIewmNc+OZjYIOOaMoVWKL65ERSav2PYYZHPSPCrfOeLMkv+Dyh8Y0QGwkO29wMWQ==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@4.0.19': resolution: {integrity: sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg==} engines: {node: '>=18'} @@ -3244,57 +3218,16 @@ packages: resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==} engines: {node: '>=18'} - '@ai-sdk/provider@3.0.0': - resolution: {integrity: sha512-m9ka3ptkPQbaHHZHqDXDF9C9B5/Mav0KTdky1k2HZ3/nrW2t1AgObxIVPyGDWQNS9FXT/FS6PIoSjpcP/No8rQ==} - engines: {node: '>=18'} - '@ai-sdk/provider@3.0.8': resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} engines: {node: '>=18'} - '@ai-sdk/react@1.0.0': - resolution: {integrity: sha512-BDrZqQA07Btg64JCuhFvBgYV+tt2B8cXINzEqWknGoxqcwgdE8wSLG2gkXoLzyC2Rnj7oj0HHpOhLUxDCmoKZg==} - engines: {node: '>=18'} - peerDependencies: - react: ^18 || ^19 || ^19.0.0-rc - zod: ^3.0.0 - peerDependenciesMeta: - react: - optional: true - zod: - optional: true - - '@ai-sdk/react@1.2.2': - resolution: {integrity: sha512-rxyNTFjUd3IilVOJFuUJV5ytZBYAIyRi50kFS2gNmSEiG4NHMBBm31ddrxI/i86VpY8gzZVp1/igtljnWBihUA==} - engines: {node: '>=18'} - peerDependencies: - react: ^18 || ^19 || ^19.0.0-rc - zod: ^3.23.8 - peerDependenciesMeta: - zod: - optional: true - '@ai-sdk/react@3.0.170': resolution: {integrity: sha512-YUDn+mK0c8iUz14rCBf1A0zg6SV5b5aSVUz+azF1bdBd1SFXVI19dKYR+PQSpZY+0+z+zs252AAsacUqiO98Kw==} engines: {node: '>=18'} peerDependencies: react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 - '@ai-sdk/ui-utils@1.0.0': - resolution: {integrity: sha512-oXBDIM/0niWeTWyw77RVl505dNxBUDLLple7bTsqo2d3i1UKwGlzBUX8XqZsh7GbY7I6V05nlG0Y8iGlWxv1Aw==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.0.0 - peerDependenciesMeta: - zod: - optional: true - - '@ai-sdk/ui-utils@1.2.1': - resolution: {integrity: sha512-BzvMbYm7LHBlbWuLlcG1jQh4eu14MGpz7L+wrGO1+F4oQ+O0fAjgUSNwPWGlZpKmg4NrcVq/QLmxiVJrx2R4Ew==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.23.8 - '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -9332,6 +9265,9 @@ packages: '@s2-dev/streamstore@0.22.10': resolution: {integrity: sha512-dtm+oFHVE8szINwOUoNQdx9xpGSJOrcAEvsxspPFvomjYKGnmhIRmU4OX8o6kxcPoiK76S1tPeU0smjZdmOngA==} + '@s2-dev/streamstore@0.22.5': + resolution: {integrity: sha512-GqdOKIbIoIxT+40fnKzHbrsHB6gBqKdECmFe7D3Ojk4FoN1Hu0LhFzZv6ZmVMjoHHU+55debS1xSWjZwQmbIyQ==} + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -10429,12 +10365,20 @@ packages: '@total-typescript/ts-reset@0.4.2': resolution: {integrity: sha512-vqd7ZUDSrXFVT1n8b2kc3LnklncDQFPvR58yUS1kEP23/nHPAO9l1lMjUfnPrXYYk4Hj54rrLKMW5ipwk7k09A==} + '@trigger.dev/build@4.4.6': + resolution: {integrity: sha512-eHPPaeuFe9GZDndQzP4QUlxocyIJWYSx0FMx1GEiAnEVKwXWUqiW72DRFH7cr9v7IQnI9YbAWRuWvyMPHSVLwg==} + engines: {node: '>=18.20.0'} + '@trigger.dev/companyicons@1.5.35': resolution: {integrity: sha512-AhY7yshwh0onlgB6EGiyjyLSzl38Cuxo4tpUJVHxs5im8gDA+fuUq7o6Vz1WetFeNXwjMqh3f+bPW7bfqR4epg==} peerDependencies: react: ^18.2.0 react-dom: 18.2.0 + '@trigger.dev/core@4.4.6': + resolution: {integrity: sha512-oXAjxBNVMiKXUKj1EnHUlO2ULujc4Dy8ad+H/59DYZGY1barkGVzrQAQIpBZo/kDygu7dKGT+F5yBkbIkYjAUw==} + engines: {node: '>=18.20.0'} + '@trigger.dev/platform@1.0.27': resolution: {integrity: sha512-gYfYDBnp2RJpgL4V2oOeqzV20flMZg+oGALhxHmmNJdJM01MW4aJSLsFFjoj+dikpL+4/3gocJcoyZk1G02UNg==} @@ -10598,9 +10542,6 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/diff-match-patch@1.0.36': - resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} - '@types/docker-modem@3.0.6': resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==} @@ -11058,22 +10999,10 @@ packages: '@vanilla-extract/private@1.0.3': resolution: {integrity: sha512-17kVyLq3ePTKOkveHxXuIJZtGYs+cSoev7BlP+Lf4916qfDhk/HBjvlYDe8egrea7LNPHKwSZJK/bzZC+Q6AwQ==} - '@vercel/oidc@3.0.3': - resolution: {integrity: sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg==} - engines: {node: '>= 20'} - - '@vercel/oidc@3.0.5': - resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==} - engines: {node: '>= 20'} - '@vercel/oidc@3.1.0': resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} engines: {node: '>= 20'} - '@vercel/oidc@3.2.0': - resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} - engines: {node: '>= 20'} - '@vercel/otel@1.13.0': resolution: {integrity: sha512-esRkt470Y2jRK1B1g7S1vkt4Csu44gp83Zpu8rIyPoqy2BKgk4z7ik1uSMswzi45UogLHFl6yR5TauDurBQi4Q==} engines: {node: '>=18'} @@ -11369,58 +11298,12 @@ packages: ahocorasick@1.0.2: resolution: {integrity: sha512-hCOfMzbFx5IDutmWLAt6MZwOUjIfSM9G9FyVxytmE4Rs/5YDPWQrD/+IR1w+FweD9H2oOZEnv36TmkjhNURBVA==} - ai@4.0.0: - resolution: {integrity: sha512-cqf2GCaXnOPhUU+Ccq6i+5I0jDjnFkzfq7t6mc0SUSibSa1wDPn5J4p8+Joh2fDGDYZOJ44rpTW9hSs40rXNAw==} - engines: {node: '>=18'} - peerDependencies: - react: ^18 || ^19 || ^19.0.0-rc - zod: ^3.0.0 - peerDependenciesMeta: - react: - optional: true - zod: - optional: true - - ai@4.2.5: - resolution: {integrity: sha512-URJEslI3cgF/atdTJHtz+Sj0W1JTmiGmD3znw9KensL3qV605odktDim+GTazNJFPR4QaIu1lUio5b8RymvOjA==} - engines: {node: '>=18'} - peerDependencies: - react: ^18 || ^19 || ^19.0.0-rc - zod: ^3.23.8 - peerDependenciesMeta: - react: - optional: true - - ai@5.0.14: - resolution: {integrity: sha512-xiujFa879skB7YxGzbeHAxepsr6AEaWcHPXrc5a9MRM6p4WdVAwn6mGwVZkBnhqGfZtXFr4LUnU2ayvcjWp5ig==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4 - - ai@5.0.76: - resolution: {integrity: sha512-ZCxi1vrpyCUnDbtYrO/W8GLvyacV9689f00yshTIQ3mFFphbD7eIv40a2AOZBv3GGRA7SSRYIDnr56wcS/gyQg==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - ai@6.0.116: resolution: {integrity: sha512-7yM+cTmyRLeNIXwt4Vj+mrrJgVQ9RMIW5WO0ydoLoYkewIvsMcvUmqS4j2RJTUXaF1HphwmSKUMQ/HypNRGOmA==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - ai@6.0.168: - resolution: {integrity: sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - - ai@6.0.3: - resolution: {integrity: sha512-OOo+/C+sEyscoLnbY3w42vjQDICioVNyS+F+ogwq6O5RJL/vgWGuiLzFwuP7oHTeni/MkmX8tIge48GTdaV7QQ==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -12953,9 +12836,6 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} - diff-match-patch@1.0.5: - resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} - diff@5.1.0: resolution: {integrity: sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==} engines: {node: '>=0.3.1'} @@ -13663,7 +13543,7 @@ packages: resolution: {integrity: sha512-14G+Y1Rqi9xOJck1vykPwaRSPSPpSvaklMS9WjJA04KWIOUwrT3jHUyA/6GkMC0twi1vdkssGS5FstNtVqVCWQ==} hasBin: true peerDependencies: - ai: ^6 + ai: 6.0.116 better-sqlite3: ^11.6.0 peerDependenciesMeta: ai: @@ -14987,9 +14867,6 @@ packages: jose@5.4.0: resolution: {integrity: sha512-6rpxTHPAQyWMb9A35BroFl1Sp0ST3DpPcm5EVIxZxdH+e0Hv9fwhyB3XLKFUcHNpdSDnETmBfuPPTTlYz5+USw==} - jose@6.0.8: - resolution: {integrity: sha512-EyUPtOKyTYq+iMOszO42eobQllaIjJnwkZ2U93aJzNyPibCy7CEvT9UQnaCVB51IAd49gbNdCew1c0LcLTCB2g==} - jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} @@ -15100,11 +14977,6 @@ packages: jsonc-parser@3.2.1: resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==} - jsondiffpatch@0.6.0: - resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} @@ -19193,9 +19065,6 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - tinyexec@1.0.1: - resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==} - tinyexec@1.2.3: resolution: {integrity: sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==} engines: {node: '>=18'} @@ -20392,33 +20261,6 @@ snapshots: '@ai-sdk/provider-utils': 4.0.23(zod@3.25.76) zod: 3.25.76 - '@ai-sdk/gateway@1.0.6(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.3(zod@3.25.76) - zod: 3.25.76 - - '@ai-sdk/gateway@2.0.0(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.12(zod@3.25.76) - '@vercel/oidc': 3.0.3 - zod: 3.25.76 - - '@ai-sdk/gateway@3.0.104(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.23(zod@3.25.76) - '@vercel/oidc': 3.2.0 - zod: 3.25.76 - - '@ai-sdk/gateway@3.0.2(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 3.0.0 - '@ai-sdk/provider-utils': 4.0.1(zod@3.25.76) - '@vercel/oidc': 3.0.5 - zod: 3.25.76 - '@ai-sdk/gateway@3.0.66(zod@3.25.76)': dependencies: '@ai-sdk/provider': 3.0.8 @@ -20496,13 +20338,6 @@ snapshots: zod: 3.25.76 zod-to-json-schema: 3.24.6(zod@3.25.76) - '@ai-sdk/provider-utils@4.0.1(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 3.0.0 - '@standard-schema/spec': 1.1.0 - eventsource-parser: 3.0.6 - zod: 3.25.76 - '@ai-sdk/provider-utils@4.0.19(zod@3.25.76)': dependencies: '@ai-sdk/provider': 3.0.8 @@ -20533,38 +20368,14 @@ snapshots: dependencies: json-schema: 0.4.0 - '@ai-sdk/provider@3.0.0': - dependencies: - json-schema: 0.4.0 - '@ai-sdk/provider@3.0.8': dependencies: json-schema: 0.4.0 - '@ai-sdk/react@1.0.0(react@18.3.1)(zod@3.25.76)': - dependencies: - '@ai-sdk/provider-utils': 2.0.0(zod@3.25.76) - '@ai-sdk/ui-utils': 1.0.0(zod@3.25.76) - swr: 2.2.5(react@18.3.1) - throttleit: 2.1.0 - optionalDependencies: - react: 18.3.1 - zod: 3.25.76 - - '@ai-sdk/react@1.2.2(react@19.0.0)(zod@3.25.76)': - dependencies: - '@ai-sdk/provider-utils': 2.2.1(zod@3.25.76) - '@ai-sdk/ui-utils': 1.2.1(zod@3.25.76) - react: 19.0.0 - swr: 2.2.5(react@19.0.0) - throttleit: 2.1.0 - optionalDependencies: - zod: 3.25.76 - '@ai-sdk/react@3.0.170(react@18.2.0)(zod@3.25.76)': dependencies: '@ai-sdk/provider-utils': 4.0.23(zod@3.25.76) - ai: 6.0.168(zod@3.25.76) + ai: 6.0.116(zod@3.25.76) react: 18.2.0 swr: 2.2.5(react@18.2.0) throttleit: 2.1.0 @@ -20574,28 +20385,13 @@ snapshots: '@ai-sdk/react@3.0.170(react@19.1.0)(zod@3.25.76)': dependencies: '@ai-sdk/provider-utils': 4.0.23(zod@3.25.76) - ai: 6.0.168(zod@3.25.76) + ai: 6.0.116(zod@3.25.76) react: 19.1.0 swr: 2.2.5(react@19.1.0) throttleit: 2.1.0 transitivePeerDependencies: - zod - '@ai-sdk/ui-utils@1.0.0(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 1.0.0 - '@ai-sdk/provider-utils': 2.0.0(zod@3.25.76) - zod-to-json-schema: 3.24.6(zod@3.25.76) - optionalDependencies: - zod: 3.25.76 - - '@ai-sdk/ui-utils@1.2.1(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 1.1.0 - '@ai-sdk/provider-utils': 2.2.1(zod@3.25.76) - zod: 3.25.76 - zod-to-json-schema: 3.24.6(zod@3.25.76) - '@alloc/quick-lru@5.2.0': {} '@ampproject/remapping@2.3.0': @@ -20608,7 +20404,7 @@ snapshots: '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.4.1 - tinyexec: 1.0.1 + tinyexec: 1.2.3 '@antfu/utils@9.3.0': {} @@ -28170,8 +27966,8 @@ snapshots: dependencies: html-to-text: 9.0.5 js-beautify: 1.15.1 - react: 18.3.1 - react-dom: 18.2.0(react@18.3.1) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) '@react-email/row@0.0.7(react@18.3.1)': dependencies: @@ -28868,6 +28664,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@s2-dev/streamstore@0.22.5': + dependencies: + '@protobuf-ts/runtime': 2.11.1 + debug: 4.4.3(supports-color@10.0.0) + transitivePeerDependencies: + - supports-color + '@sec-ant/readable-stream@0.4.1': {} '@selderee/plugin-htmlparser2@0.11.0': @@ -30350,11 +30153,69 @@ snapshots: '@total-typescript/ts-reset@0.4.2': {} + '@trigger.dev/build@4.4.6(bufferutil@4.0.9)(typescript@5.5.4)': + dependencies: + '@prisma/config': 6.19.0(magicast@0.3.5) + '@trigger.dev/core': 4.4.6(bufferutil@4.0.9) + mlly: 1.7.4 + pkg-types: 1.1.3 + resolve: 1.22.8 + tinyglobby: 0.2.16 + tsconfck: 3.1.3(typescript@5.5.4) + transitivePeerDependencies: + - bufferutil + - magicast + - supports-color + - typescript + - utf-8-validate + '@trigger.dev/companyicons@1.5.35(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) + '@trigger.dev/core@4.4.6(bufferutil@4.0.9)': + dependencies: + '@bugsnag/cuid': 3.1.1 + '@electric-sql/client': 1.0.14 + '@google-cloud/precise-date': 4.0.0 + '@jsonhero/path': 1.0.21 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.203.0 + '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-http': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/host-metrics': 0.37.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)(supports-color@10.0.0) + '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.203.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + '@s2-dev/streamstore': 0.22.5 + dequal: 2.0.3 + eventsource: 3.0.5 + eventsource-parser: 3.0.6 + execa: 8.0.1 + humanize-duration: 3.27.3 + jose: 5.4.0 + nanoid: 3.3.8 + prom-client: 15.1.0 + socket.io: 4.7.4(bufferutil@4.0.9) + socket.io-client: 4.7.5(bufferutil@4.0.9)(supports-color@10.0.0) + std-env: 3.10.0 + tinyexec: 0.3.2 + uncrypto: 0.1.3 + zod: 3.25.76 + zod-error: 1.5.0 + zod-validation-error: 1.5.0(zod@3.25.76) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@trigger.dev/platform@1.0.27': dependencies: zod: 3.23.8 @@ -30548,8 +30409,6 @@ snapshots: '@types/deep-eql@4.0.2': {} - '@types/diff-match-patch@1.0.36': {} - '@types/docker-modem@3.0.6': dependencies: '@types/node': 20.14.14 @@ -31154,14 +31013,8 @@ snapshots: '@vanilla-extract/private@1.0.3': {} - '@vercel/oidc@3.0.3': {} - - '@vercel/oidc@3.0.5': {} - '@vercel/oidc@3.1.0': {} - '@vercel/oidc@3.2.0': {} - '@vercel/otel@1.13.0(@opentelemetry/api-logs@0.203.0)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))': dependencies: '@opentelemetry/api': 1.9.0 @@ -31529,47 +31382,6 @@ snapshots: ahocorasick@1.0.2: {} - ai@4.0.0(react@18.3.1)(zod@3.25.76): - dependencies: - '@ai-sdk/provider': 1.0.0 - '@ai-sdk/provider-utils': 2.0.0(zod@3.25.76) - '@ai-sdk/react': 1.0.0(react@18.3.1)(zod@3.25.76) - '@ai-sdk/ui-utils': 1.0.0(zod@3.25.76) - '@opentelemetry/api': 1.9.0 - jsondiffpatch: 0.6.0 - zod-to-json-schema: 3.24.6(zod@3.25.76) - optionalDependencies: - react: 18.3.1 - zod: 3.25.76 - - ai@4.2.5(react@19.0.0)(zod@3.25.76): - dependencies: - '@ai-sdk/provider': 1.1.0 - '@ai-sdk/provider-utils': 2.2.1(zod@3.25.76) - '@ai-sdk/react': 1.2.2(react@19.0.0)(zod@3.25.76) - '@ai-sdk/ui-utils': 1.2.1(zod@3.25.76) - '@opentelemetry/api': 1.9.0 - jsondiffpatch: 0.6.0 - zod: 3.25.76 - optionalDependencies: - react: 19.0.0 - - ai@5.0.14(zod@3.25.76): - dependencies: - '@ai-sdk/gateway': 1.0.6(zod@3.25.76) - '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.3(zod@3.25.76) - '@opentelemetry/api': 1.9.0 - zod: 3.25.76 - - ai@5.0.76(zod@3.25.76): - dependencies: - '@ai-sdk/gateway': 2.0.0(zod@3.25.76) - '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.12(zod@3.25.76) - '@opentelemetry/api': 1.9.0 - zod: 3.25.76 - ai@6.0.116(zod@3.25.76): dependencies: '@ai-sdk/gateway': 3.0.66(zod@3.25.76) @@ -31578,22 +31390,6 @@ snapshots: '@opentelemetry/api': 1.9.0 zod: 3.25.76 - ai@6.0.168(zod@3.25.76): - dependencies: - '@ai-sdk/gateway': 3.0.104(zod@3.25.76) - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.23(zod@3.25.76) - '@opentelemetry/api': 1.9.0 - zod: 3.25.76 - - ai@6.0.3(zod@3.25.76): - dependencies: - '@ai-sdk/gateway': 3.0.2(zod@3.25.76) - '@ai-sdk/provider': 3.0.0 - '@ai-sdk/provider-utils': 4.0.1(zod@3.25.76) - '@opentelemetry/api': 1.9.0 - zod: 3.25.76 - ajv-formats@2.1.1(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -33201,8 +32997,6 @@ snapshots: didyoumean@1.2.2: {} - diff-match-patch@1.0.5: {} - diff@5.1.0: {} dir-glob@3.0.1: @@ -34217,7 +34011,7 @@ snapshots: dependencies: require-like: 0.1.2 - evalite@1.0.0-beta.16(ai@6.0.168(zod@3.25.76))(better-sqlite3@11.10.0)(bufferutil@4.0.9): + evalite@1.0.0-beta.16(ai@6.0.116(zod@3.25.76))(better-sqlite3@11.10.0)(bufferutil@4.0.9): dependencies: '@fastify/static': 8.2.0 '@fastify/websocket': 11.2.0(bufferutil@4.0.9) @@ -34234,7 +34028,7 @@ snapshots: table: 6.9.0 tinyrainbow: 3.1.0 optionalDependencies: - ai: 6.0.168(zod@3.25.76) + ai: 6.0.116(zod@3.25.76) better-sqlite3: 11.10.0 transitivePeerDependencies: - bufferutil @@ -35784,8 +35578,6 @@ snapshots: jose@5.4.0: {} - jose@6.0.8: {} - jose@6.1.3: {} joycon@3.1.1: {} @@ -35869,12 +35661,6 @@ snapshots: jsonc-parser@3.2.1: {} - jsondiffpatch@0.6.0: - dependencies: - '@types/diff-match-patch': 1.0.36 - chalk: 5.3.0 - diff-match-patch: 1.0.5 - jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 @@ -36278,8 +36064,8 @@ snapshots: magicast@0.3.5: dependencies: - '@babel/parser': 7.27.5 - '@babel/types': 7.27.3 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 source-map-js: 1.2.1 optional: true @@ -37695,7 +37481,7 @@ snapshots: consola: 3.4.2 pathe: 2.0.3 pkg-types: 2.3.0 - tinyexec: 1.0.1 + tinyexec: 1.2.3 oauth-sign@0.9.0: {} @@ -37909,7 +37695,7 @@ snapshots: openid-client@6.3.3: dependencies: - jose: 6.0.8 + jose: 6.1.3 oauth4webapi: 3.3.0 optionator@0.9.1: @@ -40805,12 +40591,6 @@ snapshots: react: 18.3.1 use-sync-external-store: 1.2.2(react@18.3.1) - swr@2.2.5(react@19.0.0): - dependencies: - client-only: 0.0.1 - react: 19.0.0 - use-sync-external-store: 1.2.2(react@19.0.0) - swr@2.2.5(react@19.1.0): dependencies: client-only: 0.0.1 @@ -41140,8 +40920,6 @@ snapshots: tinyexec@0.3.2: {} - tinyexec@1.0.1: {} - tinyexec@1.2.3: {} tinyglobby@0.2.10: @@ -41795,10 +41573,6 @@ snapshots: dependencies: react: 18.3.1 - use-sync-external-store@1.2.2(react@19.0.0): - dependencies: - react: 19.0.0 - use-sync-external-store@1.2.2(react@19.1.0): dependencies: react: 19.1.0 @@ -41988,11 +41762,11 @@ snapshots: vite@6.4.2(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3): dependencies: esbuild: 0.25.1 - fdir: 6.4.4(picomatch@4.0.4) + fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.10 rollup: 4.60.1 - tinyglobby: 0.2.13 + tinyglobby: 0.2.16 optionalDependencies: '@types/node': 20.14.14 fsevents: 2.3.3 @@ -42005,11 +41779,11 @@ snapshots: vite@6.4.2(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3): dependencies: esbuild: 0.25.1 - fdir: 6.4.4(picomatch@4.0.4) + fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.10 rollup: 4.60.1 - tinyglobby: 0.2.13 + tinyglobby: 0.2.16 optionalDependencies: '@types/node': 20.14.14 fsevents: 2.3.3 From f35a328e8ac54f11f74b4898f667a1be914b992e Mon Sep 17 00:00:00 2001 From: Dan <51248046+danton267@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:30:06 +0100 Subject: [PATCH 03/12] feat(ai-assistant): add chat panel UI and wire into dashboard nav Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/webapp/app/components/AskAI.tsx | 562 +----------------- .../ai-assistant/AIChatContextBanner.tsx | 23 + .../components/ai-assistant/AIChatHeader.tsx | 83 +++ .../components/ai-assistant/AIChatInput.tsx | 73 +++ .../ai-assistant/AIChatMessages.tsx | 101 ++++ .../components/ai-assistant/AIChatPanel.tsx | 154 +++++ .../ai-assistant/AIChatProvider.tsx | 203 +++++++ .../ai-assistant/AIChatSuggestedPrompts.tsx | 33 + .../ai-assistant/AIChatToolCall.tsx | 27 + .../ai-assistant/suggested-prompts.ts | 40 ++ .../app/components/navigation/SideMenu.tsx | 2 - .../app/components/primitives/PageHeader.tsx | 6 +- .../route.tsx | 43 +- 13 files changed, 809 insertions(+), 541 deletions(-) create mode 100644 apps/webapp/app/components/ai-assistant/AIChatContextBanner.tsx create mode 100644 apps/webapp/app/components/ai-assistant/AIChatHeader.tsx create mode 100644 apps/webapp/app/components/ai-assistant/AIChatInput.tsx create mode 100644 apps/webapp/app/components/ai-assistant/AIChatMessages.tsx create mode 100644 apps/webapp/app/components/ai-assistant/AIChatPanel.tsx create mode 100644 apps/webapp/app/components/ai-assistant/AIChatProvider.tsx create mode 100644 apps/webapp/app/components/ai-assistant/AIChatSuggestedPrompts.tsx create mode 100644 apps/webapp/app/components/ai-assistant/AIChatToolCall.tsx create mode 100644 apps/webapp/app/components/ai-assistant/suggested-prompts.ts diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx index 814d4649c8f..a7dc4671d29 100644 --- a/apps/webapp/app/components/AskAI.tsx +++ b/apps/webapp/app/components/AskAI.tsx @@ -1,549 +1,45 @@ -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 { 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 { 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; -} +import { useOptionalAIChat } from "./ai-assistant/AIChatProvider"; -export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) { - const { isManagedCloud } = useFeatures(); - const websiteId = useKapaWebsiteId(); +export function AskAI() { + const chat = useOptionalAIChat(); - if (!isManagedCloud || !websiteId) { + // The provider is only mounted in the project layout. On account/settings + // pages there's no assistant, so render nothing. + if (!chat) { return null; } return ( - - - - } - > - {() => } - - ); -} - -type AskAIProviderProps = { - websiteId: string; - isCollapsed?: boolean; -}; - -function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) { - 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 ( - 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} -
-
+ + + + + + + AI Assistant + + + + + + + ); -} +} \ No newline at end of file diff --git a/apps/webapp/app/components/ai-assistant/AIChatContextBanner.tsx b/apps/webapp/app/components/ai-assistant/AIChatContextBanner.tsx new file mode 100644 index 00000000000..be6912ce69a --- /dev/null +++ b/apps/webapp/app/components/ai-assistant/AIChatContextBanner.tsx @@ -0,0 +1,23 @@ +interface AIChatContextBannerProps { + projectSlug: string; + environmentSlug: string; + currentPage: string; +} + +export function AIChatContextBanner({ + projectSlug, + environmentSlug, + currentPage, +}: AIChatContextBannerProps) { + if (!projectSlug) return null; + + return ( +
+ {projectSlug} + / + {environmentSlug} + / + {currentPage} +
+ ); +} \ No newline at end of file diff --git a/apps/webapp/app/components/ai-assistant/AIChatHeader.tsx b/apps/webapp/app/components/ai-assistant/AIChatHeader.tsx new file mode 100644 index 00000000000..d5774e95a18 --- /dev/null +++ b/apps/webapp/app/components/ai-assistant/AIChatHeader.tsx @@ -0,0 +1,83 @@ +import { XMarkIcon, PlusIcon, ClockIcon } from "@heroicons/react/20/solid"; +import { useState, useRef, useEffect } from "react"; +import { AISparkleIcon } from "~/assets/icons/AISparkleIcon"; +import { useAIChat } from "./AIChatProvider"; + +export function AIChatHeader() { + const { close, startNewChat, chatHistory, switchChat } = useAIChat(); + const [showHistory, setShowHistory] = useState(false); + const historyRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (historyRef.current && !historyRef.current.contains(event.target as Node)) { + setShowHistory(false); + } + } + if (showHistory) { + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + } + }, [showHistory]); + + return ( +
+
+ + AI Assistant +
+
+ {/* History */} +
+ + {showHistory && chatHistory.length > 0 && ( +
+
+ {chatHistory.map((chat) => ( + + ))} +
+
+ )} +
+ + {/* New chat */} + + + {/* Close */} + +
+
+ ); +} \ No newline at end of file diff --git a/apps/webapp/app/components/ai-assistant/AIChatInput.tsx b/apps/webapp/app/components/ai-assistant/AIChatInput.tsx new file mode 100644 index 00000000000..61cdec7d440 --- /dev/null +++ b/apps/webapp/app/components/ai-assistant/AIChatInput.tsx @@ -0,0 +1,73 @@ +import { ArrowUpIcon, StopIcon } from "@heroicons/react/20/solid"; +import { useRef, useEffect } from "react"; +import { Button } from "~/components/primitives/Buttons"; +import { Spinner } from "~/components/primitives/Spinner"; + +interface AIChatInputProps { + input: string; + onInputChange: (e: React.ChangeEvent) => void; + onSubmit: (e: React.FormEvent) => void; + onStop: () => void; + isLoading: boolean; + status: string; +} + +export function AIChatInput({ + input, + onInputChange, + onSubmit, + onStop, + isLoading, + status, +}: AIChatInputProps) { + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const isStreaming = status === "streaming"; + const isPreparing = status === "submitted"; + + return ( +
+
+ + {isStreaming ? ( + + ) : isPreparing ? ( +
+
+ +
+
+ ) : ( +
+
+ ); +} \ No newline at end of file diff --git a/apps/webapp/app/components/ai-assistant/AIChatMessages.tsx b/apps/webapp/app/components/ai-assistant/AIChatMessages.tsx new file mode 100644 index 00000000000..a18883ecf06 --- /dev/null +++ b/apps/webapp/app/components/ai-assistant/AIChatMessages.tsx @@ -0,0 +1,101 @@ +import { useEffect, useRef } from "react"; +import { Link } from "@remix-run/react"; +import DOMPurify from "dompurify"; +import { marked } from "marked"; +import type { UIMessage } from "ai"; +import { AIChatToolCall } from "./AIChatToolCall"; + +interface AIChatMessagesProps { + messages: UIMessage[]; +} + +export function AIChatMessages({ messages }: AIChatMessagesProps) { + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + return ( +
+ {messages.map((message) => ( +
+ {message.role === "user" && ( +
+ {message.parts + .filter((p): p is Extract => p.type === "text") + .map((p, i) => ( + {p.text} + ))} +
+ )} + {message.role === "assistant" && ( +
+ {message.parts.map((part, i) => { + if (part.type === "text") { + return ( +
+ ); + } + // AI SDK v6 tool parts: `tool-${name}` (typed) or "dynamic-tool". + if (part.type === "dynamic-tool" || part.type.startsWith("tool-")) { + const toolPart = part as { + type: string; + state: string; + toolName?: string; + output?: unknown; + }; + const toolName = + part.type === "dynamic-tool" + ? toolPart.toolName ?? "tool" + : part.type.slice("tool-".length); + + // Show spinner while the tool input is being produced / called. + if ( + toolPart.state === "input-streaming" || + toolPart.state === "input-available" + ) { + return ; + } + + // Render navigation results as clickable links. + if (toolName === "navigateToPage" && toolPart.state === "output-available") { + const result = toolPart.output as { + found: boolean; + url?: string; + pageName?: string; + description?: string; + message?: string; + }; + if (result?.found && result.url) { + return ( +
+ + Go to {result.pageName} → + +
+ ); + } + } + // Other tool results are consumed by the LLM, no UI needed. + return null; + } + return null; + })} +
+ )} +
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/apps/webapp/app/components/ai-assistant/AIChatPanel.tsx b/apps/webapp/app/components/ai-assistant/AIChatPanel.tsx new file mode 100644 index 00000000000..0a984efcd75 --- /dev/null +++ b/apps/webapp/app/components/ai-assistant/AIChatPanel.tsx @@ -0,0 +1,154 @@ +import { useChat } from "@ai-sdk/react"; +import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { dashboardAssistant } from "~/trigger/ai-assistant"; +import { useAIChat } from "./AIChatProvider"; +import { AIChatHeader } from "./AIChatHeader"; +import { AIChatContextBanner } from "./AIChatContextBanner"; +import { AIChatMessages } from "./AIChatMessages"; +import { AIChatSuggestedPrompts } from "./AIChatSuggestedPrompts"; +import { AIChatInput } from "./AIChatInput"; + +async function postAssistant(body: Record): Promise { + const res = await fetch("/resources/ai-assistant", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(`ai-assistant ${body.intent} failed: ${res.status}`); + } + return res.json(); +} + +export function AIChatPanel() { + const { + currentChatId, + currentChatMessages, + pageContext, + close, + pendingQuery, + clearPendingQuery, + refreshHistory, + } = useAIChat(); + + const [input, setInput] = useState(""); + + const transport = useTriggerChatTransport({ + task: "dashboard-assistant", + // Head Start intentionally disabled: running every turn inside the agent + // run is what surfaces the LLM + tool-call spans in the trace (at the cost + // of ~750ms first-token). + baseURL: typeof window !== "undefined" ? window.location.origin : undefined, + clientData: pageContext, + // Mint a fresh session-scoped PAT. Fired on first use + 401/403 refresh. + accessToken: async ({ chatId }) => { + const { publicAccessToken } = await postAssistant({ + intent: "refreshToken", + chatId, + clientData: pageContext, + }); + return publicAccessToken; + }, + // Create (or resume) the session + trigger the first run server-side. + startSession: async ({ chatId, clientData }) => { + const { publicAccessToken } = await postAssistant({ + intent: "createSession", + chatId, + clientData, + }); + return { publicAccessToken }; + }, + }); + + const { messages, sendMessage, status, stop: aiStop } = useChat({ + id: currentChatId, + messages: currentChatMessages, + transport, + }); + + const stop = useCallback(() => { + transport.stopGeneration(currentChatId); + aiStop(); + }, [transport, currentChatId, aiStop]); + + // Warm the agent run when the panel opens so it's waiting on `session.in` + // by the time the user sends — a cold boot races the first message ahead of + // the run's waitpoint and silently drops the turn. + useEffect(() => { + void transport.preload(currentChatId); + }, [transport, currentChatId]); + + const submit = useCallback( + (text: string) => { + const trimmed = text.trim(); + if (!trimmed) return; + void sendMessage({ text: trimmed }); + setInput(""); + }, + [sendMessage] + ); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + submit(input); + }, + [submit, input] + ); + + // A pending query is set when the assistant is opened with an initial + // question (e.g. from a "Ask AI about this" affordance). Send it once. + const sentPending = useRef(false); + useEffect(() => { + if (pendingQuery && !sentPending.current) { + sentPending.current = true; + submit(pendingQuery); + clearPendingQuery(); + } + if (!pendingQuery) { + sentPending.current = false; + } + }, [pendingQuery, submit, clearPendingQuery]); + + // Refresh the chat history list once a turn settles (title/rows are written + // server-side by the agent's onTurnStart/onTurnComplete hooks). + const prevStatus = useRef(status); + useEffect(() => { + if (prevStatus.current === "streaming" && status === "ready") { + refreshHistory(); + } + prevStatus.current = status; + }, [status, refreshHistory]); + + const isLoading = status === "submitted" || status === "streaming"; + const isEmpty = messages.length === 0; + + return ( +
+ + + + {isEmpty ? ( +
+ +
+ ) : ( + + )} + + setInput(e.target.value)} + onSubmit={handleSubmit} + onStop={stop} + isLoading={isLoading} + status={status} + /> +
+ ); +} diff --git a/apps/webapp/app/components/ai-assistant/AIChatProvider.tsx b/apps/webapp/app/components/ai-assistant/AIChatProvider.tsx new file mode 100644 index 00000000000..e2d861807d5 --- /dev/null +++ b/apps/webapp/app/components/ai-assistant/AIChatProvider.tsx @@ -0,0 +1,203 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; +import { useParams, useLocation } from "@remix-run/react"; +import type { UIMessage } from "ai"; + +interface PageContext { + userId: string; + organizationSlug: string; + projectSlug: string; + environmentSlug: string; + currentPage: string; + currentParams: Record; +} + +interface ChatHistoryEntry { + id: string; + title: string; + updatedAt: string; +} + +interface SessionState { + publicAccessToken: string; + lastEventId: string | null; +} + +interface AIChatContextValue { + isOpen: boolean; + toggle: () => void; + open: (initialQuery?: string) => void; + close: () => void; + currentChatId: string; + startNewChat: () => void; + switchChat: (chatId: string) => void; + chatHistory: ChatHistoryEntry[]; + refreshHistory: () => void; + currentChatMessages: UIMessage[] | undefined; + sessionState: SessionState | undefined; + pageContext: PageContext; + pendingQuery: string | undefined; + clearPendingQuery: () => void; +} + +const AIChatContext = createContext(null); + +export function useAIChat() { + const ctx = useContext(AIChatContext); + if (!ctx) { + throw new Error("useAIChat must be used within an AIChatProvider"); + } + return ctx; +} + +/** + * Like useAIChat, but returns null instead of throwing when there is no + * provider. The provider is only mounted inside the project layout, so + * components rendered on account/org-settings pages (e.g. the global NavBar + * AskAI button) use this to no-op when the assistant isn't available. + */ +export function useOptionalAIChat() { + return useContext(AIChatContext); +} + +function generateChatId() { + return `chat_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +} + +function usePageContext(userId: string): PageContext { + const params = useParams(); + const location = useLocation(); + const segments = location.pathname.split("/").filter(Boolean); + const currentPage = segments[segments.length - 1] ?? "overview"; + + return { + userId, + organizationSlug: params.organizationSlug ?? "", + projectSlug: params.projectParam ?? "", + environmentSlug: params.envParam ?? "", + currentPage, + currentParams: params as Record, + }; +} + +export function AIChatProvider({ + userId, + children, +}: { + userId: string; + children: ReactNode; +}) { + const [isOpen, setIsOpen] = useState(false); + const [currentChatId, setCurrentChatId] = useState(() => generateChatId()); + const [chatHistory, setChatHistory] = useState([]); + const [currentChatMessages, setCurrentChatMessages] = useState(); + const [sessionState, setSessionState] = useState(); + const [pendingQuery, setPendingQuery] = useState(); + + const pageContext = usePageContext(userId); + + const refreshHistory = useCallback(async () => { + try { + const res = await fetch("/resources/ai-assistant/history"); + if (res.ok) { + const data = (await res.json()) as { chats?: ChatHistoryEntry[] }; + setChatHistory(data.chats ?? []); + } + } catch { + // Silently fail — history is non-critical + } + }, []); + + const open = useCallback( + (initialQuery?: string) => { + if (initialQuery) { + setPendingQuery(initialQuery); + } + setIsOpen(true); + }, + [] + ); + + const close = useCallback(() => { + setIsOpen(false); + setPendingQuery(undefined); + }, []); + + const toggle = useCallback(() => { + setIsOpen((prev) => { + if (prev) { + setPendingQuery(undefined); + } + return !prev; + }); + }, []); + + const startNewChat = useCallback(() => { + setCurrentChatId(generateChatId()); + setCurrentChatMessages(undefined); + setSessionState(undefined); + setPendingQuery(undefined); + }, []); + + const switchChat = useCallback( + async (chatId: string) => { + setCurrentChatId(chatId); + setPendingQuery(undefined); + try { + const res = await fetch(`/resources/ai-assistant/chat/${chatId}`); + if (res.ok) { + const data = (await res.json()) as { + chat?: { messages?: UIMessage[] }; + session?: SessionState; + }; + setCurrentChatMessages(data.chat?.messages ?? []); + setSessionState(data.session ?? undefined); + } + } catch { + setCurrentChatMessages(undefined); + setSessionState(undefined); + } + }, + [] + ); + + const clearPendingQuery = useCallback(() => { + setPendingQuery(undefined); + }, []); + + // Load history when panel opens + useEffect(() => { + if (isOpen) { + refreshHistory(); + } + }, [isOpen, refreshHistory]); + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/apps/webapp/app/components/ai-assistant/AIChatSuggestedPrompts.tsx b/apps/webapp/app/components/ai-assistant/AIChatSuggestedPrompts.tsx new file mode 100644 index 00000000000..945c2d30b75 --- /dev/null +++ b/apps/webapp/app/components/ai-assistant/AIChatSuggestedPrompts.tsx @@ -0,0 +1,33 @@ +import { SparkleListIcon } from "~/assets/icons/SparkleListIcon"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { getPrompts } from "./suggested-prompts"; + +interface AIChatSuggestedPromptsProps { + currentPage: string; + onSelect: (prompt: string) => void; +} + +export function AIChatSuggestedPrompts({ currentPage, onSelect }: AIChatSuggestedPromptsProps) { + const prompts = getPrompts(currentPage); + + return ( +
+ + I can help you navigate the dashboard, find documentation, and understand Trigger.dev + features. Ask me anything. + + {prompts.map((prompt, index) => ( + + ))} +
+ ); +} \ No newline at end of file diff --git a/apps/webapp/app/components/ai-assistant/AIChatToolCall.tsx b/apps/webapp/app/components/ai-assistant/AIChatToolCall.tsx new file mode 100644 index 00000000000..3156e055cce --- /dev/null +++ b/apps/webapp/app/components/ai-assistant/AIChatToolCall.tsx @@ -0,0 +1,27 @@ +import { Spinner } from "~/components/primitives/Spinner"; + +const TOOL_LABELS: Record = { + searchDocs: "Searching documentation…", + navigateToPage: "Finding page…", + getCurrentContext: "Checking context…", + searchPages: "Searching pages…", +}; + +interface AIChatToolCallProps { + toolName: string; + state: string; +} + +export function AIChatToolCall({ toolName, state }: AIChatToolCallProps) { + const label = TOOL_LABELS[toolName] ?? `Running ${toolName}…`; + const isRunning = state === "input-streaming" || state === "input-available"; + + if (!isRunning) return null; + + return ( +
+ + {label} +
+ ); +} \ No newline at end of file diff --git a/apps/webapp/app/components/ai-assistant/suggested-prompts.ts b/apps/webapp/app/components/ai-assistant/suggested-prompts.ts new file mode 100644 index 00000000000..c2de1f34456 --- /dev/null +++ b/apps/webapp/app/components/ai-assistant/suggested-prompts.ts @@ -0,0 +1,40 @@ +// Page → suggested prompts. Imported by frontend components, so keep it free +// of server-side dependencies. + +const DEFAULT_PROMPTS = [ + "How do retries work?", + "Where do I configure concurrency?", + "How do I deploy my task?", +]; + +export const SUGGESTED_PROMPTS: Record = { + runs: [ + "How do I filter runs?", + "How do I replay a failed run?", + "What do the run statuses mean?", + ], + errors: [ + "How do I debug task errors?", + "How do I set up error alerts?", + "What causes SYSTEM_FAILURE?", + ], + deployments: [ + "How do I set up CI/CD deployments?", + "How do preview branches work?", + "How do I rollback a deployment?", + ], + schedules: [ + "How do I create a cron schedule?", + "How does timezone handling work?", + "Can I pause a schedule?", + ], + "environment-variables": [ + "How do environment variables work?", + "How do I sync env vars from Vercel?", + "Can I use different values per environment?", + ], +}; + +export function getPrompts(pageId: string): string[] { + return SUGGESTED_PROMPTS[pageId] ?? DEFAULT_PROMPTS; +} \ No newline at end of file diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index b8cf5c8e72e..132d198305e 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -98,7 +98,6 @@ import { v3WaitpointTokensPath, } from "~/utils/pathBuilder"; import { AlphaBadge } from "../AlphaBadge"; -import { AskAI } from "../AskAI"; import { FreePlanUsage } from "../billing/FreePlanUsage"; import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence"; import { ImpersonationBanner } from "../ImpersonationBanner"; @@ -1202,7 +1201,6 @@ function HelpAndAI({ isCollapsed, organizationId, projectId }: { isCollapsed: bo > -
); diff --git a/apps/webapp/app/components/primitives/PageHeader.tsx b/apps/webapp/app/components/primitives/PageHeader.tsx index 7855e241e34..41aff42b8db 100644 --- a/apps/webapp/app/components/primitives/PageHeader.tsx +++ b/apps/webapp/app/components/primitives/PageHeader.tsx @@ -6,6 +6,7 @@ import { BreadcrumbIcon } from "./BreadcrumbIcon"; import { Header2 } from "./Headers"; import { LoadingBarDivider } from "./LoadingBarDivider"; import { EnvironmentBanner } from "../navigation/EnvironmentBanner"; +import { AskAI } from "../AskAI"; type WithChildren = { children: React.ReactNode; @@ -22,7 +23,10 @@ export function NavBar({ children }: WithChildren) { return (
-
{children}
+
+
{children}
+ +
{showUpgradePrompt.shouldShow && organization ? : } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam/route.tsx index 3f4be602aa7..bf3c4cbc8cd 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam/route.tsx @@ -8,6 +8,39 @@ import { useIsImpersonating, useOrganization, useOrganizations } from "~/hooks/u import { useProject } from "~/hooks/useProject"; import { useUser } from "~/hooks/useUser"; import { v3ProjectPath } from "~/utils/pathBuilder"; +import { AIChatProvider, useAIChat } from "~/components/ai-assistant/AIChatProvider"; +import { AIChatPanel } from "~/components/ai-assistant/AIChatPanel"; +import { useEffect, useState, type ReactNode } from "react"; + +function AIChatLayout({ children }: { children: ReactNode }) { + const { isOpen } = useAIChat(); + + // Keep the panel mounted after the first open so the conversation persists + // across toggles and so the close transition has something to animate. Lazy + // mounting avoids the input stealing focus on every page load. + const [hasOpened, setHasOpened] = useState(false); + useEffect(() => { + if (isOpen) { + setHasOpened(true); + } + }, [isOpen]); + + return ( +
+ {children} + {/* Right-anchored + overflow-hidden so the panel slides in from the right + edge as the column grows, rather than being revealed left-to-right. */} +
+ {hasOpened && } +
+
+ ); +} export default function Project() { const organizations = useOrganizations(); @@ -18,8 +51,8 @@ export default function Project() { const isImpersonating = useIsImpersonating(); return ( - <> -
+ + -
- + + ); } @@ -41,4 +74,4 @@ export function ErrorBoundary() { const org = useOrganization(); const project = useProject(); return ; -} +} \ No newline at end of file From 36628d6bd3956d0270e57652ef8b121773de54e7 Mon Sep 17 00:00:00 2001 From: Dan <51248046+danton267@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:19:20 +0100 Subject: [PATCH 04/12] feat(ai-assistant): enhance chat UI with improved input handling and message display - Updated AskAI component to hide when the chat drawer is open. - Modified AIChatContextBanner for better styling. - Refactored AIChatHeader to include scroll handling and improved chat history display. - Enhanced AIChatInput to support auto-growing text area and focus management. - Improved AIChatMessages to handle scrolling and display messages more effectively. - Added error handling and retry functionality in AIChatPanel. - Introduced new suggested prompts with animation effects. These changes aim to provide a more seamless and user-friendly chat experience in the AI assistant interface. --- apps/webapp/app/components/AskAI.tsx | 4 +- .../ai-assistant/AIChatContextBanner.tsx | 2 +- .../components/ai-assistant/AIChatHeader.tsx | 292 +++++++++++++++--- .../components/ai-assistant/AIChatInput.tsx | 70 ++++- .../ai-assistant/AIChatMessages.tsx | 287 ++++++++++++----- .../components/ai-assistant/AIChatPanel.tsx | 40 ++- .../ai-assistant/AIChatProvider.tsx | 7 +- .../ai-assistant/AIChatSuggestedPrompts.tsx | 65 +++- .../ai-assistant/AIChatToolCall.tsx | 7 +- .../ai-assistant/suggested-prompts.ts | 5 + .../route.tsx | 2 +- .../resources.ai-assistant.chat.$chatId.ts | 2 +- .../routes/resources.ai-assistant.history.ts | 29 +- apps/webapp/app/trigger/ai-assistant.ts | 18 ++ 14 files changed, 643 insertions(+), 187 deletions(-) diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx index a7dc4671d29..24800b75884 100644 --- a/apps/webapp/app/components/AskAI.tsx +++ b/apps/webapp/app/components/AskAI.tsx @@ -13,8 +13,8 @@ export function AskAI() { const chat = useOptionalAIChat(); // The provider is only mounted in the project layout. On account/settings - // pages there's no assistant, so render nothing. - if (!chat) { + // pages there's no assistant, so render nothing. Hide while the drawer is open. + if (!chat || chat.isOpen) { return null; } diff --git a/apps/webapp/app/components/ai-assistant/AIChatContextBanner.tsx b/apps/webapp/app/components/ai-assistant/AIChatContextBanner.tsx index be6912ce69a..4ff3ad480dd 100644 --- a/apps/webapp/app/components/ai-assistant/AIChatContextBanner.tsx +++ b/apps/webapp/app/components/ai-assistant/AIChatContextBanner.tsx @@ -12,7 +12,7 @@ export function AIChatContextBanner({ if (!projectSlug) return null; return ( -
+
{projectSlug} / {environmentSlug} diff --git a/apps/webapp/app/components/ai-assistant/AIChatHeader.tsx b/apps/webapp/app/components/ai-assistant/AIChatHeader.tsx index d5774e95a18..3f9482303d8 100644 --- a/apps/webapp/app/components/ai-assistant/AIChatHeader.tsx +++ b/apps/webapp/app/components/ai-assistant/AIChatHeader.tsx @@ -1,78 +1,268 @@ import { XMarkIcon, PlusIcon, ClockIcon } from "@heroicons/react/20/solid"; -import { useState, useRef, useEffect } from "react"; +import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { AISparkleIcon } from "~/assets/icons/AISparkleIcon"; +import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover"; +import { cn } from "~/utils/cn"; import { useAIChat } from "./AIChatProvider"; -export function AIChatHeader() { - const { close, startNewChat, chatHistory, switchChat } = useAIChat(); - const [showHistory, setShowHistory] = useState(false); - const historyRef = useRef(null); - - // Close dropdown when clicking outside - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (historyRef.current && !historyRef.current.contains(event.target as Node)) { - setShowHistory(false); - } +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +function formatRelativeTime(iso: string): string { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ""; + const diff = Date.now() - then; + + if (diff < MINUTE) return "Just now"; + if (diff < HOUR) { + const mins = Math.floor(diff / MINUTE); + return `${mins} minute${mins === 1 ? "" : "s"} ago`; + } + if (diff < DAY) { + const hours = Math.floor(diff / HOUR); + return `${hours} hour${hours === 1 ? "" : "s"} ago`; + } + if (diff < 2 * DAY) return "Yesterday"; + return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +const SCROLL_END_THRESHOLD_PX = 4; + +type ScrollFadeEdge = "top" | "bottom"; + +const SCROLL_FADE_HEIGHT = "h-8"; + +const scrollFadeBlurLayers: Record = { + bottom: [ + { + blur: "backdrop-blur-[2px]", + mask: "linear-gradient(to top, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.12) 50%, transparent 100%)", + }, + { + blur: "backdrop-blur-[6px]", + mask: "linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.2) 45%, transparent 100%)", + }, + { + blur: "backdrop-blur-[14px]", + mask: "linear-gradient(to top, black 0%, rgba(0,0,0,0.35) 40%, transparent 100%)", + }, + ], + top: [ + { + blur: "backdrop-blur-[2px]", + mask: "linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.12) 50%, transparent 100%)", + }, + { + blur: "backdrop-blur-[6px]", + mask: "linear-gradient(to bottom, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.2) 45%, transparent 100%)", + }, + { + blur: "backdrop-blur-[14px]", + mask: "linear-gradient(to bottom, black 0%, rgba(0,0,0,0.35) 40%, transparent 100%)", + }, + ], +}; + +function historyListLabel(chat: { title: string | null; updatedAt: string }) { + if (chat.title && chat.title !== "New chat") return chat.title; + return formatRelativeTime(chat.updatedAt); +} + +function ScrollEdgeGradientBlur({ edge, visible }: { edge: ScrollFadeEdge; visible: boolean }) { + const tintMask = + edge === "bottom" + ? "linear-gradient(to top, black 0%, transparent 72%)" + : "linear-gradient(to bottom, black 0%, transparent 72%)"; + + return ( +
+ {scrollFadeBlurLayers[edge].map((layer) => ( +
+ ))} +
+
+ ); +} + +function measureScrollFades(el: HTMLDivElement) { + const canScroll = el.scrollHeight > el.clientHeight + 1; + const atTop = el.scrollTop <= SCROLL_END_THRESHOLD_PX; + const atBottom = + el.scrollHeight - el.scrollTop - el.clientHeight <= SCROLL_END_THRESHOLD_PX; + return { + showTop: canScroll && !atTop, + showBottom: canScroll && !atBottom, + }; +} + +function ChatHistoryList({ + chats, + currentChatId, + isOpen, + onSelect, +}: { + chats: { id: string; title: string | null; updatedAt: string }[]; + currentChatId: string; + isOpen: boolean; + onSelect: (chatId: string) => void; +}) { + const scrollRef = useRef(null); + const [scrollFades, setScrollFades] = useState({ showTop: false, showBottom: false }); + + const updateScrollFades = useCallback(() => { + const el = scrollRef.current; + if (!el) { + setScrollFades({ showTop: false, showBottom: false }); + return; } - if (showHistory) { - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); + setScrollFades(measureScrollFades(el)); + }, []); + + const setScrollContainerRef = useCallback( + (node: HTMLDivElement | null) => { + scrollRef.current = node; + if (node) { + setScrollFades(measureScrollFades(node)); + } + }, + [] + ); + + useLayoutEffect(() => { + if (!isOpen) { + setScrollFades({ showTop: false, showBottom: false }); + return; } - }, [showHistory]); + + updateScrollFades(); + const el = scrollRef.current; + if (!el) return; + + const observer = new ResizeObserver(updateScrollFades); + observer.observe(el); + + const raf = requestAnimationFrame(() => { + requestAnimationFrame(updateScrollFades); + }); + + return () => { + observer.disconnect(); + cancelAnimationFrame(raf); + }; + }, [isOpen, chats, updateScrollFades]); + + return ( +
+ +
+ {chats.map((chat) => { + const isActive = chat.id === currentChatId; + const hasTitle = chat.title && chat.title !== "New chat"; + return ( + + ); + })} +
+ +
+ ); +} + +export function AIChatHeader() { + const { close, startNewChat, chatHistory, switchChat, currentChatId } = useAIChat(); + const [historyOpen, setHistoryOpen] = useState(false); return (
- + AI Assistant
- {/* History */} -
- + + - - - {showHistory && chatHistory.length > 0 && ( -
-
- {chatHistory.map((chat) => ( - - ))} -
+
+ Chat History
- )} -
+ {chatHistory.length === 0 ? ( +
+ ) : ( + { + switchChat(chatId); + setHistoryOpen(false); + }} + /> + )} + + - {/* New chat */} - {/* Close */}
); -} \ No newline at end of file +} diff --git a/apps/webapp/app/components/ai-assistant/AIChatInput.tsx b/apps/webapp/app/components/ai-assistant/AIChatInput.tsx index 61cdec7d440..859ffba7a3a 100644 --- a/apps/webapp/app/components/ai-assistant/AIChatInput.tsx +++ b/apps/webapp/app/components/ai-assistant/AIChatInput.tsx @@ -1,17 +1,21 @@ import { ArrowUpIcon, StopIcon } from "@heroicons/react/20/solid"; -import { useRef, useEffect } from "react"; +import { useLayoutEffect, useRef, useEffect } from "react"; import { Button } from "~/components/primitives/Buttons"; import { Spinner } from "~/components/primitives/Spinner"; interface AIChatInputProps { input: string; - onInputChange: (e: React.ChangeEvent) => void; + onInputChange: (e: React.ChangeEvent) => void; onSubmit: (e: React.FormEvent) => void; onStop: () => void; isLoading: boolean; status: string; + // Changes when the user switches/starts a chat — used to re-focus the input. + chatId: string; } +const INDIGO_FUCHSIA = { background: "rgba(99, 102, 241, 1)", foreground: "rgba(217, 70, 239, 1)" }; + export function AIChatInput({ input, onInputChange, @@ -19,43 +23,75 @@ export function AIChatInput({ onStop, isLoading, status, + chatId, }: AIChatInputProps) { - const inputRef = useRef(null); + const inputRef = useRef(null); + // Focus on mount and whenever the active chat changes (e.g. switching via + // history or starting a new chat) so the user can type immediately. useEffect(() => { inputRef.current?.focus(); - }, []); + }, [chatId]); + + // Auto-grow the textarea with its content. A single row collapses to the + // `min-h-8` (32px = the send-button height) so `items-end` centers them; as + // content wraps it grows and the button stays pinned to the bottom. The + // `max-h-[70px]` class caps it at 3 rows, after which it scrolls. scrollHeight + // excludes the border, so add it back to avoid a phantom scrollbar. Recompute + // on every value change and on chat switch. + useLayoutEffect(() => { + const ta = inputRef.current; + if (!ta) return; + ta.style.height = "auto"; + const styles = getComputedStyle(ta); + const border = parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth); + ta.style.height = `${ta.scrollHeight + border}px`; + }, [input, chatId]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Enter submits; Shift+Enter inserts a newline. + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + e.currentTarget.form?.requestSubmit(); + } + }; const isStreaming = status === "streaming"; const isPreparing = status === "submitted"; return (
-
- +