diff --git a/messages/en-US.json b/messages/en-US.json
index a4dec1b17..e0d18f68d 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -1789,6 +1789,8 @@
"aiClientConfigTabCli": "Automatic (CLI)",
"aiClientConfigTabManual": "Manual Configuration",
"aiClientConfigEndpointPlaceholder": "https://example.resource.url.com",
+ "aiClientConfigRevealError": "Could not load your API key.",
+ "aiClientConfigRevealRetry": "Try again",
"resourceGeneralAiClientConfigLink": "See how to configure access to this resource in common clients like Claude Code and OpenCode",
"aiProvidersTitle": "AI Providers",
"aiProvidersDescription": "Connect model providers for AI workloads in this organization",
diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx
index 37c69ec1a..c56644a9e 100644
--- a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx
+++ b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx
@@ -74,7 +74,7 @@ export default function PrivateResourceGeneralPage() {
{siteResource.mode === "inference" ? (
{t("resourceGeneralAiClientConfigLink")}
diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx
index d4d32b044..af47de7bf 100644
--- a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx
+++ b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx
@@ -259,7 +259,7 @@ export default function GeneralForm() {
{resource.mode === "inference" ? (
{t("resourceGeneralAiClientConfigLink")}
diff --git a/src/components/UserVirtualApiKeys.tsx b/src/components/UserVirtualApiKeys.tsx
index 9b2c9ba7b..fa0bf0eb8 100644
--- a/src/components/UserVirtualApiKeys.tsx
+++ b/src/components/UserVirtualApiKeys.tsx
@@ -171,10 +171,6 @@ export default function UserVirtualApiKeys({
orgId,
initialData.userKey.virtualApiKeyId
);
- const keyPreview = formatVirtualApiKeyPreview(
- initialData.userKey.virtualApiKeyId,
- initialData.userKey.lastChars
- );
return (
<>
@@ -186,15 +182,6 @@ export default function UserVirtualApiKeys({
resourceName={resourceName}
/>
-
-
{initialData.manualKeys.length > 0 ? (
@@ -227,6 +214,15 @@ export default function UserVirtualApiKeys({
) : null}
+
+
>
);
diff --git a/src/components/ai-client-config/AiClientConfigCard.tsx b/src/components/ai-client-config/AiClientConfigCard.tsx
index 0bda5f40b..08f0b22d1 100644
--- a/src/components/ai-client-config/AiClientConfigCard.tsx
+++ b/src/components/ai-client-config/AiClientConfigCard.tsx
@@ -1,6 +1,7 @@
"use client";
import { AiConfigCodeBlock } from "@app/components/ai-client-config/AiConfigCodeBlock";
+import { Button } from "@app/components/ui/button";
import {
Collapsible,
CollapsibleContent,
@@ -19,35 +20,80 @@ import {
TabsList,
TabsTrigger
} from "@app/components/ui/tabs";
-import type { AiClientGuide, AiClientPresetId } from "@app/lib/aiClientConfig";
+import type {
+ AiClientAuthInput,
+ AiClientId,
+ AiClientPresetId
+} from "@app/lib/aiClientConfig";
+import { buildAiClientGuide } from "@app/lib/aiClientConfig";
import { cn } from "@app/lib/cn";
-import { ChevronDown, type LucideIcon } from "lucide-react";
+import { ChevronDown, Loader2, type LucideIcon } from "lucide-react";
import { useTranslations } from "next-intl";
-import { useState } from "react";
+import { useMemo, useState } from "react";
type AiClientConfigCardProps = {
- guide: AiClientGuide;
+ clientId: AiClientId;
+ name: string;
+ endpoint: string;
+ keyAuth: AiClientAuthInput;
description: string;
icon: LucideIcon;
- defaultOpen?: boolean;
+ stackBlocks?: boolean;
};
export function AiClientConfigCard({
- guide,
+ clientId,
+ name,
+ endpoint,
+ keyAuth,
description,
icon: Icon,
- defaultOpen = false
+ stackBlocks = true
}: AiClientConfigCardProps) {
const t = useTranslations();
- const [open, setOpen] = useState(defaultOpen);
- const [presetId, setPresetId] = useState(
- guide.presets[0]?.id ?? "default"
- );
+ const [open, setOpen] = useState(false);
+ const [presetId, setPresetId] = useState("default");
+ const [revealedKey, setRevealedKey] = useState(null);
+ const [revealing, setRevealing] = useState(false);
+ const [revealError, setRevealError] = useState(false);
+
+ const reveal = () => {
+ if (keyAuth.mode !== "keyed" || revealedKey !== null || revealing) {
+ return;
+ }
+ setRevealing(true);
+ setRevealError(false);
+ keyAuth
+ .getKeyText()
+ .then(setRevealedKey)
+ .catch(() => setRevealError(true))
+ .finally(() => setRevealing(false));
+ };
+
+ const handleOpenChange = (next: boolean) => {
+ setOpen(next);
+ if (next) {
+ reveal();
+ }
+ };
+
+ const guide = useMemo(() => {
+ if (keyAuth.mode === "keyless") {
+ return buildAiClientGuide(clientId, endpoint, { mode: "keyless" });
+ }
+ if (revealedKey === null) {
+ return null;
+ }
+ return buildAiClientGuide(clientId, endpoint, {
+ mode: "keyed",
+ key: revealedKey
+ });
+ }, [clientId, endpoint, keyAuth.mode, revealedKey]);
const preset =
- guide.presets.find((p) => p.id === presetId) ?? guide.presets[0];
+ guide?.presets.find((p) => p.id === presetId) ?? guide?.presets[0];
- const manualContent = (
+ const manualContent = guide ? (
{guide.presets.length > 1 ? (
) : null}
-
+
{preset?.blocks.map((block) => (
))}
- );
+ ) : null;
return (
-
{guide.name}
+
{name}
{description}
@@ -98,40 +149,62 @@ export function AiClientConfigCard({
/>
- {guide.cli ? (
-
-
-
- {t("aiClientConfigTabCli")}
-
-
- {t("aiClientConfigTabManual")}
-
-
-
-
-
- {guide.cli.configureWithKey ? (
+ {!guide && revealing ? (
+
+
+
+ ) : null}
+ {!guide && revealError ? (
+
+
+ {t("aiClientConfigRevealError")}
+
+
+ {t("aiClientConfigRevealRetry")}
+
+
+ ) : null}
+ {guide ? (
+ guide.cli ? (
+
+
+
+ {t("aiClientConfigTabCli")}
+
+
+ {t("aiClientConfigTabManual")}
+
+
+
- ) : null}
- {guide.cli.runWithKey ? (
-
- ) : null}
-
-
- {manualContent}
-
-
- ) : (
- manualContent
- )}
+
+ {guide.cli.configureWithKey ? (
+
+ ) : null}
+ {guide.cli.runWithKey ? (
+
+ ) : null}
+
+
+ {manualContent}
+
+
+ ) : (
+ manualContent
+ )
+ ) : null}
);
diff --git a/src/components/ai-client-config/AiClientConfigSection.tsx b/src/components/ai-client-config/AiClientConfigSection.tsx
index 4b208b885..77be92f09 100644
--- a/src/components/ai-client-config/AiClientConfigSection.tsx
+++ b/src/components/ai-client-config/AiClientConfigSection.tsx
@@ -8,37 +8,43 @@ import {
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
-import type { AiClientAuth } from "@app/lib/aiClientConfig";
-import { buildAiClientGuides } from "@app/lib/aiClientConfig";
+import {
+ AI_CLIENT_IDS,
+ AI_CLIENT_NAMES,
+ type AiClientAuthInput
+} from "@app/lib/aiClientConfig";
import { cn } from "@app/lib/cn";
import { MousePointerClick, Sparkles, SquareTerminal, TerminalSquare } from "lucide-react";
import { useTranslations } from "next-intl";
-import { useMemo } from "react";
type AiClientConfigSectionProps = {
endpoint: string;
- auth: AiClientAuth;
+ auth: AiClientAuthInput;
+ /**
+ * "wide" lays the client cards out side by side and allows a card's
+ * code blocks to sit side by side once there's room (e.g. the Keys
+ * page). "compact" always stacks both, which is what fits the
+ * Resource Launcher's side panel.
+ */
+ layout?: "wide" | "compact";
className?: string;
};
+const CLIENT_ICONS = {
+ claude: Sparkles,
+ codex: TerminalSquare,
+ opencode: SquareTerminal,
+ cursor: MousePointerClick
+} as const;
+
export function AiClientConfigSection({
endpoint,
auth,
+ layout = "compact",
className
}: AiClientConfigSectionProps) {
const t = useTranslations();
-
- const guides = useMemo(
- () => buildAiClientGuides(endpoint, auth),
- [endpoint, auth]
- );
-
- const icons = {
- claude: Sparkles,
- codex: TerminalSquare,
- opencode: SquareTerminal,
- cursor: MousePointerClick
- } as const;
+ const isWide = layout === "wide";
const descriptions: Record
= {
claude: t("aiClientConfigDescriptionClaude"),
@@ -58,18 +64,26 @@ export function AiClientConfigSection({
-
- {guides.map((guide, index) => (
-
- ))}
+
+
+ {AI_CLIENT_IDS.map((clientId) => (
+
+ ))}
+
diff --git a/src/components/ai-client-config/AiConfigCodeBlock.tsx b/src/components/ai-client-config/AiConfigCodeBlock.tsx
index db2d2658d..c4021a8c4 100644
--- a/src/components/ai-client-config/AiConfigCodeBlock.tsx
+++ b/src/components/ai-client-config/AiConfigCodeBlock.tsx
@@ -18,7 +18,6 @@ export function AiConfigCodeBlock({ block }: { block: AiConfigBlock }) {
>
diff --git a/src/components/resource-launcher/LauncherResourcePanel.tsx b/src/components/resource-launcher/LauncherResourcePanel.tsx
index e8d7398d6..83f4ac14f 100644
--- a/src/components/resource-launcher/LauncherResourcePanel.tsx
+++ b/src/components/resource-launcher/LauncherResourcePanel.tsx
@@ -36,7 +36,6 @@ import {
import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref";
import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess";
import { launcherQueries } from "@app/lib/queries";
-import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
import type { LauncherResource } from "@server/routers/launcher/types";
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
import type { GetResourceResponse } from "@server/routers/resource/getResource";
@@ -350,10 +349,6 @@ function PublicResourceDetails({
endpoint={launcherResource.accessUrl ?? ""}
auth={{
mode: "keyed",
- keyDisplay: formatVirtualApiKeyPreview(
- aiKeysData.userKey.virtualApiKeyId,
- aiKeysData.userKey.lastChars
- ),
getKeyText: getAiKeyCopyText
}}
/>
diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx
index f675bf4c6..bf65779e3 100644
--- a/src/components/resource-launcher/ResourceLauncher.tsx
+++ b/src/components/resource-launcher/ResourceLauncher.tsx
@@ -27,6 +27,7 @@ import {
parseLauncherUrlState,
serializeLauncherUrlState
} from "@app/lib/launcherUrlState";
+import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
import { useToast } from "@app/hooks/useToast";
import { useEnvContext } from "@app/hooks/useEnvContext";
import {
@@ -38,13 +39,16 @@ import {
import { launcherQueries } from "@app/lib/queries";
import {
getEffectiveDefaultLauncherConfig,
+ LAUNCHER_FLAT_GROUP_KEY,
type LauncherDefaultViewOverrides,
type LauncherGroup,
type LauncherResource,
type LauncherScaleInfo,
type LauncherViewConfig,
- type LauncherViewRecord
+ type LauncherViewRecord,
+ type ListLauncherResourcesResponse
} from "@server/routers/launcher/types";
+import type { AxiosResponse } from "axios";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Search } from "lucide-react";
import { useTranslations } from "next-intl";
@@ -580,6 +584,61 @@ export default function ResourceLauncher({
}
}, []);
+ const hasHandledAutoOpen = useRef(false);
+
+ useEffect(() => {
+ if (hasHandledAutoOpen.current) {
+ return;
+ }
+
+ const targetNiceId = searchParams.get("openResource");
+ if (!targetNiceId) {
+ return;
+ }
+
+ // The launcher search endpoint matches on name/domain/labels, not
+ // niceId, so search by name (if provided) and pick the exact niceId
+ // match out of the results.
+ const searchTerm =
+ searchParams.get("openResourceQuery") ?? targetNiceId;
+
+ hasHandledAutoOpen.current = true;
+
+ (async () => {
+ try {
+ const sp = buildLauncherSearchParams(
+ {
+ query: searchTerm,
+ groupBy: configRef.current.groupBy,
+ groupKey: LAUNCHER_FLAT_GROUP_KEY,
+ siteIds: [],
+ labelIds: [],
+ sort_by: configRef.current.sortBy,
+ order: configRef.current.order
+ },
+ 1
+ );
+ const res = await api.get<
+ AxiosResponse
+ >(`/org/${orgId}/launcher/resources?${sp.toString()}`);
+ const resources = res.data.data.resources ?? [];
+ const match =
+ resources.find((r) => r.niceId === targetNiceId) ??
+ resources[0];
+ if (match) {
+ handleResourceSelect(match);
+ }
+ } catch {
+ // Resource may no longer exist or be accessible; ignore.
+ } finally {
+ const params = new URLSearchParams(searchParams.toString());
+ params.delete("openResource");
+ params.delete("openResourceQuery");
+ navigate({ searchParams: params, replace: true });
+ }
+ })();
+ }, [api, handleResourceSelect, navigate, orgId, searchParams]);
+
const savedViewTabs = views.map((view) => ({
viewId: view.viewId,
name: view.name
diff --git a/src/lib/aiClientConfig.ts b/src/lib/aiClientConfig.ts
index 1d7ade034..6420bd8de 100644
--- a/src/lib/aiClientConfig.ts
+++ b/src/lib/aiClientConfig.ts
@@ -1,16 +1,26 @@
export const AI_CLIENT_IDS = ["claude", "codex", "opencode", "cursor"] as const;
export type AiClientId = (typeof AI_CLIENT_IDS)[number];
-export type AiClientAuth =
- | { mode: "keyed"; keyDisplay: string; getKeyText: () => Promise }
+export const AI_CLIENT_NAMES: Record = {
+ claude: "Claude Code",
+ codex: "Codex",
+ opencode: "OpenCode",
+ cursor: "Cursor"
+};
+
+/** Auth as supplied by callers: the real key isn't fetched yet. */
+export type AiClientAuthInput =
+ | { mode: "keyed"; getKeyText: () => Promise }
| { mode: "keyless" };
+/** Auth once the real key (if any) has been resolved. */
+export type AiClientAuth = { mode: "keyed"; key: string } | { mode: "keyless" };
+
export type AiConfigBlock = {
id: string;
label: string;
kind?: "code" | "steps";
displayText: string;
- getCopyText?: () => Promise;
};
export type AiClientPresetId = "default" | "bedrock" | "vertex" | "kimi";
@@ -35,14 +45,8 @@ export type AiClientGuide = {
presets: AiConfigPreset[];
};
-function authValue(auth: AiClientAuth): {
- display: string;
- getCopyText?: () => Promise;
-} {
- if (auth.mode === "keyed") {
- return { display: auth.keyDisplay, getCopyText: auth.getKeyText };
- }
- return { display: "-" };
+function keyValue(auth: AiClientAuth): string {
+ return auth.mode === "keyed" ? auth.key : "-";
}
function block(
@@ -52,14 +56,7 @@ function block(
auth: AiClientAuth,
kind: "code" | "steps" = "code"
): AiConfigBlock {
- const { display, getCopyText } = authValue(auth);
- return {
- id,
- label,
- kind,
- displayText: build(display),
- getCopyText: getCopyText ? async () => build(await getCopyText()) : undefined
- };
+ return { id, label, kind, displayText: build(keyValue(auth)) };
}
function buildCli(clientArg: "claude" | "codex", auth: AiClientAuth): AiCliCommands {
@@ -84,16 +81,12 @@ function buildCli(clientArg: "claude" | "codex", auth: AiClientAuth): AiCliComma
configureWithKey: {
id: `cli-configure-key-${clientArg}`,
label: "Configure with an API key",
- displayText: `pangolin configure ${clientArg} ${auth.keyDisplay}`,
- getCopyText: async () =>
- `pangolin configure ${clientArg} ${await auth.getKeyText()}`
+ displayText: `pangolin configure ${clientArg} ${auth.key}`
},
runWithKey: {
id: `cli-run-key-${clientArg}`,
label: "Run with an API key",
- displayText: `pangolin run ${clientArg} ${auth.keyDisplay}`,
- getCopyText: async () =>
- `pangolin run ${clientArg} ${await auth.getKeyText()}`
+ displayText: `pangolin run ${clientArg} ${auth.key}`
}
};
}
@@ -184,7 +177,7 @@ function buildClaudeGuide(endpoint: string, auth: AiClientAuth): AiClientGuide {
return {
id: "claude",
- name: "Claude Code",
+ name: AI_CLIENT_NAMES.claude,
cli: buildCli("claude", auth),
presets: [
{
@@ -240,7 +233,7 @@ function buildCodexGuide(endpoint: string, auth: AiClientAuth): AiClientGuide {
return {
id: "codex",
- name: "Codex",
+ name: AI_CLIENT_NAMES.codex,
cli: buildCli("codex", auth),
presets: [
{
@@ -284,7 +277,7 @@ function buildOpencodeGuide(endpoint: string, auth: AiClientAuth): AiClientGuide
return {
id: "opencode",
- name: "OpenCode",
+ name: AI_CLIENT_NAMES.opencode,
cli: null,
presets: [
{
@@ -316,7 +309,7 @@ function buildCursorGuide(endpoint: string, auth: AiClientAuth): AiClientGuide {
return {
id: "cursor",
- name: "Cursor",
+ name: AI_CLIENT_NAMES.cursor,
cli: null,
presets: [
{
@@ -328,11 +321,20 @@ function buildCursorGuide(endpoint: string, auth: AiClientAuth): AiClientGuide {
};
}
-export function buildAiClientGuides(endpoint: string, auth: AiClientAuth): AiClientGuide[] {
- return [
- buildClaudeGuide(endpoint, auth),
- buildCodexGuide(endpoint, auth),
- buildOpencodeGuide(endpoint, auth),
- buildCursorGuide(endpoint, auth)
- ];
+const GUIDE_BUILDERS: Record<
+ AiClientId,
+ (endpoint: string, auth: AiClientAuth) => AiClientGuide
+> = {
+ claude: buildClaudeGuide,
+ codex: buildCodexGuide,
+ opencode: buildOpencodeGuide,
+ cursor: buildCursorGuide
+};
+
+export function buildAiClientGuide(
+ clientId: AiClientId,
+ endpoint: string,
+ auth: AiClientAuth
+): AiClientGuide {
+ return GUIDE_BUILDERS[clientId](endpoint, auth);
}