Merge branch 'dev' into otto/secrt-2035-thinking-indicator-between-streams

This commit is contained in:
Ubbe
2026-02-25 23:46:32 +08:00
committed by GitHub
17 changed files with 548 additions and 641 deletions

View File

@@ -0,0 +1,61 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { BookOpenIcon, PencilSimpleIcon } from "@phosphor-icons/react";
import Image from "next/image";
import sparklesImg from "../MiniGame/assets/sparkles.png";
interface Props {
agentName: string;
message: string;
libraryAgentLink: string;
agentPageLink: string;
}
export function AgentSavedCard({
agentName,
message,
libraryAgentLink,
agentPageLink,
}: Props) {
return (
<div className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
<div className="flex items-baseline gap-2">
<Image
src={sparklesImg}
alt="sparkles"
width={24}
height={24}
className="relative top-1"
/>
<Text variant="body-medium" className="mb-2 text-[16px] text-black">
Agent <span className="text-violet-600">{agentName}</span> {message}
</Text>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button
size="small"
as="NextLink"
href={libraryAgentLink}
target="_blank"
rel="noopener noreferrer"
>
<BookOpenIcon size={14} weight="regular" />
Open in library
</Button>
<Button
as="NextLink"
variant="secondary"
size="small"
href={agentPageLink}
target="_blank"
rel="noopener noreferrer"
>
<PencilSimpleIcon size={14} weight="regular" />
Open in builder
</Button>
</div>
</div>
);
}

View File

