diff --git a/apps/local/server/src/implementation/server.ts b/apps/local/server/src/implementation/server.ts index 904019e782..cb9b0c3a31 100644 --- a/apps/local/server/src/implementation/server.ts +++ b/apps/local/server/src/implementation/server.ts @@ -418,6 +418,8 @@ export const localApi = ( HttpRouter.add("GET", "/apps/:app/open", web.document), HttpRouter.add("GET", "/apps/:app/delete", web.document), HttpRouter.add("GET", "/connect", web.document), + HttpRouter.add("GET", "/connections", web.document), + HttpRouter.add("GET", "/connections/:connection", web.document), HttpRouter.add("GET", "/approvals", web.document), HttpRouter.add("GET", "/approvals/:run", web.document), HttpRouter.add("GET", "/account-connect/:connection", web.document), diff --git a/apps/local/web/src/implementation/app.tsx b/apps/local/web/src/implementation/app.tsx index fe5f4fae84..ed677cc8ef 100644 --- a/apps/local/web/src/implementation/app.tsx +++ b/apps/local/web/src/implementation/app.tsx @@ -9,7 +9,7 @@ import { UserCircleIcon, LaptopIcon, PackageIcon, - Plug01Icon, + ConnectIcon, Shield01Icon, } from "@hugeicons/core-free-icons"; import { overviewAtom } from "../contracts/api.ts"; @@ -145,14 +145,14 @@ function Dashboard() { Apps{Option.isSome(data) && {data.value.apps.length}} - - Connect + + Connections + `${count.toLocaleString("en-US")} ${count === 1 ? one : many}`; + +const choicesFor = (inventory: Inventory): ReadonlyMap => { + const profiles = new Map(); + for (const profile of inventory.profiles) { + const list = profiles.get(profile.app); + if (list === undefined) profiles.set(profile.app, [profile]); + else list.push(profile); + } + return new Map( + inventory.apps.map((app) => [ + app.id, + appChoices(app, profiles.get(app.id) ?? [], inventory.accounts), + ]), + ); +}; + +/** A stored connection as an editable draft, keeping removed apps and profiles visible. */ +const draftFrom = ( + connection: ConnectionView, + choices: ReadonlyMap, +): ConnectionDraft => ({ + id: connection.id, + name: connection.name, + apps: connection.policy.apps.map((item): ConnectionApp => { + const choice = choices.get(item.app); + return { + id: item.app, + name: choice?.app.name ?? "Unavailable app", + tools: item.tools, + ...(item.events === undefined ? {} : { events: item.events }), + targets: item.runsAs.map((target): ConnectionTarget => { + if (target.kind === "app") return target; + const option = choice?.options.find( + (candidate) => candidate.target.kind === "profile" && candidate.target.id === target.id, + ); + return option?.target ?? { kind: "profile", id: target.id, label: "Profile unavailable" }; + }), + }; + }), +}); + +export function ConnectionPage({ connectionId }: { readonly connectionId: string }) { + const inventory = useQuery(overviewAtom); + const connections = useQuery(mcpConnectionsAtom); + if (Option.isNone(inventory.data) || Option.isNone(connections.data)) + return ( + + + + ); + const choices = choicesFor(inventory.data.value); + if (connectionId === "new") + return ( + + ); + const stored = connections.data.value.find((item) => item.id === connectionId); + if (stored === undefined) + return ( + + +

Connection unavailable

+

+ It was revoked or no longer exists. +

+
+ ); + return ( + + ); +} + +function BackLink() { + return ( + + ); +} + +function ConnectionForm({ + choices, + accounts, + stored, + initial, +}: { + readonly choices: ReadonlyMap; + readonly accounts: Inventory["accounts"]; + /** Undefined while creating. */ + readonly stored: ConnectionView | undefined; + readonly initial: ConnectionDraft; +}) { + const navigate = useNavigate(); + const submit = useAtomSet(saveMcpConnectionAtom, { mode: "promiseExit" }); + const saving = useAtomValue(saveMcpConnectionAtom); + const [draft, setDraft] = useState(initial); + const [expanded, setExpanded] = useState(); + const [connecting, setConnecting] = useState(false); + const [revoking, setRevoking] = useState(false); + const [copied, setCopied] = useState(false); + const [search, setSearch] = useState(""); + const [needsSetupOnly, setNeedsSetupOnly] = useState(false); + const query = useDeferredValue(search.trim().toLowerCase()); + const nameInput = useRef(null); + const renaming = useRef(false); + // Included apps stay on top in a fixed order so rows never jump while you toggle them. + const [pinned] = useState(() => new Set(initial.apps.map((app) => app.id))); + const ordered = useMemo( + () => + [...choices.values()].toSorted( + (a, b) => + Number(pinned.has(b.app.id)) - Number(pinned.has(a.app.id)) || + a.app.name.localeCompare(b.app.name), + ), + [choices, pinned], + ); + const selections = new Map(draft.apps.map((app) => [app.id, app])); + const unavailable = draft.apps.filter((app) => !choices.has(app.id)); + const attention = new Set( + draft.apps.flatMap((selection) => { + const choice = choices.get(selection.id); + return choice !== undefined && selectionIssue(choice, selection) !== undefined + ? [selection.id] + : []; + }), + ); + const blocked = attention.size + unavailable.length; + const visible = ordered.filter( + (choice) => + (!needsSetupOnly || attention.has(choice.app.id)) && + (query === "" || choice.app.name.toLowerCase().includes(query)), + ); + const included = visible.filter((choice) => pinned.has(choice.app.id)); + const others = visible.filter((choice) => !pinned.has(choice.app.id)); + const { count, sentinel } = useIncrementalList(others.length, `${query}:${needsSetupOnly}`); + const addable = visible.filter((choice) => !selections.has(choice.app.id)); + const unassigned = draft.apps.filter( + (app) => app.targets.length === 0 && (choices.get(app.id)?.options.length ?? 0) > 0, + ); + const input = connectionInput(draft); + const creating = stored === undefined; + const dirty = creating || JSON.stringify(input) !== JSON.stringify(connectionInput(initial)); + const valid = + input !== undefined && input.name.length > 0 && draft.apps.length > 0 && blocked === 0; + const setSelection = (id: AppId, next: ConnectionApp | undefined) => + setDraft((previous) => ({ + ...previous, + apps: + next === undefined + ? previous.apps.filter((app) => app.id !== id) + : previous.apps.some((app) => app.id === id) + ? previous.apps.map((app) => (app.id === id ? next : app)) + : [...previous.apps, next], + })); + const renderRow = (choice: AppChoices) => ( + setExpanded(open ? choice.app.id : undefined)} + onChange={(next) => setSelection(choice.app.id, next)} + renderConnectAccount={(app) => ( + + )} + renderTools={({ app, profile, names, onChange }) => ( + + )} + /> + ); + return ( + +
{ + event.preventDefault(); + if (!valid || saving.waiting) return; + const saved = await submit({ existing: !creating, input }); + if (Exit.isSuccess(saved) && creating) + void navigate({ + to: "/connections/$connectionId", + params: { connectionId: saved.value.id }, + }); + }} + > + +
+
+ + setDraft((previous) => ({ ...previous, name: event.target.value })) + } + className="-ml-2 w-full max-w-120 rounded-md border border-transparent bg-transparent px-2 py-0.5 text-[22px] font-semibold tracking-[-0.035em] outline-none placeholder:text-muted-foreground/60 hover:border-border focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30" + /> +

+ {creating + ? "Turn on the apps this agent needs. It can't see anything else." + : "Agents using this connection see only the apps turned on below. Changes apply right away."} +

+
+ {stored !== undefined && ( +
+ + !open && setCopied(false)}> + + + + { + // Rename moves focus to the title instead of back to the menu button. + if (!renaming.current) return; + renaming.current = false; + event.preventDefault(); + nameInput.current?.select(); + }} + > + { + event.preventDefault(); + void navigator.clipboard.writeText(stored.url).then(() => setCopied(true)); + }} + > + + {copied ? "Copied" : "Copy MCP URL"} + + { + renaming.current = true; + }} + > + + Rename + + + setRevoking(true)} + > + + Revoke connection + + + +
+ )} +
+ + {unavailable.length > 0 && ( +
+ + {plural(unavailable.length, "app")} in this connection{" "} + {unavailable.length === 1 ? "no longer exists" : "no longer exist"}:{" "} + {unavailable.map((app) => app.name).join(", ")} + + +
+ )} + +
+ + {query !== "" && addable.length > 1 && ( + + )} + {unassigned.length > 0 && ( + + setDraft((previous) => ({ + ...previous, + apps: previous.apps.map((app) => { + const choice = choices.get(app.id); + const option = + choice === undefined ? undefined : accountOption(choice, account); + return app.targets.length === 0 && option !== undefined + ? { ...app, targets: [option.target] } + : app; + }), + })) + } + /> + )} +
+ {needsSetupOnly && ( +

+ Showing apps that need setup.{" "} + +

+ )} + + {included.length > 0 && ( + pinned.has(app.id)).length} + hint={undefined} + > + {included.map(renderRow)} + + )} + {others.length > 0 && ( + 0 ? "Other apps" : "Apps"} + count={undefined} + hint={ + pinned.size > 0 && !others.some((choice) => selections.has(choice.app.id)) + ? "Turn one on to give this agent access." + : undefined + } + > + {others.slice(0, count).map(renderRow)} + + )} + {sentinel} + {visible.length === 0 && ( +

+ {query !== "" ? "No apps match your search." : "Every app that's on is ready."} +

+ )} + +
+

