chore: further fixes

This commit is contained in:
Lluis Agusti
2026-02-06 00:23:43 +08:00
parent 5a878e0af0
commit 62edd73020
20 changed files with 353 additions and 308 deletions

View File

@@ -110,7 +110,7 @@ export function ChatSidebar() {
<Text variant="h3" size="body-medium">
Your chats
</Text>
<div className="relative left-5">
<div className="relative left-6">
<SidebarTrigger />
</div>
</motion.div>

View File

@@ -57,7 +57,7 @@ export function MobileDrawer({
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 z-[60] bg-black/10 backdrop-blur-sm" />
<Drawer.Content className="fixed left-0 top-0 z-[70] flex h-full w-80 flex-col border-r border-zinc-200 bg-zinc-50">
<div className="shrink-0 border-b border-zinc-200 p-4">
<div className="shrink-0 border-b border-zinc-200 px-4 py-2">
<div className="flex items-center justify-between">
<Drawer.Title className="text-lg font-semibold text-zinc-800">
Your chats
@@ -68,7 +68,7 @@ export function MobileDrawer({
aria-label="Close sessions"
onClick={onClose}
>
<X width="1.25rem" height="1.25rem" />
<X width="1rem" height="1rem" />
</Button>
</div>
</div>

View File

@@ -1,14 +1,16 @@
import { cn } from "@/lib/utils";
import { AnimatePresence, motion } from "framer-motion";
interface Props {
text: string;
className?: string;
}
export function MorphingTextAnimation({ text }: Props) {
export function MorphingTextAnimation({ text, className }: Props) {
const letters = text.split("");
return (
<div>
<div className={cn(className)}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div key={text} className="whitespace-nowrap">
<motion.span className="inline-flex overflow-hidden">

View File

@@ -1,9 +1,9 @@
"use client";
import { cn } from "@/lib/utils";
import { CaretDownIcon } from "@phosphor-icons/react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useId } from "react";
import { cn } from "@/lib/utils";
import { useToolAccordion } from "./useToolAccordion";
interface Props {
@@ -36,12 +36,7 @@ export function ToolAccordion({
});
return (
<div
className={cn(
"mt-2 w-full rounded-2xl border bg-background px-3 py-2",
className,
)}
>
<div className={cn("mt-2 w-full rounded-lg border px-3 py-2", className)}>
<button
type="button"
aria-expanded={isExpanded}
@@ -50,7 +45,7 @@ export function ToolAccordion({
className="flex w-full items-center justify-between gap-3 py-1 text-left"
>
<div className="flex min-w-0 items-center gap-2">
<span className="rounded-full border bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
<span className="px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{badgeText}
</span>
<div className="min-w-0">

View File

@@ -20,7 +20,7 @@ import {
isOperationInProgressOutput,
isOperationPendingOutput,
isOperationStartedOutput,
StateIcon,
ToolIcon,
truncateText,
type CreateAgentToolOutput,
} from "./helpers";
@@ -73,8 +73,12 @@ function getAccordionMeta(output: CreateAgentToolOutput): {
export function CreateAgentTool({ part }: Props) {
const text = getAnimationText(part);
const { onSend } = useCopilotChatActions();
const isStreaming =
part.state === "input-streaming" || part.state === "input-available";
const output = getCreateAgentToolOutput(part);
const isError =
part.state === "output-error" || (!!output && isErrorOutput(output));
const hasExpandableContent =
part.state === "output-available" &&
!!output &&
@@ -99,8 +103,11 @@ export function CreateAgentTool({ part }: Props) {
return (
<div className="py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<StateIcon state={part.state} />
<MorphingTextAnimation text={text} />
<ToolIcon isStreaming={isStreaming} isError={isError} />
<MorphingTextAnimation
text={text}
className={isError ? "text-red-500" : undefined}
/>
</div>
{hasExpandableContent && output && (

View File

@@ -1,9 +1,5 @@
import type { ToolUIPart } from "ai";
import {
CheckCircleIcon,
CircleNotchIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import { PlusIcon } from "@phosphor-icons/react";
import type { AgentPreviewResponse } from "@/app/api/__generated__/models/agentPreviewResponse";
import type { AgentSavedResponse } from "@/app/api/__generated__/models/agentSavedResponse";
import type { ClarificationNeededResponse } from "@/app/api/__generated__/models/clarificationNeededResponse";
@@ -126,45 +122,47 @@ export function getAnimationText(part: {
}): string {
switch (part.state) {
case "input-streaming":
return "Creating agent";
case "input-available":
return "Generating agent workflow";
return "Creating a new agent";
case "output-available": {
const output = parseOutput(part.output);
if (!output) return "Agent created";
if (!output) return "Creating a new agent";
if (isOperationStartedOutput(output)) return "Agent creation started";
if (isOperationPendingOutput(output)) return "Agent creation in progress";
if (isOperationInProgressOutput(output))
return "Agent creation already in progress";
if (isAgentSavedOutput(output)) return `Saved: ${output.agent_name}`;
if (isAgentPreviewOutput(output)) return `Preview: ${output.agent_name}`;
if (isAgentSavedOutput(output)) return `Saved "${output.agent_name}"`;
if (isAgentPreviewOutput(output)) return `Preview "${output.agent_name}"`;
if (isClarificationNeededOutput(output)) return "Needs clarification";
return "Error creating agent";
}
case "output-error":
return "Error creating agent";
default:
return "Processing";
return "Creating a new agent";
}
}
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
switch (state) {
case "input-streaming":
case "input-available":
return (
<CircleNotchIcon
className="h-4 w-4 animate-spin text-muted-foreground"
weight="bold"
/>
);
case "output-available":
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
case "output-error":
return <XCircleIcon className="h-4 w-4 text-red-500" />;
default:
return null;
}
export function ToolIcon({
isStreaming,
isError,
}: {
isStreaming?: boolean;
isError?: boolean;
}) {
return (
<PlusIcon
size={14}
weight="regular"
className={
isError
? "text-red-500"
: isStreaming
? "text-neutral-500"
: "text-neutral-400"
}
/>
);
}
export function formatMaybeJson(value: unknown): string {

View File

@@ -20,7 +20,7 @@ import {
isOperationInProgressOutput,
isOperationPendingOutput,
isOperationStartedOutput,
StateIcon,
ToolIcon,
truncateText,
type EditAgentToolOutput,
} from "./helpers";
@@ -73,8 +73,12 @@ function getAccordionMeta(output: EditAgentToolOutput): {
export function EditAgentTool({ part }: Props) {
const text = getAnimationText(part);
const { onSend } = useCopilotChatActions();
const isStreaming =
part.state === "input-streaming" || part.state === "input-available";
const output = getEditAgentToolOutput(part);
const isError =
part.state === "output-error" || (!!output && isErrorOutput(output));
const hasExpandableContent =
part.state === "output-available" &&
!!output &&
@@ -99,8 +103,11 @@ export function EditAgentTool({ part }: Props) {
return (
<div className="py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<StateIcon state={part.state} />
<MorphingTextAnimation text={text} />
<ToolIcon isStreaming={isStreaming} isError={isError} />
<MorphingTextAnimation
text={text}
className={isError ? "text-red-500" : undefined}
/>
</div>
{hasExpandableContent && output && (

View File

@@ -1,9 +1,5 @@
import type { ToolUIPart } from "ai";
import {
CheckCircleIcon,
CircleNotchIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import { PencilLineIcon } from "@phosphor-icons/react";
import type { AgentPreviewResponse } from "@/app/api/__generated__/models/agentPreviewResponse";
import type { AgentSavedResponse } from "@/app/api/__generated__/models/agentSavedResponse";
import type { ClarificationNeededResponse } from "@/app/api/__generated__/models/clarificationNeededResponse";
@@ -126,45 +122,47 @@ export function getAnimationText(part: {
}): string {
switch (part.state) {
case "input-streaming":
return "Editing agent";
case "input-available":
return "Updating agent workflow";
return "Editing the agent";
case "output-available": {
const output = parseOutput(part.output);
if (!output) return "Agent updated";
if (!output) return "Editing the agent";
if (isOperationStartedOutput(output)) return "Agent update started";
if (isOperationPendingOutput(output)) return "Agent update in progress";
if (isOperationInProgressOutput(output))
return "Agent update already in progress";
if (isAgentSavedOutput(output)) return `Saved: ${output.agent_name}`;
if (isAgentPreviewOutput(output)) return `Preview: ${output.agent_name}`;
if (isAgentSavedOutput(output)) return `Saved "${output.agent_name}"`;
if (isAgentPreviewOutput(output)) return `Preview "${output.agent_name}"`;
if (isClarificationNeededOutput(output)) return "Needs clarification";
return "Error editing agent";
}
case "output-error":
return "Error editing agent";
default:
return "Processing";
return "Editing the agent";
}
}
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
switch (state) {
case "input-streaming":
case "input-available":
return (
<CircleNotchIcon
className="h-4 w-4 animate-spin text-muted-foreground"
weight="bold"
/>
);
case "output-available":
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
case "output-error":
return <XCircleIcon className="h-4 w-4 text-red-500" />;
default:
return null;
}
export function ToolIcon({
isStreaming,
isError,
}: {
isStreaming?: boolean;
isError?: boolean;
}) {
return (
<PencilLineIcon
size={14}
weight="regular"
className={
isError
? "text-red-500"
: isStreaming
? "text-neutral-500"
: "text-neutral-400"
}
/>
);
}
export function formatMaybeJson(value: unknown): string {

View File

@@ -9,7 +9,8 @@ import {
getFindAgentsOutput,
getSourceLabelFromToolType,
isAgentsFoundOutput,
StateIcon,
isErrorOutput,
ToolIcon,
} from "./helpers";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
@@ -28,6 +29,10 @@ interface Props {
export function FindAgentsTool({ part }: Props) {
const text = getAnimationText(part);
const output = getFindAgentsOutput(part);
const isStreaming =
part.state === "input-streaming" || part.state === "input-available";
const isError =
part.state === "output-error" || (!!output && isErrorOutput(output));
const query =
typeof part.input === "object" && part.input !== null
@@ -59,8 +64,11 @@ export function FindAgentsTool({ part }: Props) {
return (
<div className="py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<StateIcon state={part.state} />
<MorphingTextAnimation text={text} />
<ToolIcon toolType={part.type} isStreaming={isStreaming} isError={isError} />
<MorphingTextAnimation
text={text}
className={isError ? "text-red-500" : undefined}
/>
</div>
{hasAgents && agentsFoundOutput && (

View File

@@ -1,8 +1,7 @@
import { ToolUIPart } from "ai";
import {
CheckCircleIcon,
CircleNotchIcon,
XCircleIcon,
MagnifyingGlassIcon,
SquaresFourIcon,
} from "@phosphor-icons/react";
import type { AgentInfo } from "@/app/api/__generated__/models/agentInfo";
import type { AgentsFoundResponse } from "@/app/api/__generated__/models/agentsFoundResponse";
@@ -99,52 +98,45 @@ export function getAnimationText(part: {
input?: unknown;
output?: unknown;
}): string {
const { label, source } = getSourceLabelFromToolType(part.type);
const { source } = getSourceLabelFromToolType(part.type);
const query = (part.input as FindAgentInput | undefined)?.query?.trim();
// Action phrase matching legacy ToolCallMessage
const actionPhrase =
source === "library"
? "Looking for library agents"
: "Looking for agents in the marketplace";
const queryText = query ? ` matching "${query}"` : "";
switch (part.state) {
case "input-streaming":
return `Searching ${label.toLowerCase()} agents for you`;
case "input-available": {
const query = (part.input as FindAgentInput | undefined)?.query?.trim();
if (query) {
return source === "library"
? `Finding library agents matching "${query}"`
: `Finding marketplace agents matching "${query}"`;
}
return source === "library" ? "Finding library agents" : "Finding agents";
}
case "input-available":
return `${actionPhrase}${queryText}`;
case "output-available": {
const output = parseOutput(part.output);
const query = (part.input as FindAgentInput | undefined)?.query?.trim();
const scope = source === "library" ? "in your library" : "in marketplace";
if (!output) {
return query ? `Found agents ${scope} for "${query}"` : "Found agents";
return `${actionPhrase}${queryText}`;
}
if (isNoResultsOutput(output)) {
return query
? `No agents found ${scope} for "${query}"`
: `No agents found ${scope}`;
return `No agents found${queryText}`;
}
if (isAgentsFoundOutput(output)) {
const count = output.count ?? output.agents?.length ?? 0;
const countText = `Found ${count} agent${count === 1 ? "" : "s"}`;
if (query) return `${countText} ${scope} for "${query}"`;
return `${countText} ${scope}`;
return `Found ${count} agent${count === 1 ? "" : "s"}${queryText}`;
}
if (isErrorOutput(output)) {
return `Error finding agents ${scope}`;
return `Error finding agents${queryText}`;
}
return `Found agents ${scope}`;
return `${actionPhrase}${queryText}`;
}
case "output-error":
return source === "library"
? "Error finding agents in your library"
: "Error finding agents in marketplace";
return `Error finding agents${queryText}`;
default:
return "Processing";
return actionPhrase;
}
}
@@ -158,21 +150,30 @@ export function getAgentHref(agent: AgentInfo): string | null {
return `/marketplace/agent/${encodeURIComponent(creator)}/${encodeURIComponent(slug)}`;
}
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
switch (state) {
case "input-streaming":
case "input-available":
return (
<CircleNotchIcon
className="h-4 w-4 animate-spin text-muted-foreground"
weight="bold"
/>
);
case "output-available":
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
case "output-error":
return <XCircleIcon className="h-4 w-4 text-red-500" />;
default:
return null;
}
export function ToolIcon({
toolType,
isStreaming,
isError,
}: {
toolType?: FindAgentsToolType;
isStreaming?: boolean;
isError?: boolean;
}) {
const { source } = getSourceLabelFromToolType(toolType);
const IconComponent =
source === "library" ? MagnifyingGlassIcon : SquaresFourIcon;
return (
<IconComponent
size={14}
weight="regular"
className={
isError
? "text-red-500"
: isStreaming
? "text-neutral-500"
: "text-neutral-400"
}
/>
);
}

View File

@@ -1,7 +1,7 @@
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import type { BlockListResponse } from "@/app/api/__generated__/models/blockListResponse";
import { ToolUIPart } from "ai";
import { getAnimationText, StateIcon } from "./helpers";
import { getAnimationText, ToolIcon } from "./helpers";
export interface FindBlockInput {
query: string;
@@ -25,11 +25,17 @@ interface Props {
export function FindBlocksTool({ part }: Props) {
const text = getAnimationText(part);
const isStreaming =
part.state === "input-streaming" || part.state === "input-available";
const isError = part.state === "output-error";
return (
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
<StateIcon state={part.state} />
<MorphingTextAnimation text={text} />
<ToolIcon isStreaming={isStreaming} isError={isError} />
<MorphingTextAnimation
text={text}
className={isError ? "text-red-500" : undefined}
/>
</div>
);
}

View File

@@ -2,11 +2,7 @@ import { ToolUIPart } from "ai";
import type { BlockListResponse } from "@/app/api/__generated__/models/blockListResponse";
import { ResponseType } from "@/app/api/__generated__/models/responseType";
import { FindBlockInput, FindBlockToolPart } from "./FindBlocks";
import {
CheckCircleIcon,
CircleNotchIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import { PackageIcon } from "@phosphor-icons/react";
function parseOutput(output: unknown): BlockListResponse | null {
if (!output) return null;
@@ -29,46 +25,48 @@ function parseOutput(output: unknown): BlockListResponse | null {
}
export function getAnimationText(part: FindBlockToolPart): string {
const query = (part.input as FindBlockInput | undefined)?.query?.trim();
const queryText = query ? ` matching "${query}"` : "";
switch (part.state) {
case "input-streaming":
return "Searching blocks for you";
case "input-available": {
const query = (part.input as FindBlockInput).query;
return `Finding "${query}" blocks`;
}
case "input-available":
return `Searching for blocks${queryText}`;
case "output-available": {
const parsed = parseOutput(part.output);
if (parsed) {
return `Found ${parsed.count} "${(part.input as FindBlockInput).query}" blocks`;
return `Found ${parsed.count} block${parsed.count === 1 ? "" : "s"}${queryText}`;
}
return "Found blocks";
return `Searching for blocks${queryText}`;
}
case "output-error":
return "Error finding blocks";
return `Error finding blocks${queryText}`;
default:
return "Processing";
return "Searching for blocks";
}
}
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
switch (state) {
case "input-streaming":
case "input-available":
return (
<CircleNotchIcon
className="h-4 w-4 animate-spin text-muted-foreground"
weight="bold"
/>
);
case "output-available":
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
case "output-error":
return <XCircleIcon className="h-4 w-4 text-red-500" />;
default:
return null;
}
export function ToolIcon({
isStreaming,
isError,
}: {
isStreaming?: boolean;
isError?: boolean;
}) {
return (
<PackageIcon
size={14}
weight="regular"
className={
isError
? "text-red-500"
: isStreaming
? "text-neutral-500"
: "text-neutral-400"
}
/>
);
}

View File

@@ -18,7 +18,7 @@ import {
isRunAgentExecutionStartedOutput,
isRunAgentNeedLoginOutput,
isRunAgentSetupRequirementsOutput,
StateIcon,
ToolIcon,
type RunAgentToolOutput,
} from "./helpers";
@@ -205,8 +205,12 @@ function coerceExpectedInputs(rawInputs: unknown): Array<{
export function RunAgentTool({ part }: Props) {
const text = getAnimationText(part);
const { onSend } = useCopilotChatActions();
const isStreaming =
part.state === "input-streaming" || part.state === "input-available";
const output = getRunAgentToolOutput(part);
const isError =
part.state === "output-error" || (!!output && isRunAgentErrorOutput(output));
const hasExpandableContent =
part.state === "output-available" &&
!!output &&
@@ -225,8 +229,11 @@ export function RunAgentTool({ part }: Props) {
return (
<div className="py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<StateIcon state={part.state} />
<MorphingTextAnimation text={text} />
<ToolIcon isStreaming={isStreaming} isError={isError} />
<MorphingTextAnimation
text={text}
className={isError ? "text-red-500" : undefined}
/>
</div>
{hasExpandableContent && output && (

View File

@@ -1,9 +1,5 @@
import type { ToolUIPart } from "ai";
import {
CheckCircleIcon,
CircleNotchIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import { PlayIcon } from "@phosphor-icons/react";
import type { AgentDetailsResponse } from "@/app/api/__generated__/models/agentDetailsResponse";
import type { ErrorResponse } from "@/app/api/__generated__/models/errorResponse";
import type { ExecutionStartedResponse } from "@/app/api/__generated__/models/executionStartedResponse";
@@ -128,24 +124,29 @@ export function getAnimationText(part: {
}): string {
const input = part.input as RunAgentInput | undefined;
const agentIdentifier = getAgentIdentifierText(input);
const mode = getExecutionModeText(input);
const isSchedule = Boolean(
input?.schedule_name?.trim() || input?.cron?.trim(),
);
const actionPhrase = isSchedule
? "Scheduling the agent to run"
: "Running the agent";
const identifierText = agentIdentifier ? ` "${agentIdentifier}"` : "";
switch (part.state) {
case "input-streaming":
return "Preparing to run agent";
case "input-available":
return agentIdentifier ? `${mode}: ${agentIdentifier}` : "Running agent";
return `${actionPhrase}${identifierText}`;
case "output-available": {
const output = parseOutput(part.output);
if (!output) return "Agent run updated";
if (!output) return `${actionPhrase}${identifierText}`;
if (isRunAgentExecutionStartedOutput(output)) {
return `Started: ${output.graph_name}`;
return `Started "${output.graph_name}"`;
}
if (isRunAgentAgentDetailsOutput(output)) {
return `Agent inputs: ${output.agent.name}`;
return `Agent inputs needed for "${output.agent.name}"`;
}
if (isRunAgentSetupRequirementsOutput(output)) {
return `Needs setup: ${output.setup_info.agent_name}`;
return `Setup needed for "${output.setup_info.agent_name}"`;
}
if (isRunAgentNeedLoginOutput(output))
return "Sign in required to run agent";
@@ -154,27 +155,30 @@ export function getAnimationText(part: {
case "output-error":
return "Error running agent";
default:
return "Processing";
return actionPhrase;
}
}
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
switch (state) {
case "input-streaming":
case "input-available":
return (
<CircleNotchIcon
className="h-4 w-4 animate-spin text-muted-foreground"
weight="bold"
/>
);
case "output-available":
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
case "output-error":
return <XCircleIcon className="h-4 w-4 text-red-500" />;
default:
return null;
}
export function ToolIcon({
isStreaming,
isError,
}: {
isStreaming?: boolean;
isError?: boolean;
}) {
return (
<PlayIcon
size={14}
weight="regular"
className={
isError
? "text-red-500"
: isStreaming
? "text-neutral-500"
: "text-neutral-400"
}
/>
);
}
export function formatMaybeJson(value: unknown): string {

View File

@@ -15,7 +15,7 @@ import {
isRunBlockBlockOutput,
isRunBlockErrorOutput,
isRunBlockSetupRequirementsOutput,
StateIcon,
ToolIcon,
type RunBlockToolOutput,
} from "./helpers";
@@ -190,8 +190,12 @@ function coerceExpectedInputs(rawInputs: unknown): Array<{
export function RunBlockTool({ part }: Props) {
const text = getAnimationText(part);
const { onSend } = useCopilotChatActions();
const isStreaming =
part.state === "input-streaming" || part.state === "input-available";
const output = getRunBlockToolOutput(part);
const isError =
part.state === "output-error" || (!!output && isRunBlockErrorOutput(output));
const hasExpandableContent =
part.state === "output-available" &&
!!output &&
@@ -208,8 +212,11 @@ export function RunBlockTool({ part }: Props) {
return (
<div className="py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<StateIcon state={part.state} />
<MorphingTextAnimation text={text} />
<ToolIcon isStreaming={isStreaming} isError={isError} />
<MorphingTextAnimation
text={text}
className={isError ? "text-red-500" : undefined}
/>
</div>
{hasExpandableContent && output && (

View File

@@ -1,9 +1,5 @@
import type { ToolUIPart } from "ai";
import {
CheckCircleIcon,
CircleNotchIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import { PlayIcon } from "@phosphor-icons/react";
import type { BlockOutputResponse } from "@/app/api/__generated__/models/blockOutputResponse";
import type { ErrorResponse } from "@/app/api/__generated__/models/errorResponse";
import { ResponseType } from "@/app/api/__generated__/models/responseType";
@@ -89,47 +85,50 @@ export function getAnimationText(part: {
output?: unknown;
}): string {
const input = part.input as RunBlockInput | undefined;
const blockLabel = getBlockLabel(input);
const blockId = input?.block_id?.trim();
const blockText = blockId ? ` "${blockId}"` : "";
switch (part.state) {
case "input-streaming":
return "Preparing to run block";
case "input-available":
return blockLabel ? `Running ${blockLabel}` : "Running block";
return `Running the block${blockText}`;
case "output-available": {
const output = parseOutput(part.output);
if (!output) return "Block run updated";
if (!output) return `Running the block${blockText}`;
if (isRunBlockBlockOutput(output))
return `Block ran: ${output.block_name}`;
return `Ran "${output.block_name}"`;
if (isRunBlockSetupRequirementsOutput(output)) {
return `Needs setup: ${output.setup_info.agent_name}`;
return `Setup needed for "${output.setup_info.agent_name}"`;
}
return "Error running block";
}
case "output-error":
return "Error running block";
default:
return "Processing";
return "Running the block";
}
}
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
switch (state) {
case "input-streaming":
case "input-available":
return (
<CircleNotchIcon
className="h-4 w-4 animate-spin text-muted-foreground"
weight="bold"
/>
);
case "output-available":
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
case "output-error":
return <XCircleIcon className="h-4 w-4 text-red-500" />;
default:
return null;
}
export function ToolIcon({
isStreaming,
isError,
}: {
isStreaming?: boolean;
isError?: boolean;
}) {
return (
<PlayIcon
size={14}
weight="regular"
className={
isError
? "text-red-500"
: isStreaming
? "text-neutral-500"
: "text-neutral-400"
}
/>
);
}
export function formatMaybeJson(value: unknown): string {

View File

@@ -14,7 +14,7 @@ import {
isDocSearchResultsOutput,
isErrorOutput,
isNoResultsOutput,
StateIcon,
ToolIcon,
toDocsUrl,
type DocsToolType,
} from "./helpers";
@@ -40,6 +40,10 @@ function truncate(text: string, maxChars: number): string {
export function SearchDocsTool({ part }: Props) {
const output = getDocsToolOutput(part);
const text = getAnimationText(part);
const isStreaming =
part.state === "input-streaming" || part.state === "input-available";
const isError =
part.state === "output-error" || (!!output && isErrorOutput(output));
const normalized = useMemo(() => {
if (!output) return null;
@@ -80,8 +84,11 @@ export function SearchDocsTool({ part }: Props) {
return (
<div className="py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<StateIcon state={part.state} />
<MorphingTextAnimation text={text} />
<ToolIcon toolType={part.type} isStreaming={isStreaming} isError={isError} />
<MorphingTextAnimation
text={text}
className={isError ? "text-red-500" : undefined}
/>
</div>
{hasExpandableContent && normalized && (

View File

@@ -1,9 +1,5 @@
import { ToolUIPart } from "ai";
import {
CheckCircleIcon,
CircleNotchIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import { FileMagnifyingGlassIcon, FileTextIcon } from "@phosphor-icons/react";
import type { DocPageResponse } from "@/app/api/__generated__/models/docPageResponse";
import type { DocSearchResultsResponse } from "@/app/api/__generated__/models/docSearchResultsResponse";
import type { ErrorResponse } from "@/app/api/__generated__/models/errorResponse";
@@ -123,51 +119,42 @@ export function getAnimationText(part: {
}): string {
switch (part.type) {
case "tool-search_docs": {
const query = (part.input as SearchDocsInput | undefined)?.query?.trim();
const queryText = query ? ` for "${query}"` : "";
switch (part.state) {
case "input-streaming":
return "Searching docs for you";
case "input-available": {
const query = (
part.input as SearchDocsInput | undefined
)?.query?.trim();
return query ? `Searching docs for "${query}"` : "Searching docs";
}
case "input-available":
return `Searching documentation${queryText}`;
case "output-available": {
const output = parseOutput(part.output);
const query = (
part.input as SearchDocsInput | undefined
)?.query?.trim();
if (!output) return "Found documentation";
if (!output) return `Searching documentation${queryText}`;
if (isDocSearchResultsOutput(output)) {
const count = output.count ?? output.results.length;
return query
? `Found ${count} doc result${count === 1 ? "" : "s"} for "${query}"`
: `Found ${count} doc result${count === 1 ? "" : "s"}`;
return `Found ${count} result${count === 1 ? "" : "s"}${queryText}`;
}
if (isNoResultsOutput(output)) {
return query ? `No docs found for "${query}"` : "No docs found";
return `No results found${queryText}`;
}
return "Error searching docs";
return `Error searching documentation${queryText}`;
}
case "output-error":
return "Error searching docs";
return `Error searching documentation${queryText}`;
default:
return "Processing";
return "Searching documentation";
}
}
case "tool-get_doc_page": {
const path = (part.input as GetDocPageInput | undefined)?.path?.trim();
const pathText = path ? ` "${path}"` : "";
switch (part.state) {
case "input-streaming":
return "Loading documentation page";
case "input-available": {
const path = (
part.input as GetDocPageInput | undefined
)?.path?.trim();
return path ? `Loading "${path}"` : "Loading documentation page";
}
case "input-available":
return `Loading documentation page${pathText}`;
case "output-available": {
const output = parseOutput(part.output);
if (!output) return "Loaded documentation page";
if (!output) return `Loading documentation page${pathText}`;
if (isDocPageOutput(output)) return `Loaded "${output.title}"`;
if (isNoResultsOutput(output)) return "Documentation page not found";
return "Error loading documentation page";
@@ -175,7 +162,7 @@ export function getAnimationText(part: {
case "output-error":
return "Error loading documentation page";
default:
return "Processing";
return "Loading documentation page";
}
}
}
@@ -183,23 +170,31 @@ export function getAnimationText(part: {
return "Processing";
}
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
switch (state) {
case "input-streaming":
case "input-available":
return (
<CircleNotchIcon
className="h-4 w-4 animate-spin text-muted-foreground"
weight="bold"
/>
);
case "output-available":
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
case "output-error":
return <XCircleIcon className="h-4 w-4 text-red-500" />;
default:
return null;
}
export function ToolIcon({
toolType,
isStreaming,
isError,
}: {
toolType: DocsToolType;
isStreaming?: boolean;
isError?: boolean;
}) {
const IconComponent =
toolType === "tool-get_doc_page" ? FileTextIcon : FileMagnifyingGlassIcon;
return (
<IconComponent
size={14}
weight="regular"
className={
isError
? "text-red-500"
: isStreaming
? "text-neutral-500"
: "text-neutral-400"
}
/>
);
}
export function toDocsUrl(path: string): string {

View File

@@ -11,7 +11,7 @@ import {
isAgentOutputResponse,
isErrorResponse,
isNoResultsResponse,
StateIcon,
ToolIcon,
type ViewAgentOutputToolOutput,
} from "./helpers";
@@ -48,8 +48,12 @@ function getAccordionMeta(output: ViewAgentOutputToolOutput): {
export function ViewAgentOutputTool({ part }: Props) {
const text = getAnimationText(part);
const isStreaming =
part.state === "input-streaming" || part.state === "input-available";
const output = getViewAgentOutputToolOutput(part);
const isError =
part.state === "output-error" || (!!output && isErrorResponse(output));
const hasExpandableContent =
part.state === "output-available" &&
!!output &&
@@ -60,8 +64,11 @@ export function ViewAgentOutputTool({ part }: Props) {
return (
<div className="py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<StateIcon state={part.state} />
<MorphingTextAnimation text={text} />
<ToolIcon isStreaming={isStreaming} isError={isError} />
<MorphingTextAnimation
text={text}
className={isError ? "text-red-500" : undefined}
/>
</div>
{hasExpandableContent && output && (

View File

@@ -1,9 +1,5 @@
import type { ToolUIPart } from "ai";
import {
CheckCircleIcon,
CircleNotchIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import { EyeIcon } from "@phosphor-icons/react";
import type { AgentOutputResponse } from "@/app/api/__generated__/models/agentOutputResponse";
import type { ErrorResponse } from "@/app/api/__generated__/models/errorResponse";
import type { NoResultsResponse } from "@/app/api/__generated__/models/noResultsResponse";
@@ -102,19 +98,19 @@ export function getAnimationText(part: {
}): string {
const input = part.input as ViewAgentOutputInput | undefined;
const agent = getAgentIdentifierText(input);
const agentText = agent ? ` "${agent}"` : "";
switch (part.state) {
case "input-streaming":
return "Looking up agent outputs";
case "input-available":
return agent ? `Loading outputs: ${agent}` : "Loading agent outputs";
return `Retrieving agent output${agentText}`;
case "output-available": {
const output = parseOutput(part.output);
if (!output) return "Loaded agent outputs";
if (!output) return `Retrieving agent output${agentText}`;
if (isAgentOutputResponse(output)) {
if (output.execution)
return `Loaded output (${output.execution.status})`;
return "Loaded agent outputs";
return `Retrieved output (${output.execution.status})`;
return "Retrieved agent output";
}
if (isNoResultsResponse(output)) return "No outputs found";
return "Error loading agent output";
@@ -122,27 +118,30 @@ export function getAnimationText(part: {
case "output-error":
return "Error loading agent output";
default:
return "Processing";
return "Retrieving agent output";
}
}
export function StateIcon({ state }: { state: ToolUIPart["state"] }) {
switch (state) {
case "input-streaming":
case "input-available":
return (
<CircleNotchIcon
className="h-4 w-4 animate-spin text-muted-foreground"
weight="bold"
/>
);
case "output-available":
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
case "output-error":
return <XCircleIcon className="h-4 w-4 text-red-500" />;
default:
return null;
}
export function ToolIcon({
isStreaming,
isError,
}: {
isStreaming?: boolean;
isError?: boolean;
}) {
return (
<EyeIcon
size={14}
weight="regular"
className={
isError
? "text-red-500"
: isStreaming
? "text-neutral-500"
: "text-neutral-400"
}
/>
);
}
export function formatMaybeJson(value: unknown): string {