feat(frontend): new agent library run page (#10835)

## Changes 🏗️

This is the new **Agent Library Run** page. Sorry in advance for the
massive PR 🙏🏽 . I got carried away and it has been tricky to split it (
_maybe I abused the agent too much_ 🤔 )

<img width="800" height="1085" alt="Screenshot 2025-09-04 at 13 58 33"
src="https://github.com/user-attachments/assets/b709edb9-d2b5-48ad-a04d-dddf10c89af3"
/>

<img width="800" height="338" alt="Screenshot 2025-09-04 at 13 54 51"
src="https://github.com/user-attachments/assets/efa28be2-d2dd-477f-af13-33ddd1d639dd"
/>

<img width="800" height="598" alt="Screenshot 2025-09-04 at 13 54 18"
src="https://github.com/user-attachments/assets/806ab620-3492-4c5b-b4e2-f17b89756dd8"
/>

- Schedules are now on the sidebar tabbed along with runs
- The whole UI has been updated to match the new designs and design
system
- There is no more "run draft" view as the modal is in charge of new
runs now 💪🏽
- The page is responsive and mobile friendly 📱 


Uploading mobile.mov…


https://github.com/user-attachments/assets/0e483062-0e50-4fa6-aaad-a1f6766df931

### Safety

I understand this is a lot of changes. However is all behind a feature
flag, `new-agent-runs`, when OFF it will display the old library agent
view. The old library agent view can still be accessed under:
`/library/legacy/{id}` for reference 👍🏽

### Testing

I haven't any tests for now... 💆🏽 I want to get this enabled on dev so
we can start running our agents there through the new page and modal and
start catching edge-cases.

Tests will come later in the form of E2E for the happy paths, and
probably I will introduce [Vitest](https://vitest.dev/) + [Testing
Library](https://testing-library.com/) for the finer details...

## Checklist 📋

### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Test the above

### For configuration changes:

None, the feature flag is already configured 🙏🏽

---------

Co-authored-by: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com>
This commit is contained in:
Ubbe
2025-09-05 15:39:54 +09:00
committed by GitHub
parent 9cd186a2f3
commit 3beafae955
46 changed files with 2407 additions and 153 deletions

View File

@@ -4,18 +4,27 @@ import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { useAgentRunsView } from "./useAgentRunsView";
import { AgentRunsLoading } from "./components/AgentRunsLoading";
import { Button } from "@/components/atoms/Button/Button";
import { RunAgentModal } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/RunAgentModal/RunAgentModal";
import { PlusIcon } from "@phosphor-icons/react/dist/ssr";
import { RunsSidebar } from "./components/RunsSidebar/RunsSidebar";
import React from "react";
import { RunDetails } from "./components/RunDetails/RunDetails";
import { ScheduleDetails } from "./components/ScheduleDetails/ScheduleDetails";
export function AgentRunsView() {
const { response, ready, error, agentId } = useAgentRunsView();
const {
response,
ready,
error,
agentId,
selectedRun,
handleSelectRun,
clearSelectedRun,
} = useAgentRunsView();
if (!ready) {
return <AgentRunsLoading />;
}
if (error || (response && response.status !== 200)) {
if (error) {
return (
<ErrorCard
isSuccess={false}
@@ -34,8 +43,7 @@ export function AgentRunsView() {
);
}
// Handle missing data
if (!response?.data) {
if (!response?.data || response.status !== 200) {
return (
<ErrorCard
isSuccess={false}
@@ -49,19 +57,12 @@ export function AgentRunsView() {
const agent = response.data;
return (
<div className="grid h-screen grid-cols-[25%_85%] gap-4 pt-8">
{/* Left Sidebar - 30% */}
<div className="bg-gray-50 p-4">
<RunAgentModal
triggerSlot={
<Button variant="primary" size="large" className="w-full">
<PlusIcon size={20} /> New Run
</Button>
}
agent={agent}
agentId={agent.id.toString()}
/>
</div>
<div className="grid h-screen grid-cols-1 gap-0 pt-6 md:gap-4 lg:grid-cols-[25%_70%]">
<RunsSidebar
agent={agent}
selectedRunId={selectedRun}
onSelectRun={handleSelectRun}
/>
{/* Main Content - 70% */}
<div className="p-4">
@@ -71,8 +72,27 @@ export function AgentRunsView() {
{ name: agent.name, link: `/library/agents/${agentId}` },
]}
/>
{/* Main content will go here */}
<div className="mt-4 text-gray-600">Main content area</div>
<div className="mt-1">
{selectedRun ? (
selectedRun.startsWith("schedule:") ? (
<ScheduleDetails
agent={agent}
scheduleId={selectedRun.replace("schedule:", "")}
/>
) : (
<RunDetails
agent={agent}
runId={selectedRun}
onSelectRun={handleSelectRun}
onClearSelectedRun={clearSelectedRun}
/>
)
) : (
<div className="text-gray-600">
Select a run to view its details
</div>
)}
</div>
</div>
</div>
);

View File

@@ -0,0 +1,58 @@
"use client";
import React from "react";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
type Props = {
agent: LibraryAgent;
inputs?: Record<string, any> | null;
};
function getAgentInputFields(agent: LibraryAgent): Record<string, any> {
const schema = agent.input_schema as unknown as {
properties?: Record<string, any>;
} | null;
if (!schema || !schema.properties) return {};
const properties = schema.properties as Record<string, any>;
const visibleEntries = Object.entries(properties).filter(
([, sub]) => !sub?.hidden,
);
return Object.fromEntries(visibleEntries);
}
function renderValue(value: any): string {
if (value === undefined || value === null) return "";
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
)
return String(value);
try {
return JSON.stringify(value, undefined, 2);
} catch {
return String(value);
}
}
export function AgentInputsReadOnly({ agent, inputs }: Props) {
const fields = getAgentInputFields(agent);
const entries = Object.entries(fields);
if (!inputs || entries.length === 0) {
return <div className="text-neutral-600">No input for this run.</div>;
}
return (
<div className="flex flex-col gap-4">
{entries.map(([key, sub]) => (
<div key={key} className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{sub?.title || key}</label>
<p className="whitespace-pre-wrap break-words text-sm text-neutral-700">
{renderValue((inputs as Record<string, any>)[key])}
</p>
</div>
))}
</div>
);
}

View File

@@ -26,3 +26,25 @@ export function validateSchedule(
}
return fieldErrors;
}
export type ParsedCron = {
repeat: "daily" | "weekly";
selectedDays: string[]; // for weekly, 0-6 (0=Sun) as strings
time: string; // HH:MM
};
export function parseCronToForm(cron: string): ParsedCron | undefined {
if (!cron) return undefined;
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return undefined;
const [minute, hour, _dom, _mon, dow] = parts;
const hh = String(hour ?? "0").padStart(2, "0");
const mm = String(minute ?? "0").padStart(2, "0");
const time = `${hh}:${mm}`; // Cron is stored in UTC; we keep raw HH:MM
if (dow && dow !== "*") {
return { repeat: "weekly", selectedDays: dow.split(","), time };
}
return { repeat: "daily", selectedDays: [], time };
}

View File

@@ -1,9 +1,16 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useState, useCallback, useMemo } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { isEmpty } from "@/lib/utils";
import { usePostV1ExecuteGraphAgent } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { usePostV1CreateExecutionSchedule as useCreateSchedule } from "@/app/api/__generated__/endpoints/schedules/schedules";
import {
usePostV1ExecuteGraphAgent,
getGetV1ListGraphExecutionsInfiniteQueryOptions,
} from "@/app/api/__generated__/endpoints/graphs/graphs";
import {
usePostV1CreateExecutionSchedule as useCreateSchedule,
getGetV1ListExecutionSchedulesForAGraphQueryKey,
} from "@/app/api/__generated__/endpoints/schedules/schedules";
import { usePostV2SetupTrigger } from "@/app/api/__generated__/endpoints/presets/presets";
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
@@ -26,6 +33,7 @@ export function useAgentRunModal(
callbacks?: UseAgentRunModalCallbacks,
) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState(false);
const [showScheduleView, setShowScheduleView] = useState(false);
const [inputValues, setInputValues] = useState<Record<string, any>>({});
@@ -51,7 +59,13 @@ export function useAgentRunModal(
toast({
title: "Agent execution started",
});
callbacks?.onRun?.(response.data);
callbacks?.onRun?.(response.data as unknown as GraphExecutionMeta);
// Invalidate runs list for this graph
queryClient.invalidateQueries({
queryKey: getGetV1ListGraphExecutionsInfiniteQueryOptions(
agent.graph_id,
).queryKey,
});
setIsOpen(false);
}
},
@@ -73,6 +87,12 @@ export function useAgentRunModal(
title: "Schedule created",
});
callbacks?.onCreateSchedule?.(response.data);
// Invalidate schedules list for this graph
queryClient.invalidateQueries({
queryKey: getGetV1ListExecutionSchedulesForAGraphQueryKey(
agent.graph_id,
),
});
setIsOpen(false);
}
},

View File

@@ -0,0 +1,11 @@
type Props = {
children: React.ReactNode;
};
export function RunDetailCard({ children }: Props) {
return (
<div className="min-h-20 rounded-xlarge border border-slate-50/70 bg-white p-6">
{children}
</div>
);
}

View File

@@ -0,0 +1,169 @@
import { RunStatusBadge } from "../RunDetails/components/RunStatusBadge";
import { Text } from "@/components/atoms/Text/Text";
import { Button } from "@/components/atoms/Button/Button";
import {
PencilSimpleIcon,
TrashIcon,
StopIcon,
PlayIcon,
ArrowSquareOut,
} from "@phosphor-icons/react";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import moment from "moment";
import { GraphExecution } from "@/app/api/__generated__/models/graphExecution";
import { useRunDetailHeader } from "./useRunDetailHeader";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
import Link from "next/link";
type Props = {
agent: LibraryAgent;
run: GraphExecution | undefined;
scheduleRecurrence?: string;
onSelectRun?: (id: string) => void;
onClearSelectedRun?: () => void;
};
export function RunDetailHeader({
agent,
run,
scheduleRecurrence,
onSelectRun,
onClearSelectedRun,
}: Props) {
const {
stopRun,
canStop,
isStopping,
deleteRun,
isDeleting,
runAgain,
isRunningAgain,
openInBuilderHref,
} = useRunDetailHeader(agent.graph_id, run, onSelectRun, onClearSelectedRun);
return (
<div>
<div className="flex w-full items-center justify-between">
<div className="flex w-full flex-col gap-0">
<div className="flex w-full flex-col flex-wrap items-start justify-between gap-2 md:flex-row md:items-center">
<div className="flex min-w-0 flex-1 flex-col items-start gap-2 md:flex-row md:items-center">
{run?.status ? <RunStatusBadge status={run.status} /> : null}
<Text
variant="h3"
className="truncate text-ellipsis !font-normal"
>
{agent.name}
</Text>
</div>
{run ? (
<div className="my-4 flex flex-wrap items-center gap-2 md:my-2 lg:my-0">
<Button
variant="secondary"
size="small"
onClick={runAgain}
loading={isRunningAgain}
>
<PlayIcon size={16} /> Run again
</Button>
<Button
variant="secondary"
size="small"
onClick={deleteRun}
loading={isDeleting}
>
<TrashIcon size={16} /> Delete run
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="small">
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canStop ? (
<DropdownMenuItem onClick={stopRun} disabled={isStopping}>
<StopIcon size={14} className="mr-2" /> Stop run
</DropdownMenuItem>
) : null}
{openInBuilderHref ? (
<DropdownMenuItem asChild>
<Link
href={openInBuilderHref}
target="_blank"
className="flex items-center gap-2"
>
<ArrowSquareOut size={14} /> Open in builder
</Link>
</DropdownMenuItem>
) : null}
<DropdownMenuItem asChild>
<Link
href={`/build?flowID=${agent.graph_id}&flowVersion=${agent.graph_version}`}
target="_blank"
className="flex items-center gap-2"
>
<PencilSimpleIcon size={16} /> Edit agent
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
) : null}
</div>
{run ? (
<div className="mt-1 flex flex-wrap items-center gap-2 gap-y-1 text-zinc-600">
<Text variant="small" className="!text-zinc-600">
Started {moment(run.started_at).fromNow()}
</Text>
<span className="mx-1 inline-block text-zinc-200">|</span>
<Text variant="small" className="!text-zinc-600">
Version: {run.graph_version}
</Text>
{run.stats?.node_exec_count !== undefined && (
<>
<span className="mx-1 inline-block text-zinc-200">|</span>
<Text variant="small" className="!text-zinc-600">
Steps: {run.stats.node_exec_count}
</Text>
</>
)}
{run.stats?.duration !== undefined && (
<>
<span className="mx-1 inline-block text-zinc-200">|</span>
<Text variant="small" className="!text-zinc-600">
Duration:{" "}
{moment.duration(run.stats.duration, "seconds").humanize()}
</Text>
</>
)}
{run.stats?.cost !== undefined && (
<>
<span className="mx-1 inline-block text-zinc-200">|</span>
<Text variant="small" className="!text-zinc-600">
Cost: ${run.stats.cost}
</Text>
</>
)}
{run.stats?.activity_status && (
<>
<span className="mx-1 inline-block text-zinc-200">|</span>
<Text variant="small" className="!text-zinc-600">
{String(run.stats.activity_status)}
</Text>
</>
)}
</div>
) : scheduleRecurrence ? (
<Text variant="small" className="mt-1 !text-zinc-600">
{scheduleRecurrence}
</Text>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,125 @@
"use client";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { useQueryClient } from "@tanstack/react-query";
import {
usePostV1StopGraphExecution,
getGetV1ListGraphExecutionsInfiniteQueryOptions,
} from "@/app/api/__generated__/endpoints/graphs/graphs";
import { useDeleteV1DeleteGraphExecution } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { usePostV1ExecuteGraphAgent } from "@/app/api/__generated__/endpoints/graphs/graphs";
import type { GraphExecution } from "@/app/api/__generated__/models/graphExecution";
export function useRunDetailHeader(
agentGraphId: string,
run?: GraphExecution,
onSelectRun?: (id: string) => void,
onClearSelectedRun?: () => void,
) {
const queryClient = useQueryClient();
const { toast } = useToast();
const stopMutation = usePostV1StopGraphExecution({
mutation: {
onSuccess: () => {
toast({ title: "Run stopped" });
queryClient.invalidateQueries({
queryKey:
getGetV1ListGraphExecutionsInfiniteQueryOptions(agentGraphId)
.queryKey,
});
},
onError: (error: any) => {
toast({
title: "Failed to stop run",
description: error?.message || "An unexpected error occurred.",
variant: "destructive",
});
},
},
});
function stopRun() {
if (!run) return;
stopMutation.mutate({ graphId: run.graph_id, graphExecId: run.id });
}
const canStop = run?.status === "RUNNING" || run?.status === "QUEUED";
// Delete run
const deleteMutation = useDeleteV1DeleteGraphExecution({
mutation: {
onSuccess: () => {
toast({ title: "Run deleted" });
queryClient.invalidateQueries({
queryKey:
getGetV1ListGraphExecutionsInfiniteQueryOptions(agentGraphId)
.queryKey,
});
if (onClearSelectedRun) onClearSelectedRun();
},
onError: (error: any) =>
toast({
title: "Failed to delete run",
description: error?.message || "An unexpected error occurred.",
variant: "destructive",
}),
},
});
function deleteRun() {
if (!run) return;
deleteMutation.mutate({ graphExecId: run.id });
}
// Run again (execute agent with previous inputs/credentials)
const executeMutation = usePostV1ExecuteGraphAgent({
mutation: {
onSuccess: async (res) => {
toast({ title: "Run started" });
const newRunId = res?.status === 200 ? (res?.data?.id ?? "") : "";
await queryClient.invalidateQueries({
queryKey:
getGetV1ListGraphExecutionsInfiniteQueryOptions(agentGraphId)
.queryKey,
});
if (newRunId && onSelectRun) onSelectRun(newRunId);
},
onError: (error: any) =>
toast({
title: "Failed to start run",
description: error?.message || "An unexpected error occurred.",
variant: "destructive",
}),
},
});
function runAgain() {
if (!run) return;
executeMutation.mutate({
graphId: run.graph_id,
graphVersion: run.graph_version,
data: {
inputs: (run as any).inputs || {},
credentials_inputs: (run as any).credential_inputs || {},
},
} as any);
}
// Open in builder URL helper
const openInBuilderHref = run
? `/build?flowID=${run.graph_id}&flowVersion=${run.graph_version}&flowExecutionID=${run.id}`
: undefined;
return {
stopRun,
canStop,
isStopping: stopMutation.isPending,
deleteRun,
isDeleting: deleteMutation.isPending,
runAgain,
isRunningAgain: executeMutation.isPending,
openInBuilderHref,
} as const;
}

View File

@@ -0,0 +1,49 @@
"use client";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { useQueryClient } from "@tanstack/react-query";
import {
useDeleteV1DeleteExecutionSchedule,
getGetV1ListExecutionSchedulesForAGraphQueryOptions,
} from "@/app/api/__generated__/endpoints/schedules/schedules";
export function useScheduleDetailHeader(
agentGraphId: string,
scheduleId?: string,
agentGraphVersion?: number | string,
) {
const queryClient = useQueryClient();
const { toast } = useToast();
const deleteMutation = useDeleteV1DeleteExecutionSchedule({
mutation: {
onSuccess: () => {
toast({ title: "Schedule deleted" });
queryClient.invalidateQueries({
queryKey:
getGetV1ListExecutionSchedulesForAGraphQueryOptions(agentGraphId)
.queryKey,
});
},
onError: (error: any) =>
toast({
title: "Failed to delete schedule",
description: error?.message || "An unexpected error occurred.",
variant: "destructive",
}),
},
});
function deleteSchedule() {
if (!scheduleId) return;
deleteMutation.mutate({ scheduleId });
}
const openInBuilderHref = `/build?flowID=${agentGraphId}&flowVersion=${agentGraphVersion}`;
return {
deleteSchedule,
isDeleting: deleteMutation.isPending,
openInBuilderHref,
} as const;
}

View File

@@ -0,0 +1,111 @@
"use client";
import React from "react";
import {
TabsLine,
TabsLineContent,
TabsLineList,
TabsLineTrigger,
} from "@/components/molecules/TabsLine/TabsLine";
import { useRunDetails } from "./useRunDetails";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { Skeleton } from "@/components/ui/skeleton";
import { AgentInputsReadOnly } from "../AgentInputsReadOnly/AgentInputsReadOnly";
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
interface RunDetailsProps {
agent: LibraryAgent;
runId: string;
onSelectRun?: (id: string) => void;
onClearSelectedRun?: () => void;
}
export function RunDetails({
agent,
runId,
onSelectRun,
onClearSelectedRun,
}: RunDetailsProps) {
const { run, isLoading, responseError, httpError } = useRunDetails(
agent.graph_id,
runId,
);
if (responseError || httpError) {
return (
<ErrorCard
responseError={responseError ?? undefined}
httpError={httpError ?? undefined}
context="run"
/>
);
}
if (isLoading && !run) {
return (
<div className="flex-1 space-y-4">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-32 w-full" />
</div>
);
}
return (
<div className="flex flex-col gap-6">
<RunDetailHeader
agent={agent}
run={run}
onSelectRun={onSelectRun}
onClearSelectedRun={onClearSelectedRun}
/>
{/* Content */}
<TabsLine defaultValue="output">
<TabsLineList>
<TabsLineTrigger value="output">Output</TabsLineTrigger>
<TabsLineTrigger value="input">Your input</TabsLineTrigger>
</TabsLineList>
<TabsLineContent value="output">
<RunDetailCard>
{isLoading ? (
<div className="text-neutral-500">Loading</div>
) : !run ||
!("outputs" in run) ||
Object.keys(run.outputs || {}).length === 0 ? (
<div className="text-neutral-600">No output from this run.</div>
) : (
<div className="flex flex-col gap-4">
{Object.entries(run.outputs).map(([key, values]) => (
<div key={key} className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{key}</label>
{values.map((value, i) => (
<p
key={i}
className="whitespace-pre-wrap break-words text-sm text-neutral-700"
>
{typeof value === "object"
? JSON.stringify(value, undefined, 2)
: String(value)}
</p>
))}
</div>
))}
</div>
)}
</RunDetailCard>
</TabsLineContent>
<TabsLineContent value="input">
<RunDetailCard>
<AgentInputsReadOnly agent={agent} inputs={(run as any)?.inputs} />
</RunDetailCard>
</TabsLineContent>
</TabsLine>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import {
CheckCircleIcon,
ClockIcon,
PauseCircleIcon,
StopCircleIcon,
WarningCircleIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
type StatusIconMap = {
icon: React.ReactNode;
bgColor: string;
textColor: string;
};
const statusIconMap: Record<AgentExecutionStatus, StatusIconMap> = {
INCOMPLETE: {
icon: (
<WarningCircleIcon size={16} className="text-red-700" weight="bold" />
),
bgColor: "bg-red-50",
textColor: "!text-red-700",
},
QUEUED: {
icon: <ClockIcon size={16} className="text-yellow-700" weight="bold" />,
bgColor: "bg-yellow-50",
textColor: "!text-yellow-700",
},
RUNNING: {
icon: (
<PauseCircleIcon size={16} className="text-yellow-700" weight="bold" />
),
bgColor: "bg-yellow-50",
textColor: "!text-yellow-700",
},
COMPLETED: {
icon: (
<CheckCircleIcon size={16} className="text-green-700" weight="bold" />
),
bgColor: "bg-green-50",
textColor: "!text-green-700",
},
TERMINATED: {
icon: <StopCircleIcon size={16} className="text-slate-700" weight="bold" />,
bgColor: "bg-slate-50",
textColor: "!text-slate-700",
},
FAILED: {
icon: <XCircleIcon size={16} className="text-red-700" weight="bold" />,
bgColor: "bg-red-50",
textColor: "!text-red-700",
},
};
type Props = {
status: AgentExecutionStatus;
};
export function RunStatusBadge({ status }: Props) {
return (
<div
className={cn(
"inline-flex items-center gap-1 rounded-md p-1",
statusIconMap[status].bgColor,
)}
>
{statusIconMap[status].icon}
<Text
variant="small-medium"
className={cn(statusIconMap[status].textColor, "capitalize")}
>
{status.toLowerCase()}
</Text>
</div>
);
}

View File

@@ -0,0 +1,27 @@
"use client";
import { useGetV1GetExecutionDetails } from "@/app/api/__generated__/endpoints/graphs/graphs";
import type { GetV1GetExecutionDetails200 } from "@/app/api/__generated__/models/getV1GetExecutionDetails200";
export function useRunDetails(graphId: string, runId: string) {
const query = useGetV1GetExecutionDetails(graphId, runId);
const status = query.data?.status;
const run: GetV1GetExecutionDetails200 | undefined =
status === 200
? (query.data?.data as GetV1GetExecutionDetails200)
: undefined;
const httpError =
status && status !== 200
? { status, statusText: `Request failed: ${status}` }
: undefined;
return {
run,
isLoading: query.isLoading,
responseError: query.error,
httpError,
} as const;
}

View File

@@ -0,0 +1,135 @@
"use client";
import React from "react";
import {
TabsLine,
TabsLineList,
TabsLineTrigger,
TabsLineContent,
} from "@/components/molecules/TabsLine/TabsLine";
import { Button } from "@/components/atoms/Button/Button";
import { PlusIcon } from "@phosphor-icons/react/dist/ssr";
import { RunAgentModal } from "../RunAgentModal/RunAgentModal";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useRunsSidebar } from "./useRunsSidebar";
import { RunListItem } from "./components/RunListItem";
import { ScheduleListItem } from "./components/ScheduleListItem";
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { InfiniteList } from "@/components/molecules/InfiniteList/InfiniteList";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { Skeleton } from "@/components/ui/skeleton";
interface RunsSidebarProps {
agent: LibraryAgent;
selectedRunId?: string;
onSelectRun: (id: string) => void;
}
export function RunsSidebar({
agent,
selectedRunId,
onSelectRun,
}: RunsSidebarProps) {
const {
runs,
schedules,
runsCount,
schedulesCount,
error,
loading,
fetchMoreRuns,
hasMoreRuns,
isFetchingMoreRuns,
tabValue,
setTabValue,
} = useRunsSidebar({ graphId: agent.graph_id, onSelectRun });
if (error) {
return <ErrorCard responseError={error} />;
}
if (loading) {
return (
<div className="ml-6 w-80 space-y-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-24 w-full" />
</div>
);
}
return (
<div className="min-w-0 bg-gray-50 p-4 pl-5">
<RunAgentModal
triggerSlot={
<Button variant="primary" size="large" className="w-full">
<PlusIcon size={20} /> New Run
</Button>
}
agent={agent}
agentId={agent.id.toString()}
/>
<TabsLine
value={tabValue}
onValueChange={(v) => {
const value = v as "runs" | "scheduled";
setTabValue(value);
if (value === "runs") {
if (runs && runs.length) onSelectRun(runs[0].id);
} else {
if (schedules && schedules.length)
onSelectRun(`schedule:${schedules[0].id}`);
}
}}
className="mt-6 min-w-0 overflow-hidden"
>
<TabsLineList>
<TabsLineTrigger value="runs">
Runs <span className="ml-3 inline-block">{runsCount}</span>
</TabsLineTrigger>
<TabsLineTrigger value="scheduled">
Scheduled{" "}
<span className="ml-3 inline-block">{schedulesCount}</span>
</TabsLineTrigger>
</TabsLineList>
<>
<TabsLineContent value="runs">
<InfiniteList
items={runs}
hasMore={!!hasMoreRuns}
isFetchingMore={isFetchingMoreRuns}
onEndReached={fetchMoreRuns}
className="flex flex-nowrap items-center justify-start gap-4 overflow-x-scroll px-1 pb-4 pt-1 lg:flex-col lg:gap-1 lg:overflow-x-hidden"
itemWrapperClassName="w-auto lg:w-full"
renderItem={(run) => (
<div className="mb-3 w-[15rem] lg:w-full">
<RunListItem
run={run}
title={agent.name}
selected={selectedRunId === run.id}
onClick={() => onSelectRun && onSelectRun(run.id)}
/>
</div>
)}
/>
</TabsLineContent>
<TabsLineContent value="scheduled">
<div className="flex flex-nowrap items-center justify-start gap-4 overflow-x-scroll px-1 pb-4 pt-1 lg:flex-col lg:gap-1 lg:overflow-x-hidden">
{schedules.map((s: GraphExecutionJobInfo) => (
<div className="mb-3 w-[15rem] lg:w-full" key={s.id}>
<ScheduleListItem
schedule={s}
selected={selectedRunId === `schedule:${s.id}`}
onClick={() => onSelectRun(`schedule:${s.id}`)}
/>
</div>
))}
</div>
</TabsLineContent>
</>
</TabsLine>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { cn } from "@/lib/utils";
type Props = {
children: React.ReactNode;
className: string;
};
export function IconWrapper({ children, className }: Props) {
return (
<div
className={cn(
"flex h-5 w-5 items-center justify-center rounded-large border",
className,
)}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import React from "react";
import moment from "moment";
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import { RunSidebarCard } from "./RunSidebarCard";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import {
CheckCircleIcon,
ClockIcon,
PauseCircleIcon,
StopCircleIcon,
WarningCircleIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import { IconWrapper } from "./RunIconWrapper";
const statusIconMap: Record<AgentExecutionStatus, React.ReactNode> = {
INCOMPLETE: (
<IconWrapper className="border-red-50 bg-red-50">
<WarningCircleIcon size={16} className="text-red-700" weight="bold" />
</IconWrapper>
),
QUEUED: (
<IconWrapper className="border-yellow-50 bg-yellow-50">
<ClockIcon size={16} className="text-yellow-700" weight="bold" />
</IconWrapper>
),
RUNNING: (
<IconWrapper className="border-yellow-50 bg-yellow-50">
<PauseCircleIcon size={16} className="text-yellow-700" weight="bold" />
</IconWrapper>
),
COMPLETED: (
<IconWrapper className="border-green-50 bg-green-50">
<CheckCircleIcon size={16} className="text-green-700" weight="bold" />
</IconWrapper>
),
TERMINATED: (
<IconWrapper className="border-slate-50 bg-slate-50">
<StopCircleIcon size={16} className="text-slate-700" weight="bold" />
</IconWrapper>
),
FAILED: (
<IconWrapper className="border-red-50 bg-red-50">
<XCircleIcon size={16} className="text-red-700" weight="bold" />
</IconWrapper>
),
};
interface RunListItemProps {
run: GraphExecutionMeta;
title: string;
selected?: boolean;
onClick?: () => void;
}
export function RunListItem({
run,
title,
selected,
onClick,
}: RunListItemProps) {
return (
<RunSidebarCard
icon={statusIconMap[run.status]}
title={title}
description={moment(run.started_at).fromNow()}
onClick={onClick}
selected={selected}
/>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
import { Text } from "@/components/atoms/Text/Text";
interface RunListItemProps {
title: string;
description?: string;
icon?: React.ReactNode;
selected?: boolean;
onClick?: () => void;
}
export function RunSidebarCard({
title,
description,
icon,
selected,
onClick,
}: RunListItemProps) {
return (
<button
className={cn(
"w-full rounded-large border border-slate-50/70 bg-white p-3 text-left transition-all duration-150 hover:scale-[1.01] hover:bg-slate-50/50",
selected ? "ring-2 ring-slate-800" : undefined,
)}
onClick={onClick}
>
<div className="flex min-w-0 items-center justify-start gap-3">
{icon}
<div className="flex min-w-0 flex-1 flex-col items-start justify-between">
<Text
variant="body-medium"
className="block w-full truncate text-ellipsis"
>
{title}
</Text>
<Text variant="small" className="!text-zinc-500">
{description}
</Text>
</div>
</div>
</button>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import React from "react";
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import moment from "moment";
import { RunSidebarCard } from "./RunSidebarCard";
import { IconWrapper } from "./RunIconWrapper";
import { ClockClockwiseIcon } from "@phosphor-icons/react";
interface ScheduleListItemProps {
schedule: GraphExecutionJobInfo;
selected?: boolean;
onClick?: () => void;
}
export function ScheduleListItem({
schedule,
selected,
onClick,
}: ScheduleListItemProps) {
return (
<RunSidebarCard
title={schedule.name}
description={moment(schedule.next_run_time).fromNow()}
onClick={onClick}
selected={selected}
icon={
<IconWrapper className="border-slate-50 bg-slate-50">
<ClockClockwiseIcon
size={16}
className="text-slate-700"
weight="bold"
/>
</IconWrapper>
}
/>
);
}

View File

@@ -0,0 +1,117 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useGetV1ListGraphExecutionsInfinite } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { useGetV1ListExecutionSchedulesForAGraph } from "@/app/api/__generated__/endpoints/schedules/schedules";
import { GraphExecutionsPaginated } from "@/app/api/__generated__/models/graphExecutionsPaginated";
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { useSearchParams } from "next/navigation";
type Args = {
graphId?: string;
onSelectRun: (runId: string) => void;
};
export function useRunsSidebar({ graphId, onSelectRun }: Args) {
const params = useSearchParams();
const existingRunId = params.get("run") as string | undefined;
const [tabValue, setTabValue] = useState<"runs" | "scheduled">("runs");
const runsQuery = useGetV1ListGraphExecutionsInfinite(
graphId || "",
{ page: 1, page_size: 20 },
{
query: {
enabled: !!graphId,
// Lightweight polling so statuses refresh; only poll if any run is active
refetchInterval: (q) => {
if (tabValue !== "runs") return false;
const pages = q.state.data?.pages as
| Array<{ data: unknown }>
| undefined;
if (!pages || pages.length === 0) return false;
try {
const executions = pages.flatMap((p) => {
const response = p.data as GraphExecutionsPaginated;
return response.executions || [];
});
const hasActive = executions.some(
(e: { status?: string }) =>
e.status === "RUNNING" || e.status === "QUEUED",
);
return hasActive ? 3000 : false;
} catch {
return false;
}
},
refetchIntervalInBackground: true,
refetchOnWindowFocus: false,
getNextPageParam: (lastPage) => {
const pagination = (lastPage.data as GraphExecutionsPaginated)
.pagination;
const hasMore =
pagination.current_page * pagination.page_size <
pagination.total_items;
return hasMore ? pagination.current_page + 1 : undefined;
},
},
},
);
const schedulesQuery = useGetV1ListExecutionSchedulesForAGraph(
graphId || "",
{
query: { enabled: !!graphId },
},
);
const runs = useMemo(
() =>
runsQuery.data?.pages.flatMap((p) => {
const response = p.data as GraphExecutionsPaginated;
return response.executions;
}) || [],
[runsQuery.data],
);
useEffect(() => {
if (runs.length > 0) {
if (existingRunId) {
onSelectRun(existingRunId);
return;
}
onSelectRun(runs[0].id);
}
}, [runs, existingRunId]);
useEffect(() => {
if (existingRunId && existingRunId.startsWith("schedule:"))
setTabValue("scheduled");
else setTabValue("runs");
}, [existingRunId]);
const schedules: GraphExecutionJobInfo[] =
schedulesQuery.data?.status === 200 ? schedulesQuery.data.data : [];
return {
runs,
schedules,
error: schedulesQuery.error || runsQuery.error,
loading: !schedulesQuery.isSuccess || !runsQuery.isSuccess,
runsQuery,
tabValue,
setTabValue,
runsCount:
(
runsQuery.data?.pages.at(-1)?.data as
| GraphExecutionsPaginated
| undefined
)?.pagination.total_items || runs.length,
schedulesCount: schedules.length,
fetchMoreRuns: runsQuery.fetchNextPage,
hasMoreRuns: runsQuery.hasNextPage,
isFetchingMoreRuns: runsQuery.isFetchingNextPage,
};
}

View File

@@ -0,0 +1,239 @@
"use client";
import React from "react";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { Text } from "@/components/atoms/Text/Text";
import {
TabsLine,
TabsLineContent,
TabsLineList,
TabsLineTrigger,
} from "@/components/molecules/TabsLine/TabsLine";
import { useScheduleDetails } from "./useScheduleDetails";
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
import { PencilSimpleIcon, ArrowSquareOut } from "@phosphor-icons/react";
import Link from "next/link";
import { useScheduleDetailHeader } from "../RunDetailHeader/useScheduleDetailHeader";
import { DeleteScheduleButton } from "./components/DeleteScheduleButton/DeleteScheduleButton";
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
import { useGetV1GetUserTimezone } from "@/app/api/__generated__/endpoints/auth/auth";
import { formatInTimezone } from "@/lib/timezone-utils";
import { Skeleton } from "@/components/ui/skeleton";
import { AgentInputsReadOnly } from "../AgentInputsReadOnly/AgentInputsReadOnly";
import { Button } from "@/components/atoms/Button/Button";
interface ScheduleDetailsProps {
agent: LibraryAgent;
scheduleId: string;
onClearSelectedRun?: () => void;
}
export function ScheduleDetails({
agent,
scheduleId,
onClearSelectedRun,
}: ScheduleDetailsProps) {
const { schedule, isLoading, error } = useScheduleDetails(
agent.graph_id,
scheduleId,
);
const { data: userTzRes } = useGetV1GetUserTimezone({
query: {
select: (res) => (res.status === 200 ? res.data.timezone : undefined),
},
});
if (error) {
return (
<ErrorCard
responseError={
error
? {
message: String(
(error as unknown as { message?: string })?.message ||
"Failed to load schedule",
),
}
: undefined
}
httpError={
(error as any)?.status
? {
status: (error as any).status,
statusText: (error as any).statusText,
}
: undefined
}
context="schedule"
/>
);
}
if (isLoading && !schedule) {
return (
<div className="flex-1 space-y-4">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-32 w-full" />
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div>
<div className="flex w-full items-center justify-between">
<div className="flex w-full flex-col gap-0">
<RunDetailHeader
agent={agent}
run={undefined}
scheduleRecurrence={humanizeCronExpression(
schedule?.cron || "",
userTzRes,
)}
/>
</div>
{schedule ? (
<div className="flex items-center gap-2">
<DeleteScheduleButton
agent={agent}
scheduleId={schedule.id}
onDeleted={onClearSelectedRun}
/>
<ScheduleActions agent={agent} scheduleId={schedule.id} />
</div>
) : null}
</div>
</div>
<TabsLine defaultValue="input">
<TabsLineList>
<TabsLineTrigger value="input">Your input</TabsLineTrigger>
<TabsLineTrigger value="schedule">Schedule</TabsLineTrigger>
</TabsLineList>
<TabsLineContent value="input">
<RunDetailCard>
<div className="relative">
{/* {// TODO: re-enable edit inputs modal once the API supports it */}
{/* {schedule && Object.keys(schedule.input_data).length > 0 && (
<EditInputsModal agent={agent} schedule={schedule} />
)} */}
<AgentInputsReadOnly
agent={agent}
inputs={schedule?.input_data}
/>
</div>
</RunDetailCard>
</TabsLineContent>
<TabsLineContent value="schedule">
<RunDetailCard>
{isLoading || !schedule ? (
<div className="text-neutral-500">Loading</div>
) : (
<div className="relative flex flex-col gap-8">
{
// TODO: re-enable edit schedule modal once the API supports it
/* <EditScheduleModal
graphId={agent.graph_id}
schedule={schedule}
/> */
}
<div className="flex flex-col gap-1.5">
<Text variant="body-medium" className="!text-black">
Name
</Text>
<p className="text-sm text-zinc-600">{schedule.name}</p>
</div>
<div className="flex flex-col gap-1.5">
<Text variant="body-medium" className="!text-black">
Recurrence
</Text>
<p className="text-sm text-zinc-600">
{humanizeCronExpression(schedule.cron, userTzRes)}
</p>
</div>
<div className="flex flex-col gap-1.5">
<Text variant="body-medium" className="!text-black">
Next run
</Text>
<p className="text-sm text-zinc-600">
{formatInTimezone(
schedule.next_run_time,
userTzRes || "UTC",
{
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
},
)}
</p>
</div>
</div>
)}
</RunDetailCard>
</TabsLineContent>
</TabsLine>
</div>
);
}
function ScheduleActions({
agent,
scheduleId,
}: {
agent: LibraryAgent;
scheduleId: string;
}) {
const { openInBuilderHref } = useScheduleDetailHeader(
agent.graph_id,
scheduleId,
agent.graph_version,
);
return (
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="small">
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{openInBuilderHref ? (
<DropdownMenuItem asChild>
<Link
href={openInBuilderHref}
target="_blank"
className="flex items-center gap-2"
>
<ArrowSquareOut size={14} /> Open in builder
</Link>
</DropdownMenuItem>
) : null}
<DropdownMenuItem asChild>
<Link
href={`/build?flowID=${agent.graph_id}&flowVersion=${agent.graph_version}`}
target="_blank"
className="flex items-center gap-2"
>
<PencilSimpleIcon size={16} /> Edit agent
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { TrashIcon } from "@phosphor-icons/react";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useScheduleDetailHeader } from "../../../RunDetailHeader/useScheduleDetailHeader";
type Props = {
agent: LibraryAgent;
scheduleId: string;
onDeleted?: () => void;
};
export function DeleteScheduleButton({ agent, scheduleId, onDeleted }: Props) {
const { deleteSchedule, isDeleting } = useScheduleDetailHeader(
agent.graph_id,
scheduleId,
agent.graph_version,
);
return (
<Button
variant="secondary"
size="small"
onClick={() => {
deleteSchedule();
if (onDeleted) onDeleted();
}}
loading={isDeleting}
>
<TrashIcon size={16} /> Delete schedule
</Button>
);
}

View File

@@ -0,0 +1,85 @@
"use client";
import React from "react";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { RunAgentInputs } from "../../../RunAgentInputs/RunAgentInputs";
import { useEditInputsModal } from "./useEditInputsModal";
import { PencilSimpleIcon } from "@phosphor-icons/react";
type Props = {
agent: LibraryAgent;
schedule: GraphExecutionJobInfo;
};
export function EditInputsModal({ agent, schedule }: Props) {
const {
isOpen,
setIsOpen,
inputFields,
values,
setValues,
handleSave,
isSaving,
} = useEditInputsModal(agent, schedule);
return (
<Dialog
controlled={{ isOpen, set: setIsOpen }}
styling={{ maxWidth: "32rem" }}
>
<Dialog.Trigger>
<Button
variant="ghost"
size="small"
className="absolute -right-2 -top-2"
>
<PencilSimpleIcon className="size-4" /> Edit inputs
</Button>
</Dialog.Trigger>
<Dialog.Content>
<div className="flex flex-col gap-4">
<Text variant="h3">Edit inputs</Text>
<div className="flex flex-col gap-4">
{Object.entries(inputFields).map(([key, fieldSchema]) => (
<div key={key} className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{fieldSchema?.title || key}
</label>
<RunAgentInputs
schema={fieldSchema as any}
value={values[key]}
onChange={(v) => setValues((prev) => ({ ...prev, [key]: v }))}
/>
</div>
))}
</div>
</div>
<Dialog.Footer>
<div className="flex w-full justify-end gap-2">
<Button
variant="secondary"
size="small"
onClick={() => setIsOpen(false)}
className="min-w-32"
>
Cancel
</Button>
<Button
variant="primary"
size="small"
onClick={handleSave}
loading={isSaving}
className="min-w-32"
>
{isSaving ? "Saving…" : "Save"}
</Button>
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import { useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getGetV1ListExecutionSchedulesForAGraphQueryKey } from "@/app/api/__generated__/endpoints/schedules/schedules";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { useToast } from "@/components/molecules/Toast/use-toast";
function getAgentInputFields(agent: LibraryAgent): Record<string, any> {
const schema = agent.input_schema as unknown as {
properties?: Record<string, any>;
} | null;
if (!schema || !schema.properties) return {};
const properties = schema.properties as Record<string, any>;
const visibleEntries = Object.entries(properties).filter(
([, sub]) => !sub?.hidden,
);
return Object.fromEntries(visibleEntries);
}
export function useEditInputsModal(
agent: LibraryAgent,
schedule: GraphExecutionJobInfo,
) {
const queryClient = useQueryClient();
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const inputFields = useMemo(() => getAgentInputFields(agent), [agent]);
const [values, setValues] = useState<Record<string, any>>({
...(schedule.input_data as Record<string, any>),
});
async function handleSave() {
setIsSaving(true);
try {
const res = await fetch(`/api/schedules/${schedule.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ inputs: values }),
});
if (!res.ok) {
let message = "Failed to update schedule inputs";
const data = await res.json();
message = data?.message || data?.detail || message;
throw new Error(message);
}
await queryClient.invalidateQueries({
queryKey: getGetV1ListExecutionSchedulesForAGraphQueryKey(
schedule.graph_id,
),
});
toast({
title: "Schedule inputs updated",
});
setIsOpen(false);
} catch (error: any) {
toast({
title: "Failed to update schedule inputs",
description: error?.message || "An unexpected error occurred.",
variant: "destructive",
});
}
setIsSaving(false);
}
return {
isOpen,
setIsOpen,
inputFields,
values,
setValues,
handleSave,
isSaving,
} as const;
}

View File

@@ -0,0 +1,110 @@
"use client";
import React from "react";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Input } from "@/components/atoms/Input/Input";
import { MultiToggle } from "@/components/molecules/MultiToggle/MultiToggle";
import { Select } from "@/components/atoms/Select/Select";
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { useEditScheduleModal } from "./useEditScheduleModal";
import { PencilSimpleIcon } from "@phosphor-icons/react";
type Props = {
graphId: string;
schedule: GraphExecutionJobInfo;
};
export function EditScheduleModal({ graphId, schedule }: Props) {
const {
isOpen,
setIsOpen,
name,
setName,
repeat,
setRepeat,
selectedDays,
setSelectedDays,
time,
setTime,
errors,
repeatOptions,
dayItems,
mutateAsync,
isPending,
} = useEditScheduleModal(graphId, schedule);
return (
<Dialog
controlled={{ isOpen, set: setIsOpen }}
styling={{ maxWidth: "22rem" }}
>
<Dialog.Trigger>
<Button
variant="ghost"
size="small"
className="absolute -right-2 -top-2"
>
<PencilSimpleIcon className="size-4" /> Edit schedule
</Button>
</Dialog.Trigger>
<Dialog.Content>
<div className="flex flex-col gap-6">
<Text variant="h3">Edit schedule</Text>
<Input
id="schedule-name"
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
error={errors.scheduleName}
/>
<Select
id="repeat"
label="Repeats"
value={repeat}
onValueChange={setRepeat}
options={repeatOptions}
/>
{repeat === "weekly" && (
<MultiToggle
items={dayItems}
selectedValues={selectedDays}
onChange={setSelectedDays}
aria-label="Select days"
/>
)}
<Input
id="schedule-time"
label="At"
value={time}
onChange={(e) => setTime(e.target.value.trim())}
placeholder="00:00"
error={errors.time}
/>
</div>
<Dialog.Footer>
<div className="flex w-full justify-end gap-2">
<Button
variant="secondary"
size="small"
onClick={() => setIsOpen(false)}
className="min-w-32"
>
Cancel
</Button>
<Button
variant="primary"
size="small"
onClick={() => mutateAsync()}
loading={isPending}
className="min-w-32"
>
{isPending ? "Saving…" : "Save"}
</Button>
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { getGetV1ListExecutionSchedulesForAGraphQueryKey } from "@/app/api/__generated__/endpoints/schedules/schedules";
import { getGetV1ListGraphExecutionsInfiniteQueryOptions } from "@/app/api/__generated__/endpoints/graphs/graphs";
import {
parseCronToForm,
validateSchedule,
} from "../../../RunAgentModal/components/ScheduleView/helpers";
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { useToast } from "@/components/molecules/Toast/use-toast";
export function useEditScheduleModal(
graphId: string,
schedule: GraphExecutionJobInfo,
) {
const queryClient = useQueryClient();
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const [name, setName] = useState(schedule.name);
const parsed = useMemo(() => parseCronToForm(schedule.cron), [schedule.cron]);
const [repeat, setRepeat] = useState<string>(parsed?.repeat || "daily");
const [selectedDays, setSelectedDays] = useState<string[]>(
parsed?.selectedDays || [],
);
const [time, setTime] = useState<string>(parsed?.time || "00:00");
const [errors, setErrors] = useState<{
scheduleName?: string;
time?: string;
}>({});
const repeatOptions = useMemo(
() => [
{ value: "daily", label: "Daily" },
{ value: "weekly", label: "Weekly" },
],
[],
);
const dayItems = useMemo(
() => [
{ value: "0", label: "Su" },
{ value: "1", label: "Mo" },
{ value: "2", label: "Tu" },
{ value: "3", label: "We" },
{ value: "4", label: "Th" },
{ value: "5", label: "Fr" },
{ value: "6", label: "Sa" },
],
[],
);
function humanizeToCron(): string {
const [hh, mm] = time.split(":");
const minute = Number(mm || 0);
const hour = Number(hh || 0);
if (repeat === "weekly") {
const dow = selectedDays.length ? selectedDays.join(",") : "*";
return `${minute} ${hour} * * ${dow}`;
}
return `${minute} ${hour} * * *`;
}
const { mutateAsync, isPending } = useMutation({
mutationKey: ["patchSchedule", schedule.id],
mutationFn: async () => {
const errorsNow = validateSchedule({ scheduleName: name, time });
setErrors(errorsNow);
if (Object.keys(errorsNow).length > 0) throw new Error("Invalid form");
const cron = humanizeToCron();
const res = await fetch(`/api/schedules/${schedule.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, cron }),
});
if (!res.ok) {
let message = "Failed to update schedule";
try {
const data = await res.json();
message = data?.message || data?.detail || message;
} catch {
try {
message = await res.text();
} catch {}
}
throw new Error(message);
}
return res.json();
},
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: getGetV1ListExecutionSchedulesForAGraphQueryKey(graphId),
});
const runsKey = getGetV1ListGraphExecutionsInfiniteQueryOptions(graphId)
.queryKey as any;
await queryClient.invalidateQueries({ queryKey: runsKey });
setIsOpen(false);
},
onError: (error: any) => {
toast({
title: "❌ Failed to update schedule",
description: error?.message || "An unexpected error occurred.",
variant: "destructive",
});
},
});
return {
isOpen,
setIsOpen,
name,
setName,
repeat,
setRepeat,
selectedDays,
setSelectedDays,
time,
setTime,
errors,
repeatOptions,
dayItems,
mutateAsync,
isPending,
} as const;
}

View File

@@ -0,0 +1,31 @@
"use client";
import { useMemo } from "react";
import { useGetV1ListExecutionSchedulesForAGraph } from "@/app/api/__generated__/endpoints/schedules/schedules";
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
export function useScheduleDetails(graphId: string, scheduleId: string) {
const query = useGetV1ListExecutionSchedulesForAGraph(graphId, {
query: {
enabled: !!graphId,
select: (res) =>
res.status === 200 ? (res.data as GraphExecutionJobInfo[]) : [],
},
});
const schedule = useMemo(
() => query.data?.find((s) => s.id === scheduleId),
[query.data, scheduleId],
);
const httpError =
query.isSuccess && !schedule
? { status: 404, statusText: "Not found" }
: undefined;
return {
schedule,
isLoading: query.isLoading,
error: query.error || httpError,
} as const;
}

View File

@@ -1,15 +1,30 @@
import { useGetV2GetLibraryAgent } from "@/app/api/__generated__/endpoints/library/library";
import { useParams } from "next/navigation";
import { parseAsString, useQueryState } from "nuqs";
export function useAgentRunsView() {
const { id } = useParams();
const agentId = id as string;
const { data: response, isSuccess, error } = useGetV2GetLibraryAgent(agentId);
const [runParam, setRunParam] = useQueryState("run", parseAsString);
const selectedRun = runParam ?? undefined;
function handleSelectRun(id: string) {
setRunParam(id, { shallow: true });
}
function clearSelectedRun() {
setRunParam(null, { shallow: true });
}
return {
agentId: id,
ready: isSuccess,
error,
response,
selectedRun,
handleSelectRun,
clearSelectedRun,
};
}

View File

@@ -309,7 +309,7 @@ export function OldAgentLibraryView() {
const newSelectedRun = agentRuns.find((run) => run.id == selectedView.id);
if (selectedView.id !== selectedRun?.id) {
// Pull partial data from "cache" while waiting for the rest to load
setSelectedRun(newSelectedRun ?? null);
setSelectedRun((newSelectedRun as GraphExecutionMeta) ?? null);
}
}, [api, selectedView, agentRuns, selectedRun?.id]);

View File

@@ -16,13 +16,9 @@ import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/atoms/Button/Button";
import LoadingBox, { LoadingSpinner } from "@/components/ui/loading";
import { PlusIcon } from "@phosphor-icons/react";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import { RunAgentModal } from "../../AgentRunsView/components/RunAgentModal/RunAgentModal";
import { AgentRunsQuery } from "../use-agent-runs";
import { agentRunStatusMap } from "./agent-run-status-chip";
import { AgentRunSummaryCard } from "./agent-run-summary-card";
@@ -73,8 +69,6 @@ export function AgentRunsSelectorList({
"runs",
);
const isNewAgentRunsEnabled = useGetFlag(Flag.NEW_AGENT_RUNS);
useEffect(() => {
if (selectedView.type === "schedule") {
setActiveListTab("scheduled");
@@ -87,17 +81,7 @@ export function AgentRunsSelectorList({
return (
<aside className={cn("flex flex-col gap-4", className)}>
{isNewAgentRunsEnabled ? (
<RunAgentModal
triggerSlot={
<Button variant="primary" size="large" className="w-full">
<PlusIcon size={20} /> New Run
</Button>
}
agent={agent}
agentId={agent.id.toString()}
/>
) : allowDraftNewRun ? (
{allowDraftNewRun ? (
<Button
className={"mb-4 hidden lg:flex"}
onClick={onSelectDraftNewRun}
@@ -218,7 +202,7 @@ export function AgentRunsSelectorList({
timestamp={run.started_at}
selected={selectedView.id === run.id}
onClick={() => onSelectRun(run.id)}
onDelete={() => doDeleteRun(run)}
onDelete={() => doDeleteRun(run as GraphExecutionMeta)}
onPinAsPreset={
doCreatePresetFromRun
? () => doCreatePresetFromRun(run.id)

View File

@@ -1,7 +1,10 @@
"use client";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import { AgentRunsView } from "./components/AgentRunsView/AgentRunsView";
import { OldAgentLibraryView } from "./components/OldAgentLibraryView/OldAgentLibraryView";
export default function AgentLibraryPage() {
return <OldAgentLibraryView />;
const isNewLibraryPageEnabled = useGetFlag(Flag.NEW_AGENT_RUNS);
return isNewLibraryPageEnabled ? <AgentRunsView /> : <OldAgentLibraryView />;
}

View File

@@ -0,0 +1,7 @@
"use client";
import { OldAgentLibraryView } from "../../agents/[id]/components/OldAgentLibraryView/OldAgentLibraryView";
export default function OldAgentLibraryPage() {
return <OldAgentLibraryView />;
}

View File

@@ -1,7 +1,6 @@
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
@@ -12,9 +11,10 @@ import { Separator } from "@/components/ui/separator";
import { AnimatePresence, motion } from "framer-motion";
import { usePathname } from "next/navigation";
import * as React from "react";
import { IconChevronUp, IconMenu } from "../../../../ui/icons";
import { MenuItemGroup } from "../../helpers";
import { MobileNavbarMenuItem } from "./components/MobileNavbarMenuItem";
import { Button } from "@/components/atoms/Button/Button";
import { CaretUpIcon, ListIcon } from "@phosphor-icons/react";
interface MobileNavBarProps {
userName?: string;
@@ -48,14 +48,15 @@ export function MobileNavBar({
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
aria-label="Open menu"
className="fixed right-4 top-4 z-50 flex h-14 w-14 items-center justify-center rounded-lg border border-neutral-500 bg-neutral-200 hover:bg-gray-200/50 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-gray-700/50 md:hidden"
className="min-w-auto flex !w-[3.75rem] items-center justify-center md:hidden"
data-testid="mobile-nav-bar-trigger"
>
{isOpen ? (
<IconChevronUp className="h-8 w-8 stroke-black dark:stroke-white" />
<CaretUpIcon className="size-6 stroke-slate-800" />
) : (
<IconMenu className="h-8 w-8 stroke-black dark:stroke-white" />
<ListIcon className="size-6 stroke-slate-800" />
)}
<span className="sr-only">Open menu</span>
</Button>
@@ -68,10 +69,10 @@ export function MobileNavBar({
initial={{ opacity: 0, y: -32 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -32, transition: { duration: 0.2 } }}
className="w-screen rounded-b-2xl bg-white dark:bg-neutral-900"
className="w-screen rounded-b-2xl bg-white"
>
<div className="mb-4 inline-flex w-full items-end justify-start gap-4">
<Avatar className="h-14 w-14 border border-[#474747] dark:border-[#cfcfcf]">
<Avatar className="h-14 w-14">
<AvatarImage
src={avatarSrc}
alt={userName || "Unknown User"}
@@ -81,15 +82,15 @@ export function MobileNavBar({
</AvatarFallback>
</Avatar>
<div className="relative h-14 w-full">
<div className="absolute left-0 top-0 text-lg font-semibold leading-7 text-[#474747] dark:text-[#cfcfcf]">
<div className="absolute left-0 top-0 text-lg font-semibold leading-7 text-[#474747]">
{userName || "Unknown User"}
</div>
<div className="absolute left-0 top-6 font-sans text-base font-normal leading-7 text-[#474747] dark:text-[#cfcfcf]">
<div className="absolute left-0 top-6 font-sans text-base font-normal leading-7 text-[#474747]">
{userEmail || "No Email Set"}
</div>
</div>
</div>
<Separator className="mb-4 dark:bg-[#3a3a3a]" />
<Separator className="mb-4" />
{menuItemGroups.map((group, groupIndex) => (
<React.Fragment key={groupIndex}>
{group.items.map((item, itemIndex) => (
@@ -103,7 +104,7 @@ export function MobileNavBar({
/>
))}
{groupIndex < menuItemGroups.length - 1 && (
<Separator className="my-4 dark:bg-[#3a3a3a]" />
<Separator className="my-4" />
)}
</React.Fragment>
))}

View File

@@ -19,21 +19,19 @@ export function MobileNavbarMenuItem({
onClick,
}: Props) {
const content = (
<div className="inline-flex w-full items-center justify-start gap-4 hover:rounded hover:bg-[#e0e0e0] dark:hover:bg-[#3a3a3a]">
<div className="inline-flex w-full items-center justify-start gap-4 hover:rounded hover:bg-[#e0e0e0]">
{getAccountMenuOptionIcon(icon)}
<div className="relative">
<div
className={cn(
"font-sans text-base font-normal leading-7",
isActive
? "font-semibold text-[#272727] dark:text-[#ffffff]"
: "text-[#474747] dark:text-[#cfcfcf]",
isActive ? "font-semibold text-[#272727]" : "text-[#474747]",
)}
>
{text}
</div>
{isActive && (
<div className="absolute bottom-[-4px] left-0 h-[2px] w-full bg-[#272727] dark:bg-[#ffffff]"></div>
<div className="absolute bottom-[-4px] left-0 h-[2px] w-full bg-[#272727]"></div>
)}
</div>
</div>

View File

@@ -26,9 +26,9 @@ export function NavbarLink({ name, href }: Props) {
<Link href={href} data-testid={`navbar-link-${name.toLowerCase()}`}>
<div
className={cn(
"flex items-center justify-start gap-1 p-2",
"flex items-center justify-start gap-1 p-1 md:p-2",
isActive &&
"rounded-small bg-neutral-800 py-2 pl-2 pr-3 transition-all duration-300 dark:bg-neutral-200",
"rounded-small bg-neutral-800 py-1 pl-1 pr-1.5 transition-all duration-300 dark:bg-neutral-200 md:py-2 md:pl-2 md:pr-3",
)}
>
{href === "/marketplace" && (

View File

@@ -27,9 +27,9 @@ export const NavbarView = ({ isLoggedIn }: NavbarViewProps) => {
return (
<>
<nav className="sticky top-0 z-40 hidden h-16 items-center border border-white/50 bg-[#f3f4f6]/20 p-3 backdrop-blur-[26px] md:inline-flex">
<nav className="sticky top-0 z-40 inline-flex h-16 items-center border border-white/50 bg-[#f3f4f6]/20 p-3 backdrop-blur-[26px]">
{/* Left section */}
<div className="flex flex-1 items-center gap-5">
<div className="hidden flex-1 items-center gap-3 md:flex md:gap-5">
{isLoggedIn
? loggedInLinks.map((link) => (
<NavbarLink key={link.name} name={link.name} href={link.href} />
@@ -40,12 +40,12 @@ export const NavbarView = ({ isLoggedIn }: NavbarViewProps) => {
</div>
{/* Centered logo */}
<div className="absolute left-1/2 top-1/2 h-10 w-[88.87px] -translate-x-1/2 -translate-y-1/2">
<div className="absolute left-16 top-1/2 h-auto w-[5.5rem] -translate-x-1/2 -translate-y-1/2 md:left-1/2">
<IconAutoGPTLogo className="h-full w-full" />
</div>
{/* Right section */}
<div className="flex flex-1 items-center justify-end gap-4">
<div className="hidden flex-1 items-center justify-end gap-4 md:flex">
{isLoggedIn ? (
<div className="flex items-center gap-4">
<AgentActivityDropdown />
@@ -66,7 +66,8 @@ export const NavbarView = ({ isLoggedIn }: NavbarViewProps) => {
{/* Mobile Navbar - Adjust positioning */}
<>
{isLoggedIn ? (
<div className="fixed right-4 top-4 z-50">
<div className="fixed -right-4 top-2 z-50 flex items-center gap-0 md:hidden">
<Wallet />
<MobileNavBar
userName={profile?.username}
menuItemGroups={[

View File

@@ -1,7 +1,6 @@
import {
IconBuilder,
IconEdit,
IconLayoutDashboard,
IconLibrary,
IconLogOut,
IconMarketplace,
@@ -11,6 +10,7 @@ import {
IconType,
IconUploadCloud,
} from "@/components/ui/icons";
import { StorefrontIcon } from "@phosphor-icons/react";
type Link = {
name: string;
@@ -155,10 +155,10 @@ export function getAccountMenuItems(userRole?: string): MenuItemGroup[] {
}
export function getAccountMenuOptionIcon(icon: IconType) {
const iconClass = "w-6 h-6";
const iconClass = "w-5 h-5";
switch (icon) {
case IconType.LayoutDashboard:
return <IconLayoutDashboard className={iconClass} />;
return <StorefrontIcon className={iconClass} />;
case IconType.UploadCloud:
return <IconUploadCloud className={iconClass} />;
case IconType.Edit:

View File

@@ -13,24 +13,22 @@ interface Props {
export function Breadcrumbs({ items }: Props) {
return (
<div className="flex items-center gap-4">
<div className="flex h-auto flex-wrap items-center justify-start gap-3 rounded-[5rem] dark:bg-transparent">
{items.map((item, index) => (
<React.Fragment key={index}>
<Link
href={item.link}
className="text-zinc-700 transition-colors hover:text-zinc-900 hover:no-underline"
>
{item.name}
</Link>
{index < items.length - 1 && (
<Text variant="body-medium" className="text-zinc-700">
/
</Text>
)}
</React.Fragment>
))}
</div>
<div className="mb-4 flex h-auto flex-wrap items-center justify-start gap-2 md:mb-0 md:gap-3">
{items.map((item, index) => (
<React.Fragment key={index}>
<Link
href={item.link}
className="text-zinc-700 transition-colors hover:text-zinc-900 hover:no-underline"
>
{item.name}
</Link>
{index < items.length - 1 && (
<Text variant="body-medium" className="text-zinc-700">
/
</Text>
)}
</React.Fragment>
))}
</div>
);
}

View File

@@ -0,0 +1,42 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/nextjs";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "./DropdownMenu";
import { Button } from "@/components/atoms/Button/Button";
const meta: Meta = {
title: "Molecules/DropdownMenu",
component: DropdownMenuContent,
};
export default meta;
type Story = StoryObj<typeof DropdownMenuContent>;
export const Basic: Story = {
render: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="small">
Open menu
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => alert("Action 1")}>
Action 1
</DropdownMenuItem>
<DropdownMenuItem onClick={() => alert("Action 2")}>
Action 2
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => alert("Danger")}>
Danger
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
};

View File

@@ -0,0 +1,202 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import {
CheckIcon,
ChevronRightIcon,
DotFilledIcon,
} from "@radix-ui/react-icons";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-neutral-100 data-[state=open]:bg-neutral-100 dark:focus:bg-neutral-800 dark:data-[state=open]:bg-neutral-800",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-neutral-200 bg-white p-1 text-neutral-950 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-neutral-200 bg-white p-1 text-neutral-950 shadow-md dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-neutral-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-neutral-800 dark:focus:text-neutral-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-neutral-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-neutral-800 dark:focus:text-neutral-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-neutral-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-neutral-800 dark:focus:text-neutral-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<DotFilledIcon className="h-4 w-4 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn(
"-mx-1 my-1 h-px bg-neutral-100 dark:bg-neutral-800",
className,
)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@@ -1,6 +1,5 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { ErrorCard } from "./ErrorCard";
import { ArrowClockwise } from "@phosphor-icons/react";
const meta: Meta<typeof ErrorCard> = {
title: "Molecules/ErrorCard",
@@ -86,11 +85,6 @@ The component will automatically:
defaultValue: { summary: '"data"' },
},
},
loadingSlot: {
control: false,
description:
"Custom loading component to show instead of default spinner",
},
onRetry: {
control: false,
description:
@@ -190,24 +184,6 @@ export const LoadingState: Story = {
},
};
/**
* You can provide a custom loading component via the loadingSlot prop.
*/
export const CustomLoadingSlot: Story = {
args: {
loadingSlot: (
<div className="flex items-center gap-3">
<ArrowClockwise
size={20}
weight="bold"
className="animate-spin text-purple-500"
/>
<span className="text-zinc-600">Loading your awesome data...</span>
</div>
),
},
};
/**
* Response errors can also have string error details instead of arrays.
*/

View File

@@ -1,5 +1,5 @@
import React from "react";
import { getErrorMessage, getHttpErrorMessage, isHttpError } from "./helpers";
import { getErrorMessage, getHttpErrorMessage } from "./helpers";
import { CardWrapper } from "./components/CardWrapper";
import { ErrorHeader } from "./components/ErrorHeader";
import { ErrorMessage } from "./components/ErrorMessage";
@@ -17,7 +17,6 @@ export interface ErrorCardProps {
message?: string;
};
context?: string;
loadingSlot?: React.ReactNode;
onRetry?: () => void;
className?: string;
}
@@ -34,21 +33,24 @@ export function ErrorCard({
return null;
}
const isHttp = isHttpError(httpError);
const hasResponseDetail = !!(
responseError &&
((typeof responseError.detail === "string" &&
responseError.detail.length > 0) ||
(Array.isArray(responseError.detail) &&
responseError.detail.length > 0) ||
(responseError.message && responseError.message.length > 0))
);
const errorMessage = isHttp
? getHttpErrorMessage(httpError)
: getErrorMessage(responseError);
const errorMessage = hasResponseDetail
? getErrorMessage(responseError)
: getHttpErrorMessage(httpError);
return (
<CardWrapper className={className}>
<div className="relative space-y-4 p-6">
<ErrorHeader />
<ErrorMessage
isHttpError={isHttp}
errorMessage={errorMessage}
context={context}
/>
<ErrorMessage errorMessage={errorMessage} context={context} />
<ActionButtons
onRetry={onRetry}
responseError={responseError}

View File

@@ -8,12 +8,12 @@ interface CardWrapperProps {
export function CardWrapper({ children, className = "" }: CardWrapperProps) {
return (
<div className={`relative overflow-hidden rounded-xl ${className}`}>
<div className={`relative my-6 overflow-hidden rounded-xl ${className}`}>
{/* Purple gradient border */}
<div
className="absolute inset-0 rounded-xl p-[1px]"
style={{
background: `linear-gradient(135deg, ${colors.zinc[500]}, ${colors.zinc[200]}, ${colors.zinc[100]})`,
background: `linear-gradient(135deg, ${colors.zinc[100]}, ${colors.zinc[200]}, ${colors.zinc[100]})`,
}}
>
<div className="h-full w-full rounded-xl bg-white" />

View File

@@ -1,35 +1,22 @@
import React from "react";
import { Text } from "@/components/atoms/Text/Text";
interface ErrorMessageProps {
isHttpError: boolean;
interface Props {
errorMessage: string;
context: string;
}
export function ErrorMessage({
isHttpError,
errorMessage,
context,
}: ErrorMessageProps) {
export function ErrorMessage({ errorMessage, context }: Props) {
return (
<div className="space-y-2">
{isHttpError ? (
<Text variant="body" className="text-zinc-700">
<Text variant="body" className="text-zinc-700">
We had the following error when retrieving {context ?? "your data"}:
</Text>
<div className="rounded-lg border border-zinc-100 bg-zinc-50 p-3">
<Text variant="body" className="!text-red-700">
{errorMessage}
</Text>
) : (
<>
<Text variant="body" className="text-zinc-700">
We had the following error when retrieving {context ?? "your data"}:
</Text>
<div className="rounded-lg border border-zinc-100 bg-zinc-50 p-3">
<Text variant="body" className="!text-red-700">
{errorMessage}
</Text>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -21,6 +21,9 @@ export function getHttpErrorMessage(
const status = httpError.status || 0;
if (httpError.message || httpError.statusText)
return httpError.message || httpError.statusText || "Unknown error";
if (status >= 500) {
return "An internal server error has occurred. Please try again in a few minutes.";
}
@@ -52,10 +55,6 @@ export function shouldShowError(
return !isSuccess || !!responseError || !!httpError;
}
export function isHttpError(httpError?: ErrorCardProps["httpError"]): boolean {
return !!httpError;
}
export function handleReportError(
responseError?: ErrorCardProps["responseError"],
httpError?: ErrorCardProps["httpError"],

View File

@@ -0,0 +1,162 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import React from "react";
import { InfiniteList } from "./InfiniteList";
const meta: Meta<typeof InfiniteList> = {
title: "Molecules/InfiniteList",
component: InfiniteList,
};
export default meta;
type Story = StoryObj<typeof InfiniteList>;
function useMockInfiniteData(total: number, pageSize: number) {
const [items, setItems] = React.useState<number[]>(
Array.from({ length: Math.min(pageSize, total) }, (_, i) => i + 1),
);
const [isFetchingMore, setIsFetchingMore] = React.useState(false);
const hasMore = items.length < total;
function fetchMore() {
if (!hasMore || isFetchingMore) return;
setIsFetchingMore(true);
setTimeout(() => {
setItems((prev) => {
const nextStart = prev.length + 1;
const nextEnd = Math.min(prev.length + pageSize, total);
const next = Array.from(
{ length: nextEnd - nextStart + 1 },
(_, i) => nextStart + i,
);
return [...prev, ...next];
});
setIsFetchingMore(false);
}, 400);
}
return { items, isFetchingMore, hasMore, fetchMore };
}
export const Basic: Story = {
render: () => {
const { items, hasMore, isFetchingMore, fetchMore } = useMockInfiniteData(
40,
10,
);
return (
<div
style={{
height: 320,
overflow: "auto",
border: "1px solid #eee",
padding: 8,
}}
>
<InfiniteList
items={items}
hasMore={hasMore}
isFetchingMore={isFetchingMore}
onEndReached={fetchMore}
renderItem={(n) => (
<div
style={{
padding: 8,
marginBottom: 8,
background: "#fff",
border: "1px solid #e5e5e5",
borderRadius: 8,
}}
>
Item {n}
</div>
)}
/>
</div>
);
},
};
export const LongList: Story = {
render: () => {
const { items, hasMore, isFetchingMore, fetchMore } = useMockInfiniteData(
200,
20,
);
return (
<div
style={{
height: 320,
overflow: "auto",
border: "1px solid #eee",
padding: 8,
}}
>
<InfiniteList
items={items}
hasMore={hasMore}
isFetchingMore={isFetchingMore}
onEndReached={fetchMore}
renderItem={(n) => (
<div
style={{
padding: 8,
marginBottom: 8,
background: "#fff",
border: "1px solid #e5e5e5",
borderRadius: 8,
}}
>
Row {n}
</div>
)}
/>
</div>
);
},
};
export const WithLoadingIndicator: Story = {
render: () => {
const { items, hasMore, isFetchingMore, fetchMore } = useMockInfiniteData(
100,
10,
);
return (
<div
style={{
height: 320,
overflow: "auto",
border: "1px solid #eee",
padding: 8,
}}
>
<InfiniteList
items={items}
hasMore={hasMore}
isFetchingMore={isFetchingMore}
onEndReached={fetchMore}
renderItem={(n) => (
<div
style={{
padding: 8,
marginBottom: 8,
background: "#fff",
border: "1px solid #e5e5e5",
borderRadius: 8,
}}
>
#{n}
</div>
)}
/>
{isFetchingMore && (
<div style={{ padding: 8, color: "#666" }}>Loading more</div>
)}
</div>
);
},
};

View File

@@ -0,0 +1,53 @@
"use client";
import React from "react";
interface InfiniteListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
onEndReached: () => void;
hasMore: boolean;
isFetchingMore?: boolean;
className?: string;
itemWrapperClassName?: string;
}
export function InfiniteList<T>(props: InfiniteListProps<T>) {
const {
items,
renderItem,
onEndReached,
hasMore,
isFetchingMore,
className,
itemWrapperClassName,
} = props;
const sentinelRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
if (!hasMore) return;
const node = sentinelRef.current;
if (!node) return;
const observer = new IntersectionObserver((entries) => {
const entry = entries[0];
if (entry.isIntersecting && hasMore && !isFetchingMore) onEndReached();
});
observer.observe(node);
return () => observer.disconnect();
}, [hasMore, isFetchingMore, onEndReached]);
return (
<div className={className}>
{items.map((item, idx) => (
<div key={idx} className={itemWrapperClassName}>
{renderItem(item, idx)}
</div>
))}
<div ref={sentinelRef} />
</div>
);
}

View File

@@ -109,7 +109,7 @@ const TabsLineTrigger = React.forwardRef<
elementRef.current = node;
}}
className={cn(
"relative inline-flex items-center justify-center whitespace-nowrap px-3 py-2 font-sans text-[1rem] font-medium leading-[1.5rem] text-zinc-700 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-purple-600",
"relative inline-flex items-center justify-center whitespace-nowrap px-3 py-3 font-sans text-[1rem] font-medium leading-[1.5rem] text-zinc-700 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-purple-600",
className,
)}
{...props}

View File

@@ -6,7 +6,7 @@ function Skeleton({
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
className={cn("animate-pulse rounded-md bg-slate-50", className)}
{...props}
/>
);