chore: further refinements

This commit is contained in:
Lluis Agusti
2026-02-09 21:19:37 +08:00
parent fba027b7a4
commit 20d680d8ee
21 changed files with 898 additions and 410 deletions

View File

@@ -8,9 +8,10 @@ import {
MessageContent,
MessageResponse,
} from "@/components/ai-elements/message";
import { getGetWorkspaceDownloadFileByIdUrl } from "@/app/api/__generated__/endpoints/workspace/workspace";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { UIDataTypes, UIMessage, UITools, ToolUIPart } from "ai";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { FindBlocksTool } from "../../tools/FindBlocks/FindBlocks";
import { FindAgentsTool } from "../../tools/FindAgents/FindAgents";
import { SearchDocsTool } from "../../tools/SearchDocs/SearchDocs";
@@ -20,6 +21,75 @@ import { ViewAgentOutputTool } from "../../tools/ViewAgentOutput/ViewAgentOutput
import { CreateAgentTool } from "../../tools/CreateAgent/CreateAgent";
import { EditAgentTool } from "../../tools/EditAgent/EditAgent";
// ---------------------------------------------------------------------------
// Workspace media support
// ---------------------------------------------------------------------------
/**
* 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.
*/
function resolveWorkspaceUrls(text: string): string {
return text.replace(
/!\[([^\]]*)\]\(workspace:\/\/([^)#\s]+)(?:#([^)\s]*))?\)/g,
(_match, alt: string, fileId: string, mimeHint?: string) => {
const apiPath = getGetWorkspaceDownloadFileByIdUrl(fileId);
const url = `/api/proxy${apiPath}`;
if (mimeHint?.startsWith("video/")) {
return `![video:${alt || "Video"}](${url})`;
}
return `![${alt || "Image"}](${url})`;
},
);
}
/**
* Custom img component for Streamdown that renders <video> elements
* for workspace video files (detected via "video:" alt-text prefix).
* 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 [imgFailed, setImgFailed] = useState(false);
const isWorkspace = src?.includes("/workspace/files/") ?? false;
if (!src) return null;
if (alt?.startsWith("video:") || (imgFailed && isWorkspace)) {
return (
<span className="my-2 inline-block">
<video
controls
className="h-auto max-w-full rounded-md border border-zinc-200"
preload="metadata"
>
<source src={src} />
Your browser does not support the video tag.
</video>
</span>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={alt || "Image"}
className="h-auto max-w-full rounded-md border border-zinc-200"
loading="lazy"
onError={() => {
if (isWorkspace) setImgFailed(true);
}}
{...rest}
/>
);
}
/** Stable components override for Streamdown (avoids re-creating on every render). */
const STREAMDOWN_COMPONENTS = { img: WorkspaceMediaImage };
const THINKING_PHRASES = [
"Thinking...",
"Considering this...",
@@ -102,8 +172,11 @@ export const ChatMessagesContainer = ({
switch (part.type) {
case "text":
return (
<MessageResponse key={`${message.id}-${i}`}>
{part.text}
<MessageResponse
key={`${message.id}-${i}`}
components={STREAMDOWN_COMPONENTS}
>
{resolveWorkspaceUrls(part.text)}
</MessageResponse>
);
case "tool-find_block":

View File

@@ -0,0 +1,69 @@
.loader {
position: relative;
animation: rotate 1s infinite;
}
.loader::before,
.loader::after {
border-radius: 50%;
content: "";
display: block;
/* 40% of container size */
height: 40%;
width: 40%;
}
.loader::before {
animation: ball1 1s infinite;
background-color: #a1a1aa; /* zinc-400 */
box-shadow: calc(var(--spacing)) 0 0 #18181b; /* zinc-900 */
margin-bottom: calc(var(--gap));
}
.loader::after {
animation: ball2 1s infinite;
background-color: #18181b; /* zinc-900 */
box-shadow: calc(var(--spacing)) 0 0 #a1a1aa; /* zinc-400 */
}
@keyframes rotate {
0% {
transform: rotate(0deg) scale(0.8);
}
50% {
transform: rotate(360deg) scale(1.2);
}
100% {
transform: rotate(720deg) scale(0.8);
}
}
@keyframes ball1 {
0% {
box-shadow: calc(var(--spacing)) 0 0 #18181b;
}
50% {
box-shadow: 0 0 0 #18181b;
margin-bottom: 0;
transform: translate(calc(var(--spacing) / 2), calc(var(--spacing) / 2));
}
100% {
box-shadow: calc(var(--spacing)) 0 0 #18181b;
margin-bottom: calc(var(--gap));
}
}
@keyframes ball2 {
0% {
box-shadow: calc(var(--spacing)) 0 0 #a1a1aa;
}
50% {
box-shadow: 0 0 0 #a1a1aa;
margin-top: calc(var(--ball-size) * -1);
transform: translate(calc(var(--spacing) / 2), calc(var(--spacing) / 2));
}
100% {
box-shadow: calc(var(--spacing)) 0 0 #a1a1aa;
margin-top: 0;
}
}

View File

@@ -0,0 +1,28 @@
import { cn } from "@/lib/utils";
import styles from "./OrbitLoader.module.css";
interface Props {
size?: number;
className?: string;
}
export function OrbitLoader({ size = 24, className }: Props) {
const ballSize = Math.round(size * 0.4);
const spacing = Math.round(size * 0.6);
const gap = Math.round(size * 0.2);
return (
<div
className={cn(styles.loader, className)}
style={
{
width: size,
height: size,
"--ball-size": `${ballSize}px`,
"--spacing": `${spacing}px`,
"--gap": `${gap}px`,
} as React.CSSProperties
}
/>
);
}

View File

@@ -0,0 +1,26 @@
import { cn } from "@/lib/utils";
interface Props {
value: number;
label?: string;
className?: string;
}
export function ProgressBar({ value, label, className }: Props) {
const clamped = Math.min(100, Math.max(0, value));
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<div className="flex items-center justify-between text-xs text-neutral-500">
<span>{label ?? "Working on it..."}</span>
<span>{Math.round(clamped)}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-neutral-200">
<div
className="h-full rounded-full bg-neutral-900 transition-[width] duration-300 ease-out"
style={{ width: `${clamped}%` }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
.loader {
position: relative;
display: inline-block;
flex-shrink: 0;
}
.loader::before,
.loader::after {
content: "";
box-sizing: border-box;
width: 100%;
height: 100%;
border-radius: 50%;
background: currentColor;
position: absolute;
left: 0;
top: 0;
animation: ripple 2s linear infinite;
}
.loader::after {
animation-delay: 1s;
}
@keyframes ripple {
0% {
transform: scale(0);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 0;
}
}

View File

@@ -0,0 +1,16 @@
import { cn } from "@/lib/utils";
import styles from "./PulseLoader.module.css";
interface Props {
size?: number;
className?: string;
}
export function PulseLoader({ size = 24, className }: Props) {
return (
<div
className={cn(styles.loader, className)}
style={{ width: size, height: size }}
/>
);
}

View File

@@ -65,7 +65,10 @@ export function ContentCardTitle({
className?: string;
}) {
return (
<Text variant="body-medium" className={cn("truncate text-zinc-800", className)}>
<Text
variant="body-medium"
className={cn("truncate text-zinc-800", className)}
>
{children}
</Text>
);
@@ -79,7 +82,10 @@ export function ContentCardSubtitle({
className?: string;
}) {
return (
<Text variant="small" className={cn("mt-0.5 truncate text-zinc-800", className)}>
<Text
variant="small"
className={cn("mt-0.5 truncate font-mono text-zinc-800", className)}
>
{children}
</Text>
);
@@ -93,7 +99,9 @@ export function ContentCardDescription({
className?: string;
}) {
return (
<Text variant="small" className={cn("mt-2 text-zinc-800", className)}>{children}</Text>
<Text variant="body" className={cn("mt-2 text-zinc-800", className)}>
{children}
</Text>
);
}
@@ -108,7 +116,11 @@ export function ContentMessage({
children: React.ReactNode;
className?: string;
}) {
return <Text variant="body" className={cn("text-zinc-800", className)}>{children}</Text>;
return (
<Text variant="body" className={cn("text-zinc-800", className)}>
{children}
</Text>
);
}
export function ContentHint({
@@ -119,7 +131,7 @@ export function ContentHint({
className?: string;
}) {
return (
<Text variant="small" className={cn("italic text-neutral-800", className)}>
<Text variant="small" className={cn("text-neutral-500", className)}>
{children}
</Text>
);

View File

@@ -0,0 +1,47 @@
import { useEffect, useRef, useState } from "react";
/**
* Hook that returns a progress value that starts fast and slows down,
* asymptotically approaching but never reaching the max value.
*
* Uses a half-life formula: progress = max * (1 - 0.5^(time/halfLife))
* This creates a "loading bar" effect where:
* - 50% is reached at halfLifeSeconds
* - 75% is reached at 2 * halfLifeSeconds
* - 87.5% is reached at 3 * halfLifeSeconds
*
* @param isActive - Whether the progress should be animating
* @param halfLifeSeconds - Time in seconds to reach 50% progress (default: 30)
* @param maxProgress - Maximum progress value to approach (default: 100)
* @param intervalMs - Update interval in milliseconds (default: 100)
* @returns Current progress value (0maxProgress)
*/
export function useAsymptoticProgress(
isActive: boolean,
halfLifeSeconds = 30,
maxProgress = 100,
intervalMs = 100,
) {
const [progress, setProgress] = useState(0);
const elapsedTimeRef = useRef(0);
useEffect(() => {
if (!isActive) {
setProgress(0);
elapsedTimeRef.current = 0;
return;
}
const interval = setInterval(() => {
elapsedTimeRef.current += intervalMs / 1000;
const newProgress =
maxProgress *
(1 - Math.pow(0.5, elapsedTimeRef.current / halfLifeSeconds));
setProgress(newProgress);
}, intervalMs);
return () => clearInterval(interval);
}, [isActive, halfLifeSeconds, maxProgress, intervalMs]);
return progress;
}

View File

@@ -24,6 +24,29 @@ import { ResponseType } from "@/app/api/__generated__/models/responseType";
// Helpers
// ---------------------------------------------------------------------------
function slugify(text: string) {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
}
const SECTIONS = [
"Messages",
"Tool: Find Blocks",
"Tool: Find Agents (Marketplace)",
"Tool: Find Agents (Library)",
"Tool: Search Docs",
"Tool: Get Doc Page",
"Tool: Run Block",
"Tool: Run Agent",
"Tool: Schedule Agent",
"Tool: Create Agent",
"Tool: Edit Agent",
"Tool: View Agent Output",
"Full Conversation Example",
] as const;
function Section({
title,
children,
@@ -32,7 +55,7 @@ function Section({
children: React.ReactNode;
}) {
return (
<div className="mb-10">
<div id={slugify(title)} className="mb-10 scroll-mt-6">
<h2 className="mb-4 border-b border-neutral-200 pb-2 text-lg font-semibold text-neutral-800">
{title}
</h2>
@@ -74,15 +97,36 @@ function uid() {
export default function StyleguidePage() {
return (
<CopilotChatActionsProvider onSend={(msg) => alert(`onSend: ${msg}`)}>
<div className="h-[calc(100vh-72px)] overflow-y-auto bg-[#f8f8f9]">
<div className="mx-auto max-w-3xl px-4 py-10">
<h1 className="mb-1 text-2xl font-bold text-neutral-900">
Copilot Styleguide
</h1>
<p className="mb-8 text-sm text-neutral-500">
Static showcase of all chat message types, tool states &amp;
variants.
<div className="flex h-[calc(100vh-72px)] bg-[#f8f8f9]">
{/* Sidebar */}
<nav className="sticky top-0 hidden h-full w-56 shrink-0 overflow-y-auto border-r border-neutral-200 bg-white px-3 py-6 lg:block">
<p className="mb-3 px-2 text-[11px] font-semibold uppercase tracking-wider text-neutral-400">
Sections
</p>
<ul className="space-y-0.5">
{SECTIONS.map((title) => (
<li key={title}>
<a
href={`#${slugify(title)}`}
className="block rounded-md px-2 py-1.5 text-[13px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-900"
>
{title.replace(/^Tool: /, "")}
</a>
</li>
))}
</ul>
</nav>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-3xl px-4 py-10">
<h1 className="mb-1 text-2xl font-bold text-neutral-900">
Copilot Styleguide
</h1>
<p className="mb-8 text-sm text-neutral-500">
Static showcase of all chat message types, tool states &amp;
variants.
</p>
{/* ============================================================= */}
{/* MESSAGE TYPES */}
@@ -566,6 +610,131 @@ export default function StyleguidePage() {
/>
</SubSection>
<SubSection label="Output available (JSON object output — interactive viewer)">
<RunBlockTool
part={{
type: "tool-run_block",
toolCallId: uid(),
state: "output-available",
input: { block_id: "api-block-456" },
output: {
type: ResponseType.block_output,
block_id: "api-block-456",
block_name: "API Request",
message: "Successfully fetched user profile data.",
outputs: {
response: [
{
id: 42,
name: "Jane Doe",
email: "jane@example.com",
roles: ["admin", "editor"],
settings: {
theme: "dark",
notifications: true,
language: "en",
},
},
],
},
},
}}
/>
</SubSection>
<SubSection label="Output available (image URL — ImageRenderer)">
<RunBlockTool
part={{
type: "tool-run_block",
toolCallId: uid(),
state: "output-available",
input: { block_id: "image-gen-789" },
output: {
type: ResponseType.block_output,
block_id: "image-gen-789",
block_name: "Image Generator",
message: "Generated image successfully.",
outputs: {
image: [
"https://picsum.photos/seed/styleguide/600/400",
],
},
},
}}
/>
</SubSection>
<SubSection label="Output available (markdown text — MarkdownRenderer)">
<RunBlockTool
part={{
type: "tool-run_block",
toolCallId: uid(),
state: "output-available",
input: { block_id: "summarizer-101" },
output: {
type: ResponseType.block_output,
block_id: "summarizer-101",
block_name: "Text Summarizer",
message: "Document summarized successfully.",
outputs: {
summary: [
"## Executive Summary\n\nThe quarterly report shows **strong growth** across all departments:\n\n- Revenue increased by *23%* compared to Q3\n- Customer satisfaction score: `4.8/5.0`\n- New user sign-ups doubled\n\n### Key Takeaways\n\n1. **Product launches** drove the majority of growth\n2. **Marketing campaigns** exceeded ROI targets\n3. **Infrastructure costs** remained flat despite scaling\n\n> Overall, this was our strongest quarter to date.\n\n| Metric | Q3 | Q4 | Change |\n|--------|-----|-----|--------|\n| Revenue | $2.1M | $2.6M | +23% |\n| Users | 10k | 20k | +100% |\n| NPS | 72 | 78 | +6 |",
],
},
},
}}
/>
</SubSection>
<SubSection label="Output available (plain text — TextRenderer)">
<RunBlockTool
part={{
type: "tool-run_block",
toolCallId: uid(),
state: "output-available",
input: { block_id: "translate-202" },
output: {
type: ResponseType.block_output,
block_id: "translate-202",
block_name: "Translate Text",
message: "Translation completed.",
outputs: {
translated_text: [
"Bonjour le monde! Ceci est un exemple de texte traduit du bloc de traduction.",
],
},
},
}}
/>
</SubSection>
<SubSection label="Output available (multiple items with expand)">
<RunBlockTool
part={{
type: "tool-run_block",
toolCallId: uid(),
state: "output-available",
input: { block_id: "scraper-303" },
output: {
type: ResponseType.block_output,
block_id: "scraper-303",
block_name: "Web Scraper",
message: "Scraped 6 articles from the feed.",
outputs: {
articles: [
{ title: "AI Advances in 2026", url: "https://example.com/1", score: 142 },
{ title: "New Framework Released", url: "https://example.com/2", score: 98 },
{ title: "Open Source Milestone", url: "https://example.com/3", score: 87 },
{ title: "Cloud Computing Trends", url: "https://example.com/4", score: 76 },
{ title: "Developer Survey Results", url: "https://example.com/5", score: 64 },
{ title: "Security Best Practices", url: "https://example.com/6", score: 51 },
],
},
},
}}
/>
</SubSection>
<SubSection label="Output error">
<RunBlockTool
part={{
@@ -1111,6 +1280,57 @@ export default function StyleguidePage() {
/>
</SubSection>
<SubSection label="Output available (rich outputs — JSON + markdown + image)">
<ViewAgentOutputTool
part={{
type: "tool-view_agent_output",
toolCallId: uid(),
state: "output-available",
input: {
agent_name: "Research Agent",
execution_id: "exec-rich-456",
},
output: {
type: ResponseType.agent_output,
agent_id: "agent-456",
agent_name: "Research Agent",
message: "Research completed with multiple output types.",
library_agent_link: "/library/agents/lib-agent-456",
execution: {
execution_id: "exec-rich-456",
status: "completed",
inputs_summary: {
topic: "Artificial Intelligence in Healthcare",
depth: "comprehensive",
format: "report",
},
outputs: {
report: [
"## AI in Healthcare: 2026 Landscape\n\n### Key Findings\n\n- **Diagnostic accuracy** improved by 34% with AI-assisted imaging\n- Drug discovery timelines reduced from *10 years to 3 years*\n- Patient outcomes improved across `87%` of pilot programs\n\n> AI is not replacing doctors — it's augmenting their capabilities.\n\n### Adoption by Region\n\n| Region | Adoption Rate | Growth |\n|--------|--------------|--------|\n| North America | 78% | +15% |\n| Europe | 62% | +22% |\n| Asia Pacific | 71% | +31% |",
],
metadata: [
{
sources_analyzed: 142,
confidence_score: 0.94,
processing_time_ms: 3420,
model_version: "v2.3.1",
categories: [
"healthcare",
"machine-learning",
"diagnostics",
],
},
],
chart: [
"https://picsum.photos/seed/chart-demo/500/300",
],
},
},
},
}}
/>
</SubSection>
<SubSection label="Output available (no execution selected)">
<ViewAgentOutputTool
part={{
@@ -1282,6 +1502,7 @@ export default function StyleguidePage() {
</Conversation>
</Section>
</div>
</div>
</div>
</CopilotChatActionsProvider>
);

View File

@@ -1,23 +1,27 @@
"use client";
import {
ClarificationQuestionsWidget,
type ClarifyingQuestion as WidgetClarifyingQuestion,
} from "@/components/contextual/Chat/components/ClarificationQuestionsWidget/ClarificationQuestionsWidget";
import { WarningDiamondIcon } from "@phosphor-icons/react";
import type { ToolUIPart } from "ai";
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
import { OrbitLoader } from "../../components/OrbitLoader/OrbitLoader";
import { ProgressBar } from "../../components/ProgressBar/ProgressBar";
import {
ContentCardDescription,
ContentCardSubtitle,
ContentCodeBlock,
ContentGrid,
ContentHint,
ContentLink,
ContentMessage,
} from "../../components/ToolAccordion/AccordionContent";
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
import {
ClarificationQuestionsWidget,
type ClarifyingQuestion as WidgetClarifyingQuestion,
} from "@/components/contextual/Chat/components/ClarificationQuestionsWidget/ClarificationQuestionsWidget";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
import { useAsymptoticProgress } from "../../hooks/useAsymptoticProgress";
import {
AccordionIcon,
formatMaybeJson,
getAnimationText,
getCreateAgentToolOutput,
@@ -28,7 +32,6 @@ import {
isOperationInProgressOutput,
isOperationPendingOutput,
isOperationStartedOutput,
AccordionIcon,
ToolIcon,
truncateText,
type CreateAgentToolOutput,
@@ -48,7 +51,8 @@ interface Props {
function getAccordionMeta(output: CreateAgentToolOutput): {
icon: React.ReactNode;
title: string;
title: React.ReactNode;
titleClassName?: string;
description?: string;
} {
const icon = <AccordionIcon />;
@@ -76,9 +80,16 @@ function getAccordionMeta(output: CreateAgentToolOutput): {
isOperationPendingOutput(output) ||
isOperationInProgressOutput(output)
) {
return { icon, title: "Creating agent" };
return {
icon: <OrbitLoader size={32} />,
title: "Creating agent, this may take a few minutes. Sit back and relax.",
};
}
return { icon, title: "Error" };
return {
icon: <WarningDiamondIcon size={32} weight="light" className="text-red-500" />,
title: "Error",
titleClassName: "text-red-500",
};
}
export function CreateAgentTool({ part }: Props) {
@@ -90,6 +101,12 @@ export function CreateAgentTool({ part }: Props) {
const output = getCreateAgentToolOutput(part);
const isError =
part.state === "output-error" || (!!output && isErrorOutput(output));
const isOperating =
!!output &&
(isOperationStartedOutput(output) ||
isOperationPendingOutput(output) ||
isOperationInProgressOutput(output));
const progress = useAsymptoticProgress(isOperating);
const hasExpandableContent =
part.state === "output-available" &&
!!output &&
@@ -102,12 +119,20 @@ export function CreateAgentTool({ part }: Props) {
isErrorOutput(output));
function handleClarificationAnswers(answers: Record<string, string>) {
const contextMessage = Object.entries(answers)
.map(([keyword, answer]) => `${keyword}: ${answer}`)
.join("\n");
const questions =
output && isClarificationNeededOutput(output)
? (output.questions ?? [])
: [];
const contextMessage = questions
.map((q) => {
const answer = answers[q.keyword] || "";
return `> ${q.question}\n\n${answer}`;
})
.join("\n\n");
onSend(
`I have the answers to your questions:\n\n${contextMessage}\n\nPlease proceed with creating the agent.`,
`**Here are my answers:**\n\n${contextMessage}\n\nPlease proceed with creating the agent.`,
);
}
@@ -124,26 +149,13 @@ export function CreateAgentTool({ part }: Props) {
{hasExpandableContent && output && (
<ToolAccordion
{...getAccordionMeta(output)}
defaultExpanded={isClarificationNeededOutput(output)}
defaultExpanded={isOperating || isClarificationNeededOutput(output)}
>
{(isOperationStartedOutput(output) ||
isOperationPendingOutput(output)) && (
{isOperating && (
<ContentGrid>
<ContentMessage>{output.message}</ContentMessage>
<ContentCardSubtitle>
Operation: {output.operation_id}
</ContentCardSubtitle>
<ProgressBar value={progress} className="max-w-[280px]" />
<ContentHint>
Check your library in a few minutes.
</ContentHint>
</ContentGrid>
)}
{isOperationInProgressOutput(output) && (
<ContentGrid>
<ContentMessage>{output.message}</ContentMessage>
<ContentHint>
Please wait for the current operation to finish.
This could take a few minutes, grab a coffee
</ContentHint>
</ContentGrid>
)}

View File

@@ -6,8 +6,13 @@ import type { OperationInProgressResponse } from "@/app/api/__generated__/models
import type { OperationPendingResponse } from "@/app/api/__generated__/models/operationPendingResponse";
import type { OperationStartedResponse } from "@/app/api/__generated__/models/operationStartedResponse";
import { ResponseType } from "@/app/api/__generated__/models/responseType";
import { PlusCircleIcon, PlusIcon } from "@phosphor-icons/react";
import {
PlusCircleIcon,
PlusIcon,
WarningDiamondIcon,
} from "@phosphor-icons/react";
import type { ToolUIPart } from "ai";
import { OrbitLoader } from "../../components/OrbitLoader/OrbitLoader";
export type CreateAgentToolOutput =
| OperationStartedResponse
@@ -150,19 +155,13 @@ export function ToolIcon({
isStreaming?: boolean;
isError?: boolean;
}) {
return (
<PlusIcon
size={14}
weight="regular"
className={
isError
? "text-red-500"
: isStreaming
? "text-neutral-500"
: "text-neutral-400"
}
/>
);
if (isError) {
return <WarningDiamondIcon size={14} weight="regular" className="text-red-500" />;
}
if (isStreaming) {
return <OrbitLoader size={24} />;
}
return <PlusIcon size={14} weight="regular" className="text-neutral-400" />;
}
export function AccordionIcon() {

View File

@@ -1,17 +1,20 @@
"use client";
import { WarningDiamondIcon } from "@phosphor-icons/react";
import type { ToolUIPart } from "ai";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import { OrbitLoader } from "../../components/OrbitLoader/OrbitLoader";
import { ProgressBar } from "../../components/ProgressBar/ProgressBar";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
import {
ContentCardDescription,
ContentCardSubtitle,
ContentCodeBlock,
ContentGrid,
ContentHint,
ContentLink,
ContentMessage,
} from "../../components/ToolAccordion/AccordionContent";
import { useAsymptoticProgress } from "../../hooks/useAsymptoticProgress";
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
import {
ClarificationQuestionsWidget,
@@ -49,6 +52,7 @@ interface Props {
function getAccordionMeta(output: EditAgentToolOutput): {
icon: React.ReactNode;
title: string;
titleClassName?: string;
description?: string;
} {
const icon = <AccordionIcon />;
@@ -76,9 +80,13 @@ function getAccordionMeta(output: EditAgentToolOutput): {
isOperationPendingOutput(output) ||
isOperationInProgressOutput(output)
) {
return { icon, title: "Editing agent" };
return { icon: <OrbitLoader size={32} />, title: "Editing agent" };
}
return { icon, title: "Error" };
return {
icon: <WarningDiamondIcon size={32} weight="light" className="text-red-500" />,
title: "Error",
titleClassName: "text-red-500",
};
}
export function EditAgentTool({ part }: Props) {
@@ -90,6 +98,12 @@ export function EditAgentTool({ part }: Props) {
const output = getEditAgentToolOutput(part);
const isError =
part.state === "output-error" || (!!output && isErrorOutput(output));
const isOperating =
!!output &&
(isOperationStartedOutput(output) ||
isOperationPendingOutput(output) ||
isOperationInProgressOutput(output));
const progress = useAsymptoticProgress(isOperating);
const hasExpandableContent =
part.state === "output-available" &&
!!output &&
@@ -102,12 +116,20 @@ export function EditAgentTool({ part }: Props) {
isErrorOutput(output));
function handleClarificationAnswers(answers: Record<string, string>) {
const contextMessage = Object.entries(answers)
.map(([keyword, answer]) => `${keyword}: ${answer}`)
.join("\n");
const questions =
output && isClarificationNeededOutput(output)
? (output.questions ?? [])
: [];
const contextMessage = questions
.map((q) => {
const answer = answers[q.keyword] || "";
return `> ${q.question}\n\n${answer}`;
})
.join("\n\n");
onSend(
`I have the answers to your questions:\n\n${contextMessage}\n\nPlease proceed with editing the agent.`,
`**Here are my answers:**\n\n${contextMessage}\n\nPlease proceed with editing the agent.`,
);
}
@@ -124,26 +146,13 @@ export function EditAgentTool({ part }: Props) {
{hasExpandableContent && output && (
<ToolAccordion
{...getAccordionMeta(output)}
defaultExpanded={isClarificationNeededOutput(output)}
defaultExpanded={isOperating || isClarificationNeededOutput(output)}
>
{(isOperationStartedOutput(output) ||
isOperationPendingOutput(output)) && (
{isOperating && (
<ContentGrid>
<ContentMessage>{output.message}</ContentMessage>
<ContentCardSubtitle>
Operation: {output.operation_id}
</ContentCardSubtitle>
<ProgressBar value={progress} className="max-w-[280px]" />
<ContentHint>
Check your library in a few minutes.
</ContentHint>
</ContentGrid>
)}
{isOperationInProgressOutput(output) && (
<ContentGrid>
<ContentMessage>{output.message}</ContentMessage>
<ContentHint>
Please wait for the current operation to finish.
This could take a few minutes, grab a coffee
</ContentHint>
</ContentGrid>
)}

View File

@@ -6,8 +6,13 @@ import type { OperationInProgressResponse } from "@/app/api/__generated__/models
import type { OperationPendingResponse } from "@/app/api/__generated__/models/operationPendingResponse";
import type { OperationStartedResponse } from "@/app/api/__generated__/models/operationStartedResponse";
import { ResponseType } from "@/app/api/__generated__/models/responseType";
import { NotePencilIcon, PencilLineIcon } from "@phosphor-icons/react";
import {
NotePencilIcon,
PencilLineIcon,
WarningDiamondIcon,
} from "@phosphor-icons/react";
import type { ToolUIPart } from "ai";
import { OrbitLoader } from "../../components/OrbitLoader/OrbitLoader";
export type EditAgentToolOutput =
| OperationStartedResponse
@@ -150,19 +155,13 @@ export function ToolIcon({
isStreaming?: boolean;
isError?: boolean;
}) {
return (
<PencilLineIcon
size={14}
weight="regular"
className={
isError
? "text-red-500"
: isStreaming
? "text-neutral-500"
: "text-neutral-400"
}
/>
);
if (isError) {
return <WarningDiamondIcon size={14} weight="regular" className="text-red-500" />;
}
if (isStreaming) {
return <OrbitLoader size={24} />;
}
return <PencilLineIcon size={14} weight="regular" className="text-neutral-400" />;
}
export function AccordionIcon() {

View File

@@ -64,6 +64,7 @@ export function RunAgentTool({ part }: Props) {
<ToolAccordion
{...getAccordionMeta(output)}
defaultExpanded={
isRunAgentExecutionStartedOutput(output) ||
isRunAgentSetupRequirementsOutput(output) ||
isRunAgentAgentDetailsOutput(output)
}

View File

@@ -1,8 +1,8 @@
"use client";
import { useRouter } from "next/navigation";
import { Button } from "@/components/atoms/Button/Button";
import type { ExecutionStartedResponse } from "@/app/api/__generated__/models/executionStartedResponse";
import { Button } from "@/components/atoms/Button/Button";
import { useRouter } from "next/navigation";
import {
ContentCard,
ContentCardDescription,
@@ -26,9 +26,8 @@ export function ExecutionStartedCard({ output }: Props) {
<ContentCardDescription>{output.message}</ContentCardDescription>
{output.library_agent_link && (
<Button
variant="outline"
size="small"
className="mt-3 w-full"
className="mt-3"
onClick={() => router.push(output.library_agent_link!)}
>
View Execution

View File

@@ -10,6 +10,7 @@ import {
WarningDiamondIcon,
} from "@phosphor-icons/react";
import type { ToolUIPart } from "ai";
import { PulseLoader } from "../../components/PulseLoader/PulseLoader";
export interface RunAgentInput {
username_agent_slug?: string;
@@ -167,13 +168,10 @@ export function ToolIcon({
if (isError) {
return <WarningDiamondIcon size={14} weight="regular" className="text-red-500" />;
}
return (
<PlayIcon
size={14}
weight="regular"
className={isStreaming ? "text-neutral-500" : "text-neutral-400"}
/>
);
if (isStreaming) {
return <PulseLoader size={40} className="text-neutral-700" />;
}
return <PlayIcon size={14} weight="regular" className="text-neutral-400" />;
}
export function AccordionIcon() {
@@ -203,7 +201,7 @@ export function getAccordionMeta(output: RunAgentToolOutput): {
? output.status.trim()
: "started";
return {
icon,
icon: <PulseLoader size={28} className="text-neutral-700" />,
title: output.graph_name,
description: `Status: ${statusText}`,
};

View File

@@ -57,7 +57,10 @@ export function RunBlockTool({ part }: Props) {
{hasExpandableContent && output && (
<ToolAccordion
{...getAccordionMeta(output)}
defaultExpanded={isRunBlockSetupRequirementsOutput(output)}
defaultExpanded={
isRunBlockBlockOutput(output) ||
isRunBlockSetupRequirementsOutput(output)
}
>
{isRunBlockBlockOutput(output) && <BlockOutputCard output={output} />}

View File

@@ -4,6 +4,11 @@ import React, { useState } from "react";
import { getGetWorkspaceDownloadFileByIdUrl } from "@/app/api/__generated__/endpoints/workspace/workspace";
import { Button } from "@/components/atoms/Button/Button";
import type { BlockOutputResponse } from "@/app/api/__generated__/models/blockOutputResponse";
import {
globalRegistry,
OutputItem,
} from "@/components/contextual/OutputRenderers";
import type { OutputMetadata } from "@/components/contextual/OutputRenderers";
import {
ContentBadge,
ContentCard,
@@ -11,7 +16,6 @@ import {
ContentGrid,
ContentMessage,
} from "../../../../components/ToolAccordion/AccordionContent";
import { formatMaybeJson } from "../../helpers";
interface Props {
output: BlockOutputResponse;
@@ -19,81 +23,59 @@ interface Props {
const COLLAPSED_LIMIT = 3;
function resolveWorkspaceUrl(src: string): string {
const withoutPrefix = src.replace("workspace://", "");
const fileId = withoutPrefix.split("#")[0];
const apiPath = getGetWorkspaceDownloadFileByIdUrl(fileId);
return `/api/proxy${apiPath}`;
}
function getWorkspaceMimeHint(src: string): string | undefined {
const hashIndex = src.indexOf("#");
if (hashIndex === -1) return undefined;
return src.slice(hashIndex + 1) || undefined;
}
function isWorkspaceRef(value: unknown): value is string {
return typeof value === "string" && value.startsWith("workspace://");
}
function WorkspaceMedia({ value }: { value: string }) {
const [imgFailed, setImgFailed] = useState(false);
const resolvedUrl = resolveWorkspaceUrl(value);
const mime = getWorkspaceMimeHint(value);
function resolveForRenderer(value: unknown): {
value: unknown;
metadata?: OutputMetadata;
} {
if (!isWorkspaceRef(value)) return { value };
if (mime?.startsWith("video/") || imgFailed) {
const withoutPrefix = value.replace("workspace://", "");
const fileId = withoutPrefix.split("#")[0];
const apiPath = getGetWorkspaceDownloadFileByIdUrl(fileId);
const url = `/api/proxy${apiPath}`;
const hashIndex = value.indexOf("#");
const mimeHint =
hashIndex !== -1 ? value.slice(hashIndex + 1) || undefined : undefined;
const metadata: OutputMetadata = {};
if (mimeHint) {
metadata.mimeType = mimeHint;
if (mimeHint.startsWith("image/")) metadata.type = "image";
else if (mimeHint.startsWith("video/")) metadata.type = "video";
}
return { value: url, metadata };
}
function RenderOutputValue({ value }: { value: unknown }) {
const resolved = resolveForRenderer(value);
const renderer = globalRegistry.getRenderer(resolved.value, resolved.metadata);
if (renderer) {
return (
<video
controls
className="mt-2 h-auto max-w-full rounded-md border border-zinc-200"
preload="metadata"
>
<source src={resolvedUrl} />
</video>
<OutputItem
value={resolved.value}
metadata={resolved.metadata}
renderer={renderer}
/>
);
}
if (mime?.startsWith("audio/")) {
return <audio controls src={resolvedUrl} className="mt-2 w-full" />;
// Fallback for audio workspace refs
if (
isWorkspaceRef(value) &&
resolved.metadata?.mimeType?.startsWith("audio/")
) {
return (
<audio controls src={String(resolved.value)} className="mt-2 w-full" />
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolvedUrl}
alt="Output media"
className="mt-2 h-auto max-w-full rounded-md border border-zinc-200"
loading="lazy"
onError={() => setImgFailed(true)}
/>
);
}
function renderOutputValue(value: unknown): React.ReactNode {
if (isWorkspaceRef(value)) {
return <WorkspaceMedia value={value} />;
}
if (Array.isArray(value)) {
const hasWorkspace = value.some(isWorkspaceRef);
if (hasWorkspace) {
return (
<>
{value.map((item, i) =>
isWorkspaceRef(item) ? (
<WorkspaceMedia key={i} value={item} />
) : (
<pre
key={i}
className="mt-1 whitespace-pre-wrap text-xs text-muted-foreground"
>
{formatMaybeJson(item)}
</pre>
),
)}
</>
);
}
}
return null;
}
@@ -105,7 +87,6 @@ function OutputKeySection({
items: unknown[];
}) {
const [expanded, setExpanded] = useState(false);
const mediaContent = renderOutputValue(items);
const hasMoreItems = items.length > COLLAPSED_LIMIT;
const visibleItems = expanded ? items : items.slice(0, COLLAPSED_LIMIT);
@@ -117,12 +98,12 @@ function OutputKeySection({
{items.length} item{items.length === 1 ? "" : "s"}
</ContentBadge>
</div>
{mediaContent || (
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted-foreground">
{formatMaybeJson(visibleItems)}
</pre>
)}
{!mediaContent && hasMoreItems && (
<div className="mt-2">
{visibleItems.map((item, i) => (
<RenderOutputValue key={i} value={item} />
))}
</div>
{hasMoreItems && (
<Button
variant="ghost"
size="small"

View File

@@ -8,6 +8,7 @@ import {
WarningDiamondIcon,
} from "@phosphor-icons/react";
import type { ToolUIPart } from "ai";
import { PulseLoader } from "../../components/PulseLoader/PulseLoader";
export interface RunBlockInput {
block_id?: string;
@@ -116,13 +117,10 @@ export function ToolIcon({
if (isError) {
return <WarningDiamondIcon size={14} weight="regular" className="text-red-500" />;
}
return (
<PlayIcon
size={14}
weight="regular"
className={isStreaming ? "text-neutral-500" : "text-neutral-400"}
/>
);
if (isStreaming) {
return <PulseLoader size={40} className="text-neutral-700" />;
}
return <PlayIcon size={14} weight="regular" className="text-neutral-400" />;
}
export function AccordionIcon() {
@@ -149,7 +147,7 @@ export function getAccordionMeta(output: RunBlockToolOutput): {
if (isRunBlockBlockOutput(output)) {
const keys = Object.keys(output.outputs ?? {});
return {
icon,
icon: <PulseLoader size={32} className="text-neutral-700" />,
title: output.block_name,
description:
keys.length > 0

View File

@@ -1,8 +1,13 @@
"use client";
import type { ToolUIPart } from "ai";
import React, { useState } from "react";
import React from "react";
import { getGetWorkspaceDownloadFileByIdUrl } from "@/app/api/__generated__/endpoints/workspace/workspace";
import {
globalRegistry,
OutputItem,
} from "@/components/contextual/OutputRenderers";
import type { OutputMetadata } from "@/components/contextual/OutputRenderers";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
import {
@@ -41,6 +46,65 @@ interface Props {
part: ViewAgentOutputToolPart;
}
function isWorkspaceRef(value: unknown): value is string {
return typeof value === "string" && value.startsWith("workspace://");
}
function resolveForRenderer(value: unknown): {
value: unknown;
metadata?: OutputMetadata;
} {
if (!isWorkspaceRef(value)) return { value };
const withoutPrefix = value.replace("workspace://", "");
const fileId = withoutPrefix.split("#")[0];
const apiPath = getGetWorkspaceDownloadFileByIdUrl(fileId);
const url = `/api/proxy${apiPath}`;
const hashIndex = value.indexOf("#");
const mimeHint =
hashIndex !== -1 ? value.slice(hashIndex + 1) || undefined : undefined;
const metadata: OutputMetadata = {};
if (mimeHint) {
metadata.mimeType = mimeHint;
if (mimeHint.startsWith("image/")) metadata.type = "image";
else if (mimeHint.startsWith("video/")) metadata.type = "video";
}
return { value: url, metadata };
}
function RenderOutputValue({ value }: { value: unknown }) {
const resolved = resolveForRenderer(value);
const renderer = globalRegistry.getRenderer(
resolved.value,
resolved.metadata,
);
if (renderer) {
return (
<OutputItem
value={resolved.value}
metadata={resolved.metadata}
renderer={renderer}
/>
);
}
// Fallback for audio workspace refs
if (
isWorkspaceRef(value) &&
resolved.metadata?.mimeType?.startsWith("audio/")
) {
return (
<audio controls src={String(resolved.value)} className="mt-2 w-full" />
);
}
return null;
}
function getAccordionMeta(output: ViewAgentOutputToolOutput): {
icon: React.ReactNode;
title: string;
@@ -62,87 +126,6 @@ function getAccordionMeta(output: ViewAgentOutputToolOutput): {
return { icon, title: "Error" };
}
function resolveWorkspaceUrl(src: string): string {
if (src.startsWith("workspace://")) {
const withoutPrefix = src.replace("workspace://", "");
const fileId = withoutPrefix.split("#")[0];
const apiPath = getGetWorkspaceDownloadFileByIdUrl(fileId);
return `/api/proxy${apiPath}`;
}
return src;
}
function getWorkspaceMimeHint(src: string): string | undefined {
const hashIndex = src.indexOf("#");
if (hashIndex === -1) return undefined;
return src.slice(hashIndex + 1) || undefined;
}
function WorkspaceMedia({ value }: { value: string }) {
const [imgFailed, setImgFailed] = useState(false);
const resolvedUrl = resolveWorkspaceUrl(value);
const mime = getWorkspaceMimeHint(value);
if (mime?.startsWith("video/") || imgFailed) {
return (
<video
controls
className="mt-2 h-auto max-w-full rounded-md border border-zinc-200"
preload="metadata"
>
<source src={resolvedUrl} />
</video>
);
}
if (mime?.startsWith("audio/")) {
return <audio controls src={resolvedUrl} className="mt-2 w-full" />;
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolvedUrl}
alt="Output media"
className="mt-2 h-auto max-w-full rounded-md border border-zinc-200"
loading="lazy"
onError={() => setImgFailed(true)}
/>
);
}
function isWorkspaceRef(value: unknown): value is string {
return typeof value === "string" && value.startsWith("workspace://");
}
function renderOutputValue(value: unknown): React.ReactNode {
if (isWorkspaceRef(value)) {
return <WorkspaceMedia value={value} />;
}
if (Array.isArray(value)) {
const workspaceItems = value.filter(isWorkspaceRef);
if (workspaceItems.length > 0) {
return (
<>
{value.map((item, i) =>
isWorkspaceRef(item) ? (
<WorkspaceMedia key={i} value={item} />
) : (
<pre
key={i}
className="mt-1 whitespace-pre-wrap text-xs text-muted-foreground"
>
{formatMaybeJson(item)}
</pre>
),
)}
</>
);
}
}
return null;
}
export function ViewAgentOutputTool({ part }: Props) {
const text = getAnimationText(part);
const isStreaming =
@@ -204,34 +187,33 @@ export function ViewAgentOutputTool({ part }: Props) {
<ContentCardTitle className="text-xs">
Inputs summary
</ContentCardTitle>
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted-foreground">
{formatMaybeJson(output.execution.inputs_summary)}
</pre>
<div className="mt-2">
<RenderOutputValue
value={output.execution.inputs_summary}
/>
</div>
</ContentCard>
)}
{Object.entries(output.execution.outputs ?? {}).map(
([key, items]) => {
const mediaContent = renderOutputValue(items);
return (
<ContentCard key={key}>
<div className="flex items-center justify-between gap-2">
<ContentCardTitle className="text-xs">
{key}
</ContentCardTitle>
<ContentBadge>
{items.length} item
{items.length === 1 ? "" : "s"}
</ContentBadge>
</div>
{mediaContent || (
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted-foreground">
{formatMaybeJson(items.slice(0, 3))}
</pre>
)}
</ContentCard>
);
},
([key, items]) => (
<ContentCard key={key}>
<div className="flex items-center justify-between gap-2">
<ContentCardTitle className="text-xs">
{key}
</ContentCardTitle>
<ContentBadge>
{items.length} item
{items.length === 1 ? "" : "s"}
</ContentBadge>
</div>
<div className="mt-2">
{items.slice(0, 3).map((item, i) => (
<RenderOutputValue key={i} value={item} />
))}
</div>
</ContentCard>
),
)}
</ContentGrid>
) : (

View File

@@ -5,8 +5,8 @@ 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 { useState, useEffect, useRef } from "react";
import { CheckCircleIcon } from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react";
export interface ClarifyingQuestion {
question: string;
@@ -114,23 +114,14 @@ export function ClarificationQuestionsWidget({
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-green-500">
<CheckCircleIcon className="h-4 w-4 text-white" weight="bold" />
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col">
<Card className="p-4">
<Text variant="h4" className="mb-1 text-slate-900">
Answers submitted
</Text>
<Text variant="small" className="text-slate-600">
Processing your responses...
</Text>
</Card>
</div>
</div>
<Card className="w-full p-4">
<Text variant="h4" className="mb-1 text-slate-900">
Answers submitted
</Text>
<Text variant="small" className="text-slate-600">
Processing your responses...
</Text>
</Card>
</div>
);
}
@@ -142,102 +133,92 @@ export function ClarificationQuestionsWidget({
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>
<Card className="w-full space-y-4 rounded-xl p-4">
<div>
<Text variant="h4" className="mb-1 text-slate-900">
I need more information
</Text>
<Text variant="body" className="italic text-slate-600">
{message}
</Text>
</div>
<div className="flex min-w-0 flex-1 flex-col">
<Card className="space-y-4 p-4">
<div>
<Text variant="h4" className="mb-1 text-slate-900">
I need more information
</Text>
<Text variant="small" className="text-slate-600">
{message}
</Text>
</div>
<div className="space-y-3">
{questions.map((q, index) => {
const isAnswered = !!answers[q.keyword]?.trim();
<div className="space-y-3">
{questions.map((q, index) => {
const isAnswered = !!answers[q.keyword]?.trim();
return (
<div
key={`${q.keyword}-${index}`}
className={cn(
"relative rounded-lg border p-3",
isAnswered
? "border-green-500 bg-green-50/50"
: "border-slate-200 bg-white/50",
)}
>
<div className="mb-2 flex items-start gap-2">
{isAnswered ? (
<CheckCircleIcon
size={16}
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">
{index + 1}
</div>
)}
<div className="flex-1">
<Text
variant="small"
className="mb-2 font-semibold text-slate-900"
>
{q.question}
</Text>
{q.example && (
<Text
variant="small"
className="mb-2 italic text-slate-500"
>
Example: {q.example}
</Text>
)}
<Input
type="textarea"
id={`clarification-${q.keyword}-${index}`}
label={q.question}
hideLabel
placeholder="Your answer..."
rows={2}
value={answers[q.keyword] || ""}
onChange={(e) =>
handleAnswerChange(q.keyword, e.target.value)
}
/>
</div>
</div>
</div>
);
})}
</div>
<div className="flex gap-2">
<Button
onClick={handleSubmit}
disabled={!allAnswered}
className="flex-1"
variant="primary"
return (
<div
key={`${q.keyword}-${index}`}
className={cn(
"relative rounded-lg border p-3",
isAnswered
? "border-green-500 bg-green-50/50"
: "border-slate-100 bg-white/50",
)}
>
Submit Answers
</Button>
{onCancel && (
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
)}
</div>
</Card>
<div className="mb-2 flex items-start gap-2">
{isAnswered ? (
<CheckCircleIcon
size={16}
className="mt-0.5 text-green-500"
weight="bold"
/>
) : (
<div className="relative top-0 flex h-6 w-6 items-center justify-center rounded-full border border-purple-200 text-xs text-purple-400">
{index + 1}
</div>
)}
<div className="flex-1">
<Text
variant="body"
className="mb-2 font-semibold text-slate-900"
>
{q.question}
</Text>
{q.example && (
<Text
variant="body"
className="mb-2 italic text-slate-500"
>
Example: {q.example}
</Text>
)}
<Input
type="textarea"
id={`clarification-${q.keyword}-${index}`}
label={q.question}
hideLabel
placeholder="Your answer..."
rows={2}
value={answers[q.keyword] || ""}
onChange={(e) =>
handleAnswerChange(q.keyword, e.target.value)
}
/>
</div>
</div>
</div>
);
})}
</div>
</div>
<div className="flex gap-2">
<Button
onClick={handleSubmit}
disabled={!allAnswered}
className="flex-1"
variant="primary"
>
Submit Answers
</Button>
{onCancel && (
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
)}
</div>
</Card>
</div>
);
}