Skip to content
Open
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
50 changes: 39 additions & 11 deletions apps/host-selfhost/web/routes/app/admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { CopyButton } from "@executor-js/react/components/copy-button";
import { Input } from "@executor-js/react/components/input";
import { Label } from "@executor-js/react/components/label";
import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select";
import { PageContainer, PageHeader } from "@executor-js/react/components/page";
import { Skeleton } from "@executor-js/react/components/skeleton";
import { useExecutorDocumentTitle } from "@executor-js/react/lib/document-title";
import {
orgMembersAtom,
Expand All @@ -32,18 +34,13 @@ const ROLES = ["member", "admin"] as const;
function AdminPage() {
useExecutorDocumentTitle("Admin");
return (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto flex max-w-3xl flex-col gap-10 px-6 py-10 lg:px-8 lg:py-14">
<header className="space-y-1">
<h1 className="font-display text-3xl tracking-tight text-foreground">Admin</h1>
<p className="text-sm text-muted-foreground">
Manage members and invite links for this instance.
</p>
</header>
<PageContainer>
<PageHeader title="Admin" description="Manage members and invite links for this instance." />
<div className="space-y-10">
<MembersSection />
<InvitesSection />
</div>
</div>
</PageContainer>
);
}

Expand Down Expand Up @@ -76,7 +73,7 @@ function MembersSection() {
<section className="space-y-3">
<h2 className="text-sm font-medium text-foreground">Members</h2>
{AsyncResult.match(result, {
onInitial: () => <Notice>Loading members…</Notice>,
onInitial: () => <MembersSkeleton />,
onFailure: () => <Notice tone="destructive">Admin access required.</Notice>,
onSuccess: ({ value }) => (
<div className="divide-y divide-border rounded-lg border border-border">
Expand Down Expand Up @@ -187,7 +184,7 @@ function InvitesSection() {
</div>

{AsyncResult.match(result, {
onInitial: () => <Notice>Loading invites…</Notice>,
onInitial: () => <InvitesSkeleton />,
onFailure: () => <Notice tone="destructive">Admin access required.</Notice>,
onSuccess: ({ value }) => {
const pending = value.invites.filter((i) => !i.usedAt);
Expand Down Expand Up @@ -240,6 +237,37 @@ function InvitesSection() {
);
}

function MembersSkeleton() {
return (
<div className="divide-y divide-border rounded-lg border border-border">
{[0, 1, 2].map((row) => (
<div key={row} className="flex items-center gap-3 p-3">
<Skeleton className="size-8 rounded-full" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-40" />
<Skeleton className="h-3 w-56 max-w-full" />
</div>
<Skeleton className="h-8 w-20 rounded-md" />
<Skeleton className="h-8 w-14 rounded-md" />
</div>
))}
</div>
);
}

function InvitesSkeleton() {
return (
<div className="divide-y divide-border rounded-lg border border-border">
<div className="flex items-center gap-3 p-3">
<Skeleton className="h-3.5 w-28" />
<Skeleton className="h-3.5 w-32 flex-1" />
<Skeleton className="size-8 rounded-md" />
<Skeleton className="h-8 w-14 rounded-md" />
</div>
</div>
);
}

function Notice({ children, tone }: { children: React.ReactNode; tone?: "destructive" }) {
return (
<div
Expand Down
2 changes: 1 addition & 1 deletion e2e/scenarios/mcp-catalog-sync-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ scenario(
// typing in the filter box expands every match, so it is both the
// reveal mechanism and a search the video shows off.
const filterTools = async (query: string) => {
const filter = page.getByPlaceholder(/^Filter \d+ tools/);
const filter = page.getByPlaceholder("Filter tools…");
await filter.waitFor();
await filter.fill(query);
};
Expand Down
12 changes: 8 additions & 4 deletions e2e/scenarios/policies-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ scenario(
// Group rows are the only tree buttons carrying aria-expanded.
const closedGroup = (connection: string, text: string) =>
sectionFor(connection).locator('button[aria-expanded="false"]').filter({ hasText: text });
const expandGroup = async (connection: string, text: string) => {
const group = closedGroup(connection, text);
if ((await group.count()) > 0) await group.first().click();
};
const policyMenuFor = (connection: string, node: string) =>
sectionFor(connection).getByRole("button", {
name: `Set policy for ${node}`,
Expand All @@ -169,8 +173,8 @@ scenario(
});

await step("Expand the records category in the first account", async () => {
await closedGroup(alpha, integration).click();
await closedGroup(alpha, "records").click();
await expandGroup(alpha, integration);
await expandGroup(alpha, "records");
await policyMenuFor(alpha, `${integration}.records.create`).waitFor();
});

Expand Down Expand Up @@ -249,8 +253,8 @@ scenario(
);

await step("The same rules govern the second account's rows", async () => {
await closedGroup(beta, integration).click();
await closedGroup(beta, "records").click();
await expandGroup(beta, integration);
await expandGroup(beta, "records");
await leafIndicator(beta, "create", `Blocked (matched ${leafPattern})`).waitFor();
await leafIndicator(
beta,
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/web/shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { CommandPalette } from "@executor-js/react/components/command-palette";
import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client";
import { SidebarUpdateCard } from "@executor-js/react/components/update-card";
import { Wordmark } from "@executor-js/react/components/wordmark";
import { NavigationProgress } from "@executor-js/react/components/navigation-progress";
import { ServerConnectionMenu } from "./server-connection-menu";

// ── Env ─────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -314,6 +315,7 @@ export function Shell() {

return (
<div className="flex h-screen overflow-hidden">
<NavigationProgress />
<CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} />
{/* Desktop sidebar */}
<aside className="desktop-macos-sidebar hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">
Expand Down
2 changes: 2 additions & 0 deletions packages/core/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export interface PluginPageProps {
readonly path: string;
/** The plugin id from `/plugins/$pluginId/...`. */
readonly pluginId: string;
/** Validated search parameters from the host route, when provided. */
readonly search?: Readonly<Record<string, unknown>>;
}

export interface PageDecl {
Expand Down
42 changes: 34 additions & 8 deletions packages/plugins/toolkits/src/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ import { Skeleton } from "@executor-js/react/components/skeleton";
import { ToolDetail, ToolDetailEmpty } from "@executor-js/react/components/tool-detail";
import { ToolTree, type ToolSummary } from "@executor-js/react/components/tool-tree";
import { cn } from "@executor-js/react/lib/utils";
import {
toolSelectionFromSearch,
toolSelectionSearch,
} from "@executor-js/react/lib/integration-detail-tabs";

import {
ToolkitsApi,
Expand Down Expand Up @@ -600,18 +604,24 @@ function AddToolkitCard(props: { owner: Owner; showOwnerLabels: boolean; onClick
variant="ghost"
onClick={props.onClick}
aria-label={scopeLabel ? `Add ${scopeLabel} toolkit` : "Add toolkit"}
className="group flex h-auto min-h-36 min-w-0 self-start items-center justify-center rounded-md border border-dashed border-border/75 bg-card/40 p-0 text-muted-foreground transition-[border-color,background-color,box-shadow,color] hover:border-foreground/25 hover:bg-muted/20 hover:text-foreground hover:shadow-sm focus-visible:ring-[3px] focus-visible:ring-ring/30 focus-visible:outline-none"
className="group flex h-auto min-h-36 min-w-0 flex-col self-start items-center justify-center rounded-md border border-dashed border-border/75 bg-card/40 p-4 text-muted-foreground transition-[border-color,background-color,box-shadow,color] hover:border-foreground/25 hover:bg-muted/20 hover:text-foreground hover:shadow-sm focus-visible:ring-[3px] focus-visible:ring-ring/30 focus-visible:outline-none"
style={toolkitCardStyle}
>
<span
className={cn(
"flex size-12 items-center justify-center rounded-md border transition-[border-color,background-color,color,transform]",
"flex size-10 items-center justify-center rounded-md border transition-[border-color,background-color,color,transform]",
neutralToolkitIconClass,
"group-hover:scale-105",
)}
>
<PlusIcon className="size-6" />
</span>
<span className="mt-3 text-sm font-semibold text-foreground">
Create {scopeLabel ? `${scopeLabel.toLowerCase()} ` : ""}toolkit
</span>
<span className="mt-1 max-w-48 px-4 text-center text-xs leading-5 text-muted-foreground">
Group connections and tools for a focused workspace.
</span>
</Button>
);
}
Expand Down Expand Up @@ -1037,6 +1047,8 @@ function ToolkitWorkspace(props: {
policies: readonly ToolkitPolicyResponse[];
connections: readonly ToolkitConnectionResponse[];
tools: readonly ToolRow[];
selectedToolId: string | null;
onSelectTool: (toolId: string) => void;
integrations: readonly Integration[];
integrationPlugins: readonly IntegrationPlugin[];
mcpUrl: string;
Expand All @@ -1048,7 +1060,6 @@ function ToolkitWorkspace(props: {
onClearPolicy: (pattern: string) => Promise<void> | void;
}) {
const [addOpen, setAddOpen] = useState(false);
const [selectedToolId, setSelectedToolId] = useState<string | null>(null);
const visibleTools = useMemo(
() => props.tools.filter((tool) => toolCanAppearInToolkit(props.toolkit, tool)),
[props.toolkit, props.tools],
Expand Down Expand Up @@ -1116,11 +1127,11 @@ function ToolkitWorkspace(props: {
}),
[accessPolicies, configuredTools],
);
const selectedTool = selectedToolId
? (configuredTools.find((tool) => toolMatchId(tool) === selectedToolId) ?? null)
const selectedTool = props.selectedToolId
? (configuredTools.find((tool) => toolMatchId(tool) === props.selectedToolId) ?? null)
: null;
const selectedToolPolicy = selectedTool
? (toolkitTools.find((tool) => tool.id === selectedToolId)?.policy ?? null)
? (toolkitTools.find((tool) => tool.id === props.selectedToolId)?.policy ?? null)
: null;

return (
Expand All @@ -1137,10 +1148,10 @@ function ToolkitWorkspace(props: {
<div className="flex min-h-0 flex-1 overflow-hidden">
<ToolkitToolsPanel
tools={toolkitTools}
selectedToolId={selectedToolId}
selectedToolId={props.selectedToolId}
policies={accessPolicies}
onManageConnections={() => setAddOpen(true)}
onSelectTool={setSelectedToolId}
onSelectTool={props.onSelectTool}
onSetPolicy={(pattern, action) => void props.onSetPolicy(pattern, action)}
onClearPolicy={(pattern) => void props.onClearPolicy(pattern)}
/>
Expand Down Expand Up @@ -1291,6 +1302,8 @@ function ToolkitDetailView(props: {
toolkit: ToolkitResponse;
showOwnerLabels: boolean;
tools: readonly ToolRow[];
selectedToolId: string | null;
onSelectTool: (toolId: string) => void;
integrations: readonly Integration[];
integrationPlugins: readonly IntegrationPlugin[];
orgSlug?: string;
Expand Down Expand Up @@ -1359,6 +1372,8 @@ function ToolkitDetailView(props: {
<ToolkitWorkspace
toolkit={props.toolkit}
showOwnerLabels={props.showOwnerLabels}
selectedToolId={props.selectedToolId}
onSelectTool={props.onSelectTool}
policies={policyRows}
connections={connectionRows}
tools={props.tools}
Expand Down Expand Up @@ -1386,6 +1401,7 @@ export function ToolkitsPage(props: PluginPageProps) {
const doCreateToolkit = useAtomSet(createToolkit, { mode: "promiseExit" });
const doRemoveToolkit = useAtomSet(removeToolkit, { mode: "promiseExit" });
const selectedToolkitSlug = props.params.toolkitSlug ?? null;
const selectedToolId = toolSelectionFromSearch(props.search);

const toolkitRows = AsyncResult.isSuccess(toolkits) ? toolkits.value.toolkits : [];
const selectedToolkit =
Expand All @@ -1403,6 +1419,14 @@ export function ToolkitsPage(props: PluginPageProps) {
navigate({
to: "/{-$orgSlug}/toolkits",
});
const navigateToSelectedTool = (toolId: string | null) => {
if (selectedToolkitSlug === null) return;
void navigate({
to: "/{-$orgSlug}/toolkits/$toolkitSlug",
params: { toolkitSlug: selectedToolkitSlug },
search: toolSelectionSearch(toolId),
});
};

const createToolkitHandler = async (input: { owner: Owner; name: string }) => {
await doCreateToolkit({
Expand Down Expand Up @@ -1456,6 +1480,8 @@ export function ToolkitsPage(props: PluginPageProps) {
toolkit={selectedToolkit}
showOwnerLabels={ownerDisplay.showOwnerLabels}
tools={toolRows}
selectedToolId={selectedToolId}
onSelectTool={navigateToSelectedTool}
integrations={integrationRows}
integrationPlugins={integrationPlugins}
orgSlug={organizationSlug ?? undefined}
Expand Down
23 changes: 18 additions & 5 deletions packages/react/src/components/accounts-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
} from "./alert-dialog";
import { Badge } from "./badge";
import { Button } from "./button";
import { Skeleton } from "./skeleton";
import {
CardStack,
CardStackContent,
Expand Down Expand Up @@ -152,7 +153,7 @@ function AccountRow(props: {
className={`size-2 shrink-0 rounded-full ${indicator.dot}`}
/>
<span className="truncate">{displayLabel}</span>
{needsHealthAttention ? (
{status !== "healthy" ? (
<Badge variant={expired ? "destructive" : "outline"} className="shrink-0">
{HEALTH_STATUS_LABEL[status]}
</Badge>
Expand Down Expand Up @@ -545,10 +546,7 @@ export function AccountsSection(props: {
</div>

{loading ? (
<div className="flex items-center gap-2 py-6">
<div className="size-1.5 animate-pulse rounded-full bg-muted-foreground/30" />
<p className="text-sm text-muted-foreground">Loading accounts…</p>
</div>
<AccountsSkeleton />
) : showEmptyState ? (
<div className="rounded-lg border border-dashed border-border/60 px-6 py-8 text-center">
<p className="text-sm font-medium text-foreground">No connections yet</p>
Expand Down Expand Up @@ -611,3 +609,18 @@ export function AccountsSection(props: {
</section>
);
}

export function AccountsSkeleton() {
return (
<div className="space-y-8">
<div className="space-y-3">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-16 w-full rounded-lg" />
</div>
<div className="space-y-3">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-20 w-full rounded-lg" />
</div>
</div>
);
}
11 changes: 9 additions & 2 deletions packages/react/src/components/add-account-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import { PlacementLine, type AuthMethod } from "../lib/auth-placements";
import { connectionIdentifier } from "../lib/connection-name";
import { Badge } from "./badge";
import { Button } from "./button";
import { Skeleton } from "./skeleton";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./collapsible";
import {
DropdownMenu,
Expand Down Expand Up @@ -374,7 +375,11 @@ function OnePasswordItemSelect(props: {
);

if (state.loading) {
return <p className="text-xs text-muted-foreground">Loading 1Password items…</p>;
return (
<div className="flex h-9 items-center">
<Skeleton className="h-3.5 w-40" />
</div>
);
}
if (state.error) {
return <p className="text-xs text-destructive">{state.error}</p>;
Expand Down Expand Up @@ -2555,7 +2560,9 @@ function AddAccountModalView(props: AddAccountModalProps) {
</p>
</div>
) : oauthLoading ? (
<p className="text-xs text-muted-foreground">Loading OAuth apps…</p>
<div className="flex h-9 items-center">
<Skeleton className="h-3.5 w-36" />
</div>
) : (
<div className="space-y-3">
{dcrFallbackMessage ? (
Expand Down
20 changes: 20 additions & 0 deletions packages/react/src/components/empty-state.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { ReactNode } from "react";

import { cn } from "../lib/utils";

export function EmptyState(props: {
readonly title: string;
readonly description: string;
readonly action?: ReactNode;
readonly className?: string;
}) {
return (
<div
className={cn("rounded-md border border-dashed border-border bg-card p-8", props.className)}
>
<h3 className="text-base font-semibold text-foreground">{props.title}</h3>
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">{props.description}</p>
{props.action ? <div className="mt-4">{props.action}</div> : null}
</div>
);
}
Loading
Loading