Compare commits

..

7 Commits

Author SHA1 Message Date
Lluis Agusti
1090f90d95 chore: clean ups 2026-02-23 20:56:08 +08:00
Lluis Agusti
a7c9a3c5ae fix(frontend): address CodeRabbit review comments
- MarkdownRenderer: add null guard for empty image src
- GenericTool: use composite key for todo items
- ViewAgentOutput/BlockOutputCard: use parent key + index instead of bare index
- SidebarItemCard: extract onKeyDown to named function declaration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:49:21 +08:00
Lluis Agusti
ec06c1278a ci(frontend): add react-doctor CI job with score threshold + fix remaining warnings
Add react-doctor to frontend CI pipeline with a minimum score threshold of 90.
Fix additional a11y and React pattern warnings (autoFocus removal, missing
roles/keyboard handlers) to bring score from 93 to 95.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:33:37 +08:00
Lluis Agusti
e2525cb8a8 Merge 'dev' into 'chore/react-doctor' 2026-02-23 19:09:31 +08:00
Ubbe
a18ffd0b21 fix(frontend/copilot): always-visible credentials, inputs, and login prompts (#12194)
Credentials, inputs, and login prompts in copilot tool outputs were
hidden inside collapsible accordions — users could accidentally collapse
them, hiding blocking actionable UI. This PR extracts all blocking
requirements out of accordions so they're always visible.

### Changes 🏗️

- **RunAgent & RunBlock**: Extract `SetupRequirementsCard` (credentials
picker) out of `ToolAccordion` — renders standalone, always visible
- **RunAgent**: Also extract `AgentDetailsCard` (inputs needed) and
`need_login` message out of accordion
- **SetupRequirementsCard (RunBlock)**: Input form always visible
(removed toggle button and animation), unified "Proceed" button disabled
until credentials + inputs are satisfied
- **SetupRequirementsCard (RunAgent)**: "Proceed" button disabled until
all credentials are selected
- **Both cards**: Added titled box with border for credentials section
("Block credentials" / "Agent credentials"), matching the existing
inputs box pattern
- **CredentialsFlatView**: "Add" button uses `variant="primary"` when
user has no credentials (was `secondary`)
- **Styleguide**: Added mock `CredentialsProvidersContext` with two
scenarios:
  - No credentials → shows "add new" flow
  - Has credentials → shows selection list with existing accounts
- **CreateAgent & EditAgent**: Picked up user-initiated styling
refinements

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] `pnpm format && pnpm lint && pnpm types` all pass
  - [ ] Visit `/copilot/styleguide` and verify:
- [ ] "Setup requirements — no credentials" shows add-credential button
(primary variant)
- [ ] "Setup requirements — has credentials" shows credential selection
dropdown
- [ ] Both RunAgent and RunBlock setup requirements render outside
accordion
- [ ] Trigger a copilot agent run that requires credentials — credential
picker always visible
- [ ] Trigger a copilot block run that requires credentials + inputs —
both sections visible, "Proceed" disabled until ready
- [ ] Trigger a copilot agent run that returns "agent details" — card
renders outside accordion
- [ ] Verify other output types (execution_started, error) still render
inside accordions


🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 16:39:21 +07:00
Lluis Agusti
02a3a163e7 fix: restore autoFocus attributes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 22:54:55 +08:00
Lluis Agusti
d9d24dcfe6 chore: wip 2026-02-19 22:14:37 +08:00
46 changed files with 951 additions and 658 deletions

View File

@@ -83,6 +83,65 @@ jobs:
- name: Run lint
run: pnpm lint
react-doctor:
runs-on: ubuntu-latest
needs: setup
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Enable corepack
run: corepack enable
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: "22.18.0"
cache: "pnpm"
cache-dependency-path: autogpt_platform/frontend/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run React Doctor
id: react-doctor
continue-on-error: true
run: |
OUTPUT=$(pnpm react-doctor:diff 2>&1) || true
echo "$OUTPUT"
SCORE=$(echo "$OUTPUT" | grep -oP '\d+(?= / 100)' | head -1)
echo "score=${SCORE:-0}" >> "$GITHUB_OUTPUT"
- name: Check React Doctor score
env:
RD_SCORE: ${{ steps.react-doctor.outputs.score }}
MIN_SCORE: "90"
run: |
echo "React Doctor score: ${RD_SCORE}/100 (minimum: ${MIN_SCORE})"
if [ "${RD_SCORE}" -lt "${MIN_SCORE}" ]; then
echo "::error::React Doctor score ${RD_SCORE} is below the minimum threshold of ${MIN_SCORE}."
echo ""
echo "=========================================="
echo " React Doctor score too low!"
echo "=========================================="
echo ""
echo "To fix these issues, run Claude Code locally:"
echo ""
echo " cd autogpt_platform/frontend"
echo " claude"
echo ""
echo "Then ask Claude to run react-doctor and fix the issues."
echo "You can also run it manually:"
echo ""
echo " pnpm react-doctor # scan all files"
echo " pnpm react-doctor:diff # scan only changed files"
echo ""
exit 1
fi
chromatic:
runs-on: ubuntu-latest
needs: setup

View File

@@ -192,8 +192,3 @@ POSTHOG_HOST=https://eu.i.posthog.com
# Other Services
AUTOMOD_API_KEY=
# Agent Generator Service
# The Agent Generator microservice handles AI-powered agent creation from natural language
AGENTGENERATOR_HOST=localhost
AGENTGENERATOR_PORT=8009

View File

@@ -364,11 +364,11 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
)
agentgenerator_host: str = Field(
default="localhost",
description="The host for the Agent Generator service",
default="",
description="The host for the Agent Generator service (empty to use built-in)",
)
agentgenerator_port: int = Field(
default=8009,
default=8000,
description="The port for the Agent Generator service",
)
agentgenerator_timeout: int = Field(

View File

@@ -23,6 +23,8 @@
"build-storybook": "storybook build",
"test-storybook": "test-storybook",
"test-storybook:ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"pnpm run build-storybook -- --quiet && npx http-server storybook-static --port 6006 --silent\" \"wait-on tcp:6006 && pnpm run test-storybook\"",
"react-doctor": "npx -y react-doctor@latest . --verbose",
"react-doctor:diff": "npx -y react-doctor@latest . --verbose --diff",
"generate:api": "npx --yes tsx ./scripts/generate-api-queries.ts && orval --config ./orval.config.ts",
"generate:api:force": "npx --yes tsx ./scripts/generate-api-queries.ts --force && orval --config ./orval.config.ts"
},

View File

@@ -0,0 +1,13 @@
"use client";
import { ReactFlowProvider } from "@xyflow/react";
import { Flow } from "./components/FlowEditor/Flow/Flow";
export function BuilderContent() {
return (
<div className="relative h-full w-full">
<ReactFlowProvider>
<Flow />
</ReactFlowProvider>
</div>
);
}

View File

