Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Catalog API on the PC. Desktop (`localhost:5173`) calls this URL directly.
# A phone on the LAN (`http://PC-IP:5173`) uses the Vite `/api` proxy instead.
VITE_API_URL=http://localhost:3001

# WalletConnect Cloud project id. The placeholder is fine for injected
# wallets (MetaMask). WalletConnect QR / mobile linking needs a real id.
VITE_WC_PROJECT_ID=era-marketplace-dev

# local = mock hire via POST /jobs (no wallet).
# bsc-testnet | bsc-mainnet = wagmi + ERC-8183 on that chain.
VITE_CHAIN=local

# Optional. Both default to the canonical BNB Agent Studio deployment for the
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/features/account/activity-list.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Link } from "@tanstack/react-router";
import { Bell, Copy, History, Unplug, Wallet, Zap } from "lucide-react";
import { EmptyState } from "@/components/ui";
import { Icon } from "@/components/ui/icon";
Expand Down Expand Up @@ -39,6 +40,15 @@ export function ActivityList() {
{item.status ? `${item.status} · ` : ""}
{formatDateTime(item.createdAt)}
</p>
{item.href?.startsWith("/agents/") ? (
<Link
to="/agents/$agentId"
params={{ agentId: item.href.replace(/^\/agents\//, "").split("?")[0] ?? "" }}
className="mt-1 inline-flex text-xs text-accent hover:underline"
>
Open agent
</Link>
) : null}
</div>
</li>
))}
Expand Down
39 changes: 37 additions & 2 deletions apps/web/src/features/account/notice-list.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { type ReactNode } from "react";
import { Link } from "@tanstack/react-router";
import { Bell, CircleAlert, Info, Wallet, TriangleAlert, Zap } from "lucide-react";
import { Badge, Button, EmptyState } from "@/components/ui";
import { Icon } from "@/components/ui/icon";
Expand Down Expand Up @@ -94,9 +96,9 @@ function NoticeRow({ item, onRead }: { item: Notice; onRead: () => void }) {
return (
<div>
{item.href ? (
<a href={item.href} onClick={onRead} className="block">
<NoticeLink href={item.href} onRead={onRead}>
{body}
</a>
</NoticeLink>
) : (
body
)}
Expand All @@ -108,3 +110,36 @@ function NoticeRow({ item, onRead }: { item: Notice; onRead: () => void }) {
</div>
);
}

function NoticeLink({
href,
onRead,
children,
}: {
href: string;
onRead: () => void;
children: ReactNode;
}) {
const profile = href.startsWith("/profile");
if (profile) {
const tab = new URLSearchParams(href.split("?")[1] ?? "").get("tab") ?? "overview";
return (
<Link to="/profile" search={{ tab }} onClick={onRead} className="block">
{children}
</Link>
);
}
const agent = href.match(/^\/agents\/([^/?#]+)/);
if (agent?.[1]) {
return (
<Link to="/agents/$agentId" params={{ agentId: agent[1] }} onClick={onRead} className="block">
{children}
</Link>
);
}
return (
<a href={href} onClick={onRead} className="block">
{children}
</a>
);
}
4 changes: 3 additions & 1 deletion apps/web/src/features/catalog/marketplace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@ export function Marketplace({
status,
onRetry,
initialSearch = "",
error,
}: {
agents: AgentListing[];
signals: Record<string, AgentSignal>;
status: Status;
onRetry: () => void;
initialSearch?: string;
error?: string | null;
}) {
const [query, setQuery] = useState<CatalogQuery>({
...DEFAULT_CATALOG_QUERY,
Expand Down Expand Up @@ -100,7 +102,7 @@ export function Marketplace({
</Button>
}
>
<p>{friendlyLoadError()}</p>
<p>{friendlyLoadError(error)}</p>
</ErrorState>
);
}
Expand Down
51 changes: 51 additions & 0 deletions apps/web/src/features/catalog/use-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useQuery } from "@tanstack/react-query";
import { ApiError, getAgent, getSignal, isNotFound } from "@/lib/api";

function retryAgent(count: number, err: Error) {
if (err instanceof ApiError && (err.status === 404 || err.status === 400)) return false;
return count < 1;
}

export function useAgent(agentId: string) {
const agentQuery = useQuery({
queryKey: ["agent", agentId],
queryFn: ({ signal }) => getAgent(agentId, { signal }),
retry: retryAgent,
});

const signalQuery = useQuery({
queryKey: ["agent-signal", agentId],
queryFn: ({ signal }) => getSignal(agentId, { signal }),
enabled: agentQuery.isSuccess,
retry: false,
staleTime: 30_000,
});

const signalMissing = signalQuery.isError && isNotFound(signalQuery.error);
const signalFailed = signalQuery.isError && !signalMissing;

return {
agent: agentQuery.data?.agent ?? null,
signal: signalQuery.data?.signal ?? null,
loading: agentQuery.isPending,
notFound: agentQuery.isError && isNotFound(agentQuery.error),
error: agentQuery.isError
? agentQuery.error instanceof Error
? agentQuery.error.message
: "This agent could not be loaded"
: null,
signalStatus: agentQuery.isPending || (agentQuery.isSuccess && signalQuery.isPending)
? ("loading" as const)
: signalQuery.isSuccess
? ("ready" as const)
: ("empty" as const),
signalRefreshing: signalQuery.isFetching && !signalQuery.isPending,
signalFailed,
reload: () => {
void agentQuery.refetch();
},
reloadSignal: () => {
void signalQuery.refetch();
},
};
}
87 changes: 35 additions & 52 deletions apps/web/src/features/catalog/use-catalog.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,48 @@
import { useEffect, useState } from "react";
import type { AgentListing, AgentSignal, Category } from "@era/domain";
import { getSignal, listAgents } from "@/lib/api";
import { useQuery } from "@tanstack/react-query";
import type { Category } from "@era/domain";
import { ApiError, fetchSignals, listAgents } from "@/lib/api";

type Status = "loading" | "ready" | "error";

export function useCatalog(category?: Category) {
const [agents, setAgents] = useState<AgentListing[]>([]);
const [signals, setSignals] = useState<Record<string, AgentSignal>>({});
const [status, setStatus] = useState<Status>("loading");
const [error, setError] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0);
function retryCatalog(count: number, err: Error) {
if (err instanceof ApiError && (err.status === 404 || err.status === 400)) return false;
return count < 1;
}

useEffect(() => {
let cancelled = false;
setStatus("loading");
setError(null);
setAgents([]);
setSignals({});
export function useCatalog(category?: Category) {
const agentsQuery = useQuery({
queryKey: ["catalog-agents", category ?? "all"],
queryFn: ({ signal }) => listAgents(category, { signal }),
retry: retryCatalog,
});

listAgents(category)
.then(async ({ agents: rows }) => {
if (cancelled) return;
setAgents(rows);
setStatus("ready");
const agents = agentsQuery.data?.agents ?? [];

const settled = await Promise.allSettled(
rows.map(async (agent) => {
const { signal } = await getSignal(agent.id);
return [agent.id, signal] as const;
}),
);
if (cancelled) return;
setSignals(
Object.fromEntries(
settled
.filter(
(
entry,
): entry is PromiseFulfilledResult<readonly [string, AgentSignal]> =>
entry.status === "fulfilled",
)
.map((entry) => entry.value),
),
);
})
.catch((err: unknown) => {
if (cancelled) return;
setStatus("error");
setError(err instanceof Error ? err.message : "Failed to load agents");
});
const signalsQuery = useQuery({
queryKey: ["catalog-signals", category ?? "all", agents.map((agent) => agent.id)],
queryFn: ({ signal }) => fetchSignals(
agents.map((agent) => agent.id),
signal,
),
enabled: agentsQuery.isSuccess && agents.length > 0,
retry: false,
staleTime: 30_000,
});

return () => {
cancelled = true;
};
}, [category, attempt]);
const status: Status = agentsQuery.isPending
? "loading"
: agentsQuery.isError
? "error"
: "ready";

return {
agents,
signals,
signals: signalsQuery.data ?? {},
status,
error,
retry: () => setAttempt((n) => n + 1),
error: agentsQuery.error instanceof Error ? agentsQuery.error.message : null,
retry: () => {
void agentsQuery.refetch();
void signalsQuery.refetch();
},
};
}
Loading
Loading