+ {draft.apps.length === 0 ? ( + "No apps turned on" + ) : blocked > 0 ? ( + + ) : creating ? ( + `${plural(draft.apps.length, "app")} turned on` + ) : ( + "Unsaved changes" + )} +

+
+ {creating ? ( + + ) : ( + + )} + +
+ {AsyncResult.isFailure(saving) && ( +
+ +
+ )} +
+ + {stored !== undefined && ( + <> + app.name), + } + : undefined + } + onClose={() => setConnecting(false)} + /> + setRevoking(false)} + onRevoked={() => void navigate({ to: "/connections" })} + /> + + )} +
+ ); +} + +function AppSection({ + title, + count, + hint, + children, +}: { + readonly title: string; + readonly count: number | undefined; + readonly hint: string | undefined; + readonly children: ReactNode; +}) { + return ( +
+
+

{title}

+ {count !== undefined && ( + {count} + )} + {hint && {hint}} +
+
{children}
+
+ ); +} + +function RevokeDialog({ + connection, + onClose, + onRevoked, +}: { + readonly connection: ConnectionView | undefined; + readonly onClose: () => void; + readonly onRevoked: () => void; +}) { + const submit = useAtomSet(revokeMcpConnectionAtom, { mode: "promiseExit" }); + const state = useAtomValue(revokeMcpConnectionAtom); + return ( + !open && !state.waiting && onClose()} + > + + + Revoke {connection?.name}? + + Agents using this connection lose access immediately. Its URL stops working. + + + {AsyncResult.isFailure(state) && } + + + + + + + ); +} diff --git a/apps/local/web/src/implementation/pages/connections.tsx b/apps/local/web/src/implementation/pages/connections.tsx new file mode 100644 index 0000000000..64f6c45908 --- /dev/null +++ b/apps/local/web/src/implementation/pages/connections.tsx @@ -0,0 +1,227 @@ +/** + * Connections page prototype (EXE-2). Real connections, apps and MCP URLs only. + * Each scoped card opens its own page, where access is edited in place. + */ +import { useState } from "react"; +import { Link } from "@tanstack/react-router"; +import { Option } from "effect"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Add01Icon, ConnectIcon } from "@hugeicons/core-free-icons"; +import type { App, AppId } from "@executor-js/sdk"; +import type { ConnectionView } from "@executor-js/mcp-auth/connections"; +import { Button } from "@executor-js/ui/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@executor-js/ui/components/dialog"; +import { PageFrame } from "@executor-js/ui/dashboard/page"; +import { ProviderIcon } from "@executor-js/ui/dashboard/common"; +import { McpInstallInstructions } from "@executor-js/ui/dashboard/connect"; +import { useQuery } from "@executor-js/ui/dashboard/context"; +import { providerDisplayUrl } from "@executor-js/ui/contracts/dashboard"; +import { overviewAtom } from "../../contracts/api.ts"; +import { mcpConnectionsAtom } from "../../contracts/mcp-connections.ts"; +import { mcpInstallationAtom } from "../../contracts/mcp.ts"; +import { LoadingRows } from "../components/common.tsx"; + +interface ConnectionApp { + readonly app: App | undefined; + readonly name: string; +} + +/** The full-access endpoint, then each scoped connection. */ +interface Connection { + readonly id: string; + readonly name: string; + readonly url: string; + readonly full: boolean; + readonly apps: readonly ConnectionApp[]; +} + +const connectionsFrom = ( + endpoint: string, + connections: readonly ConnectionView[], + apps: readonly App[], +): readonly Connection[] => { + const byId = new Map(apps.map((app) => [app.id, app])); + return [ + { + id: "default", + name: "Full access", + url: endpoint, + full: true, + apps: apps.map((app) => ({ app, name: app.name })), + }, + ...connections.map((connection) => ({ + id: connection.id, + name: connection.name, + url: connection.url, + full: false, + apps: connection.policy.apps.map((item) => { + const app = byId.get(item.app); + return { app, name: app?.name ?? "Unavailable app" }; + }), + })), + ]; +}; + +export function ConnectionsPage() { + const inventory = useQuery(overviewAtom); + const connections = useQuery(mcpConnectionsAtom); + const installation = useQuery(mcpInstallationAtom); + const [connecting, setConnecting] = useState(); + if ( + Option.isNone(inventory.data) || + Option.isNone(connections.data) || + Option.isNone(installation.data) + ) + return ( + + + + ); + const items = connectionsFrom( + installation.data.value.endpoint, + connections.data.value, + inventory.data.value.apps, + ); + return ( + +
+
+

Connections

+

+ Each connection gives an agent its own MCP URL that reaches only the apps you pick. +

+
+ +
+
+ {items.map((item) => ( + + ))} +
+ app.name), + } + } + onClose={() => setConnecting(undefined)} + /> +
+ ); +} + +/** What the Connect dialog needs; `apps` is undefined for the full-access URL. */ +export interface ConnectTarget { + readonly name: string; + readonly url: string; + readonly apps: readonly string[] | undefined; +} + +export function ConnectDialog({ + target, + onClose, +}: { + readonly target: ConnectTarget | undefined; + readonly onClose: () => void; +}) { + return ( + !open && onClose()}> + + + Connect {target?.name} + + {target?.apps === undefined + ? "This URL reaches every app. Make a new connection to limit what an agent sees." + : `This URL only reaches ${target.apps.join(", ")}.`} + + + {target && ( + + )} + + + ); +} + +function AppIcon({ item }: { readonly item: ConnectionApp }) { + const provider = + item.app === undefined + ? undefined + : Object.values(item.app.requirements.accounts)[0]?.definition; + return ; +} + +/** Scoped cards open their page; the stretched name link keeps Connect a separate button. */ +function ConnectionCard({ + connection, + onConnect, +}: { + readonly connection: Connection; + readonly onConnect: (connection: Connection) => void; +}) { + const shown = connection.apps.slice(0, 4); + return ( +
+ + {connection.full ? ( + {connection.name} + ) : ( + + {connection.name} + + )} + {connection.full && ( + + Default + + )} + +
    + {shown.map((item) => ( +
  • + + {item.name} +
  • + ))} + {connection.apps.length > shown.length && ( +
  • + and {connection.apps.length - shown.length} more +
  • + )} +
+
+ +
+
+ ); +} diff --git a/apps/local/web/src/implementation/routeTree.gen.ts b/apps/local/web/src/implementation/routeTree.gen.ts index badaee9bc6..c49f9a1f48 100644 --- a/apps/local/web/src/implementation/routeTree.gen.ts +++ b/apps/local/web/src/implementation/routeTree.gen.ts @@ -13,12 +13,14 @@ import { Route as DashboardRouteImport } from './routes/_dashboard' import { Route as AppAuthRouteImport } from './routes/app-auth' import { Route as DashboardInventoryRouteImport } from './routes/_dashboard._inventory' import { Route as DashboardConnectRouteImport } from './routes/_dashboard.connect' +import { Route as DashboardConnectionsRouteImport } from './routes/_dashboard.connections' import { Route as AccountConnectConnectionIdRouteImport } from './routes/account-connect.$connectionId' import { Route as McpAuthorizeRouteImport } from './routes/mcp.authorize' import { Route as DashboardInventoryIndexRouteImport } from './routes/_dashboard._inventory.index' import { Route as DashboardInventorySplatRouteImport } from './routes/_dashboard._inventory.$' import { Route as DashboardApprovalsIndexRouteImport } from './routes/_dashboard.approvals.index' import { Route as DashboardApprovalsRunIdRouteImport } from './routes/_dashboard.approvals.$runId' +import { Route as DashboardConnectionsConnectionIdRouteImport } from './routes/_dashboard.connections_.$connectionId' import { Route as McpApproveRequestIdRouteImport } from './routes/mcp.approve.$requestId' import { Route as DashboardInventoryAccountsIndexRouteImport } from './routes/_dashboard._inventory.accounts.index' import { Route as DashboardInventoryAppsIndexRouteImport } from './routes/_dashboard._inventory.apps.index' @@ -49,6 +51,11 @@ const DashboardConnectRoute = DashboardConnectRouteImport.update({ path: '/connect', getParentRoute: () => DashboardRoute, } as any) +const DashboardConnectionsRoute = DashboardConnectionsRouteImport.update({ + id: '/connections', + path: '/connections', + getParentRoute: () => DashboardRoute, +} as any) const AccountConnectConnectionIdRoute = AccountConnectConnectionIdRouteImport.update({ id: '/account-connect/$connectionId', @@ -80,6 +87,12 @@ const DashboardApprovalsRunIdRoute = DashboardApprovalsRunIdRouteImport.update({ path: '/approvals/$runId', getParentRoute: () => DashboardRoute, } as any) +const DashboardConnectionsConnectionIdRoute = + DashboardConnectionsConnectionIdRouteImport.update({ + id: '/connections_/$connectionId', + path: '/connections/$connectionId', + getParentRoute: () => DashboardRoute, + } as any) const McpApproveRequestIdRoute = McpApproveRequestIdRouteImport.update({ id: '/mcp/approve/$requestId', path: '/mcp/approve/$requestId', @@ -150,10 +163,12 @@ export interface FileRoutesByFullPath { '/': typeof DashboardInventoryIndexRoute '/app-auth': typeof AppAuthRoute '/connect': typeof DashboardConnectRoute + '/connections': typeof DashboardConnectionsRoute '/account-connect/$connectionId': typeof AccountConnectConnectionIdRoute '/mcp/authorize': typeof McpAuthorizeRoute '/$': typeof DashboardInventorySplatRoute '/approvals/$runId': typeof DashboardApprovalsRunIdRoute + '/connections/$connectionId': typeof DashboardConnectionsConnectionIdRoute '/mcp/approve/$requestId': typeof McpApproveRequestIdRoute '/approvals/': typeof DashboardApprovalsIndexRoute '/apps/$appId': typeof DashboardInventoryAppsAppIdRoute @@ -171,10 +186,12 @@ export interface FileRoutesByTo { '/': typeof DashboardInventoryIndexRoute '/app-auth': typeof AppAuthRoute '/connect': typeof DashboardConnectRoute + '/connections': typeof DashboardConnectionsRoute '/account-connect/$connectionId': typeof AccountConnectConnectionIdRoute '/mcp/authorize': typeof McpAuthorizeRoute '/$': typeof DashboardInventorySplatRoute '/approvals/$runId': typeof DashboardApprovalsRunIdRoute + '/connections/$connectionId': typeof DashboardConnectionsConnectionIdRoute '/mcp/approve/$requestId': typeof McpApproveRequestIdRoute '/approvals': typeof DashboardApprovalsIndexRoute '/apps/$appId': typeof DashboardInventoryAppsAppIdRoute @@ -194,10 +211,12 @@ export interface FileRoutesById { '/app-auth': typeof AppAuthRoute '/_dashboard/_inventory': typeof DashboardInventoryRouteWithChildren '/_dashboard/connect': typeof DashboardConnectRoute + '/_dashboard/connections': typeof DashboardConnectionsRoute '/account-connect/$connectionId': typeof AccountConnectConnectionIdRoute '/mcp/authorize': typeof McpAuthorizeRoute '/_dashboard/_inventory/$': typeof DashboardInventorySplatRoute '/_dashboard/approvals/$runId': typeof DashboardApprovalsRunIdRoute + '/_dashboard/connections_/$connectionId': typeof DashboardConnectionsConnectionIdRoute '/mcp/approve/$requestId': typeof McpApproveRequestIdRoute '/_dashboard/_inventory/': typeof DashboardInventoryIndexRoute '/_dashboard/approvals/': typeof DashboardApprovalsIndexRoute @@ -218,10 +237,12 @@ export interface FileRouteTypes { | '/' | '/app-auth' | '/connect' + | '/connections' | '/account-connect/$connectionId' | '/mcp/authorize' | '/$' | '/approvals/$runId' + | '/connections/$connectionId' | '/mcp/approve/$requestId' | '/approvals/' | '/apps/$appId' @@ -239,10 +260,12 @@ export interface FileRouteTypes { | '/' | '/app-auth' | '/connect' + | '/connections' | '/account-connect/$connectionId' | '/mcp/authorize' | '/$' | '/approvals/$runId' + | '/connections/$connectionId' | '/mcp/approve/$requestId' | '/approvals' | '/apps/$appId' @@ -261,10 +284,12 @@ export interface FileRouteTypes { | '/app-auth' | '/_dashboard/_inventory' | '/_dashboard/connect' + | '/_dashboard/connections' | '/account-connect/$connectionId' | '/mcp/authorize' | '/_dashboard/_inventory/$' | '/_dashboard/approvals/$runId' + | '/_dashboard/connections_/$connectionId' | '/mcp/approve/$requestId' | '/_dashboard/_inventory/' | '/_dashboard/approvals/' @@ -318,6 +343,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardConnectRouteImport parentRoute: typeof DashboardRoute } + '/_dashboard/connections': { + id: '/_dashboard/connections' + path: '/connections' + fullPath: '/connections' + preLoaderRoute: typeof DashboardConnectionsRouteImport + parentRoute: typeof DashboardRoute + } '/account-connect/$connectionId': { id: '/account-connect/$connectionId' path: '/account-connect/$connectionId' @@ -360,6 +392,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardApprovalsRunIdRouteImport parentRoute: typeof DashboardRoute } + '/_dashboard/connections_/$connectionId': { + id: '/_dashboard/connections_/$connectionId' + path: '/connections/$connectionId' + fullPath: '/connections/$connectionId' + preLoaderRoute: typeof DashboardConnectionsConnectionIdRouteImport + parentRoute: typeof DashboardRoute + } '/mcp/approve/$requestId': { id: '/mcp/approve/$requestId' path: '/mcp/approve/$requestId' @@ -476,7 +515,9 @@ const DashboardInventoryRouteWithChildren = interface DashboardRouteChildren { DashboardInventoryRoute: typeof DashboardInventoryRouteWithChildren DashboardConnectRoute: typeof DashboardConnectRoute + DashboardConnectionsRoute: typeof DashboardConnectionsRoute DashboardApprovalsRunIdRoute: typeof DashboardApprovalsRunIdRoute + DashboardConnectionsConnectionIdRoute: typeof DashboardConnectionsConnectionIdRoute DashboardApprovalsIndexRoute: typeof DashboardApprovalsIndexRoute DashboardWebhooksAppIdSubscriptionIdRoute: typeof DashboardWebhooksAppIdSubscriptionIdRoute } @@ -484,7 +525,9 @@ interface DashboardRouteChildren { const DashboardRouteChildren: DashboardRouteChildren = { DashboardInventoryRoute: DashboardInventoryRouteWithChildren, DashboardConnectRoute: DashboardConnectRoute, + DashboardConnectionsRoute: DashboardConnectionsRoute, DashboardApprovalsRunIdRoute: DashboardApprovalsRunIdRoute, + DashboardConnectionsConnectionIdRoute: DashboardConnectionsConnectionIdRoute, DashboardApprovalsIndexRoute: DashboardApprovalsIndexRoute, DashboardWebhooksAppIdSubscriptionIdRoute: DashboardWebhooksAppIdSubscriptionIdRoute, diff --git a/apps/local/web/src/implementation/routes/_dashboard.connections.tsx b/apps/local/web/src/implementation/routes/_dashboard.connections.tsx new file mode 100644 index 0000000000..33d7ed8b69 --- /dev/null +++ b/apps/local/web/src/implementation/routes/_dashboard.connections.tsx @@ -0,0 +1,8 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ConnectionsPage } from "../pages/connections.tsx"; + +/** Generated-tree route for /_dashboard/connections. */ +export const Route = createFileRoute("/_dashboard/connections")({ + staticData: { section: "connect" }, + component: ConnectionsPage, +}); diff --git a/apps/local/web/src/implementation/routes/_dashboard.connections_.$connectionId.tsx b/apps/local/web/src/implementation/routes/_dashboard.connections_.$connectionId.tsx new file mode 100644 index 0000000000..6953f97fca --- /dev/null +++ b/apps/local/web/src/implementation/routes/_dashboard.connections_.$connectionId.tsx @@ -0,0 +1,13 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ConnectionPage } from "../pages/connection.tsx"; + +/** Generated-tree route for /_dashboard/connections_/$connectionId. */ +export const Route = createFileRoute("/_dashboard/connections_/$connectionId")({ + staticData: { section: "connect" }, + component: ConnectionRoute, +}); + +function ConnectionRoute() { + const { connectionId } = Route.useParams(); + return ; +} diff --git a/packages/ui/src/implementation/dashboard/app-accounts.tsx b/packages/ui/src/implementation/dashboard/app-accounts.tsx index 228f48c159..b10ae4329a 100644 --- a/packages/ui/src/implementation/dashboard/app-accounts.tsx +++ b/packages/ui/src/implementation/dashboard/app-accounts.tsx @@ -404,7 +404,7 @@ export function AppAccounts({ } className="min-w-0 space-y-2.5" > -
+
-
+
([]); const normalized = name.toLowerCase().trim(); - const size = large ? 24 : 17; + // Fetched large enough to stay sharp in the biggest tile on a 2x screen. + const fetchSize = 64; const domain = url ?? domains.get(normalized) ?? (/^[a-z\d-]+(?:\.[a-z\d-]+)+$/i.test(normalized) ? normalized : null); - const source = normalized === "executor" ? "/favicon.png" : faviconUrl(domain, size); + const source = normalized === "executor" ? "/favicon.png" : faviconUrl(domain, fetchSize); const icon = source !== null && !failed.includes(source) ? source : null; return ( _svg]:w-4.25 [&_>_svg]:h-4.25 [&_>_svg]:object-contain max-[740px]:[.catalog-row_>_&]:row-[1_/_3]", - large && - "provider-icon-large w-11 h-11 rounded-[9px] [&_img]:w-6 [&_img]:h-6 [&_>_svg]:w-6 [&_>_svg]:h-6", + "provider-icon relative inline-flex size-8.5 shrink-0 items-center justify-center overflow-hidden rounded-[8px] max-[740px]:[.catalog-row_>_&]:row-[1_/_3]", + large && "provider-icon-large size-11 rounded-[10px]", + // A logo fills its tile edge to edge; the hairline keeps white logos visible on light pages. + icon + ? "bg-white after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:ring-1 after:ring-foreground/10 after:ring-inset [&_img]:size-full [&_img]:object-cover" + : "border border-border bg-background [&_>_svg]:size-4.25", + !icon && large && "[&_>_svg]:size-6", )} aria-hidden > @@ -42,8 +47,8 @@ export function ProviderIcon({ setFailed((current) => (current.includes(icon) ? current : [...current, icon])) diff --git a/packages/ui/src/implementation/dashboard/connection-app-picker.tsx b/packages/ui/src/implementation/dashboard/connection-app-picker.tsx index 305086c324..796edb1c52 100644 --- a/packages/ui/src/implementation/dashboard/connection-app-picker.tsx +++ b/packages/ui/src/implementation/dashboard/connection-app-picker.tsx @@ -149,6 +149,7 @@ export function ConnectionAppRow({ onExpandedChange, onChange, renderTools, + renderConnectAccount, }: { readonly choices: AppChoices; readonly selection: ConnectionApp | undefined; @@ -156,6 +157,8 @@ export function ConnectionAppRow({ readonly onExpandedChange: (expanded: boolean) => void; readonly onChange: (selection: ConnectionApp | undefined) => void; readonly renderTools: (props: ConnectionToolPickerProps) => ReactNode; + /** A host link to connect an account, shown when the app has none yet. */ + readonly renderConnectAccount?: (app: App) => ReactNode; }) { const id = useId(); const { app } = choices; @@ -193,17 +196,27 @@ export function ConnectionAppRow({ issue === undefined ? "text-muted-foreground" : "text-amber-600 dark:text-amber-400" }`} > - {issue ?? - (!choices.needsAccount - ? "No account needed" - : choices.options.length === 0 - ? "No connected accounts" - : only !== undefined - ? connectionTargetLabel(only.target) - : `${choices.options.length} ways to run`)} + {renderConnectAccount !== undefined && + choices.needsAccount && + choices.options.length === 0 + ? "No connected accounts" + : (issue ?? + (!choices.needsAccount + ? "No account needed" + : choices.options.length === 0 + ? "No connected accounts" + : only !== undefined + ? connectionTargetLabel(only.target) + : `${choices.options.length} ways to run`))} + {renderConnectAccount !== undefined && + choices.needsAccount && + choices.options.length === 0 && ( + // Outside the label so following the link never toggles the app. + {renderConnectAccount(app)} + )} {selection !== undefined && (
{choices.needsAccount && ( @@ -375,15 +388,8 @@ function AccountsPicker({ }) { const selected = new Set(selection.targets.map(connectionTargetKey)); const only = choices.options.length === 1 ? choices.options[0] : undefined; - if (only !== undefined && selected.has(connectionTargetKey(only.target))) - return ( - - {connectionTargetLabel(only.target)} - - ); + // The row's subtitle already names the only way to run it. + if (only !== undefined && selected.has(connectionTargetKey(only.target))) return null; const label = selection.targets.length === 0 ? "Runs as…" diff --git a/packages/ui/src/implementation/dashboard/scoped-connections.tsx b/packages/ui/src/implementation/dashboard/scoped-connections.tsx index 52d4b433c4..95d5ac0d3d 100644 --- a/packages/ui/src/implementation/dashboard/scoped-connections.tsx +++ b/packages/ui/src/implementation/dashboard/scoped-connections.tsx @@ -420,7 +420,7 @@ function ConnectionList({ ); } -function SearchInput({ +export function SearchInput({ value, onChange, placeholder, @@ -491,7 +491,7 @@ function Tabs({ } /** Bulk-including apps leaves each waiting for an account; assign one account to all that accept it. */ -function AssignAccount({ +export function AssignAccount({ apps, byId, accounts,