@@ -32,13 +32,16 @@ import { ViewAgentOutputTool } from "../../tools/ViewAgentOutput/ViewAgentOutput
/**
* Resolve workspace:// URLs in markdown text to proxy download URLs.
* Detects MIME type from the hash fragment (e.g. workspace://id#video/mp4)
* and prefixes the alt text with "video:" so the custom img component can
* render a <video> element instead.
*
* Handles both image syntax `![alt](workspace://id#mime)` and regular link
* syntax `[text](workspace://id)`. For images the MIME type hash fragment is
* inspected so that videos can be rendered with a `<video>` element via the
* custom img component.
*/
function resolveWorkspaceUrls(text: string): string {
return text.replace(
/!\[([^\]]*)\]\(workspace:\/\/([^)#\s]+)(?:#([^)\s]*))?\)/g,
// Handle image links: ![alt](workspace://id#mime)
let resolved = text.replace(
/!\[([^\]]*)\]\(workspace:\/\/([^)#\s]+)(?:#([^)#\s]*))?\)/g,
(_match, alt: string, fileId: string, mimeHint?: string) => {
const apiPath = getGetWorkspaceDownloadFileByIdUrl(fileId);
const url = `/api/proxy${apiPath}`;
@@ -48,6 +51,21 @@ function resolveWorkspaceUrls(text: string): string {
return `![${alt || "Image"}](${url})`;
},
);
// Handle regular links: [text](workspace://id) — without the leading "!"
// These are blocked by Streamdown's rehype-harden sanitizer because
// "workspace://" is not in the allowed URL-scheme whitelist, which causes
// "[blocked]" to appear next to the link text.
resolved = resolved.replace(
/(?<!!)\[([^\]]*)\]\(workspace:\/\/([^)#\s]+)(?:#[^)#\s]*)?\)/g,
(_match, linkText: string, fileId: string) => {
const apiPath = getGetWorkspaceDownloadFileByIdUrl(fileId);
const url = `/api/proxy${apiPath}`;
return `[${linkText || "Download file"}](${url})`;
},
);
return resolved;
}
/**

View File

@@ -0,0 +1,69 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { WarningDiamondIcon } from "@phosphor-icons/react";
interface Props {
message?: string;
fallbackMessage: string;
error?: string;
details?: string;
actions: Array<{
label: string;
onClick: () => void;
variant?: "outline" | "ghost";
}>;
}
export function ToolErrorCard({
message,
fallbackMessage,
error,
details,
actions,
}: Props) {
return (
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4">
<div className="flex items-start gap-2">
<WarningDiamondIcon
size={20}
weight="regular"
className="mt-0.5 shrink-0 text-red-500"
/>
<div className="flex-1 space-y-2">
<Text variant="body-medium" className="text-red-900">
{message || fallbackMessage}
</Text>
{error && (
<details className="text-xs text-red-700">
<summary className="cursor-pointer font-medium">
Technical details
</summary>
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-red-100 p-2">
{error}
</pre>
</details>
)}
{details && (
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-red-100 p-2 text-xs text-red-700">
{details}
</pre>
)}
</div>
</div>
<div className="flex gap-2 pt-3">
{actions.map((action, i) => (
<Button
key={i}
variant={action.variant ?? "outline"}
size="small"
onClick={action.onClick}
>
{action.label}
</Button>
))}
</div>
</div>
);
}

View File

@@ -921,26 +921,29 @@ export default function StyleguidePage() {
output: {
type: ResponseType.agent_details,
agent: {
id: "agent-yt-1",
name: "YouTube Summarizer",
description:
"Summarizes YouTube videos into key points.",
inputs: [
{
name: "video_url",
title: "Video URL",
type: "string",
required: true,
description: "The YouTube video URL to summarize",
inputs: {
type: "object",
properties: {
video_url: {
type: "string",
title: "Video URL",
description: "The YouTube video URL to summarize",
default: "https://youtube.com/watch?v=example",
},
language: {
type: "string",
title: "Output Language",
description:
"Language for the summary (default: English)",
default: "English",
},
},
{
name: "language",
title: "Output Language",
type: "string",
required: false,
description:
"Language for the summary (default: English)",
},
],
required: ["video_url"],
},
},
message: "This agent requires inputs to run.",
},

View File

@@ -1,16 +1,9 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import {
BookOpenIcon,
PencilSimpleIcon,
WarningDiamondIcon,
} from "@phosphor-icons/react";
import type { ToolUIPart } from "ai";
import Image from "next/image";
import NextLink from "next/link";
import { AgentSavedCard } from "../../components/AgentSavedCard/AgentSavedCard";
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
import { ToolErrorCard } from "../../components/ToolErrorCard/ToolErrorCard";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import {
ContentCardDescription,
@@ -20,11 +13,7 @@ import {
ContentMessage,
} from "../../components/ToolAccordion/AccordionContent";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
import {
ClarificationQuestionsCard,
ClarifyingQuestion,
} from "./components/ClarificationQuestionsCard";
import sparklesImg from "../../components/MiniGame/assets/sparkles.png";
import { ClarificationQuestionsCard } from "./components/ClarificationQuestionsCard";
import { MiniGame } from "../../components/MiniGame/MiniGame";
import { SuggestedGoalCard } from "./components/SuggestedGoalCard";
import {
@@ -39,6 +28,7 @@ import {
isSuggestedGoalOutput,
ToolIcon,
truncateText,
normalizeClarifyingQuestions,
type CreateAgentToolOutput,
} from "./helpers";
@@ -66,9 +56,6 @@ function getAccordionMeta(output: CreateAgentToolOutput | null) {
};
}
if (isAgentSavedOutput(output)) {
return { icon, title: output.agent_name, expanded: true };
}
if (isAgentPreviewOutput(output)) {
return {
icon,
@@ -92,13 +79,7 @@ function getAccordionMeta(output: CreateAgentToolOutput | null) {
expanded: true,
};
}
return {
icon: (
<WarningDiamondIcon size={32} weight="light" className="text-red-500" />
),
title: "Error",
titleClassName: "text-red-500",
};
return { icon, title: "" };
}
export function CreateAgentTool({ part }: Props) {
@@ -154,154 +135,79 @@ export function CreateAgentTool({ part }: Props) {
)}
{isError && output && isErrorOutput(output) && (
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4">
<div className="flex items-start gap-2">
<WarningDiamondIcon
size={20}
weight="regular"
className="mt-0.5 shrink-0 text-red-500"
/>
<div className="flex-1 space-y-2">
<Text variant="body-medium" className="text-red-900">
{output.message ||
"Failed to generate the agent. Please try again."}
</Text>
{output.error && (
<details className="text-xs text-red-700">
<summary className="cursor-pointer font-medium">
Technical details
</summary>
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-red-100 p-2">
{formatMaybeJson(output.error)}
</pre>
</details>
)}
{output.details && (
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-red-100 p-2 text-xs text-red-700">
{formatMaybeJson(output.details)}
</pre>
)}
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="small"
onClick={() => onSend("Please try creating the agent again.")}
>
Try again
</Button>
<Button
variant="outline"
size="small"
onClick={() => onSend("Can you help me simplify this goal?")}
>
Simplify goal
</Button>
</div>
</div>
<ToolErrorCard
message={output.message}
fallbackMessage="Failed to generate the agent. Please try again."
error={output.error ? formatMaybeJson(output.error) : undefined}
details={output.details ? formatMaybeJson(output.details) : undefined}
actions={[
{
label: "Try again",
onClick: () => onSend("Please try creating the agent again."),
},
{
label: "Simplify goal",
variant: "ghost",
onClick: () => onSend("Can you help me simplify this goal?"),
},
]}
/>
)}
{hasExpandableContent && (
<ToolAccordion {...getAccordionMeta(output)}>
{isOperating && (
<ContentGrid>
<MiniGame />
<ContentHint>
This could take a few minutes play while you wait!
</ContentHint>
</ContentGrid>
)}
{hasExpandableContent &&
!(output && isClarificationNeededOutput(output)) &&
!(output && isAgentSavedOutput(output)) && (
<ToolAccordion {...getAccordionMeta(output)}>
{isOperating && (
<ContentGrid>
<MiniGame />
<ContentHint>
This could take a few minutes play while you wait!
</ContentHint>
</ContentGrid>
)}
{output && isAgentSavedOutput(output) && (
<div className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
<div className="flex items-baseline gap-2">
<Image
src={sparklesImg}
alt="sparkles"
width={24}
height={24}
className="relative top-1"
/>
<Text
variant="body-medium"
className="mb-2 text-[16px] text-black"
>
Agent{" "}
<span className="text-violet-600">{output.agent_name}</span>{" "}
has been saved to your library!
</Text>
</div>
<div className="mt-3 flex flex-wrap gap-4">
<Button variant="outline" size="small">
<NextLink
href={output.library_agent_link}
className="inline-flex items-center gap-1.5"
target="_blank"
rel="noopener noreferrer"
>
<BookOpenIcon size={14} weight="regular" />
Open in library
</NextLink>
</Button>
<Button variant="outline" size="small">
<NextLink
href={output.agent_page_link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5"
>
<PencilSimpleIcon size={14} weight="regular" />
Open in builder
</NextLink>
</Button>
</div>
</div>
)}
{output && isAgentPreviewOutput(output) && (
<ContentGrid>
<ContentMessage>{output.message}</ContentMessage>
{output.description?.trim() && (
<ContentCardDescription>
{output.description}
</ContentCardDescription>
)}
<ContentCodeBlock>
{truncateText(formatMaybeJson(output.agent_json), 1600)}
</ContentCodeBlock>
</ContentGrid>
)}
{output && isAgentPreviewOutput(output) && (
<ContentGrid>
<ContentMessage>{output.message}</ContentMessage>
{output.description?.trim() && (
<ContentCardDescription>
{output.description}
</ContentCardDescription>
)}
<ContentCodeBlock>
{truncateText(formatMaybeJson(output.agent_json), 1600)}
</ContentCodeBlock>
</ContentGrid>
)}
{output && isSuggestedGoalOutput(output) && (
<SuggestedGoalCard
message={output.message}
suggestedGoal={output.suggested_goal}
reason={output.reason}
goalType={output.goal_type ?? "vague"}
onUseSuggestedGoal={handleUseSuggestedGoal}
/>
)}
</ToolAccordion>
)}
{output && isClarificationNeededOutput(output) && (
<ClarificationQuestionsCard
questions={(output.questions ?? []).map((q) => {
const item: ClarifyingQuestion = {
question: q.question,
keyword: q.keyword,
};
const example =
typeof q.example === "string" && q.example.trim()
? q.example.trim()
: null;
if (example) item.example = example;
return item;
})}
message={output.message}
onSubmitAnswers={handleClarificationAnswers}
/>
)}
{output && isAgentSavedOutput(output) && (
<AgentSavedCard
agentName={output.agent_name}
message="has been saved to your library!"
libraryAgentLink={output.library_agent_link}
agentPageLink={output.agent_page_link}
/>
)}
{output && isSuggestedGoalOutput(output) && (
<SuggestedGoalCard
message={output.message}
suggestedGoal={output.suggested_goal}
reason={output.reason}
goalType={output.goal_type ?? "vague"}
onUseSuggestedGoal={handleUseSuggestedGoal}
/>
)}
</ToolAccordion>
{output && isClarificationNeededOutput(output) && (
<ClarificationQuestionsCard
questions={normalizeClarifyingQuestions(output.questions ?? [])}
message={output.message}
onSubmitAnswers={handleClarificationAnswers}
/>
)}
</div>
);

View File

@@ -5,14 +5,9 @@ import { Card } from "@/components/atoms/Card/Card";
import { Input } from "@/components/atoms/Input/Input";
import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
import { CheckCircleIcon, QuestionIcon } from "@phosphor-icons/react";
import { ChatTeardropDotsIcon, CheckCircleIcon } from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react";
export interface ClarifyingQuestion {
question: string;
keyword: string;
example?: string;
}
import type { ClarifyingQuestion } from "../helpers";
interface Props {
questions: ClarifyingQuestion[];
@@ -133,29 +128,26 @@ export function ClarificationQuestionsCard({
return (
<div
className={cn(
"group relative flex w-full justify-start gap-3 px-4 py-3",
"group relative flex w-full justify-start gap-3",
className,
)}
>
<div className="flex w-full max-w-3xl gap-3">
<div className="flex-shrink-0">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-500">
<QuestionIcon className="h-4 w-4 text-indigo-50" weight="bold" />
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col">
<Card className="space-y-4 p-4">
<Card className="space-y-6 p-8">
<div>
<Text variant="h4" className="mb-1 text-slate-900">
I need more information
</Text>
<Text variant="small" className="text-slate-600">
<div className="flex gap-3">
<ChatTeardropDotsIcon className="size-6" />
<Text variant="h4" className="mb-1 text-slate-900">
I need more information
</Text>
</div>
<Text variant="body" className="text-slate-600">
{message}
</Text>
</div>
<div className="space-y-3">
<div className="space-y-6">
{questions.map((q, index) => {
const isAnswered = !!answers[q.keyword]?.trim();
@@ -163,34 +155,34 @@ export function ClarificationQuestionsCard({
<div
key={`${q.keyword}-${index}`}
className={cn(
"relative rounded-lg border p-3",
"relative rounded-lg border border-dotted p-3",
isAnswered
? "border-green-500 bg-green-50/50"
: "border-slate-200 bg-white/50",
: "border-slate-100 bg-slate-50/50",
)}
>
<div className="mb-2 flex items-start gap-2">
{isAnswered ? (
<CheckCircleIcon
size={16}
size={20}
className="mt-0.5 text-green-500"
weight="bold"
/>
) : (
<div className="mt-0.5 flex h-4 w-4 items-center justify-center rounded-full border border-slate-300 bg-white text-xs text-slate-500">
<div className="mt-0 flex h-6 w-6 items-center justify-center rounded-full border border-slate-300 font-mono">
{index + 1}
</div>
)}
<div className="flex-1">
<Text
variant="small"
variant="h5"
className="mb-2 font-semibold text-slate-900"
>
{q.question}
</Text>
{q.example && (
<Text
variant="small"
variant="body"
className="mb-2 italic text-slate-500"
>
Example: {q.example}
@@ -215,11 +207,11 @@ export function ClarificationQuestionsCard({
})}
</div>
<div className="flex gap-2">
<div className="flex max-w-[25rem] gap-2">
<Button
onClick={handleSubmit}
disabled={!allAnswered}
className="flex-1"
className="w-auto flex-1"
variant="primary"
>
Submit Answers

View File

@@ -157,3 +157,41 @@ export function truncateText(text: string, maxChars: number): string {
if (trimmed.length <= maxChars) return trimmed;
return `${trimmed.slice(0, maxChars).trimEnd()}`;
}
export interface ClarifyingQuestion {
question: string;
keyword: string;
example?: string;
}
export function normalizeClarifyingQuestions(
questions: Array<{ question: string; keyword: string; example?: unknown }>,
): ClarifyingQuestion[] {
const seen = new Set<string>();
return questions.map((q, index) => {
let keyword = q.keyword?.trim().toLowerCase() || "";
if (!keyword) {
keyword = `question-${index}`;
}
let unique = keyword;
let suffix = 1;
while (seen.has(unique)) {
unique = `${keyword}-${suffix}`;
suffix++;
}
seen.add(unique);
const item: ClarifyingQuestion = {
question: q.question,
keyword: unique,
};
const example =
typeof q.example === "string" && q.example.trim()
? q.example.trim()
: null;
if (example) item.example = example;
return item;
});
}

View File

@@ -1,17 +1,9 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import {
BookOpenIcon,
PencilSimpleIcon,
WarningDiamondIcon,
} from "@phosphor-icons/react";
import type { ToolUIPart } from "ai";
import Image from "next/image";
import NextLink from "next/link";
import { AgentSavedCard } from "../../components/AgentSavedCard/AgentSavedCard";
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
import sparklesImg from "../../components/MiniGame/assets/sparkles.png";
import { ToolErrorCard } from "../../components/ToolErrorCard/ToolErrorCard";
import { MiniGame } from "../../components/MiniGame/MiniGame";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import {
@@ -22,10 +14,8 @@ import {
ContentMessage,
} from "../../components/ToolAccordion/AccordionContent";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
import {
ClarificationQuestionsCard,
ClarifyingQuestion,
} from "../CreateAgent/components/ClarificationQuestionsCard";
import { ClarificationQuestionsCard } from "../CreateAgent/components/ClarificationQuestionsCard";
import { normalizeClarifyingQuestions } from "../CreateAgent/helpers";
import {
AccordionIcon,
formatMaybeJson,
@@ -69,9 +59,6 @@ function getAccordionMeta(output: EditAgentToolOutput | null): {
};
}
if (isAgentSavedOutput(output)) {
return { icon, title: output.agent_name, expanded: true };
}
if (isAgentPreviewOutput(output)) {
return {
icon,
@@ -87,13 +74,7 @@ function getAccordionMeta(output: EditAgentToolOutput | null): {
description: `${questions.length} question${questions.length === 1 ? "" : "s"}`,
};
}
return {
icon: (
<WarningDiamondIcon size={32} weight="light" className="text-red-500" />
),
title: "Error",
titleClassName: "text-red-500",
};
return { icon, title: "" };
}
export function EditAgentTool({ part }: Props) {
@@ -143,135 +124,64 @@ export function EditAgentTool({ part }: Props) {
)}
{isError && output && isErrorOutput(output) && (
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4">
<div className="flex items-start gap-2">
<WarningDiamondIcon
size={20}
weight="regular"
className="mt-0.5 shrink-0 text-red-500"
/>
<div className="flex-1 space-y-2">
<Text variant="body-medium" className="text-red-900">
{output.message ||
"Failed to edit the agent. Please try again."}
</Text>
{output.error && (
<details className="text-xs text-red-700">
<summary className="cursor-pointer font-medium">
Technical details
</summary>
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-red-100 p-2">
{formatMaybeJson(output.error)}
</pre>
</details>
)}
{output.details && (
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-red-100 p-2 text-xs text-red-700">
{formatMaybeJson(output.details)}
</pre>
)}
</div>
</div>
<Button
variant="outline"
size="small"
onClick={() => onSend("Please try editing the agent again.")}
>
Try again
</Button>
</div>
<ToolErrorCard
message={output.message}
fallbackMessage="Failed to edit the agent. Please try again."
error={output.error ? formatMaybeJson(output.error) : undefined}
details={output.details ? formatMaybeJson(output.details) : undefined}
actions={[
{
label: "Try again",
onClick: () => onSend("Please try editing the agent again."),
},
]}
/>
)}
{hasExpandableContent && (
<ToolAccordion {...getAccordionMeta(output)}>
{isOperating && (
<ContentGrid>
<MiniGame />
<ContentHint>
This could take a few minutes play while you wait!
</ContentHint>
</ContentGrid>
)}
{hasExpandableContent &&
!(output && isClarificationNeededOutput(output)) &&
!(output && isAgentSavedOutput(output)) && (
<ToolAccordion {...getAccordionMeta(output)}>
{isOperating && (
<ContentGrid>
<MiniGame />
<ContentHint>
This could take a few minutes play while you wait!
</ContentHint>
</ContentGrid>
)}
{output && isAgentSavedOutput(output) && (
<div className="rounded-xl border border-border/60 bg-card p-4 shadow-sm">
<div className="flex items-baseline gap-2">
<Image
src={sparklesImg}
alt="sparkles"
width={24}
height={24}
className="relative top-1"
/>
<Text
variant="body-medium"
className="mb-2 text-[16px] text-black"
>
Agent{" "}
<span className="text-violet-600">{output.agent_name}</span>{" "}
has been updated!
</Text>
</div>
<div className="mt-3 flex flex-wrap gap-4">
<Button variant="outline" size="small">
<NextLink
href={output.library_agent_link}
className="inline-flex items-center gap-1.5"
target="_blank"
rel="noopener noreferrer"
>
<BookOpenIcon size={14} weight="regular" />
Open in library
</NextLink>
</Button>
<Button variant="outline" size="small">
<NextLink
href={output.agent_page_link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5"
>
<PencilSimpleIcon size={14} weight="regular" />
Open in builder
</NextLink>
</Button>
</div>
</div>
)}
{output && isAgentPreviewOutput(output) && (
<ContentGrid>
<ContentMessage>{output.message}</ContentMessage>
{output.description?.trim() && (
<ContentCardDescription>
{output.description}
</ContentCardDescription>
)}
<ContentCodeBlock>
{truncateText(formatMaybeJson(output.agent_json), 1600)}
</ContentCodeBlock>
</ContentGrid>
)}
</ToolAccordion>
)}
{output && isAgentPreviewOutput(output) && (
<ContentGrid>
<ContentMessage>{output.message}</ContentMessage>
{output.description?.trim() && (
<ContentCardDescription>
{output.description}
</ContentCardDescription>
)}
<ContentCodeBlock>
{truncateText(formatMaybeJson(output.agent_json), 1600)}
</ContentCodeBlock>
</ContentGrid>
)}
{output && isAgentSavedOutput(output) && (
<AgentSavedCard
agentName={output.agent_name}
message="has been updated!"
libraryAgentLink={output.library_agent_link}
agentPageLink={output.agent_page_link}
/>
)}
{output && isClarificationNeededOutput(output) && (
<ClarificationQuestionsCard
questions={(output.questions ?? []).map((q) => {
const item: ClarifyingQuestion = {
question: q.question,
keyword: q.keyword,
};
const example =
typeof q.example === "string" && q.example.trim()
? q.example.trim()
: null;
if (example) item.example = example;
return item;
})}
message={output.message}
onSubmitAnswers={handleClarificationAnswers}
/>
)}
</ToolAccordion>
{output && isClarificationNeededOutput(output) && (
<ClarificationQuestionsCard
questions={normalizeClarifyingQuestions(output.questions ?? [])}
message={output.message}
onSubmitAnswers={handleClarificationAnswers}
/>
)}
</div>
);

View File

@@ -2,13 +2,11 @@
import type { AgentDetailsResponse } from "@/app/api/__generated__/models/agentDetailsResponse";
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 { useState } from "react";
import { useCopilotChatActions } from "../../../../components/CopilotChatActionsProvider/useCopilotChatActions";
import { ContentMessage } from "../../../../components/ToolAccordion/AccordionContent";
import { buildInputSchema } from "./helpers";
import { buildInputSchema, extractDefaults, isFormValid } from "./helpers";
interface Props {
output: AgentDetailsResponse;
@@ -16,16 +14,25 @@ interface Props {
export function AgentDetailsCard({ output }: Props) {
const { onSend } = useCopilotChatActions();
const [showInputForm, setShowInputForm] = useState(false);
const [inputValues, setInputValues] = useState<Record<string, unknown>>({});
const schema = buildInputSchema(output.agent.inputs);
function handleRunWithExamples() {
onSend(
`Run the agent "${output.agent.name}" with placeholder/example values so I can test it.`,
);
const defaults = schema ? extractDefaults(schema) : {};
const [inputValues, setInputValues] =
useState<Record<string, unknown>>(defaults);
const [valid, setValid] = useState(() =>
schema ? isFormValid(schema, defaults) : false,
);
function handleChange(v: { formData?: Record<string, unknown> }) {
const data = v.formData ?? {};
setInputValues(data);
if (schema) {
setValid(isFormValid(schema, data));
}
}
function handleRunWithInputs() {
function handleProceed() {
const nonEmpty = Object.fromEntries(
Object.entries(inputValues).filter(
([, v]) => v !== undefined && v !== null && v !== "",
@@ -34,83 +41,61 @@ export function AgentDetailsCard({ output }: Props) {
onSend(
`Run the agent "${output.agent.name}" with these inputs: ${JSON.stringify(nonEmpty, null, 2)}`,
);
setShowInputForm(false);
setInputValues({});
}
if (!schema) {
return (
<div className="grid gap-2">
<ContentMessage>This agent has no configurable inputs.</ContentMessage>
<div className="flex gap-2 pt-2">
<Button
size="small"
className="w-fit"
onClick={() =>
onSend(
`Run the agent "${output.agent.name}" with placeholder/example values so I can test it.`,
)
}
>
Proceed
</Button>
</div>
</div>
);
}
return (
<div className="grid gap-2">
<ContentMessage>
Run this agent with example values or your own inputs.
Review the inputs below and press Proceed to run.
</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 className="mt-2 rounded-2xl border bg-background p-3 pt-4">
<FormRenderer
jsonSchema={schema}
handleChange={handleChange}
uiSchema={{
"ui:submitButtonOptions": { norender: true },
}}
initialValues={inputValues}
formContext={{
showHandles: false,
size: "small",
}}
/>
</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="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({});
}}
>
Cancel
</Button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="mt-4">
<Button
variant="primary"
size="small"
className="w-fit"
disabled={!valid}
onClick={handleProceed}
>
Proceed
</Button>
</div>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import type { RJSFSchema } from "@rjsf/utils";
import { customValidator } from "@/components/renderers/InputRenderer/utils/custom-validator";
export function buildInputSchema(inputs: unknown): RJSFSchema | null {
if (!inputs || typeof inputs !== "object") return null;
@@ -6,3 +7,31 @@ export function buildInputSchema(inputs: unknown): RJSFSchema | null {
if (!properties || Object.keys(properties).length === 0) return null;
return inputs as RJSFSchema;
}
export function extractDefaults(schema: RJSFSchema): Record<string, unknown> {
const defaults: Record<string, unknown> = {};
const props = schema.properties;
if (!props || typeof props !== "object") return defaults;
for (const [key, prop] of Object.entries(props)) {
if (typeof prop !== "object" || prop === null) continue;
if ("default" in prop && prop.default !== undefined) {
defaults[key] = prop.default;
} else if (
"examples" in prop &&
Array.isArray(prop.examples) &&
prop.examples.length > 0
) {
defaults[key] = prop.examples[0];
}
}
return defaults;
}
export function isFormValid(
schema: RJSFSchema,
formData: Record<string, unknown>,
): boolean {
const { errors } = customValidator.validateFormData(formData, schema);
return errors.length === 0;
}

View File

@@ -1,188 +0,0 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { ResponseType } from "@/app/api/__generated__/models/responseType";
import type { BlockDetailsResponse } from "../../helpers";
import { BlockDetailsCard } from "./BlockDetailsCard";
const meta: Meta<typeof BlockDetailsCard> = {
title: "Copilot/RunBlock/BlockDetailsCard",
component: BlockDetailsCard,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
decorators: [
(Story) => (
<div style={{ maxWidth: 480 }}>
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof meta>;
const baseBlock: BlockDetailsResponse = {
type: ResponseType.block_details,
message:
"Here are the details for the GetWeather block. Provide the required inputs to run it.",
session_id: "session-123",
user_authenticated: true,
block: {
id: "block-abc-123",
name: "GetWeather",
description: "Fetches current weather data for a given location.",
inputs: {
type: "object",
properties: {
location: {
title: "Location",
type: "string",
description:
"City name or coordinates (e.g. 'London' or '51.5,-0.1')",
},
units: {
title: "Units",
type: "string",
description: "Temperature units: 'metric' or 'imperial'",
},
},
required: ["location"],
},
outputs: {
type: "object",
properties: {
temperature: {
title: "Temperature",
type: "number",
description: "Current temperature in the requested units",
},
condition: {
title: "Condition",
type: "string",
description: "Weather condition description (e.g. 'Sunny', 'Rain')",
},
},
},
credentials: [],
},
};
export const Default: Story = {
args: {
output: baseBlock,
},
};
export const InputsOnly: Story = {
args: {
output: {
...baseBlock,
message: "This block requires inputs. No outputs are defined.",
block: {
...baseBlock.block,
outputs: {},
},
},
},
};
export const OutputsOnly: Story = {
args: {
output: {
...baseBlock,
message: "This block has no required inputs.",
block: {
...baseBlock.block,
inputs: {},
},
},
},
};
export const ManyFields: Story = {
args: {
output: {
...baseBlock,
message: "Block with many input and output fields.",
block: {
...baseBlock.block,
name: "SendEmail",
description: "Sends an email via SMTP.",
inputs: {
type: "object",
properties: {
to: {
title: "To",
type: "string",
description: "Recipient email address",
},
subject: {
title: "Subject",
type: "string",
description: "Email subject line",
},
body: {
title: "Body",
type: "string",
description: "Email body content",
},
cc: {
title: "CC",
type: "string",
description: "CC recipients (comma-separated)",
},
bcc: {
title: "BCC",
type: "string",
description: "BCC recipients (comma-separated)",
},
},
required: ["to", "subject", "body"],
},
outputs: {
type: "object",
properties: {
message_id: {
title: "Message ID",
type: "string",
description: "Unique ID of the sent email",
},
status: {
title: "Status",
type: "string",
description: "Delivery status",
},
},
},
},
},
},
};
export const NoFieldDescriptions: Story = {
args: {
output: {
...baseBlock,
message: "Fields without descriptions.",
block: {
...baseBlock.block,
name: "SimpleBlock",
inputs: {
type: "object",
properties: {
input_a: { title: "Input A", type: "string" },
input_b: { title: "Input B", type: "number" },
},
required: ["input_a"],
},
outputs: {
type: "object",
properties: {
result: { title: "Result", type: "string" },
},
},
},
},
},
};

View File

@@ -134,7 +134,7 @@ export function SetupRequirementsCard({ output }: Props) {
<Button
variant="primary"
size="small"
className="w-fit"
className="mt-4 w-fit"
disabled={!canRun}
onClick={handleRun}
>

View File

@@ -2,6 +2,7 @@
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { AuthCard } from "@/components/auth/AuthCard";
import { ExpiredLinkMessage } from "@/components/auth/ExpiredLinkMessage";
import { Form, FormField } from "@/components/__legacy__/ui/form";
import LoadingBox from "@/components/__legacy__/ui/loading";
import { useToast } from "@/components/molecules/Toast/use-toast";
@@ -21,18 +22,42 @@ function ResetPasswordContent() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [disabled, setDisabled] = useState(false);
const [showExpiredMessage, setShowExpiredMessage] = useState(false);
useEffect(() => {
const error = searchParams.get("error");
if (error) {
toast({
title: "Password Reset Failed",
description: error,
variant: "destructive",
});
const errorCode = searchParams.get("error_code");
const errorDescription = searchParams.get("error_description");
if (error || errorCode) {
// Check if this is an expired/used link error
// Avoid broad checks like "invalid" which can match unrelated errors (e.g., PKCE errors)
const descLower = errorDescription?.toLowerCase() || "";
const isExpiredOrUsed =
error === "link_expired" ||
errorCode === "otp_expired" ||
descLower.includes("expired") ||
descLower.includes("already") ||
descLower.includes("used");
if (isExpiredOrUsed) {
setShowExpiredMessage(true);
} else {
// Show toast for other errors
const errorMessage =
errorDescription || error || "Password reset failed";
toast({
title: "Password Reset Failed",
description: errorMessage,
variant: "destructive",
});
}
// Clear all error params from URL
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete("error");
newUrl.searchParams.delete("error_code");
newUrl.searchParams.delete("error_description");
router.replace(newUrl.pathname + newUrl.search);
}
}, [searchParams, toast, router]);
@@ -82,6 +107,10 @@ function ResetPasswordContent() {
[sendEmailForm, toast],
);
function handleShowEmailForm() {
setShowExpiredMessage(false);
}
const onChangePassword = useCallback(
async (data: z.infer<typeof changePasswordFormSchema>) => {
setIsLoading(true);
@@ -122,6 +151,17 @@ function ResetPasswordContent() {
);
}
// Show expired link message if detected
if (showExpiredMessage && !user) {
return (
<div className="flex h-full min-h-[85vh] w-full flex-col items-center justify-center">
<AuthCard title="Reset Password">
<ExpiredLinkMessage onRequestNewLink={handleShowEmailForm} />
</AuthCard>
</div>
);
}
return (
<div className="flex h-full min-h-[85vh] w-full flex-col items-center justify-center">
<AuthCard title="Reset Password">

View File

@@ -10,7 +10,7 @@ export async function GET(request: NextRequest) {
if (!code) {
return NextResponse.redirect(
`${origin}/reset-password?error=Missing verification code`,
`${origin}/reset-password?error=${encodeURIComponent("Missing verification code")}`,
);
}
@@ -26,8 +26,21 @@ export async function GET(request: NextRequest) {
const result = await exchangePasswordResetCode(supabase, code);
if (!result.success) {
// Check for expired or used link errors
// Avoid broad checks like "invalid" which can match unrelated errors (e.g., PKCE errors)
const errorMessage = result.error?.toLowerCase() || "";
const isExpiredOrUsed =
errorMessage.includes("expired") ||
errorMessage.includes("otp_expired") ||
errorMessage.includes("already") ||
errorMessage.includes("used");
const errorParam = isExpiredOrUsed
? "link_expired"
: encodeURIComponent(result.error || "Password reset failed");
return NextResponse.redirect(
`${origin}/reset-password?error=${encodeURIComponent(result.error || "Password reset failed")}`,
`${origin}/reset-password?error=${errorParam}`,
);
}
@@ -35,7 +48,7 @@ export async function GET(request: NextRequest) {
} catch (error) {
console.error("Password reset callback error:", error);
return NextResponse.redirect(
`${origin}/reset-password?error=Password reset failed`,
`${origin}/reset-password?error=${encodeURIComponent("Password reset failed")}`,
);
}
}

View File

@@ -16,7 +16,7 @@ export const extendedButtonVariants = cva(
primary:
"bg-zinc-800 border-zinc-800 text-white hover:bg-zinc-900 hover:border-zinc-900 rounded-full disabled:text-white disabled:bg-zinc-200 disabled:border-zinc-200 disabled:opacity-1",
secondary:
"bg-zinc-200 border-zinc-200 text-black hover:bg-zinc-300 hover:border-zinc-300 rounded-full disabled:text-zinc-300 disabled:bg-zinc-50 disabled:border-zinc-50 disabled:opacity-1",
"bg-zinc-100 border-zinc-100 text-black hover:bg-zinc-200 hover:border-zinc-200 rounded-full disabled:text-zinc-300 disabled:bg-zinc-50 disabled:border-zinc-50 disabled:opacity-1",
destructive:
"bg-red-500 border-red-500 text-white hover:bg-red-600 hover:border-red-600 rounded-full disabled:text-white disabled:bg-zinc-200 disabled:border-zinc-200 disabled:opacity-1",
outline:

View File

@@ -92,7 +92,7 @@ export function Input({
className={cn(
baseStyles,
errorStyles,
"-mb-1 h-auto min-h-[2.875rem] rounded-3xl",
"-mb-1 h-auto min-h-[2.875rem] rounded-xl",
// Size variants for textarea
size === "small" && [
"min-h-[2.25rem]", // 36px minimum

View File

@@ -0,0 +1,31 @@
import { Button } from "../atoms/Button/Button";
import { Link } from "../atoms/Link/Link";
import { Text } from "../atoms/Text/Text";
interface Props {
onRequestNewLink: () => void;
}
export function ExpiredLinkMessage({ onRequestNewLink }: Props) {
return (
<div className="flex flex-col items-center gap-6">
<Text variant="h3" className="text-center">
Your reset password link has expired or has already been used
</Text>
<Text variant="body-medium" className="text-center text-muted-foreground">
Click below to request a new password reset link.
</Text>
<Button variant="primary" onClick={onRequestNewLink} className="w-full">
Request a New Link
</Button>
<div className="flex items-center gap-1">
<Text variant="small" className="text-muted-foreground">
Already have access?
</Text>
<Link href="/login" variant="secondary">
Log in here
</Link>
</div>
</div>
);
}