From a4c7121b93b3c6824883d58adecd7746f36b296f Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 18 Aug 2026 14:19:50 -0400 Subject: [PATCH] Pass 2 showing the usage commands --- messages/en-US.json | 2 + .../private/[niceId]/general/page.tsx | 2 +- .../public/[niceId]/general/page.tsx | 2 +- src/components/UserVirtualApiKeys.tsx | 22 +-- .../ai-client-config/AiClientConfigCard.tsx | 171 +++++++++++++----- .../AiClientConfigSection.tsx | 70 ++++--- .../ai-client-config/AiConfigCodeBlock.tsx | 1 - .../LauncherResourcePanel.tsx | 5 - .../resource-launcher/ResourceLauncher.tsx | 61 ++++++- src/lib/aiClientConfig.ts | 74 ++++---- 10 files changed, 275 insertions(+), 135 deletions(-) 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")} +

+ +
+ ) : 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); }