@@ -7,7 +7,7 @@ import {
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { beautifyString, cn } from "@/lib/utils";
import { useState } from "react";
import { useCallback, useState } from "react";
import { CustomNodeData } from "../CustomNode";
import { NodeBadges } from "./NodeBadges";
import { NodeContextMenu } from "./NodeContextMenu";
@@ -25,6 +25,9 @@ export const NodeHeader = ({ data, nodeId }: Props) => {
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editedTitle, setEditedTitle] = useState(title);
const titleInputRef = useCallback((node: HTMLInputElement | null) => {
node?.focus();
}, []);
const handleTitleEdit = () => {
updateNodeData(nodeId, {
@@ -52,10 +55,10 @@ export const NodeHeader = ({ data, nodeId }: Props) => {
>
{isEditingTitle ? (
<input
ref={titleInputRef}
id="node-title-input"
value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
autoFocus
className={cn(
"m-0 h-fit w-full border-none bg-transparent p-0 focus:outline-none focus:ring-0",
"font-sans text-[1rem] font-semibold leading-[1.5rem] text-zinc-800",

View File

@@ -300,7 +300,6 @@ export function MCPToolDialog({
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleDiscoverTools()}
autoFocus
/>
</div>
@@ -327,7 +326,6 @@ export function MCPToolDialog({
value={manualToken}
onChange={(e) => setManualToken(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleDiscoverTools()}
autoFocus
/>
</div>
)}

View File

@@ -52,7 +52,7 @@ export const HorizontalScroll: React.FC<HorizontalScrollAreaProps> = ({
return;
}
const handleScroll = () => updateScrollState();
element.addEventListener("scroll", handleScroll);
element.addEventListener("scroll", handleScroll, { passive: true });
window.addEventListener("resize", handleScroll);
return () => {
element.removeEventListener("scroll", handleScroll);

View File

@@ -85,12 +85,20 @@ export const GraphSearchContent: React.FC<GraphSearchContentProps> = ({
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<div
role="button"
tabIndex={0}
className={`mx-4 my-2 flex h-20 cursor-pointer rounded-lg border border-zinc-200 bg-white ${
index === selectedIndex
? "border-zinc-400 shadow-md"
: "hover:border-zinc-300 hover:shadow-sm"
}`}
onClick={() => onNodeSelect(node.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onNodeSelect(node.id);
}
}}
onMouseEnter={() => {
setSelectedIndex(index);
onNodeHover?.(node.id);

View File

@@ -140,10 +140,7 @@ export function AgentRunDraftView({
),
[agentInputSchema],
);
const agentCredentialsInputFields = useMemo(
() => graph.credentials_input_schema.properties,
[graph],
);
const agentCredentialsInputFields = graph.credentials_input_schema.properties;
const credentialFields = useMemo(
function getCredentialFields() {
return Object.entries(agentCredentialsInputFields);

View File

@@ -1,13 +1,11 @@
"use client";
import { ReactFlowProvider } from "@xyflow/react";
import { Flow } from "./components/FlowEditor/Flow/Flow";
import type { Metadata } from "next";
import { BuilderContent } from "./BuilderContent";
export const metadata: Metadata = {
title: "Build",
description: "Build your agent",
};
export default function BuilderPage() {
return (
<div className="relative h-full w-full">
<ReactFlowProvider>
<Flow />
</ReactFlowProvider>
</div>
);
return <BuilderContent />;
}

View File

@@ -1,7 +1,7 @@
"use client";
import { ChatInput } from "@/app/(platform)/copilot/components/ChatInput/ChatInput";
import { UIDataTypes, UIMessage, UITools } from "ai";
import { LayoutGroup, motion } from "framer-motion";
import { LayoutGroup, LazyMotion, domAnimation, m } from "framer-motion";
import { ReactNode } from "react";
import { ChatMessagesContainer } from "../ChatMessagesContainer/ChatMessagesContainer";
import { CopilotChatActionsProvider } from "../CopilotChatActionsProvider/CopilotChatActionsProvider";
@@ -38,45 +38,47 @@ export const ChatContainer = ({
const inputLayoutId = "copilot-2-chat-input";
return (
<CopilotChatActionsProvider onSend={onSend}>
<LayoutGroup id="copilot-2-chat-layout">
<div className="flex h-full min-h-0 w-full flex-col bg-[#f8f8f9] px-2 lg:px-0">
{sessionId ? (
<div className="mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col">
<ChatMessagesContainer
messages={messages}
status={status}
error={error}
isLoading={isLoadingSession}
headerSlot={headerSlot}
/>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="relative px-3 pb-2 pt-2"
>
<div className="pointer-events-none absolute left-0 right-0 top-[-18px] z-10 h-6 bg-gradient-to-b from-transparent to-[#f8f8f9]" />
<ChatInput
inputId="chat-input-session"
onSend={onSend}
disabled={isBusy}
isStreaming={isBusy}
onStop={onStop}
placeholder="What else can I help with?"
<LazyMotion features={domAnimation}>
<CopilotChatActionsProvider onSend={onSend}>
<LayoutGroup id="copilot-2-chat-layout">
<div className="flex h-full min-h-0 w-full flex-col bg-[#f8f8f9] px-2 lg:px-0">
{sessionId ? (
<div className="mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col">
<ChatMessagesContainer
messages={messages}
status={status}
error={error}
isLoading={isLoadingSession}
headerSlot={headerSlot}
/>
</motion.div>
</div>
) : (
<EmptySession
inputLayoutId={inputLayoutId}
isCreatingSession={isCreatingSession}
onCreateSession={onCreateSession}
onSend={onSend}
/>
)}
</div>
</LayoutGroup>
</CopilotChatActionsProvider>
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="relative px-3 pb-2 pt-2"
>
<div className="pointer-events-none absolute left-0 right-0 top-[-18px] z-10 h-6 bg-gradient-to-b from-transparent to-[#f8f8f9]" />
<ChatInput
inputId="chat-input-session"
onSend={onSend}
disabled={isBusy}
isStreaming={isBusy}
onStop={onStop}
placeholder="What else can I help with?"
/>
</m.div>
</div>
) : (
<EmptySession
inputLayoutId={inputLayoutId}
isCreatingSession={isCreatingSession}
onCreateSession={onCreateSession}
onSend={onSend}
/>
)}
</div>
</LayoutGroup>
</CopilotChatActionsProvider>
</LazyMotion>
);
};

View File

@@ -117,6 +117,7 @@ export function AudioWaveform({
{bars.map((height, i) => {
const barHeight = Math.max(minBarHeight, height);
return (
// eslint-disable-next-line react/no-array-index-key
<div
key={i}
className="relative"

View File

@@ -12,6 +12,7 @@ import {
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { toast } from "@/components/molecules/Toast/use-toast";
import { ToolUIPart, UIDataTypes, UIMessage, UITools } from "ai";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { CreateAgentTool } from "../../tools/CreateAgent/CreateAgent";
import { EditAgentTool } from "../../tools/EditAgent/EditAgent";
@@ -57,7 +58,7 @@ function resolveWorkspaceUrls(text: string): string {
* Falls back to <video> when an <img> fails to load for workspace files.
*/
function WorkspaceMediaImage(props: React.JSX.IntrinsicElements["img"]) {
const { src, alt, ...rest } = props;
const { src, alt } = props;
const [imgFailed, setImgFailed] = useState(false);
const isWorkspace = src?.includes("/workspace/files/") ?? false;
@@ -79,16 +80,17 @@ function WorkspaceMediaImage(props: React.JSX.IntrinsicElements["img"]) {
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
<Image
src={src}
alt={alt || "Image"}
className="h-auto max-w-full rounded-md border border-zinc-200"
loading="lazy"
width={0}
height={0}
sizes="100vw"
className="h-auto w-full rounded-md border border-zinc-200"
unoptimized
onError={() => {
if (isWorkspace) setImgFailed(true);
}}
{...rest}
/>
);
}
@@ -195,12 +197,12 @@ export const ChatMessagesContainer = ({
"group-[.is-assistant]:bg-transparent group-[.is-assistant]:text-slate-900"
}
>
{message.parts.map((part, i) => {
{message.parts.map((part) => {
switch (part.type) {
case "text":
return (
<MessageResponse
key={`${message.id}-${i}`}
key={`${message.id}-text`}
components={STREAMDOWN_COMPONENTS}
>
{resolveWorkspaceUrls(part.text)}
@@ -209,7 +211,7 @@ export const ChatMessagesContainer = ({
case "tool-find_block":
return (
<FindBlocksTool
key={`${message.id}-${i}`}
key={(part as ToolUIPart).toolCallId}
part={part as ToolUIPart}
/>
);
@@ -217,7 +219,7 @@ export const ChatMessagesContainer = ({
case "tool-find_library_agent":
return (
<FindAgentsTool
key={`${message.id}-${i}`}
key={(part as ToolUIPart).toolCallId}
part={part as ToolUIPart}
/>
);
@@ -225,14 +227,14 @@ export const ChatMessagesContainer = ({
case "tool-get_doc_page":
return (
<SearchDocsTool
key={`${message.id}-${i}`}
key={(part as ToolUIPart).toolCallId}
part={part as ToolUIPart}
/>
);
case "tool-run_block":
return (
<RunBlockTool
key={`${message.id}-${i}`}
key={(part as ToolUIPart).toolCallId}
part={part as ToolUIPart}
/>
);
@@ -240,42 +242,42 @@ export const ChatMessagesContainer = ({
case "tool-schedule_agent":
return (
<RunAgentTool
key={`${message.id}-${i}`}
key={(part as ToolUIPart).toolCallId}
part={part as ToolUIPart}
/>
);
case "tool-create_agent":
return (
<CreateAgentTool
key={`${message.id}-${i}`}
key={(part as ToolUIPart).toolCallId}
part={part as ToolUIPart}
/>
);
case "tool-edit_agent":
return (
<EditAgentTool
key={`${message.id}-${i}`}
key={(part as ToolUIPart).toolCallId}
part={part as ToolUIPart}
/>
);
case "tool-view_agent_output":
return (
<ViewAgentOutputTool
key={`${message.id}-${i}`}
key={(part as ToolUIPart).toolCallId}
part={part as ToolUIPart}
/>
);
case "tool-search_feature_requests":
return (
<SearchFeatureRequestsTool
key={`${message.id}-${i}`}
key={(part as ToolUIPart).toolCallId}
part={part as ToolUIPart}
/>
);
case "tool-create_feature_request":
return (
<CreateFeatureRequestTool
key={`${message.id}-${i}`}
key={(part as ToolUIPart).toolCallId}
part={part as ToolUIPart}
/>
);
@@ -285,7 +287,7 @@ export const ChatMessagesContainer = ({
if (part.type.startsWith("tool-")) {
return (
<GenericTool
key={`${message.id}-${i}`}
key={(part as ToolUIPart).toolCallId}
part={part as ToolUIPart}
/>
);

View File

@@ -25,7 +25,7 @@ import {
import { cn } from "@/lib/utils";
import { DotsThree, PlusCircleIcon, PlusIcon } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion";
import { LazyMotion, domAnimation, m } from "framer-motion";
import { parseAsString, useQueryState } from "nuqs";
import { useState } from "react";
import { DeleteChatDialog } from "../DeleteChatDialog/DeleteChatDialog";
@@ -129,7 +129,7 @@ export function ChatSidebar() {
}
return (
<>
<LazyMotion features={domAnimation}>
<Sidebar
variant="inset"
collapsible="icon"
@@ -144,7 +144,7 @@ export function ChatSidebar() {
: "flex-row items-center justify-between",
)}
>
<motion.div
<m.div
key={isCollapsed ? "header-collapsed" : "header-expanded"}
className="flex flex-col items-center gap-3 pt-4"
initial={{ opacity: 0, filter: "blur(3px)" }}
@@ -162,12 +162,12 @@ export function ChatSidebar() {
<span className="sr-only">New Chat</span>
</Button>
</div>
</motion.div>
</m.div>
</SidebarHeader>
)}
<SidebarContent className="gap-4 overflow-y-auto px-4 py-4 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{!isCollapsed && (
<motion.div
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2, delay: 0.1 }}
@@ -179,11 +179,11 @@ export function ChatSidebar() {
<div className="relative left-6">
<SidebarTrigger />
</div>
</motion.div>
</m.div>
)}
{!isCollapsed && (
<motion.div
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2, delay: 0.15 }}
@@ -256,12 +256,12 @@ export function ChatSidebar() {
</div>
))
)}
</motion.div>
</m.div>
)}
</SidebarContent>
{!isCollapsed && sessionId && (
<SidebarFooter className="shrink-0 bg-zinc-50 p-3 pb-1 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
<motion.div
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2, delay: 0.2 }}
@@ -275,7 +275,7 @@ export function ChatSidebar() {
>
New Chat
</Button>
</motion.div>
</m.div>
</SidebarFooter>
)}
</Sidebar>
@@ -286,6 +286,6 @@ export function ChatSidebar() {
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
</>
</LazyMotion>
);
}

View File

@@ -5,7 +5,7 @@ import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { SpinnerGapIcon } from "@phosphor-icons/react";
import { motion } from "framer-motion";
import { LazyMotion, domAnimation, m } from "framer-motion";
import { useEffect, useState } from "react";
import {
getGreetingName,
@@ -29,7 +29,7 @@ export function EmptySession({
const greetingName = getGreetingName(user);
const quickActions = getQuickActions();
const [loadingAction, setLoadingAction] = useState<string | null>(null);
const [inputPlaceholder, setInputPlaceholder] = useState(
const [inputPlaceholder, setInputPlaceholder] = useState(() =>
getInputPlaceholder(),
);
@@ -49,63 +49,65 @@ export function EmptySession({
}
return (
<div className="flex h-full flex-1 items-center justify-center overflow-y-auto bg-[#f8f8f9] px-0 py-5 md:px-6 md:py-10">
<motion.div
className="w-full max-w-3xl text-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<div className="mx-auto max-w-3xl">
<Text variant="h3" className="mb-1 !text-[1.375rem] text-zinc-700">
Hey, <span className="text-violet-600">{greetingName}</span>
</Text>
<Text variant="h3" className="mb-8 !font-normal">
Tell me about your work I&apos;ll find what to automate.
</Text>
<LazyMotion features={domAnimation}>
<div className="flex h-full flex-1 items-center justify-center overflow-y-auto bg-[#f8f8f9] px-0 py-5 md:px-6 md:py-10">
<m.div
className="w-full max-w-3xl text-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<div className="mx-auto max-w-3xl">
<Text variant="h3" className="mb-1 !text-[1.375rem] text-zinc-700">
Hey, <span className="text-violet-600">{greetingName}</span>
</Text>
<Text variant="h3" className="mb-8 !font-normal">
Tell me about your work I&apos;ll find what to automate.
</Text>
<div className="mb-6">
<motion.div
layoutId={inputLayoutId}
transition={{ type: "spring", bounce: 0.2, duration: 0.65 }}
className="w-full px-2"
>
<ChatInput
inputId="chat-input-empty"
onSend={onSend}
disabled={isCreatingSession}
placeholder={inputPlaceholder}
className="w-full"
/>
</motion.div>
<div className="mb-6">
<m.div
layoutId={inputLayoutId}
transition={{ type: "spring", bounce: 0.2, duration: 0.65 }}
className="w-full px-2"
>
<ChatInput
inputId="chat-input-empty"
onSend={onSend}
disabled={isCreatingSession}
placeholder={inputPlaceholder}
className="w-full"
/>
</m.div>
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-center gap-3 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{quickActions.map((action) => (
<Button
key={action}
type="button"
variant="outline"
size="small"
onClick={() => void handleQuickActionClick(action)}
disabled={isCreatingSession || loadingAction !== null}
aria-busy={loadingAction === action}
leftIcon={
loadingAction === action ? (
<SpinnerGapIcon
className="h-4 w-4 animate-spin"
weight="bold"
/>
) : null
}
className="h-auto shrink-0 border-zinc-300 px-3 py-2 text-[.9rem] text-zinc-600"
>
{action}
</Button>
))}
</div>
</motion.div>
</div>
<div className="flex flex-wrap items-center justify-center gap-3 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{quickActions.map((action) => (
<Button
key={action}
type="button"
variant="outline"
size="small"
onClick={() => void handleQuickActionClick(action)}
disabled={isCreatingSession || loadingAction !== null}
aria-busy={loadingAction === action}
leftIcon={
loadingAction === action ? (
<SpinnerGapIcon
className="h-4 w-4 animate-spin"
weight="bold"
/>
) : null
}
className="h-auto shrink-0 border-zinc-300 px-3 py-2 text-[.9rem] text-zinc-600"
>
{action}
</Button>
))}
</div>
</m.div>
</div>
</LazyMotion>
);
}

View File

@@ -1,5 +1,5 @@
import { cn } from "@/lib/utils";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, LazyMotion, domAnimation, m } from "framer-motion";
interface Props {
text: string;
@@ -10,45 +10,47 @@ export function MorphingTextAnimation({ text, className }: Props) {
const letters = text.split("");
return (
<div className={cn(className)}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div key={text} className="whitespace-nowrap">
<motion.span className="inline-flex overflow-hidden">
{letters.map((char, index) => (
<motion.span
key={`${text}-${index}`}
initial={{
opacity: 0,
y: 8,
rotateX: "80deg",
filter: "blur(6px)",
}}
animate={{
opacity: 1,
y: 0,
rotateX: "0deg",
filter: "blur(0px)",
}}
exit={{
opacity: 0,
y: -8,
rotateX: "-80deg",
filter: "blur(6px)",
}}
style={{ willChange: "transform" }}
transition={{
delay: 0.015 * index,
type: "spring",
bounce: 0.5,
}}
className="inline-block"
>
{char === " " ? "\u00A0" : char}
</motion.span>
))}
</motion.span>
</motion.div>
</AnimatePresence>
</div>
<LazyMotion features={domAnimation}>
<div className={cn(className)}>
<AnimatePresence mode="popLayout" initial={false}>
<m.div key={text} className="whitespace-nowrap">
<m.span className="inline-flex overflow-hidden">
{letters.map((char, index) => (
// eslint-disable-next-line react/no-array-index-key
<m.span
key={`${text}-${index}`}
initial={{
opacity: 0,
y: 8,
rotateX: "80deg",
filter: "blur(6px)",
}}
animate={{
opacity: 1,
y: 0,
rotateX: "0deg",
filter: "blur(0px)",
}}
exit={{
opacity: 0,
y: -8,
rotateX: "-80deg",
filter: "blur(6px)",
}}
transition={{
delay: 0.015 * index,
type: "spring",
bounce: 0.5,
}}
className="inline-block"
>
{char === " " ? "\u00A0" : char}
</m.span>
))}
</m.span>
</m.div>
</AnimatePresence>
</div>
</LazyMotion>
);
}

View File

@@ -2,7 +2,13 @@
import { cn } from "@/lib/utils";
import { CaretDownIcon } from "@phosphor-icons/react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import {
AnimatePresence,
LazyMotion,
domAnimation,
m,
useReducedMotion,
} from "framer-motion";
import { useId } from "react";
import { useToolAccordion } from "./useToolAccordion";
@@ -38,65 +44,66 @@ export function ToolAccordion({
});
return (
<div
className={cn(
"mt-2 w-full rounded-lg border border-slate-200 bg-slate-100 px-3 py-2",
className,
)}
>
<button
type="button"
aria-expanded={isExpanded}
aria-controls={contentId}
onClick={toggle}
className="flex w-full items-center justify-between gap-3 py-1 text-left"
>
<div className="flex min-w-0 items-center gap-3">
<span className="flex shrink-0 items-center text-gray-800">
{icon}
</span>
<div className="min-w-0">
<p
className={cn(
"truncate text-sm font-medium text-gray-800",
titleClassName,
)}
>
{title}
</p>
{description && (
<p className="truncate text-xs text-slate-800">{description}</p>
)}
</div>
</div>
<CaretDownIcon
className={cn(
"h-4 w-4 shrink-0 text-slate-500 transition-transform",
isExpanded && "rotate-180",
)}
weight="bold"
/>
</button>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
id={contentId}
initial={{ height: 0, opacity: 0, filter: "blur(10px)" }}
animate={{ height: "auto", opacity: 1, filter: "blur(0px)" }}
exit={{ height: 0, opacity: 0, filter: "blur(10px)" }}
transition={
shouldReduceMotion
? { duration: 0 }
: { type: "spring", bounce: 0.35, duration: 0.55 }
}
className="overflow-hidden"
style={{ willChange: "height, opacity, filter" }}
>
<div className="pb-2 pt-3">{children}</div>
</motion.div>
<LazyMotion features={domAnimation}>
<div
className={cn(
"mt-2 w-full rounded-lg border border-slate-200 bg-slate-100 px-3 py-2",
className,
)}
</AnimatePresence>
</div>
>
<button
type="button"
aria-expanded={isExpanded}
aria-controls={contentId}
onClick={toggle}
className="flex w-full items-center justify-between gap-3 py-1 text-left"
>
<div className="flex min-w-0 items-center gap-3">
<span className="flex shrink-0 items-center text-gray-800">
{icon}
</span>
<div className="min-w-0">
<p
className={cn(
"truncate text-sm font-medium text-gray-800",
titleClassName,
)}
>
{title}
</p>
{description && (
<p className="truncate text-xs text-slate-800">{description}</p>
)}
</div>
</div>
<CaretDownIcon
className={cn(
"h-4 w-4 shrink-0 text-slate-500 transition-transform",
isExpanded && "rotate-180",
)}
weight="bold"
/>
</button>
<AnimatePresence initial={false}>
{isExpanded && (
<m.div
id={contentId}
initial={{ height: 0, opacity: 0, filter: "blur(10px)" }}
animate={{ height: "auto", opacity: 1, filter: "blur(0px)" }}
exit={{ height: 0, opacity: 0, filter: "blur(10px)" }}
transition={
shouldReduceMotion
? { duration: 0 }
: { type: "spring", bounce: 0.35, duration: 0.55 }
}
className="overflow-hidden"
>
<div className="pb-2 pt-3">{children}</div>
</m.div>
)}
</AnimatePresence>
</div>
</LazyMotion>
);
}

View File

@@ -0,0 +1,14 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Copilot",
description: "Chat with your AI copilot",
};
export default function CopilotLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

View File

@@ -0,0 +1,14 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Copilot Styleguide",
description: "Copilot UI component styleguide",
};
export default function StyleguideLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

View File

@@ -11,6 +11,11 @@ import {
MessageResponse,
} from "@/components/ai-elements/message";
import { Text } from "@/components/atoms/Text/Text";
import {
CredentialsProvidersContext,
type CredentialsProviderData,
type CredentialsProvidersContextType,
} from "@/providers/agent-credentials/credentials-provider";
import { CopilotChatActionsProvider } from "../components/CopilotChatActionsProvider/CopilotChatActionsProvider";
import { CreateAgentTool } from "../tools/CreateAgent/CreateAgent";
import { EditAgentTool } from "../tools/EditAgent/EditAgent";
@@ -97,6 +102,65 @@ function uid() {
return `sg-${++_id}`;
}
// ---------------------------------------------------------------------------
// Mock credential providers for setup-requirements demos
// ---------------------------------------------------------------------------
const noop = () => Promise.reject(new Error("Styleguide mock"));
function makeMockProvider(
provider: string,
providerName: string,
savedCredentials: CredentialsProviderData["savedCredentials"] = [],
): CredentialsProviderData {
return {
provider,
providerName,
savedCredentials,
isSystemProvider: false,
oAuthCallback: noop as CredentialsProviderData["oAuthCallback"],
mcpOAuthCallback: noop as CredentialsProviderData["mcpOAuthCallback"],
createAPIKeyCredentials:
noop as CredentialsProviderData["createAPIKeyCredentials"],
createUserPasswordCredentials:
noop as CredentialsProviderData["createUserPasswordCredentials"],
createHostScopedCredentials:
noop as CredentialsProviderData["createHostScopedCredentials"],
deleteCredentials: noop as CredentialsProviderData["deleteCredentials"],
};
}
/**
* Provider context where the user already has saved credentials
* so the credential picker shows a selection list.
*/
const MOCK_PROVIDERS_WITH_CREDENTIALS: CredentialsProvidersContextType = {
google: makeMockProvider("google", "Google", [
{
id: "cred-google-1",
provider: "google",
type: "oauth2",
title: "work@company.com",
scopes: ["email", "calendar"],
},
{
id: "cred-google-2",
provider: "google",
type: "oauth2",
title: "personal@gmail.com",
scopes: ["email", "calendar"],
},
]),
};
/**
* Provider context where the user has NO saved credentials,
* so the credential picker shows an "add new" flow.
*/
const MOCK_PROVIDERS_WITHOUT_CREDENTIALS: CredentialsProvidersContextType = {
openweathermap: makeMockProvider("openweathermap", "OpenWeatherMap"),
};
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
@@ -554,45 +618,80 @@ export default function StyleguidePage() {
/>
</SubSection>
<SubSection label="Output available (setup requirements)">
<RunBlockTool
part={{
type: "tool-run_block",
toolCallId: uid(),
state: "output-available",
input: { block_id: "weather-block-123" },
output: {
type: ResponseType.setup_requirements,
message:
"This block requires API credentials to run. Please configure them below.",
setup_info: {
agent_name: "Weather Agent",
requirements: {
inputs: [
{
name: "city",
title: "City",
type: "string",
required: true,
description: "The city to get weather for",
},
],
},
user_readiness: {
missing_credentials: {
openweathermap: {
provider: "openweathermap",
credentials_type: "api_key",
title: "OpenWeatherMap API Key",
description:
"Required to access weather data. Get your key at openweathermap.org",
<SubSection label="Setup requirements — no credentials (add new)">
<CredentialsProvidersContext.Provider
value={MOCK_PROVIDERS_WITHOUT_CREDENTIALS}
>
<RunBlockTool
part={{
type: "tool-run_block",
toolCallId: uid(),
state: "output-available",
input: { block_id: "weather-block-123" },
output: {
type: ResponseType.setup_requirements,
message:
"This block requires API credentials to run. Please configure them below.",
setup_info: {
agent_id: "agent-weather-1",
agent_name: "Weather Agent",
requirements: {
inputs: [
{
name: "city",
title: "City",
type: "string",
required: true,
description: "The city to get weather for",
},
],
},
user_readiness: {
missing_credentials: {
openweathermap_key: {
provider: "openweathermap",
types: ["api_key"],
},
},
},
},
},
},
}}
/>
}}
/>
</CredentialsProvidersContext.Provider>
</SubSection>
<SubSection label="Setup requirements — has credentials (pick from list)">
<CredentialsProvidersContext.Provider
value={MOCK_PROVIDERS_WITH_CREDENTIALS}
>
<RunBlockTool
part={{
type: "tool-run_block",
toolCallId: uid(),
state: "output-available",
input: { block_id: "calendar-block-456" },
output: {
type: ResponseType.setup_requirements,
message:
"This block requires Google credentials. Pick an account below or connect a new one.",
setup_info: {
agent_id: "agent-calendar-1",
agent_name: "Calendar Agent",
user_readiness: {
missing_credentials: {
google_oauth: {
provider: "google",
types: ["oauth2"],
scopes: ["email", "calendar"],
},
},
},
},
},
}}
/>
</CredentialsProvidersContext.Provider>
</SubSection>
<SubSection label="Output available (error)">
@@ -849,34 +948,71 @@ export default function StyleguidePage() {
/>
</SubSection>
<SubSection label="Output available (setup requirements)">
<RunAgentTool
part={{
type: "tool-run_agent",
toolCallId: uid(),
state: "output-available",
input: { username_agent_slug: "creator/my-agent" },
output: {
type: ResponseType.setup_requirements,
message: "This agent requires additional setup.",
setup_info: {
agent_name: "YouTube Summarizer",
requirements: {},
user_readiness: {
missing_credentials: {
youtube_api: {
provider: "youtube",
credentials_type: "api_key",
title: "YouTube Data API Key",
description:
"Required to access YouTube video data.",
<SubSection label="Setup requirements — no credentials (add new)">
<CredentialsProvidersContext.Provider
value={MOCK_PROVIDERS_WITHOUT_CREDENTIALS}
>
<RunAgentTool
part={{
type: "tool-run_agent",
toolCallId: uid(),
state: "output-available",
input: { username_agent_slug: "creator/weather-agent" },
output: {
type: ResponseType.setup_requirements,
message:
"This agent requires an API key. Add your credentials below.",
setup_info: {
agent_id: "agent-weather-1",
agent_name: "Weather Agent",
requirements: {},
user_readiness: {
missing_credentials: {
openweathermap_key: {
provider: "openweathermap",
types: ["api_key"],
},
},
},
},
},
},
}}
/>
}}
/>
</CredentialsProvidersContext.Provider>
</SubSection>
<SubSection label="Setup requirements — has credentials (pick from list)">
<CredentialsProvidersContext.Provider
value={MOCK_PROVIDERS_WITH_CREDENTIALS}
>
<RunAgentTool
part={{
type: "tool-run_agent",
toolCallId: uid(),
state: "output-available",
input: { username_agent_slug: "creator/calendar-agent" },
output: {
type: ResponseType.setup_requirements,
message:
"This agent needs Google credentials. Pick an account or connect a new one.",
setup_info: {
agent_id: "agent-calendar-1",
agent_name: "Google Calendar Agent",
requirements: {},
user_readiness: {
missing_credentials: {
google_oauth: {
provider: "google",
types: ["oauth2"],
scopes: ["email", "calendar"],
},
},
},
},
},
}}
/>
</CredentialsProvidersContext.Provider>
</SubSection>
<SubSection label="Output available (need login)">

View File

@@ -16,7 +16,6 @@ import {
ContentCardDescription,
ContentCodeBlock,
ContentGrid,
ContentHint,
ContentMessage,
} from "../../components/ToolAccordion/AccordionContent";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
@@ -24,8 +23,8 @@ import {
ClarificationQuestionsCard,
ClarifyingQuestion,
} from "./components/ClarificationQuestionsCard";
import sparklesImg from "./components/MiniGame/assets/sparkles.png";
import { MiniGame } from "./components/MiniGame/MiniGame";
import sparklesImg from "../../components/MiniGame/assets/sparkles.png";
import { MiniGame } from "../../components/MiniGame/MiniGame";
import { SuggestedGoalCard } from "./components/SuggestedGoalCard";
import {
AccordionIcon,
@@ -93,9 +92,7 @@ function getAccordionMeta(output: CreateAgentToolOutput) {
) {
return {
icon,
title:
"Creating agent, this may take a few minutes. Play while you wait.",
expanded: true,
title: output.message || "Agent creation started",
};
}
return {
@@ -169,15 +166,22 @@ export function CreateAgentTool({ part }: Props) {
/>
</div>
{isStreaming && (
<ToolAccordion
icon={<AccordionIcon />}
title="Creating agent, this may take a few minutes. Play while you wait."
expanded
>
<ContentGrid>
<MiniGame />
</ContentGrid>
</ToolAccordion>
)}
{hasExpandableContent && output && (
<ToolAccordion {...getAccordionMeta(output)}>
{isOperating && (
<ContentGrid>
<MiniGame />
<ContentHint>
This could take a few minutes play while you wait!
</ContentHint>
</ContentGrid>
{isOperating && output.message && (
<ContentMessage>{output.message}</ContentMessage>
)}
{isAgentSavedOutput(output) && (

View File

@@ -161,7 +161,7 @@ export function ClarificationQuestionsCard({
return (
<div
key={`${q.keyword}-${index}`}
key={q.keyword}
className={cn(
"relative rounded-lg border p-3",
isAnswered

View File

@@ -4,17 +4,15 @@ import { WarningDiamondIcon } from "@phosphor-icons/react";
import type { ToolUIPart } from "ai";
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import { OrbitLoader } from "../../components/OrbitLoader/OrbitLoader";
import {
ContentCardDescription,
ContentCodeBlock,
ContentGrid,
ContentHint,
ContentLink,
ContentMessage,
} from "../../components/ToolAccordion/AccordionContent";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
import { MiniGame } from "../CreateAgent/components/MiniGame/MiniGame";
import { MiniGame } from "../../components/MiniGame/MiniGame";
import {
ClarificationQuestionsCard,
ClarifyingQuestion,
@@ -81,9 +79,8 @@ function getAccordionMeta(output: EditAgentToolOutput): {
isOperationInProgressOutput(output)
) {
return {
icon: <OrbitLoader size={32} />,
title: "Editing agent, this may take a few minutes. Play while you wait.",
expanded: true,
icon,
title: output.message || "Agent editing started",
};
}
return {
@@ -148,15 +145,22 @@ export function EditAgentTool({ part }: Props) {
/>
</div>
{isStreaming && (
<ToolAccordion
icon={<AccordionIcon />}
title="Editing agent, this may take a few minutes. Play while you wait."
expanded
>
<ContentGrid>
<MiniGame />
</ContentGrid>
</ToolAccordion>
)}
{hasExpandableContent && output && (
<ToolAccordion {...getAccordionMeta(output)}>
{isOperating && (
<ContentGrid>
<MiniGame />
<ContentHint>
This could take a few minutes play while you wait!
</ContentHint>
</ContentGrid>
{isOperating && output.message && (
<ContentMessage>{output.message}</ContentMessage>
)}
{isAgentSavedOutput(output) && (

View File

@@ -557,8 +557,11 @@ function getTodoAccordionData(input: unknown): AccordionData {
description: `${completed}/${total} completed`,
content: (
<div className="space-y-1 py-1">
{todos.map((todo, i) => (
<div key={i} className="flex items-start gap-2 text-xs">
{todos.map((todo, idx) => (
<div
key={`${todo.status}:${todo.content}:${idx}`}
className="flex items-start gap-2 text-xs"
>
<span className="mt-0.5 flex-shrink-0">
{todo.status === "completed" ? (
<CheckCircleIcon

View File

@@ -9,7 +9,7 @@ import {
ContentHint,
ContentMessage,
} from "../../components/ToolAccordion/AccordionContent";
import { MiniGame } from "../CreateAgent/components/MiniGame/MiniGame";
import { MiniGame } from "../../components/MiniGame/MiniGame";
import {
getAccordionMeta,
getAnimationText,
@@ -47,14 +47,25 @@ export function RunAgentTool({ part }: Props) {
const isError =
part.state === "output-error" ||
(!!output && isRunAgentErrorOutput(output));
const isOutputAvailable = part.state === "output-available" && !!output;
const setupRequirementsOutput =
isOutputAvailable && isRunAgentSetupRequirementsOutput(output)
? output
: null;
const agentDetailsOutput =
isOutputAvailable && isRunAgentAgentDetailsOutput(output) ? output : null;
const needLoginOutput =
isOutputAvailable && isRunAgentNeedLoginOutput(output) ? output : null;
const hasExpandableContent =
part.state === "output-available" &&
!!output &&
(isRunAgentExecutionStartedOutput(output) ||
isRunAgentAgentDetailsOutput(output) ||
isRunAgentSetupRequirementsOutput(output) ||
isRunAgentNeedLoginOutput(output) ||
isRunAgentErrorOutput(output));
isOutputAvailable &&
!setupRequirementsOutput &&
!agentDetailsOutput &&
!needLoginOutput &&
(isRunAgentExecutionStartedOutput(output) || isRunAgentErrorOutput(output));
return (
<div className="py-2">
@@ -81,24 +92,30 @@ export function RunAgentTool({ part }: Props) {
</ToolAccordion>
)}
{setupRequirementsOutput && (
<div className="mt-2">
<SetupRequirementsCard output={setupRequirementsOutput} />
</div>
)}
{agentDetailsOutput && (
<div className="mt-2">
<AgentDetailsCard output={agentDetailsOutput} />
</div>
)}
{needLoginOutput && (
<div className="mt-2">
<ContentMessage>{needLoginOutput.message}</ContentMessage>
</div>
)}
{hasExpandableContent && output && (
<ToolAccordion {...getAccordionMeta(output)}>
{isRunAgentExecutionStartedOutput(output) && (
<ExecutionStartedCard output={output} />
)}
{isRunAgentAgentDetailsOutput(output) && (
<AgentDetailsCard output={output} />
)}
{isRunAgentSetupRequirementsOutput(output) && (
<SetupRequirementsCard output={output} />
)}
{isRunAgentNeedLoginOutput(output) && (
<ContentMessage>{output.message}</ContentMessage>
)}
{isRunAgentErrorOutput(output) && <ErrorCard output={output} />}
</ToolAccordion>
)}

View File

@@ -4,7 +4,7 @@ import type { AgentDetailsResponse } from "@/app/api/__generated__/models/agentD
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, LazyMotion, domAnimation, m } from "framer-motion";
import { useState } from "react";
import { useCopilotChatActions } from "../../../../components/CopilotChatActionsProvider/useCopilotChatActions";
import { ContentMessage } from "../../../../components/ToolAccordion/AccordionContent";
@@ -39,78 +39,83 @@ export function AgentDetailsCard({ output }: Props) {
}
return (
<div className="grid gap-2">
<ContentMessage>
Run this agent with example values or your own inputs.
</ContentMessage>
<LazyMotion features={domAnimation}>
<div className="grid gap-2">
<ContentMessage>
Run this agent with example values or your own inputs.
</ContentMessage>
<div className="flex gap-2 pt-4">
<Button size="small" className="w-fit" onClick={handleRunWithExamples}>
Run with example values
</Button>
<Button
variant="outline"
size="small"
className="w-fit"
onClick={() => setShowInputForm((prev) => !prev)}
>
Run with my inputs
</Button>
</div>
<AnimatePresence initial={false}>
{showInputForm && buildInputSchema(output.agent.inputs) && (
<motion.div
initial={{ height: 0, opacity: 0, filter: "blur(6px)" }}
animate={{ height: "auto", opacity: 1, filter: "blur(0px)" }}
exit={{ height: 0, opacity: 0, filter: "blur(6px)" }}
transition={{
height: { type: "spring", bounce: 0.15, duration: 0.5 },
opacity: { duration: 0.25 },
filter: { duration: 0.2 },
}}
className="overflow-hidden"
style={{ willChange: "height, opacity, filter" }}
<div className="flex gap-2 pt-4">
<Button
size="small"
className="w-fit"
onClick={handleRunWithExamples}
>
<div className="mt-4 rounded-2xl border bg-background p-3 pt-4">
<Text variant="body-medium">Enter your inputs</Text>
<FormRenderer
jsonSchema={buildInputSchema(output.agent.inputs)!}
handleChange={(v) => setInputValues(v.formData ?? {})}
uiSchema={{
"ui:submitButtonOptions": { norender: true },
}}
initialValues={inputValues}
formContext={{
showHandles: false,
size: "small",
}}
/>
<div className="-mt-8 flex gap-2">
<Button
variant="primary"
size="small"
className="w-fit"
onClick={handleRunWithInputs}
>
Run
</Button>
<Button
variant="secondary"
size="small"
className="w-fit"
onClick={() => {
setShowInputForm(false);
setInputValues({});
Run with example values
</Button>
<Button
variant="outline"
size="small"
className="w-fit"
onClick={() => setShowInputForm((prev) => !prev)}
>
Run with my inputs
</Button>
</div>
<AnimatePresence initial={false}>
{showInputForm && buildInputSchema(output.agent.inputs) && (
<m.div
initial={{ height: 0, opacity: 0, filter: "blur(6px)" }}
animate={{ height: "auto", opacity: 1, filter: "blur(0px)" }}
exit={{ height: 0, opacity: 0, filter: "blur(6px)" }}
transition={{
height: { type: "spring", bounce: 0.15, duration: 0.5 },
opacity: { duration: 0.25 },
filter: { duration: 0.2 },
}}
className="overflow-hidden"
>
<div className="mt-4 rounded-2xl border bg-background p-3 pt-4">
<Text variant="body-medium">Enter your inputs</Text>
<FormRenderer
jsonSchema={buildInputSchema(output.agent.inputs)!}
handleChange={(v) => setInputValues(v.formData ?? {})}
uiSchema={{
"ui:submitButtonOptions": { norender: true },
}}
>
Cancel
</Button>
initialValues={inputValues}
formContext={{
showHandles: false,
size: "small",
}}
/>
<div className="-mt-8 flex gap-2">
<Button
variant="primary"
size="small"
className="w-fit"
onClick={handleRunWithInputs}
>
Run
</Button>
<Button
variant="secondary"
size="small"
className="w-fit"
onClick={() => {
setShowInputForm(false);
setInputValues({});
}}
>
Cancel
</Button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</m.div>
)}
</AnimatePresence>
</div>
</LazyMotion>
);
}

View File

@@ -1,10 +1,11 @@
"use client";
import { useState } from "react";
import { CredentialsGroupedView } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/CredentialsGroupedView";
import { Button } from "@/components/atoms/Button/Button";
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import type { SetupRequirementsResponse } from "@/app/api/__generated__/models/setupRequirementsResponse";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { CredentialsGroupedView } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/CredentialsGroupedView";
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { useState } from "react";
import { useCopilotChatActions } from "../../../../components/CopilotChatActionsProvider/useCopilotChatActions";
import {
ContentBadge,
@@ -38,40 +39,40 @@ export function SetupRequirementsCard({ output }: Props) {
setInputCredentials((prev) => ({ ...prev, [key]: value }));
}
const isAllComplete =
credentialFields.length > 0 &&
const needsCredentials = credentialFields.length > 0;
const isAllCredentialsComplete =
needsCredentials &&
[...requiredCredentials].every((key) => !!inputCredentials[key]);
const canProceed =
!hasSent && (!needsCredentials || isAllCredentialsComplete);
function handleProceed() {
setHasSent(true);
onSend(
"I've configured the required credentials. Please check if everything is ready and proceed with running the agent.",
);
const message = needsCredentials
? "I've configured the required credentials. Please check if everything is ready and proceed with running the agent."
: "Please proceed with running the agent.";
onSend(message);
}
return (
<div className="grid gap-2">
<ContentMessage>{output.message}</ContentMessage>
{credentialFields.length > 0 && (
{needsCredentials && (
<div className="rounded-2xl border bg-background p-3">
<CredentialsGroupedView
credentialFields={credentialFields}
requiredCredentials={requiredCredentials}
inputCredentials={inputCredentials}
inputValues={{}}
onCredentialChange={handleCredentialChange}
/>
{isAllComplete && !hasSent && (
<Button
variant="primary"
size="small"
className="mt-3 w-full"
onClick={handleProceed}
>
Proceed
</Button>
)}
<Text variant="small" className="w-fit border-b text-zinc-500">
Agent credentials
</Text>
<div className="mt-6">
<CredentialsGroupedView
credentialFields={credentialFields}
requiredCredentials={requiredCredentials}
inputCredentials={inputCredentials}
inputValues={{}}
onCredentialChange={handleCredentialChange}
/>
</div>
</div>
)}
@@ -100,6 +101,18 @@ export function SetupRequirementsCard({ output }: Props) {
</div>
</div>
)}
{(needsCredentials || expectedInputs.length > 0) && (
<Button
variant="primary"
size="small"
className="mt-4 w-fit"
disabled={!canProceed}
onClick={handleProceed}
>
Proceed
</Button>
)}
</div>
);
}

View File

@@ -39,12 +39,19 @@ export function RunBlockTool({ part }: Props) {
const isError =
part.state === "output-error" ||
(!!output && isRunBlockErrorOutput(output));
const setupRequirementsOutput =
part.state === "output-available" &&
output &&
isRunBlockSetupRequirementsOutput(output)
? output
: null;
const hasExpandableContent =
part.state === "output-available" &&
!!output &&
!setupRequirementsOutput &&
(isRunBlockBlockOutput(output) ||
isRunBlockDetailsOutput(output) ||
isRunBlockSetupRequirementsOutput(output) ||
isRunBlockErrorOutput(output));
return (
@@ -57,6 +64,12 @@ export function RunBlockTool({ part }: Props) {
/>
</div>
{setupRequirementsOutput && (
<div className="mt-2">
<SetupRequirementsCard output={setupRequirementsOutput} />
</div>
)}
{hasExpandableContent && output && (
<ToolAccordion {...getAccordionMeta(output)}>
{isRunBlockBlockOutput(output) && <BlockOutputCard output={output} />}
@@ -65,10 +78,6 @@ export function RunBlockTool({ part }: Props) {
<BlockDetailsCard output={output} />
)}
{isRunBlockSetupRequirementsOutput(output) && (
<SetupRequirementsCard output={output} />
)}
{isRunBlockErrorOutput(output) && <ErrorCard output={output} />}
</ToolAccordion>
)}

View File

@@ -103,7 +103,7 @@ function OutputKeySection({
</div>
<div className="mt-2">
{visibleItems.map((item, i) => (
<RenderOutputValue key={i} value={item} />
<RenderOutputValue key={`${outputKey}-${i}`} value={item} />
))}
</div>
{hasMoreItems && (

View File

@@ -6,15 +6,9 @@ import { Text } from "@/components/atoms/Text/Text";
import { CredentialsGroupedView } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/CredentialsGroupedView";
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react";
import { useCopilotChatActions } from "../../../../components/CopilotChatActionsProvider/useCopilotChatActions";
import {
ContentBadge,
ContentCardDescription,
ContentCardTitle,
ContentMessage,
} from "../../../../components/ToolAccordion/AccordionContent";
import { ContentMessage } from "../../../../components/ToolAccordion/AccordionContent";
import {
buildExpectedInputsSchema,
coerceCredentialFields,
@@ -31,10 +25,8 @@ export function SetupRequirementsCard({ output }: Props) {
const [inputCredentials, setInputCredentials] = useState<
Record<string, CredentialsMetaInput | undefined>
>({});
const [hasSentCredentials, setHasSentCredentials] = useState(false);
const [showInputForm, setShowInputForm] = useState(false);
const [inputValues, setInputValues] = useState<Record<string, unknown>>({});
const [hasSent, setHasSent] = useState(false);
const { credentialFields, requiredCredentials } = coerceCredentialFields(
output.setup_info.user_readiness?.missing_credentials,
@@ -50,27 +42,49 @@ export function SetupRequirementsCard({ output }: Props) {
setInputCredentials((prev) => ({ ...prev, [key]: value }));
}
const needsCredentials = credentialFields.length > 0;
const isAllCredentialsComplete =
credentialFields.length > 0 &&
needsCredentials &&
[...requiredCredentials].every((key) => !!inputCredentials[key]);
function handleProceedCredentials() {
setHasSentCredentials(true);
onSend(
"I've configured the required credentials. Please re-run the block now.",
);
}
const needsInputs = inputSchema !== null;
const requiredInputNames = expectedInputs
.filter((i) => i.required)
.map((i) => i.name);
const isAllInputsComplete =
needsInputs &&
requiredInputNames.every((name) => {
const v = inputValues[name];
return v !== undefined && v !== null && v !== "";
});
function handleRunWithInputs() {
const nonEmpty = Object.fromEntries(
Object.entries(inputValues).filter(
([, v]) => v !== undefined && v !== null && v !== "",
),
);
onSend(
`Run the block with these inputs: ${JSON.stringify(nonEmpty, null, 2)}`,
);
setShowInputForm(false);
const canRun =
!hasSent &&
(!needsCredentials || isAllCredentialsComplete) &&
(!needsInputs || isAllInputsComplete);
function handleRun() {
setHasSent(true);
const parts: string[] = [];
if (needsCredentials) {
parts.push("I've configured the required credentials.");
}
if (needsInputs) {
const nonEmpty = Object.fromEntries(
Object.entries(inputValues).filter(
([, v]) => v !== undefined && v !== null && v !== "",
),
);
parts.push(
`Run the block with these inputs: ${JSON.stringify(nonEmpty, null, 2)}`,
);
} else {
parts.push("Please re-run the block now.");
}
onSend(parts.join(" "));
setInputValues({});
}
@@ -78,119 +92,54 @@ export function SetupRequirementsCard({ output }: Props) {
<div className="grid gap-2">
<ContentMessage>{output.message}</ContentMessage>
{credentialFields.length > 0 && (
{needsCredentials && (
<div className="rounded-2xl border bg-background p-3">
<CredentialsGroupedView
credentialFields={credentialFields}
requiredCredentials={requiredCredentials}
inputCredentials={inputCredentials}
inputValues={{}}
onCredentialChange={handleCredentialChange}
/>
{isAllCredentialsComplete && !hasSentCredentials && (
<Button
variant="primary"
size="small"
className="mt-3 w-full"
onClick={handleProceedCredentials}
>
Proceed
</Button>
)}
<Text variant="small" className="w-fit border-b text-zinc-500">
Block credentials
</Text>
<div className="mt-6">
<CredentialsGroupedView
credentialFields={credentialFields}
requiredCredentials={requiredCredentials}
inputCredentials={inputCredentials}
inputValues={{}}
onCredentialChange={handleCredentialChange}
/>
</div>
</div>
)}
{inputSchema && (
<div className="flex gap-2 pt-2">
<Button
variant="outline"
size="small"
className="w-fit"
onClick={() => setShowInputForm((prev) => !prev)}
>
{showInputForm ? "Hide inputs" : "Fill in inputs"}
</Button>
<div className="rounded-2xl border bg-background p-3 pt-4">
<Text variant="small" className="w-fit border-b text-zinc-500">
Block inputs
</Text>
<FormRenderer
jsonSchema={inputSchema}
className="mb-3 mt-3"
handleChange={(v) => setInputValues(v.formData ?? {})}
uiSchema={{
"ui:submitButtonOptions": { norender: true },
}}
initialValues={inputValues}
formContext={{
showHandles: false,
size: "small",
}}
/>
</div>
)}
<AnimatePresence initial={false}>
{showInputForm && inputSchema && (
<motion.div
initial={{ height: 0, opacity: 0, filter: "blur(6px)" }}
animate={{ height: "auto", opacity: 1, filter: "blur(0px)" }}
exit={{ height: 0, opacity: 0, filter: "blur(6px)" }}
transition={{
height: { type: "spring", bounce: 0.15, duration: 0.5 },
opacity: { duration: 0.25 },
filter: { duration: 0.2 },
}}
className="overflow-hidden"
style={{ willChange: "height, opacity, filter" }}
>
<div className="rounded-2xl border bg-background p-3 pt-4">
<Text variant="body-medium">Block inputs</Text>
<FormRenderer
jsonSchema={inputSchema}
handleChange={(v) => setInputValues(v.formData ?? {})}
uiSchema={{
"ui:submitButtonOptions": { norender: true },
}}
initialValues={inputValues}
formContext={{
showHandles: false,
size: "small",
}}
/>
<div className="-mt-8 flex gap-2">
<Button
variant="primary"
size="small"
className="w-fit"
onClick={handleRunWithInputs}
>
Run
</Button>
<Button
variant="secondary"
size="small"
className="w-fit"
onClick={() => {
setShowInputForm(false);
setInputValues({});
}}
>
Cancel
</Button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{expectedInputs.length > 0 && !inputSchema && (
<div className="rounded-2xl border bg-background p-3">
<ContentCardTitle className="text-xs">
Expected inputs
</ContentCardTitle>
<div className="mt-2 grid gap-2">
{expectedInputs.map((input) => (
<div key={input.name} className="rounded-xl border p-2">
<div className="flex items-center justify-between gap-2">
<ContentCardTitle className="text-xs">
{input.title}
</ContentCardTitle>
<ContentBadge>
{input.required ? "Required" : "Optional"}
</ContentBadge>
</div>
<ContentCardDescription className="mt-1">
{input.name} &bull; {input.type}
{input.description ? ` \u2022 ${input.description}` : ""}
</ContentCardDescription>
</div>
))}
</div>
</div>
{(needsCredentials || needsInputs) && (
<Button
variant="primary"
size="small"
className="w-fit"
disabled={!canRun}
onClick={handleRun}
>
Proceed
</Button>
)}
</div>
);

View File

@@ -209,7 +209,10 @@ export function ViewAgentOutputTool({ part }: Props) {
</div>
<div className="mt-2">
{items.slice(0, 3).map((item, i) => (
<RenderOutputValue key={i} value={item} />
<RenderOutputValue
key={`${key}-${i}`}
value={item}
/>
))}
</div>
</ContentCard>

View File

@@ -23,13 +23,23 @@ export function SidebarItemCard({
onClick,
actions,
}: Props) {
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick?.();
}
}
return (
<div
role="button"
tabIndex={0}
className={cn(
"w-full cursor-pointer rounded-large border border-zinc-200 bg-white p-3 text-left ring-1 ring-transparent transition-all duration-150 hover:scale-[1.01] hover:bg-slate-50/50",
selected ? "border-slate-800 ring-slate-800" : undefined,
)}
onClick={onClick}
onKeyDown={handleKeyDown}
>
<div className="flex min-w-0 items-center justify-start gap-3">
{icon}
@@ -49,7 +59,13 @@ export function SidebarItemCard({
</Text>
</div>
{actions ? (
<div onClick={(e) => e.stopPropagation()}>{actions}</div>
<div
role="presentation"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{actions}
</div>
) : null}
</div>
</div>

View File

@@ -1,15 +1,12 @@
"use client";
import { redirect } from "next/navigation";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "AutoGPT Platform",
description: "AutoGPT Platform",
};
export default function Page() {
const router = useRouter();
useEffect(() => {
router.replace("/copilot");
}, [router]);
return <LoadingSpinner size="large" cover />;
redirect("/copilot");
}

View File

@@ -1,6 +1,7 @@
"use client";
import * as React from "react";
import Image from "next/image";
const getYouTubeVideoId = (url: string) => {
const regExp =
@@ -76,6 +77,7 @@ const VideoRenderer: React.FC<{ videoUrl: string }> = ({ videoUrl }) => {
width="100%"
height="315"
src={`https://www.youtube.com/embed/${videoId}`}
title="Embedded content"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
@@ -92,15 +94,15 @@ const VideoRenderer: React.FC<{ videoUrl: string }> = ({ videoUrl }) => {
const ImageRenderer: React.FC<{ imageUrl: string }> = ({ imageUrl }) => {
return (
<div className="w-full p-2">
<picture>
<img
src={imageUrl}
alt="Image"
className="h-auto max-w-full"
width="100%"
height="auto"
/>
</picture>
<Image
src={imageUrl}
alt="Image"
width={0}
height={0}
sizes="100vw"
className="h-auto w-full"
unoptimized
/>
</div>
);
};

View File

@@ -93,7 +93,7 @@ export function APIKeyCredentialsModal({
<FormDescription>
Required scope(s) for this block:{" "}
{schema.credentials_scopes?.map((s, i, a) => (
<span key={i}>
<span key={s}>
<code className="text-xs font-bold">{s}</code>
{i < a.length - 1 && ", "}
</span>

View File

@@ -119,7 +119,7 @@ export function CredentialsFlatView({
) : (
!readOnly && (
<Button
variant="secondary"
variant="primary"
size="small"
onClick={onAddCredential}
className="w-fit"

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -66,16 +66,18 @@ export function HostScopedCredentialsModal({
});
const [headerPairs, setHeaderPairs] = useState<
Array<{ key: string; value: string }>
>([{ key: "", value: "" }]);
Array<{ id: string; key: string; value: string }>
>([{ id: crypto.randomUUID(), key: "", value: "" }]);
// Update form values when siblingInputs change
const prevHostRef = useRef(currentHost);
useEffect(() => {
if (currentHost === prevHostRef.current) return;
prevHostRef.current = currentHost;
if (currentHost) {
form.setValue("host", currentHost);
form.setValue("title", currentHost);
} else {
// Reset to empty when no current host
form.setValue("host", "");
form.setValue("title", "Manual Entry");
}
@@ -91,9 +93,12 @@ export function HostScopedCredentialsModal({
const { provider, providerName, createHostScopedCredentials } = credentials;
const addHeaderPair = () => {
setHeaderPairs([...headerPairs, { key: "", value: "" }]);
};
function addHeaderPair() {
setHeaderPairs((prev) => [
...prev,
{ id: crypto.randomUUID(), key: "", value: "" },
]);
}
const removeHeaderPair = (index: number) => {
if (headerPairs.length > 1) {
@@ -192,7 +197,7 @@ export function HostScopedCredentialsModal({
</FormDescription>
{headerPairs.map((pair, index) => (
<div key={index} className="flex w-full items-center gap-4">
<div key={pair.id} className="flex w-full items-center gap-4">
<Input
id={`header-${index}-key`}
label="Header Name"

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useRef, useState } from "react";
import { Input } from "@/components/__legacy__/ui/input";
import { Button } from "@/components/__legacy__/ui/button";
import { useToast } from "@/components/molecules/Toast/use-toast";
@@ -7,6 +7,7 @@ import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { getTimezoneDisplayName } from "@/lib/timezone-utils";
import { useUserTimezone } from "@/lib/hooks/useUserTimezone";
import { InfoIcon } from "lucide-react";
import Link from "next/link";
// Base type for cron expression only
type CronOnlyCallback = (cronExpression: string) => void;
@@ -53,15 +54,15 @@ export function CronSchedulerDialog(props: CronSchedulerDialogProps) {
const userTimezone = useUserTimezone();
const timezoneDisplay = getTimezoneDisplayName(userTimezone || "UTC");
// Reset state when dialog opens
useEffect(() => {
if (open) {
const defaultName =
props.mode === "with-name" ? props.defaultScheduleName || "" : "";
setScheduleName(defaultName);
setCronExpression(defaultCronExpression);
}
}, [open, props, defaultCronExpression]);
// Reset state when dialog opens (render-time sync instead of useEffect)
const prevOpenRef = useRef(open);
if (open && !prevOpenRef.current) {
const defaultName =
props.mode === "with-name" ? props.defaultScheduleName || "" : "";
setScheduleName(defaultName);
setCronExpression(defaultCronExpression);
}
prevOpenRef.current = open;
const handleDone = () => {
if (props.mode === "with-name" && !scheduleName.trim()) {
@@ -100,8 +101,11 @@ export function CronSchedulerDialog(props: CronSchedulerDialogProps) {
<div className="flex flex-col gap-4">
{props.mode === "with-name" && (
<div className="flex max-w-[448px] flex-col space-y-2">
<label className="text-sm font-medium">Schedule Name</label>
<label htmlFor="schedule-name" className="text-sm font-medium">
Schedule Name
</label>
<Input
id="schedule-name"
value={scheduleName}
onChange={(e) => setScheduleName(e.target.value)}
placeholder="Enter a name for this schedule"
@@ -121,9 +125,9 @@ export function CronSchedulerDialog(props: CronSchedulerDialogProps) {
<InfoIcon className="h-4 w-4 text-amber-600" />
<p className="text-sm text-amber-800">
No timezone set. Schedule will run in UTC.
<a href="/profile/settings" className="ml-1 underline">
<Link href="/profile/settings" className="ml-1 underline">
Set your timezone
</a>
</Link>
</p>
</div>
) : (

View File

@@ -452,7 +452,7 @@ export function CronScheduler({
const monthNumber = i + 1;
return (
<Button
key={i}
key={month.label}
variant={
selectedMonths.includes(monthNumber) ? "default" : "outline"
}

View File

@@ -1,6 +1,7 @@
"use client";
import React from "react";
import Image from "next/image";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
@@ -359,20 +360,23 @@ function renderMarkdown(
</del>
),
// Image handling
img: ({ src, alt, ...props }) => {
img: ({ src, alt }) => {
// Check if it's a video URL pattern
if (src && isVideoUrl(src)) {
return renderVideoEmbed(src);
}
if (!src) return null;
return (
// eslint-disable-next-line @next/next/no-img-element
<img
<Image
src={src}
alt={alt}
className="my-4 h-auto max-w-full rounded-lg shadow-md"
loading="lazy"
{...props}
alt={alt || "Image"}
width={0}
height={0}
sizes="100vw"
className="my-4 h-auto w-full rounded-lg shadow-md"
unoptimized
/>
);
},

View File

@@ -89,7 +89,6 @@ export function ActivityDropdown({
className="!focus:border-1 w-full pr-10"
wrapperClassName="!mb-0"
autoComplete="off"
autoFocus
/>
<button
onClick={handleClearSearch}

View File

@@ -1,10 +1,11 @@
import { cn } from "@/lib/utils";
import { RJSFSchema } from "@rjsf/utils";
import { preprocessInputSchema } from "./utils/input-schema-pre-processor";
import { useMemo } from "react";
import { customValidator } from "./utils/custom-validator";
import Form from "./registry";
import { ExtendedFormContextType } from "./types";
import { customValidator } from "./utils/custom-validator";
import { generateUiSchemaForCustomFields } from "./utils/generate-ui-schema";
import { preprocessInputSchema } from "./utils/input-schema-pre-processor";
type FormRendererProps = {
jsonSchema: RJSFSchema;
@@ -12,15 +13,17 @@ type FormRendererProps = {
uiSchema: any;
initialValues: any;
formContext: ExtendedFormContextType;
className?: string;
};
export const FormRenderer = ({
export function FormRenderer({
jsonSchema,
handleChange,
uiSchema,
initialValues,
formContext,
}: FormRendererProps) => {
className,
}: FormRendererProps) {
const preprocessedSchema = useMemo(() => {
return preprocessInputSchema(jsonSchema);
}, [jsonSchema]);
@@ -31,7 +34,10 @@ export const FormRenderer = ({
}, [preprocessedSchema, uiSchema]);
return (
<div className={"mb-6 mt-4"} data-tutorial-id="input-handles">
<div
className={cn("mb-6 mt-4", className)}
data-tutorial-id="input-handles"
>
<Form
formContext={formContext}
idPrefix="agpt"
@@ -45,4 +51,4 @@ export const FormRenderer = ({
/>
</div>
);
};
}