| @@ -1,6 +1,6 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | | −import { useRef, useState } from "react"; |
| 3 | +import { useEffect, useMemo, useState } from "react"; |
| 4 | 4 | import { |
| 5 | 5 | Wrench, |
| 6 | 6 | Plus, |
| @@ -11,7 +11,6 @@ import { · 4 unmodified lines |
| 11 | 11 | AlertTriangle, |
| 12 | 12 | KeyRound, |
| 13 | 13 | Pencil, |
| 14 | | − Loader2, |
| 15 | 14 | } from "lucide-react"; |
| 16 | 15 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; |
| 17 | 16 | import { Button } from "@/components/ui/button"; |
| @@ -52,6 +51,7 @@ import type { · 34 unmodified lines |
| 52 | 51 | ToolSourceRecord, |
| 53 | 52 | CredentialRecord, |
| 54 | 53 | CredentialScope, |
| 54 | + SourceAuthProfile, |
| 55 | 55 | OpenApiSourceQuality, |
| 56 | 56 | } from "@/lib/types"; |
| 57 | 57 | import { parse as parseDomain } from "tldts"; |
| @@ -69,15 +69,108 @@ interface CatalogCollectionItem { · 11 unmodified lines |
| 69 | 69 | logoUrl?: string; |
| 70 | 70 | categories?: string; |
| 71 | 71 | version?: string; |
| 72 | + rank: number; |
| 73 | + addedAt: string; |
| 72 | 74 | } |
| 73 | 75 | |
| 74 | | −interface CatalogCollectionsResponse { |
| 75 | | − items?: CatalogCollectionItem[]; |
| 76 | | − totalCount?: number; |
| 77 | | − hasMore?: boolean; |
| 78 | | − error?: string; |
| 79 | | − detail?: string; |
| 80 | | −} |
| 76 | +const HARD_CODED_CATALOG_ITEMS: CatalogCollectionItem[] = [ |
| 77 | + { |
| 78 | + id: "github-rest", |
| 79 | + name: "GitHub REST API", |
| 80 | + summary: "Manage repositories, pull requests, issues, and org settings.", |
| 81 | + specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml", |
| 82 | + originUrl: "https://docs.github.com/en/rest", |
| 83 | + providerName: "GitHub", |
| 84 | + categories: "developer-tools", |
| 85 | + version: "latest", |
| 86 | + rank: 1, |
| 87 | + addedAt: "2026-01-10", |
| 88 | + }, |
| 89 | + { |
| 90 | + id: "stripe-api", |
| 91 | + name: "Stripe API", |
| 92 | + summary: "Create payments, manage customers, and handle billing workflows.", |
| 93 | + specUrl: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json", |
| 94 | + originUrl: "https://docs.stripe.com/api", |
| 95 | + providerName: "Stripe", |
| 96 | + categories: "payments", |
| 97 | + version: "2026-01", |
| 98 | + rank: 2, |
| 99 | + addedAt: "2026-01-08", |
| 100 | + }, |
| 101 | + { |
| 102 | + id: "openai-api", |
| 103 | + name: "OpenAI API", |
| 104 | + summary: "Generate text, run reasoning models, and process multimodal inputs.", |
| 105 | + specUrl: "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml", |
| 106 | + originUrl: "https://platform.openai.com/docs/api-reference", |
| 107 | + providerName: "OpenAI", |
| 108 | + categories: "ai", |
| 109 | + version: "latest", |
| 110 | + rank: 3, |
| 111 | + addedAt: "2026-01-06", |
| 112 | + }, |
| 113 | + { |
| 114 | + id: "cloudflare-api", |
| 115 | + name: "Cloudflare API", |
| 116 | + summary: "Control zones, DNS records, workers, and edge configuration.", |
| 117 | + specUrl: "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml", |
| 118 | + originUrl: "https://api.cloudflare.com/", |
| 119 | + providerName: "Cloudflare", |
| 120 | + categories: "infrastructure", |
| 121 | + version: "latest", |
| 122 | + rank: 4, |
| 123 | + addedAt: "2026-01-04", |
| 124 | + }, |
| 125 | + { |
| 126 | + id: "vercel-api", |
| 127 | + name: "Vercel API", |
| 128 | + summary: "Manage deployments, projects, domains, and team resources.", |
| 129 | + specUrl: "https://openapi.vercel.sh", |
| 130 | + originUrl: "https://vercel.com/docs/rest-api", |
| 131 | + providerName: "Vercel", |
| 132 | + categories: "developer-tools", |
| 133 | + version: "latest", |
| 134 | + rank: 5, |
| 135 | + addedAt: "2025-12-18", |
| 136 | + }, |
| 137 | + { |
| 138 | + id: "slack-api", |
| 139 | + name: "Slack API", |
| 140 | + summary: "Work with channels, messages, users, and workspace automation.", |
| 141 | + specUrl: "https://api.slack.com/specs/openapi/v2/slack_web.json", |
| 142 | + originUrl: "https://api.slack.com/web", |
| 143 | + providerName: "Slack", |
| 144 | + categories: "communications", |
| 145 | + version: "v2", |
| 146 | + rank: 6, |
| 147 | + addedAt: "2025-12-10", |
| 148 | + }, |
| 149 | + { |
| 150 | + id: "sentry-api", |
| 151 | + name: "Sentry API", |
| 152 | + summary: "Query issues, releases, projects, and alerting configuration.", |
| 153 | + specUrl: "https://raw.githubusercontent.com/getsentry/sentry-api-schema/refs/heads/main/openapi-derefed.json", |
| 154 | + originUrl: "https://docs.sentry.io/api/", |
| 155 | + providerName: "Sentry", |
| 156 | + categories: "observability", |
| 157 | + version: "latest", |
| 158 | + rank: 7, |
| 159 | + addedAt: "2025-11-30", |
| 160 | + }, |
| 161 | + { |
| 162 | + id: "jira-cloud-api", |
| 163 | + name: "Jira Cloud Platform", |
| 164 | + summary: "Manage projects, issues, workflows, and Jira metadata.", |
| 165 | + specUrl: "https://developer.atlassian.com/cloud/jira/platform/swagger-v3.v3.json", |
| 166 | + originUrl: "https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/", |
| 167 | + providerName: "Atlassian", |
| 168 | + categories: "project-management", |
| 169 | + version: "v3", |
| 170 | + rank: 8, |
| 171 | + addedAt: "2025-11-15", |
| 172 | + }, |
| 173 | +]; |
| 81 | 174 | |
| 82 | 175 | /** Derive a favicon URL from any URL string via Google's favicon service. */ |
| 83 | 176 | function faviconForUrl(url: string | undefined | null): string | null { |
| @@ -141,45 +234,94 @@ function sourceForCredentialKey(sources: ToolSourceRecord[], sourceKey: string): · 57 unmodified lines |
| 141 | 234 | return sources.find((source) => source.id === sourceId) ?? null; |
| 142 | 235 | } |
| 143 | 236 | |
| 144 | | −type SourceAuthType = "none" | "bearer" | "apiKey" | "basic"; |
| 237 | +type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed"; |
| 145 | 238 | type SourceAuthMode = "workspace" | "actor"; |
| 146 | 239 | |
| 147 | | −function readSourceAuth(source: ToolSourceRecord): { |
| 240 | +function normalizeSourceAuthProfile(profile: SourceAuthProfile | undefined): { |
| 241 | + type: SourceAuthType; |
| 242 | + mode?: SourceAuthMode; |
| 243 | + header?: string; |
| 244 | + inferred?: boolean; |
| 245 | +} { |
| 246 | + if (!profile) { |
| 247 | + return { type: "none" }; |
| 248 | + } |
| 249 | + |
| 250 | + const type = profile.type === "mixed" |
| 251 | + ? "mixed" |
| 252 | + : profile.type === "basic" |
| 253 | + ? "basic" |
| 254 | + : profile.type === "apiKey" |
| 255 | + ? "apiKey" |
| 256 | + : profile.type === "bearer" |
| 257 | + ? "bearer" |
| 258 | + : "none"; |
| 259 | + |
| 260 | + const mode = profile.mode === "actor" ? "actor" : profile.mode === "workspace" ? "workspace" : undefined; |
| 261 | + const header = typeof profile.header === "string" && profile.header.trim().length > 0 |
| 262 | + ? profile.header.trim() |
| 263 | + : undefined; |
| 264 | + |
| 265 | + return { |
| 266 | + type, |
| 267 | + ...(mode ? { mode } : {}), |
| 268 | + ...(header ? { header } : {}), |
| 269 | + inferred: Boolean(profile.inferred), |
| 270 | + }; |
| 271 | +} |
| 272 | + |
| 273 | +function readSourceAuth( |
| 274 | + source: ToolSourceRecord, |
| 275 | + inferredProfile?: SourceAuthProfile, |
| 276 | +): { |
| 148 | 277 | type: SourceAuthType; |
| 149 | 278 | mode?: SourceAuthMode; |
| 150 | 279 | header?: string; |
| 280 | + inferred?: boolean; |
| 151 | 281 | } { |
| 152 | 282 | if (source.type !== "openapi" && source.type !== "graphql") { |
| 153 | 283 | return { type: "none" }; |
| 154 | 284 | } |
| 155 | 285 | |
| 286 | + const inferred = normalizeSourceAuthProfile(inferredProfile); |
| 287 | + |
| 156 | 288 | const auth = source.config.auth as Record<string, unknown> | undefined; |
| 157 | 289 | const type = |
| 158 | | − auth && typeof auth.type === "string" && ["none", "bearer", "apiKey", "basic"].includes(auth.type) |
| 290 | + auth && typeof auth.type === "string" && ["none", "bearer", "apiKey", "basic", "mixed"].includes(auth.type) |
| 159 | 291 | ? (auth.type as SourceAuthType) |
| 160 | | − : "none"; |
| 292 | + : inferred.type; |
| 161 | 293 | |
| 162 | 294 | const mode = |
| 163 | 295 | auth && typeof auth.mode === "string" && (auth.mode === "workspace" || auth.mode === "actor") |
| 164 | 296 | ? (auth.mode as SourceAuthMode) |
| 165 | | − : undefined; |
| 297 | + : inferred.mode; |
| 166 | 298 | |
| 167 | 299 | const header = auth && typeof auth.header === "string" && auth.header.trim().length > 0 |
| 168 | 300 | ? auth.header.trim() |
| 169 | | − : undefined; |
| 301 | + : inferred.header; |
| 170 | 302 | |
| 171 | 303 | return { |
| 172 | 304 | type, |
| 173 | 305 | ...(mode ? { mode } : {}), |
| 174 | 306 | ...(header ? { header } : {}), |
| 307 | + inferred: auth?.type === undefined ? inferred.inferred : false, |
| 175 | 308 | }; |
| 176 | 309 | } |
| 177 | 310 | |
| 178 | | −function formatSourceAuthBadge(source: ToolSourceRecord): string | null { |
| 179 | | − const auth = readSourceAuth(source); |
| 311 | +function formatSourceAuthBadge(source: ToolSourceRecord, inferredProfile?: SourceAuthProfile): string | null { |
| 312 | + const auth = readSourceAuth(source, inferredProfile); |
| 180 | 313 | if (auth.type === "none") return null; |
| 314 | + if (auth.type === "mixed") return "Mixed auth"; |
| 181 | 315 | const mode = auth.mode ?? "workspace"; |
| 182 | | − return `${auth.type}:${mode}`; |
| 316 | + const authLabel = |
| 317 | + auth.type === "apiKey" |
| 318 | + ? "API Key" |
| 319 | + : auth.type === "bearer" |
| 320 | + ? "Bearer" |
| 321 | + : auth.type === "basic" |
| 322 | + ? "Basic" |
| 323 | + : "Auth"; |
| 324 | + return `${authLabel} · ${mode === "actor" ? "user" : "workspace"}`; |
| 183 | 325 | } |
| 184 | 326 | |
| 185 | 327 | function credentialStatsForSource(source: ToolSourceRecord, credentials: CredentialRecord[]): { |
| @@ -206,14 +348,62 @@ function formatQualityPercent(value: number): string { · 20 unmodified lines |
| 206 | 348 | return `${Math.round(value * 100)}%`; |
| 207 | 349 | } |
| 208 | 350 | |
| 209 | | −function qualityBadgeClass(quality: OpenApiSourceQuality): string { |
| 351 | +function qualityToneClass(quality: OpenApiSourceQuality): string { |
| 210 | 352 | if (quality.overallQuality >= 0.95) { |
| 211 | | − return "text-terminal-green border-terminal-green/30"; |
| 353 | + return "text-terminal-green"; |
| 212 | 354 | } |
| 213 | 355 | if (quality.overallQuality >= 0.85) { |
| 214 | | − return "text-terminal-amber border-terminal-amber/30"; |
| 356 | + return "text-terminal-amber"; |
| 357 | + } |
| 358 | + return "text-terminal-red"; |
| 359 | +} |
| 360 | + |
| 361 | +function qualitySummaryLabel(quality: OpenApiSourceQuality): string { |
| 362 | + if (quality.overallQuality >= 0.95) { |
| 363 | + return "strong typing"; |
| 364 | + } |
| 365 | + if (quality.overallQuality >= 0.85) { |
| 366 | + return "mostly typed"; |
| 367 | + } |
| 368 | + return "needs type cleanup"; |
| 369 | +} |
| 370 | + |
| 371 | +function displaySourceName(name: string): string { |
| 372 | + const parts = name.split(/[-_.]+/).filter(Boolean); |
| 373 | + if (parts.length === 0) return name; |
| 374 | + |
| 375 | + const deduped = parts.filter((part, index, all) => { |
| 376 | + if (index === 0) return true; |
| 377 | + return part.toLowerCase() !== all[index - 1]?.toLowerCase(); |
| 378 | + }); |
| 379 | + |
| 380 | + const tokenMap: Record<string, string> = { |
| 381 | + api: "API", |
| 382 | + oauth: "OAuth", |
| 383 | + graphql: "GraphQL", |
| 384 | + mcp: "MCP", |
| 385 | + github: "GitHub", |
| 386 | + }; |
| 387 | + |
| 388 | + return deduped |
| 389 | + .map((token) => { |
| 390 | + const lower = token.toLowerCase(); |
| 391 | + if (tokenMap[lower]) return tokenMap[lower]; |
| 392 | + return `${lower[0]?.toUpperCase() ?? ""}${lower.slice(1)}`; |
| 393 | + }) |
| 394 | + .join(" "); |
| 395 | +} |
| 396 | + |
| 397 | +function compactEndpointLabel(source: ToolSourceRecord): string { |
| 398 | + const endpoint = sourceEndpointLabel(source); |
| 399 | + if (endpoint.startsWith("catalog:")) return endpoint; |
| 400 | + try { |
| 401 | + const parsed = new URL(endpoint); |
| 402 | + const path = parsed.pathname === "/" ? "" : parsed.pathname; |
| 403 | + return `${parsed.hostname}${path}`; |
| 404 | + } catch { |
| 405 | + return endpoint; |
| 215 | 406 | } |
| 216 | | − return "text-terminal-red border-terminal-red/30"; |
| 217 | 407 | } |
| 218 | 408 | |
| 219 | 409 | // ── Add Source Dialog ── |
| @@ -284,8 +474,10 @@ function catalogSourceName(item: CatalogCollectionItem): string { · 64 unmodified lines |
| 284 | 474 | |
| 285 | 475 | function AddSourceDialog({ |
| 286 | 476 | existingSourceNames, |
| 477 | + onSourceAdded, |
| 287 | 478 | }: { |
| 288 | 479 | existingSourceNames: Set<string>; |
| 480 | + onSourceAdded?: (source: ToolSourceRecord) => void; |
| 289 | 481 | }) { |
| 290 | 482 | const { context } = useSession(); |
| 291 | 483 | const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource); |
| @@ -302,18 +494,27 @@ function AddSourceDialog({ · 10 unmodified lines |
| 302 | 494 | const [locallyReservedNames, setLocallyReservedNames] = useState<string[]>([]); |
| 303 | 495 | const [catalogQuery, setCatalogQuery] = useState(""); |
| 304 | 496 | const [catalogSort, setCatalogSort] = useState<"popular" | "recent">("popular"); |
| 305 | | − const [catalogItems, setCatalogItems] = useState<CatalogCollectionItem[]>([]); |
| 306 | | − const [catalogOffset, setCatalogOffset] = useState(0); |
| 307 | | − const [catalogHasMore, setCatalogHasMore] = useState(true); |
| 308 | | − const [catalogTotalCount, setCatalogTotalCount] = useState<number | null>(null); |
| 309 | | − const [catalogLoading, setCatalogLoading] = useState(false); |
| 310 | | − const [catalogLoadingMore, setCatalogLoadingMore] = useState(false); |
| 311 | | − const [catalogError, setCatalogError] = useState<string | null>(null); |
| 312 | 497 | const [addingCatalogId, setAddingCatalogId] = useState<string | null>(null); |
| 313 | | − const catalogRequestIdRef = useRef(0); |
| 314 | | − const catalogInFlightRef = useRef(false); |
| 315 | 498 | |
| 316 | | − const CATALOG_PAGE_SIZE = 20; |
| 499 | + const visibleCatalogItems = useMemo(() => { |
| 500 | + const query = catalogQuery.trim().toLowerCase(); |
| 501 | + const filtered = HARD_CODED_CATALOG_ITEMS.filter((item) => { |
| 502 | + if (!query) return true; |
| 503 | + return [ |
| 504 | + item.name, |
| 505 | + item.providerName, |
| 506 | + item.summary, |
| 507 | + item.categories ?? "", |
| 508 | + ].some((value) => value.toLowerCase().includes(query)); |
| 509 | + }); |
| 510 | + |
| 511 | + return [...filtered].sort((a, b) => { |
| 512 | + if (catalogSort === "recent") { |
| 513 | + return new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime(); |
| 514 | + } |
| 515 | + return a.rank - b.rank; |
| 516 | + }); |
| 517 | + }, [catalogQuery, catalogSort]); |
| 317 | 518 | |
| 318 | 519 | const getTakenSourceNames = () => new Set([...existingSourceNames, ...locallyReservedNames]); |
| 319 | 520 | |
| @@ -343,8 +544,6 @@ function AddSourceDialog({ · 23 unmodified lines |
| 343 | 544 | }; |
| 344 | 545 | |
| 345 | 546 | const resetDialogState = () => { |
| 346 | | − catalogRequestIdRef.current += 1; |
| 347 | | − catalogInFlightRef.current = false; |
| 348 | 547 | setView("catalog"); |
| 349 | 548 | setType("mcp"); |
| 350 | 549 | setName(""); |
| @@ -356,117 +555,14 @@ function AddSourceDialog({ · 5 unmodified lines |
| 356 | 555 | setLocallyReservedNames([]); |
| 357 | 556 | setCatalogQuery(""); |
| 358 | 557 | setCatalogSort("popular"); |
| 359 | | − setCatalogItems([]); |
| 360 | | − setCatalogOffset(0); |
| 361 | | − setCatalogHasMore(true); |
| 362 | | − setCatalogTotalCount(null); |
| 363 | | − setCatalogLoading(false); |
| 364 | | − setCatalogLoadingMore(false); |
| 365 | | − setCatalogError(null); |
| 366 | 558 | setAddingCatalogId(null); |
| 367 | 559 | }; |
| 368 | 560 | |
| 369 | | − const loadCatalogPage = async ({ |
| 370 | | − mode, |
| 371 | | − query, |
| 372 | | − sort, |
| 373 | | − }: { |
| 374 | | − mode: "reset" | "next"; |
| 375 | | − query?: string; |
| 376 | | − sort?: "popular" | "recent"; |
| 377 | | − }) => { |
| 378 | | − const resolvedQuery = (query ?? catalogQuery).trim(); |
| 379 | | − const resolvedSort = sort ?? catalogSort; |
| 380 | | − const nextOffset = mode === "reset" ? 0 : catalogOffset; |
| 381 | | − |
| 382 | | − if (mode === "next") { |
| 383 | | − if (catalogLoading || catalogLoadingMore || catalogInFlightRef.current || !catalogHasMore) { |
| 384 | | − return; |
| 385 | | − } |
| 386 | | − setCatalogLoadingMore(true); |
| 387 | | − } else { |
| 388 | | − setCatalogLoading(true); |
| 389 | | − setCatalogLoadingMore(false); |
| 390 | | − setCatalogError(null); |
| 391 | | − } |
| 392 | | − |
| 393 | | − const requestId = catalogRequestIdRef.current + 1; |
| 394 | | − catalogRequestIdRef.current = requestId; |
| 395 | | − catalogInFlightRef.current = true; |
| 396 | | − |
| 397 | | − try { |
| 398 | | − const params = new URLSearchParams({ |
| 399 | | − sort: resolvedSort, |
| 400 | | − limit: String(CATALOG_PAGE_SIZE), |
| 401 | | − offset: String(nextOffset), |
| 402 | | − }); |
| 403 | | − if (resolvedQuery.length > 0) { |
| 404 | | − params.set("q", resolvedQuery); |
| 405 | | − } |
| 406 | | − |
| 407 | | − const catalogBase = process.env.NEXT_PUBLIC_SOURCES_URL ?? "http://127.0.0.1:4343"; |
| 408 | | − const response = await fetch(`${catalogBase}/collections?${params.toString()}`, { |
| 409 | | − cache: "no-store", |
| 410 | | − }); |
| 411 | | − const body = await response.json() as CatalogCollectionsResponse; |
| 412 | | − |
| 413 | | − if (requestId !== catalogRequestIdRef.current) { |
| 414 | | − return; |
| 415 | | − } |
| 416 | | − |
| 417 | | − if (!response.ok) { |
| 418 | | − throw new Error(body.error || "Failed to load API catalog"); |
| 419 | | − } |
| 420 | | − |
| 421 | | − const nextItems = Array.isArray(body.items) ? body.items : []; |
| 422 | | − setCatalogItems((current) => { |
| 423 | | − if (mode === "reset") { |
| 424 | | − return nextItems; |
| 425 | | − } |
| 426 | | − |
| 427 | | − const merged = [...current]; |
| 428 | | − const seen = new Set(current.map((entry) => entry.id)); |
| 429 | | − for (const item of nextItems) { |
| 430 | | − if (seen.has(item.id)) continue; |
| 431 | | − seen.add(item.id); |
| 432 | | − merged.push(item); |
| 433 | | − } |
| 434 | | − return merged; |
| 435 | | − }); |
| 436 | | − setCatalogOffset(nextOffset + nextItems.length); |
| 437 | | − setCatalogHasMore(typeof body.hasMore === "boolean" ? body.hasMore : nextItems.length >= CATALOG_PAGE_SIZE); |
| 438 | | − setCatalogTotalCount(typeof body.totalCount === "number" ? body.totalCount : null); |
| 439 | | − setCatalogError(null); |
| 440 | | − } catch (error) { |
| 441 | | − if (requestId !== catalogRequestIdRef.current) { |
| 442 | | − return; |
| 443 | | − } |
| 444 | | − |
| 445 | | − if (mode === "reset") { |
| 446 | | − setCatalogItems([]); |
| 447 | | − setCatalogOffset(0); |
| 448 | | − } |
| 449 | | − setCatalogHasMore(false); |
| 450 | | − setCatalogTotalCount(null); |
| 451 | | − setCatalogError(error instanceof Error ? error.message : "Failed to load API catalog"); |
| 452 | | − } finally { |
| 453 | | − if (requestId === catalogRequestIdRef.current) { |
| 454 | | − setCatalogLoading(false); |
| 455 | | − setCatalogLoadingMore(false); |
| 456 | | − catalogInFlightRef.current = false; |
| 457 | | − } |
| 458 | | − } |
| 459 | | − }; |
| 460 | | − |
| 461 | 561 | const handleOpenChange = (isOpen: boolean) => { |
| 462 | 562 | setOpen(isOpen); |
| 463 | 563 | if (!isOpen) { |
| 464 | 564 | resetDialogState(); |
| 465 | | − return; |
| 466 | 565 | } |
| 467 | | − |
| 468 | | − resetDialogState(); |
| 469 | | − void loadCatalogPage({ mode: "reset", query: "", sort: "popular" }); |
| 470 | 566 | }; |
| 471 | 567 | |
| 472 | 568 | const addSource = async ( |
| @@ -475,13 +571,14 @@ function AddSourceDialog({ · 2 unmodified lines |
| 475 | 571 | config: Record<string, unknown>, |
| 476 | 572 | ) => { |
| 477 | 573 | if (!context) return; |
| 478 | | − await upsertToolSource({ |
| 574 | + const created = await upsertToolSource({ |
| 479 | 575 | workspaceId: context.workspaceId, |
| 480 | 576 | sessionId: context.sessionId, |
| 481 | 577 | name: sourceName, |
| 482 | 578 | type: sourceType, |
| 483 | 579 | config, |
| 484 | 580 | }); |
| 581 | + onSourceAdded?.(created as ToolSourceRecord); |
| 485 | 582 | toast.success(`Source "${sourceName}" added — loading tools…`); |
| 486 | 583 | }; |
| 487 | 584 | |
| @@ -563,20 +660,10 @@ function AddSourceDialog({ · 75 unmodified lines |
| 563 | 660 | onChange={(event) => setCatalogQuery(event.target.value)} |
| 564 | 661 | placeholder="Search APIs" |
| 565 | 662 | className="h-8 text-xs font-mono bg-background flex-1 min-w-[150px]" |
| 566 | | − onKeyDown={(event) => { |
| 567 | | − if (event.key === "Enter") { |
| 568 | | − event.preventDefault(); |
| 569 | | − void loadCatalogPage({ mode: "reset" }); |
| 570 | | − } |
| 571 | | − }} |
| 572 | 663 | /> |
| 573 | 664 | <Select |
| 574 | 665 | value={catalogSort} |
| 575 | | − onValueChange={(value) => { |
| 576 | | − const nextSort = value as "popular" | "recent"; |
| 577 | | − setCatalogSort(nextSort); |
| 578 | | − void loadCatalogPage({ mode: "reset", sort: nextSort }); |
| 579 | | − }} |
| 666 | + onValueChange={(value) => setCatalogSort(value as "popular" | "recent")} |
| 580 | 667 | > |
| 581 | 668 | <SelectTrigger className="h-8 w-[105px] text-xs bg-background shrink-0"> |
| 582 | 669 | <SelectValue /> |
| @@ -586,35 +673,15 @@ function AddSourceDialog({ · 3 unmodified lines |
| 586 | 673 | <SelectItem value="recent" className="text-xs">Recent</SelectItem> |
| 587 | 674 | </SelectContent> |
| 588 | 675 | </Select> |
| 589 | | − <Button |
| 590 | | − type="button" |
| 591 | | − variant="outline" |
| 592 | | − size="sm" |
| 593 | | − className="h-8 px-3 text-xs shrink-0" |
| 594 | | − onClick={() => void loadCatalogPage({ mode: "reset" })} |
| 595 | | − disabled={catalogLoading} |
| 596 | | − > |
| 597 | | − {catalogLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Search"} |
| 598 | | − </Button> |
| 599 | 676 | </div> |
| 600 | 677 | |
| 601 | 678 | <p className="text-[11px] text-muted-foreground"> |
| 602 | | − Browse API collections and add them as tool sources. |
| 603 | | − {catalogTotalCount !== null ? ` Found ${catalogTotalCount.toLocaleString()} total.` : ""} |
| 679 | + Browse curated APIs and add them as tool sources. Showing {visibleCatalogItems.length}. |
| 604 | 680 | </p> |
| 605 | 681 | |
| 606 | 682 | <Separator /> |
| 607 | 683 | |
| 608 | | − <div |
| 609 | | − className="max-h-80 overflow-y-auto overflow-x-hidden space-y-1 pr-1" |
| 610 | | − onScroll={(event) => { |
| 611 | | − const target = event.currentTarget; |
| 612 | | − const nearBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 120; |
| 613 | | − if (nearBottom) { |
| 614 | | − void loadCatalogPage({ mode: "next" }); |
| 615 | | − } |
| 616 | | − }} |
| 617 | | − > |
| 684 | + <div className="max-h-80 overflow-y-auto overflow-x-hidden space-y-1 pr-1"> |
| 618 | 685 | <button |
| 619 | 686 | type="button" |
| 620 | 687 | onClick={() => setView("custom")} |
| @@ -631,7 +698,7 @@ function AddSourceDialog({ · 10 unmodified lines |
| 631 | 698 | </div> |
| 632 | 699 | </button> |
| 633 | 700 | |
| 634 | | − {catalogItems.map((item) => ( |
| 701 | + {visibleCatalogItems.map((item) => ( |
| 635 | 702 | <div |
| 636 | 703 | key={item.id} |
| 637 | 704 | className="w-full max-w-full overflow-hidden flex items-start gap-2 px-2 py-2 rounded-md border border-border/50" |
| @@ -673,30 +740,11 @@ function AddSourceDialog({ · 35 unmodified lines |
| 673 | 740 | </div> |
| 674 | 741 | ))} |
| 675 | 742 | |
| 676 | | − {catalogLoadingMore && ( |
| 677 | | − <div className="flex items-center justify-center py-2 text-[11px] text-muted-foreground"> |
| 678 | | − <Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" /> |
| 679 | | − Loading more... |
| 680 | | − </div> |
| 681 | | − )} |
| 682 | | − |
| 683 | | − {!catalogLoading && !catalogLoadingMore && catalogItems.length === 0 && !catalogError && ( |
| 743 | + {visibleCatalogItems.length === 0 && ( |
| 684 | 744 | <p className="text-[11px] text-muted-foreground px-1 py-1"> |
| 685 | 745 | No collections found for this query. |
| 686 | 746 | </p> |
| 687 | 747 | )} |
| 688 | | − |
| 689 | | − {catalogError && ( |
| 690 | | − <p className="text-[11px] text-terminal-red px-1 py-1"> |
| 691 | | − {catalogError} |
| 692 | | − </p> |
| 693 | | − )} |
| 694 | | − |
| 695 | | − {!catalogHasMore && catalogItems.length > 0 && ( |
| 696 | | − <p className="text-[10px] text-muted-foreground/70 px-1 py-1"> |
| 697 | | − End of results. |
| 698 | | − </p> |
| 699 | | − )} |
| 700 | 748 | </div> |
| 701 | 749 | </div> |
| 702 | 750 | ) : ( |
| @@ -820,24 +868,27 @@ function AddSourceDialog({ · 117 unmodified lines |
| 820 | 868 | |
| 821 | 869 | function ConfigureSourceAuthDialog({ |
| 822 | 870 | source, |
| 871 | + inferredProfile, |
| 823 | 872 | }: { |
| 824 | 873 | source: ToolSourceRecord; |
| 874 | + inferredProfile?: SourceAuthProfile; |
| 825 | 875 | }) { |
| 826 | 876 | const { context } = useSession(); |
| 827 | 877 | const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource); |
| 828 | 878 | const [open, setOpen] = useState(false); |
| 829 | 879 | const [saving, setSaving] = useState(false); |
| 830 | 880 | |
| 831 | | − const currentAuth = readSourceAuth(source); |
| 832 | | − const [authType, setAuthType] = useState<SourceAuthType>(currentAuth.type); |
| 881 | + const currentAuth = readSourceAuth(source, inferredProfile); |
| 882 | + const editableInitialAuthType = currentAuth.type === "mixed" ? "bearer" : currentAuth.type; |
| 883 | + const [authType, setAuthType] = useState<Exclude<SourceAuthType, "mixed">>(editableInitialAuthType); |
| 833 | 884 | const [authMode, setAuthMode] = useState<SourceAuthMode>(currentAuth.mode ?? "workspace"); |
| 834 | 885 | const [apiKeyHeader, setApiKeyHeader] = useState(currentAuth.header ?? "x-api-key"); |
| 835 | 886 | |
| 836 | 887 | const configurable = source.type === "openapi" || source.type === "graphql"; |
| 837 | 888 | |
| 838 | 889 | const resetFromSource = () => { |
| 839 | | − const auth = readSourceAuth(source); |
| 840 | | − setAuthType(auth.type); |
| 890 | + const auth = readSourceAuth(source, inferredProfile); |
| 891 | + setAuthType(auth.type === "mixed" ? "bearer" : auth.type); |
| 841 | 892 | setAuthMode(auth.mode ?? "workspace"); |
| 842 | 893 | setApiKeyHeader(auth.header ?? "x-api-key"); |
| 843 | 894 | }; |
| @@ -905,7 +956,7 @@ function ConfigureSourceAuthDialog({ · 61 unmodified lines |
| 905 | 956 | |
| 906 | 957 | <div className="space-y-1.5"> |
| 907 | 958 | <Label className="text-xs text-muted-foreground">Auth Type</Label> |
| 908 | | − <Select value={authType} onValueChange={(value) => setAuthType(value as SourceAuthType)}> |
| 959 | + <Select value={authType} onValueChange={(value) => setAuthType(value as Exclude<SourceAuthType, "mixed">)}> |
| 909 | 960 | <SelectTrigger className="h-8 text-xs bg-background"> |
| 910 | 961 | <SelectValue /> |
| 911 | 962 | </SelectTrigger> |
| @@ -918,6 +969,12 @@ function ConfigureSourceAuthDialog({ · 6 unmodified lines |
| 918 | 969 | </Select> |
| 919 | 970 | </div> |
| 920 | 971 | |
| 972 | + {currentAuth.inferred && ( |
| 973 | + <p className="text-[11px] text-muted-foreground"> |
| 974 | + Suggested from spec inference. Save to pin an explicit auth config. |
| 975 | + </p> |
| 976 | + )} |
| 977 | + |
| 921 | 978 | {authType !== "none" && ( |
| 922 | 979 | <> |
| 923 | 980 | <div className="space-y-1.5"> |
| @@ -946,7 +1003,7 @@ function ConfigureSourceAuthDialog({ · 22 unmodified lines |
| 946 | 1003 | )} |
| 947 | 1004 | |
| 948 | 1005 | <p className="text-[11px] text-muted-foreground"> |
| 949 | | − Save this first, then add credentials in the Credentials tab using source key |
| 1006 | + Save this first, then add a connection in the Connections tab using source key |
| 950 | 1007 | <code className="ml-1">{sourceKeyForSource(source)}</code>. |
| 951 | 1008 | </p> |
| 952 | 1009 | </> |
| @@ -966,15 +1023,22 @@ function SourceCard({ · 13 unmodified lines |
| 966 | 1023 | quality, |
| 967 | 1024 | qualityLoading, |
| 968 | 1025 | credentialStats, |
| 1026 | + sourceAuthProfiles, |
| 1027 | + selected = false, |
| 1028 | + onFocusSource, |
| 969 | 1029 | }: { |
| 970 | 1030 | source: ToolSourceRecord; |
| 971 | 1031 | quality?: OpenApiSourceQuality; |
| 972 | 1032 | qualityLoading?: boolean; |
| 973 | 1033 | credentialStats: { workspaceCount: number; actorCount: number }; |
| 1034 | + sourceAuthProfiles: Record<string, SourceAuthProfile>; |
| 1035 | + selected?: boolean; |
| 1036 | + onFocusSource?: (sourceName: string) => void; |
| 974 | 1037 | }) { |
| 975 | 1038 | const { context } = useSession(); |
| 976 | 1039 | const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource); |
| 977 | 1040 | const [deleting, setDeleting] = useState(false); |
| 1041 | + const [detailsOpen, setDetailsOpen] = useState(false); |
| 978 | 1042 | |
| 979 | 1043 | const handleDelete = async () => { |
| 980 | 1044 | if (!context) return; |
| @@ -995,14 +1059,23 @@ function SourceCard({ · 14 unmodified lines |
| 995 | 1059 | |
| 996 | 1060 | const TypeIcon = source.type === "mcp" ? Server : Globe; |
| 997 | 1061 | const favicon = getSourceFavicon(source); |
| 998 | | − const authBadge = formatSourceAuthBadge(source); |
| 999 | | − const auth = readSourceAuth(source); |
| 1062 | + const sourceKey = sourceKeyForSource(source) ?? ""; |
| 1063 | + const authBadge = formatSourceAuthBadge(source, sourceAuthProfiles[sourceKey]); |
| 1064 | + const auth = readSourceAuth(source, sourceAuthProfiles[sourceKey]); |
| 1000 | 1065 | const hasAuthConfigured = auth.type !== "none"; |
| 1001 | 1066 | const totalCredentials = credentialStats.workspaceCount + credentialStats.actorCount; |
| 1067 | + const prettyName = displaySourceName(source.name); |
| 1068 | + const compactEndpoint = compactEndpointLabel(source); |
| 1069 | + const showTypeSummary = source.type === "openapi" && (quality || qualityLoading); |
| 1002 | 1070 | |
| 1003 | 1071 | return ( |
| 1004 | | − <div className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40 group"> |
| 1005 | | − <div className="h-8 w-8 rounded bg-muted flex items-center justify-center shrink-0 overflow-hidden"> |
| 1072 | + <div |
| 1073 | + className={cn( |
| 1074 | + "group flex items-start gap-3 rounded-lg border border-border/60 bg-gradient-to-b from-muted/45 to-muted/20 px-3 py-3", |
| 1075 | + selected && "border-primary/35 bg-primary/5", |
| 1076 | + )} |
| 1077 | + > |
| 1078 | + <div className="mt-0.5 h-9 w-9 rounded-md bg-muted/80 flex items-center justify-center shrink-0 overflow-hidden"> |
| 1006 | 1079 | {favicon ? ( |
| 1007 | 1080 | <img src={favicon} alt="" width={20} height={20} className="w-5 h-5" loading="lazy" /> |
| 1008 | 1081 | ) : ( |
| @@ -1010,20 +1083,20 @@ function SourceCard({ · 1 unmodified line |
| 1010 | 1083 | )} |
| 1011 | 1084 | </div> |
| 1012 | 1085 | <div className="flex-1 min-w-0"> |
| 1013 | | − <div className="flex items-center gap-2"> |
| 1014 | | − <span className="text-sm font-mono font-medium truncate"> |
| 1015 | | − {source.name} |
| 1086 | + <div className="flex items-center gap-2 min-w-0"> |
| 1087 | + <span className="text-sm font-medium truncate" title={source.name}> |
| 1088 | + {prettyName} |
| 1016 | 1089 | </span> |
| 1017 | 1090 | <Badge |
| 1018 | 1091 | variant="outline" |
| 1019 | | − className="text-[9px] font-mono uppercase tracking-wider" |
| 1092 | + className="text-[9px] uppercase tracking-wide" |
| 1020 | 1093 | > |
| 1021 | 1094 | {source.type} |
| 1022 | 1095 | </Badge> |
| 1023 | 1096 | {!source.enabled && ( |
| 1024 | 1097 | <Badge |
| 1025 | 1098 | variant="outline" |
| 1026 | | − className="text-[9px] font-mono uppercase tracking-wider text-terminal-red border-terminal-red/30" |
| 1099 | + className="text-[9px] uppercase tracking-wide text-terminal-red border-terminal-red/30" |
| 1027 | 1100 | > |
| 1028 | 1101 | disabled |
| 1029 | 1102 | </Badge> |
| @@ -1031,7 +1104,7 @@ function SourceCard({ · 1 unmodified line |
| 1031 | 1104 | {authBadge && ( |
| 1032 | 1105 | <Badge |
| 1033 | 1106 | variant="outline" |
| 1034 | | − className="text-[9px] font-mono uppercase tracking-wider text-primary border-primary/30" |
| 1107 | + className="text-[9px] uppercase tracking-wide text-primary border-primary/30" |
| 1035 | 1108 | > |
| 1036 | 1109 | {authBadge} |
| 1037 | 1110 | </Badge> |
| @@ -1040,43 +1113,100 @@ function SourceCard({ · 2 unmodified lines |
| 1040 | 1113 | <Badge |
| 1041 | 1114 | variant="outline" |
| 1042 | 1115 | className={cn( |
| 1043 | | − "text-[9px] font-mono uppercase tracking-wider", |
| 1116 | + "text-[9px] uppercase tracking-wide", |
| 1044 | 1117 | totalCredentials > 0 |
| 1045 | 1118 | ? "text-terminal-green border-terminal-green/30" |
| 1046 | 1119 | : "text-terminal-amber border-terminal-amber/30", |
| 1047 | 1120 | )} |
| 1048 | 1121 | > |
| 1049 | | − creds ws:{credentialStats.workspaceCount} actor:{credentialStats.actorCount} |
| 1050 | | − </Badge> |
| 1051 | | − )} |
| 1052 | | − {source.type === "openapi" && quality && ( |
| 1053 | | − <Badge |
| 1054 | | − variant="outline" |
| 1055 | | − className={cn("text-[9px] font-mono uppercase tracking-wider", qualityBadgeClass(quality))} |
| 1056 | | − > |
| 1057 | | − quality {formatQualityPercent(quality.overallQuality)} |
| 1122 | + {totalCredentials > 0 ? "connections ready" : "connection needed"} |
| 1058 | 1123 | </Badge> |
| 1059 | 1124 | )} |
| 1060 | 1125 | </div> |
| 1061 | | − <span className="text-[11px] text-muted-foreground font-mono truncate block"> |
| 1062 | | − {sourceEndpointLabel(source)} |
| 1126 | + <span className="text-[11px] text-muted-foreground truncate block mt-0.5" title={sourceEndpointLabel(source)}> |
| 1127 | + {compactEndpoint} |
| 1063 | 1128 | </span> |
| 1064 | | − {source.type === "openapi" && quality && ( |
| 1065 | | − <span className="text-[10px] text-muted-foreground/90 font-mono truncate block mt-0.5"> |
| 1066 | | − args {formatQualityPercent(quality.argsQuality)} | returns {formatQualityPercent(quality.returnsQuality)} |
| 1067 | | − {quality.unknownReturnsCount > 0 |
| 1068 | | − ? ` | ${quality.unknownReturnsCount} unknown returns` |
| 1069 | | − : " | fully typed returns"} |
| 1070 | | − </span> |
| 1129 | + {showTypeSummary && ( |
| 1130 | + <div className="mt-1.5 flex items-center gap-2"> |
| 1131 | + {quality ? ( |
| 1132 | + <Badge |
| 1133 | + variant="outline" |
| 1134 | + className={cn("text-[9px] uppercase tracking-wide", qualityToneClass(quality))} |
| 1135 | + > |
| 1136 | + {formatQualityPercent(quality.overallQuality)} {qualitySummaryLabel(quality)} |
| 1137 | + </Badge> |
| 1138 | + ) : ( |
| 1139 | + <Badge variant="outline" className="text-[9px] uppercase tracking-wide text-muted-foreground"> |
| 1140 | + analyzing type quality |
| 1141 | + </Badge> |
| 1142 | + )} |
| 1143 | + </div> |
| 1071 | 1144 | )} |
| 1072 | | − {source.type === "openapi" && !quality && qualityLoading && ( |
| 1073 | | − <span className="text-[10px] text-muted-foreground/70 font-mono truncate block mt-0.5"> |
| 1074 | | − Computing OpenAPI type quality... |
| 1075 | | − </span> |
| 1145 | + {source.type === "openapi" && ( |
| 1146 | + <Collapsible open={detailsOpen} onOpenChange={setDetailsOpen}> |
| 1147 | + <div className="mt-1.5"> |
| 1148 | + <CollapsibleTrigger asChild> |
| 1149 | + <Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px] text-muted-foreground"> |
| 1150 | + <ChevronRight |
| 1151 | + className={cn("mr-1 h-3 w-3 transition-transform", detailsOpen && "rotate-90")} |
| 1152 | + /> |
| 1153 | + {detailsOpen ? "Hide details" : "View details"} |
| 1154 | + </Button> |
| 1155 | + </CollapsibleTrigger> |
| 1156 | + </div> |
| 1157 | + <CollapsibleContent className="mt-1.5"> |
| 1158 | + <div className="rounded-md border border-border/60 bg-background/70 px-2.5 py-2"> |
| 1159 | + {quality && ( |
| 1160 | + <div className="space-y-1.5 text-[10px]"> |
| 1161 | + <div className="flex items-center justify-between"> |
| 1162 | + <span className="text-muted-foreground">Type quality</span> |
| 1163 | + <span className={cn("font-medium", qualityToneClass(quality))}> |
| 1164 | + {formatQualityPercent(quality.overallQuality)} |
| 1165 | + </span> |
| 1166 | + </div> |
| 1167 | + <div className="flex items-center justify-between text-muted-foreground"> |
| 1168 | + <span>Args quality</span> |
| 1169 | + <span>{formatQualityPercent(quality.argsQuality)}</span> |
| 1170 | + </div> |
| 1171 | + <div className="flex items-center justify-between text-muted-foreground"> |
| 1172 | + <span>Returns quality</span> |
| 1173 | + <span>{formatQualityPercent(quality.returnsQuality)}</span> |
| 1174 | + </div> |
| 1175 | + <div className="flex items-center justify-between text-muted-foreground"> |
| 1176 | + <span>Inferred returns</span> |
| 1177 | + <span>{quality.unknownReturnsCount}</span> |
| 1178 | + </div> |
| 1179 | + </div> |
| 1180 | + )} |
| 1181 | + {!quality && qualityLoading && ( |
| 1182 | + <div className="flex items-center justify-between text-[10px] text-muted-foreground"> |
| 1183 | + <span>Analyzing OpenAPI typing</span> |
| 1184 | + <span className="inline-flex items-center gap-1 text-[10px]"> |
| 1185 | + <span className="h-1.5 w-1.5 rounded-full bg-primary/60 animate-pulse" /> |
| 1186 | + in progress |
| 1187 | + </span> |
| 1188 | + </div> |
| 1189 | + )} |
| 1190 | + {!quality && !qualityLoading && ( |
| 1191 | + <div className="text-[10px] text-muted-foreground">Type quality data unavailable.</div> |
| 1192 | + )} |
| 1193 | + </div> |
| 1194 | + </CollapsibleContent> |
| 1195 | + </Collapsible> |
| 1076 | 1196 | )} |
| 1077 | 1197 | </div> |
| 1078 | 1198 | <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0"> |
| 1079 | | − <ConfigureSourceAuthDialog source={source} /> |
| 1199 | + {onFocusSource ? ( |
| 1200 | + <Button |
| 1201 | + variant={selected ? "default" : "outline"} |
| 1202 | + size="sm" |
| 1203 | + className="h-7 text-[11px]" |
| 1204 | + onClick={() => onFocusSource(source.name)} |
| 1205 | + > |
| 1206 | + {selected ? "Viewing" : "View tools"} |
| 1207 | + </Button> |
| 1208 | + ) : null} |
| 1209 | + <ConfigureSourceAuthDialog source={source} inferredProfile={sourceAuthProfiles[sourceKey]} /> |
| 1080 | 1210 | <Button |
| 1081 | 1211 | variant="ghost" |
| 1082 | 1212 | size="icon" |
| @@ -1091,62 +1221,109 @@ function SourceCard({ · 8 unmodified lines |
| 1091 | 1221 | ); |
| 1092 | 1222 | } |
| 1093 | 1223 | |
| 1094 | | −function formatCredentialSecret(secretJson: Record<string, unknown>): string { |
| 1095 | | − try { |
| 1096 | | − return JSON.stringify(secretJson, null, 2); |
| 1097 | | − } catch { |
| 1098 | | − return "{}"; |
| 1099 | | − } |
| 1100 | | −} |
| 1101 | | − |
| 1102 | 1224 | type SourceOption = { source: ToolSourceRecord; key: string; label: string }; |
| 1103 | 1225 | |
| 1104 | | −function sourceAuthForKey(sourceOptions: SourceOption[], key: string): { |
| 1226 | +function sourceAuthForKey( |
| 1227 | + sourceOptions: SourceOption[], |
| 1228 | + key: string, |
| 1229 | + inferredProfiles: Record<string, SourceAuthProfile> = {}, |
| 1230 | +): { |
| 1105 | 1231 | type: SourceAuthType; |
| 1106 | 1232 | mode?: SourceAuthMode; |
| 1107 | 1233 | header?: string; |
| 1234 | + inferred?: boolean; |
| 1108 | 1235 | } { |
| 1109 | 1236 | const match = sourceOptions.find((entry) => entry.key === key); |
| 1110 | 1237 | if (!match) { |
| 1111 | 1238 | return { type: "bearer" }; |
| 1112 | 1239 | } |
| 1113 | | − return readSourceAuth(match.source); |
| 1240 | + return readSourceAuth(match.source, inferredProfiles[key]); |
| 1114 | 1241 | } |
| 1115 | 1242 | |
| 1116 | 1243 | function sourceOptionLabel(source: ToolSourceRecord): string { |
| 1117 | 1244 | return `${source.name} (${source.type})`; |
| 1118 | 1245 | } |
| 1119 | 1246 | |
| 1120 | | −function parseJsonObject(text: string): { value?: Record<string, unknown>; error?: string } { |
| 1121 | | − try { |
| 1122 | | − const parsed = JSON.parse(text); |
| 1123 | | − if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { |
| 1124 | | − return { error: "Credential JSON must be an object" }; |
| 1247 | +function providerLabel(provider: "local-convex" | "workos-vault"): string { |
| 1248 | + return provider === "workos-vault" ? "encrypted" : "local"; |
| 1249 | +} |
| 1250 | + |
| 1251 | +function connectionDisplayName( |
| 1252 | + sources: ToolSourceRecord[], |
| 1253 | + connection: { |
| 1254 | + scope: CredentialScope; |
| 1255 | + sourceKeys: Set<string>; |
| 1256 | + actorId?: string; |
| 1257 | + }, |
| 1258 | +): string { |
| 1259 | + const sourceNames = [...connection.sourceKeys] |
| 1260 | + .map((sourceKey) => sourceForCredentialKey(sources, sourceKey)) |
| 1261 | + .filter((source): source is ToolSourceRecord => Boolean(source)) |
| 1262 | + .map((source) => displaySourceName(source.name)); |
| 1263 | + |
| 1264 | + const primary = sourceNames[0] ?? "API"; |
| 1265 | + const extraCount = Math.max(sourceNames.length - 1, 0); |
| 1266 | + const base = extraCount > 0 ? `${primary} +${extraCount}` : primary; |
| 1267 | + |
| 1268 | + if (connection.scope === "actor") { |
| 1269 | + if (connection.actorId) { |
| 1270 | + return `${base} personal (${connection.actorId})`; |
| 1125 | 1271 | } |
| 1126 | | − return { value: parsed as Record<string, unknown> }; |
| 1127 | | − } catch (error) { |
| 1128 | | − return { error: error instanceof Error ? error.message : "Invalid credential JSON" }; |
| 1272 | + return `${base} personal`; |
| 1129 | 1273 | } |
| 1274 | + |
| 1275 | + return `${base} workspace`; |
| 1130 | 1276 | } |
| 1131 | 1277 | |
| 1132 | | −function asString(value: unknown): string { |
| 1133 | | − return typeof value === "string" ? value : ""; |
| 1278 | +function parseHeaderOverrides(text: string): { value?: Record<string, string>; error?: string } { |
| 1279 | + const lines = text |
| 1280 | + .split("\n") |
| 1281 | + .map((line) => line.trim()) |
| 1282 | + .filter((line) => line.length > 0); |
| 1283 | + if (lines.length === 0) { |
| 1284 | + return { value: {} }; |
| 1285 | + } |
| 1286 | + |
| 1287 | + const headers: Record<string, string> = {}; |
| 1288 | + for (const line of lines) { |
| 1289 | + const separator = line.indexOf(":"); |
| 1290 | + if (separator <= 0) { |
| 1291 | + return { error: `Invalid header line: ${line}` }; |
| 1292 | + } |
| 1293 | + const name = line.slice(0, separator).trim(); |
| 1294 | + const value = line.slice(separator + 1).trim(); |
| 1295 | + if (!name || !value) { |
| 1296 | + return { error: `Invalid header line: ${line}` }; |
| 1297 | + } |
| 1298 | + headers[name] = value; |
| 1299 | + } |
| 1300 | + |
| 1301 | + return { value: headers }; |
| 1134 | 1302 | } |
| 1135 | 1303 | |
| 1136 | | −function providerLabel(provider: "managed" | "workos-vault"): string { |
| 1137 | | − return provider === "workos-vault" ? "encrypted" : "managed"; |
| 1304 | +function formatHeaderOverrides(overrides: Record<string, unknown> | undefined): string { |
| 1305 | + const headers = overrides && typeof overrides.headers === "object" ? (overrides.headers as Record<string, unknown>) : {}; |
| 1306 | + return Object.entries(headers) |
| 1307 | + .map(([key, value]) => `${key}: ${String(value)}`) |
| 1308 | + .join("\n"); |
| 1138 | 1309 | } |
| 1139 | 1310 | |
| 1140 | 1311 | function CredentialsPanel({ |
| 1141 | 1312 | sources, |
| 1142 | 1313 | credentials, |
| 1314 | + sourceAuthProfiles, |
| 1143 | 1315 | loading, |
| 1316 | + focusSourceKey, |
| 1317 | + onFocusHandled, |
| 1144 | 1318 | }: { |
| 1145 | 1319 | sources: ToolSourceRecord[]; |
| 1146 | 1320 | credentials: CredentialRecord[]; |
| 1321 | + sourceAuthProfiles: Record<string, SourceAuthProfile>; |
| 1147 | 1322 | loading: boolean; |
| 1323 | + focusSourceKey?: string | null; |
| 1324 | + onFocusHandled?: () => void; |
| 1148 | 1325 | }) { |
| 1149 | | − const { context } = useSession(); |
| 1326 | + const { context, clientConfig } = useSession(); |
| 1150 | 1327 | const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential); |
| 1151 | 1328 | const [open, setOpen] = useState(false); |
| 1152 | 1329 | const [saving, setSaving] = useState(false); |
| @@ -1154,13 +1331,17 @@ function CredentialsPanel({ · 1 unmodified line |
| 1154 | 1331 | const [sourceKey, setSourceKey] = useState(""); |
| 1155 | 1332 | const [scope, setScope] = useState<CredentialScope>("workspace"); |
| 1156 | 1333 | const [actorId, setActorId] = useState(""); |
| 1157 | | − const [provider, setProvider] = useState<"managed" | "workos-vault">("managed"); |
| 1158 | | − const [managedToken, setManagedToken] = useState(""); |
| 1334 | + const [connectionMode, setConnectionMode] = useState<"new" | "existing">("new"); |
| 1335 | + const [existingConnectionId, setExistingConnectionId] = useState(""); |
| 1336 | + const [tokenValue, setTokenValue] = useState(""); |
| 1159 | 1337 | const [apiKeyValue, setApiKeyValue] = useState(""); |
| 1160 | 1338 | const [basicUsername, setBasicUsername] = useState(""); |
| 1161 | 1339 | const [basicPassword, setBasicPassword] = useState(""); |
| 1162 | | − const [advancedMode, setAdvancedMode] = useState(false); |
| 1163 | | − const [secretJsonText, setSecretJsonText] = useState("{}"); |
| 1340 | + const [customHeadersText, setCustomHeadersText] = useState(""); |
| 1341 | + |
| 1342 | + const storageCopy = clientConfig?.authProviderMode === "workos" |
| 1343 | + ? "Stored encrypted" |
| 1344 | + : "Stored locally on this machine"; |
| 1164 | 1345 | |
| 1165 | 1346 | const sourceOptions = sources |
| 1166 | 1347 | .map((source) => { |
| @@ -1173,50 +1354,99 @@ function CredentialsPanel({ · 6 unmodified lines |
| 1173 | 1354 | }) |
| 1174 | 1355 | .filter((entry): entry is SourceOption => entry.key !== null); |
| 1175 | 1356 | |
| 1176 | | − const selectedAuth = sourceAuthForKey(sourceOptions, sourceKey); |
| 1357 | + const connectionOptions = useMemo(() => { |
| 1358 | + const grouped = new Map<string, { |
| 1359 | + id: string; |
| 1360 | + scope: CredentialScope; |
| 1361 | + actorId?: string; |
| 1362 | + provider: "local-convex" | "workos-vault"; |
| 1363 | + sourceKeys: Set<string>; |
| 1364 | + updatedAt: number; |
| 1365 | + }>(); |
| 1366 | + |
| 1367 | + for (const credential of credentials) { |
| 1368 | + const existing = grouped.get(credential.id); |
| 1369 | + if (existing) { |
| 1370 | + existing.sourceKeys.add(credential.sourceKey); |
| 1371 | + existing.updatedAt = Math.max(existing.updatedAt, credential.updatedAt); |
| 1372 | + } else { |
| 1373 | + grouped.set(credential.id, { |
| 1374 | + id: credential.id, |
| 1375 | + scope: credential.scope, |
| 1376 | + actorId: credential.actorId, |
| 1377 | + provider: credential.provider, |
| 1378 | + sourceKeys: new Set([credential.sourceKey]), |
| 1379 | + updatedAt: credential.updatedAt, |
| 1380 | + }); |
| 1381 | + } |
| 1382 | + } |
| 1383 | + |
| 1384 | + return [...grouped.values()].sort((a, b) => b.updatedAt - a.updatedAt); |
| 1385 | + }, [credentials]); |
| 1386 | + |
| 1387 | + const representativeCredentialByConnection = useMemo(() => { |
| 1388 | + const map = new Map<string, CredentialRecord>(); |
| 1389 | + for (const credential of credentials) { |
| 1390 | + if (!map.has(credential.id)) { |
| 1391 | + map.set(credential.id, credential); |
| 1392 | + } |
| 1393 | + } |
| 1394 | + return map; |
| 1395 | + }, [credentials]); |
| 1396 | + |
| 1397 | + const selectedAuth = sourceAuthForKey(sourceOptions, sourceKey, sourceAuthProfiles); |
| 1398 | + const compatibleConnectionOptions = connectionOptions.filter((connection) => { |
| 1399 | + if (connection.scope !== scope) { |
| 1400 | + return false; |
| 1401 | + } |
| 1402 | + if (scope === "actor") { |
| 1403 | + return connection.actorId === actorId.trim(); |
| 1404 | + } |
| 1405 | + return true; |
| 1406 | + }); |
| 1177 | 1407 | const selectedAuthBadge = selectedAuth.type === "none" |
| 1178 | 1408 | ? "none" |
| 1179 | | − : `${selectedAuth.type}:${selectedAuth.mode ?? "workspace"}`; |
| 1409 | + : selectedAuth.type === "mixed" |
| 1410 | + ? "mixed" |
| 1411 | + : `${selectedAuth.type}:${selectedAuth.mode ?? "workspace"}`; |
| 1180 | 1412 | |
| 1181 | | − const buildDraftSecretFromInputs = (): Record<string, unknown> => { |
| 1182 | | − if (selectedAuth.type === "apiKey") { |
| 1183 | | − return { value: apiKeyValue.trim() }; |
| 1413 | + useEffect(() => { |
| 1414 | + if (!existingConnectionId) { |
| 1415 | + return; |
| 1184 | 1416 | } |
| 1185 | | − if (selectedAuth.type === "basic") { |
| 1186 | | − return { |
| 1187 | | − username: basicUsername, |
| 1188 | | − password: basicPassword, |
| 1189 | | − }; |
| 1417 | + if (!compatibleConnectionOptions.some((connection) => connection.id === existingConnectionId)) { |
| 1418 | + setExistingConnectionId(""); |
| 1190 | 1419 | } |
| 1191 | | − return { token: managedToken.trim() }; |
| 1192 | | − }; |
| 1193 | | − |
| 1194 | | − const setFormFromCredential = (credential: CredentialRecord) => { |
| 1195 | | − const secret = credential.secretJson; |
| 1196 | | − setManagedToken(asString(secret.token) || asString(secret.value)); |
| 1197 | | − setApiKeyValue(asString(secret.value) || asString(secret.token)); |
| 1198 | | − setBasicUsername(asString(secret.username)); |
| 1199 | | − setBasicPassword(asString(secret.password)); |
| 1200 | | − setSecretJsonText(formatCredentialSecret(secret)); |
| 1201 | | − setAdvancedMode(false); |
| 1202 | | − }; |
| 1420 | + }, [compatibleConnectionOptions, existingConnectionId]); |
| 1203 | 1421 | |
| 1204 | 1422 | const resetForm = () => { |
| 1205 | 1423 | const defaultSourceKey = sourceOptions[0]?.key ?? ""; |
| 1206 | 1424 | setSourceKey(defaultSourceKey); |
| 1207 | | − const defaultAuth = sourceAuthForKey(sourceOptions, defaultSourceKey); |
| 1425 | + const defaultAuth = sourceAuthForKey(sourceOptions, defaultSourceKey, sourceAuthProfiles); |
| 1208 | 1426 | setScope(defaultAuth.mode ?? "workspace"); |
| 1209 | 1427 | setActorId(context?.actorId ?? ""); |
| 1210 | | − setProvider("managed"); |
| 1211 | | − setManagedToken(""); |
| 1428 | + setConnectionMode("new"); |
| 1429 | + setExistingConnectionId(""); |
| 1430 | + setTokenValue(""); |
| 1212 | 1431 | setApiKeyValue(""); |
| 1213 | 1432 | setBasicUsername(""); |
| 1214 | 1433 | setBasicPassword(""); |
| 1215 | | − setAdvancedMode(false); |
| 1216 | | − setSecretJsonText("{}"); |
| 1434 | + setCustomHeadersText(""); |
| 1217 | 1435 | setEditing(null); |
| 1218 | 1436 | }; |
| 1219 | 1437 | |
| 1438 | + useEffect(() => { |
| 1439 | + if (!focusSourceKey) { |
| 1440 | + return; |
| 1441 | + } |
| 1442 | + resetForm(); |
| 1443 | + setSourceKey(focusSourceKey); |
| 1444 | + const auth = sourceAuthForKey(sourceOptions, focusSourceKey, sourceAuthProfiles); |
| 1445 | + setScope(auth.mode ?? "workspace"); |
| 1446 | + setOpen(true); |
| 1447 | + onFocusHandled?.(); |
| 1448 | + }, [focusSourceKey, onFocusHandled, sourceAuthProfiles, sourceOptions]); |
| 1449 | + |
| 1220 | 1450 | const openForCreate = () => { |
| 1221 | 1451 | resetForm(); |
| 1222 | 1452 | setOpen(true); |
| @@ -1227,30 +1457,24 @@ function CredentialsPanel({ · 4 unmodified lines |
| 1227 | 1457 | setSourceKey(credential.sourceKey); |
| 1228 | 1458 | setScope(credential.scope); |
| 1229 | 1459 | setActorId(credential.actorId ?? context?.actorId ?? ""); |
| 1230 | | − setProvider(credential.provider === "workos-vault" ? "workos-vault" : "managed"); |
| 1231 | | − setFormFromCredential(credential); |
| 1460 | + setConnectionMode("new"); |
| 1461 | + setExistingConnectionId(credential.id); |
| 1462 | + setTokenValue(""); |
| 1463 | + setApiKeyValue(""); |
| 1464 | + setBasicUsername(""); |
| 1465 | + setBasicPassword(""); |
| 1466 | + setCustomHeadersText(formatHeaderOverrides(credential.overridesJson)); |
| 1232 | 1467 | setOpen(true); |
| 1233 | 1468 | }; |
| 1234 | 1469 | |
| 1235 | 1470 | const handleSourceKeyChange = (nextSourceKey: string) => { |
| 1236 | 1471 | setSourceKey(nextSourceKey); |
| 1237 | | − const auth = sourceAuthForKey(sourceOptions, nextSourceKey); |
| 1472 | + const auth = sourceAuthForKey(sourceOptions, nextSourceKey, sourceAuthProfiles); |
| 1238 | 1473 | if (!editing) { |
| 1239 | 1474 | setScope(auth.mode ?? "workspace"); |
| 1240 | 1475 | } |
| 1241 | 1476 | }; |
| 1242 | 1477 | |
| 1243 | | − const handleProviderChange = (value: "managed" | "workos-vault") => { |
| 1244 | | − setProvider(value); |
| 1245 | | − }; |
| 1246 | | − |
| 1247 | | − const handleAdvancedModeChange = (next: boolean) => { |
| 1248 | | − setAdvancedMode(next); |
| 1249 | | − if (next) { |
| 1250 | | − setSecretJsonText(formatCredentialSecret(buildDraftSecretFromInputs())); |
| 1251 | | − } |
| 1252 | | − }; |
| 1253 | | − |
| 1254 | 1478 | const handleSave = async () => { |
| 1255 | 1479 | if (!context) return; |
| 1256 | 1480 | if (!sourceKey.trim()) { |
| @@ -1262,61 +1486,61 @@ function CredentialsPanel({ · 5 unmodified lines |
| 1262 | 1486 | return; |
| 1263 | 1487 | } |
| 1264 | 1488 | |
| 1265 | | − let secretJson: Record<string, unknown> = {}; |
| 1266 | | − const keepExistingEncryptedSecret = provider === "workos-vault" && Boolean(editing); |
| 1489 | + const parsedHeaders = parseHeaderOverrides(customHeadersText); |
| 1490 | + if (!parsedHeaders.value) { |
| 1491 | + toast.error(parsedHeaders.error ?? "Invalid header overrides"); |
| 1492 | + return; |
| 1493 | + } |
| 1267 | 1494 | |
| 1268 | | − if (advancedMode) { |
| 1269 | | − const parsed = parseJsonObject(secretJsonText); |
| 1270 | | − if (!parsed.value) { |
| 1271 | | − toast.error(parsed.error ?? "Invalid credential JSON"); |
| 1272 | | − return; |
| 1273 | | − } |
| 1274 | | − secretJson = parsed.value; |
| 1275 | | − } else { |
| 1276 | | − if (selectedAuth.type === "none") { |
| 1277 | | − toast.error("Configure source auth before saving credentials"); |
| 1278 | | − return; |
| 1279 | | − } |
| 1495 | + const linkExisting = !editing && connectionMode === "existing"; |
| 1496 | + if (linkExisting && !existingConnectionId) { |
| 1497 | + toast.error("Select an existing connection to link"); |
| 1498 | + return; |
| 1499 | + } |
| 1500 | + if (linkExisting && !compatibleConnectionOptions.some((connection) => connection.id === existingConnectionId)) { |
| 1501 | + toast.error("Selected connection does not match this scope"); |
| 1502 | + return; |
| 1503 | + } |
| 1504 | + |
| 1505 | + if (selectedAuth.type === "none") { |
| 1506 | + toast.error("This source does not require auth"); |
| 1507 | + return; |
| 1508 | + } |
| 1509 | + |
| 1510 | + if (selectedAuth.type === "mixed" && !linkExisting && !editing) { |
| 1511 | + toast.error("Mixed-auth sources must link to an existing connection"); |
| 1512 | + return; |
| 1513 | + } |
| 1514 | + |
| 1515 | + const secretJson: Record<string, unknown> = {}; |
| 1516 | + if (!linkExisting) { |
| 1280 | 1517 | if (selectedAuth.type === "basic") { |
| 1281 | 1518 | const hasUsername = basicUsername.trim().length > 0; |
| 1282 | 1519 | const hasPassword = basicPassword.trim().length > 0; |
| 1283 | | − if (!hasUsername && !hasPassword && keepExistingEncryptedSecret) { |
| 1284 | | − secretJson = {}; |
| 1285 | | − } else if (!hasUsername || !hasPassword) { |
| 1286 | | − toast.error("Username and password are required for basic auth"); |
| 1287 | | − return; |
| 1288 | | − } else { |
| 1289 | | − secretJson = { |
| 1290 | | − username: basicUsername, |
| 1291 | | − password: basicPassword, |
| 1292 | | − }; |
| 1293 | | − } |
| 1294 | | − } else if (selectedAuth.type === "apiKey") { |
| 1295 | | − if (!apiKeyValue.trim()) { |
| 1296 | | − if (keepExistingEncryptedSecret) { |
| 1297 | | − secretJson = {}; |
| 1298 | | − } else { |
| 1299 | | − toast.error("API key value is required"); |
| 1520 | + if (hasUsername || hasPassword) { |
| 1521 | + if (!hasUsername || !hasPassword) { |
| 1522 | + toast.error("Username and password are required for basic auth"); |
| 1300 | 1523 | return; |
| 1301 | 1524 | } |
| 1302 | | − } else { |
| 1303 | | − secretJson = { value: apiKeyValue.trim() }; |
| 1525 | + secretJson.username = basicUsername; |
| 1526 | + secretJson.password = basicPassword; |
| 1304 | 1527 | } |
| 1305 | | − } else { |
| 1306 | | − if (!managedToken.trim()) { |
| 1307 | | − if (keepExistingEncryptedSecret) { |
| 1308 | | − secretJson = {}; |
| 1309 | | − } else { |
| 1310 | | − toast.error("Token is required"); |
| 1311 | | − return; |
| 1312 | | − } |
| 1313 | | − } else { |
| 1314 | | − secretJson = { token: managedToken.trim() }; |
| 1528 | + } else if (selectedAuth.type === "apiKey") { |
| 1529 | + if (apiKeyValue.trim()) { |
| 1530 | + secretJson.value = apiKeyValue.trim(); |
| 1531 | + } |
| 1532 | + } else if (selectedAuth.type === "bearer") { |
| 1533 | + if (tokenValue.trim()) { |
| 1534 | + secretJson.token = tokenValue.trim(); |
| 1315 | 1535 | } |
| 1316 | 1536 | } |
| 1317 | 1537 | } |
| 1318 | 1538 | |
| 1319 | | − if (provider === "workos-vault" && !editing && Object.keys(secretJson).length === 0) { |
| 1539 | + if (Object.keys(parsedHeaders.value).length > 0) { |
| 1540 | + secretJson.__headers = parsedHeaders.value; |
| 1541 | + } |
| 1542 | + |
| 1543 | + if (Object.keys(secretJson).length === 0 && !editing && !linkExisting) { |
| 1320 | 1544 | if (selectedAuth.type === "basic") { |
| 1321 | 1545 | toast.error("Username and password are required"); |
| 1322 | 1546 | } else if (selectedAuth.type === "apiKey") { |
| @@ -1330,20 +1554,20 @@ function CredentialsPanel({ · 7 unmodified lines |
| 1330 | 1554 | setSaving(true); |
| 1331 | 1555 | try { |
| 1332 | 1556 | await upsertCredential({ |
| 1557 | + ...(editing ? { id: editing.id } : linkExisting ? { id: existingConnectionId } : {}), |
| 1333 | 1558 | workspaceId: context.workspaceId, |
| 1334 | 1559 | sessionId: context.sessionId, |
| 1335 | 1560 | sourceKey: sourceKey.trim(), |
| 1336 | 1561 | scope, |
| 1337 | 1562 | ...(scope === "actor" ? { actorId: actorId.trim() } : {}), |
| 1338 | | − provider, |
| 1339 | 1563 | secretJson, |
| 1340 | 1564 | }); |
| 1341 | 1565 | |
| 1342 | | − toast.success(editing ? "Credential updated" : "Credential saved"); |
| 1566 | + toast.success(editing ? "Connection updated" : linkExisting ? "Connection linked" : "Connection saved"); |
| 1343 | 1567 | setOpen(false); |
| 1344 | 1568 | resetForm(); |
| 1345 | 1569 | } catch (err) { |
| 1346 | | − toast.error(err instanceof Error ? err.message : "Failed to save credential"); |
| 1570 | + toast.error(err instanceof Error ? err.message : "Failed to save connection"); |
| 1347 | 1571 | } finally { |
| 1348 | 1572 | setSaving(false); |
| 1349 | 1573 | } |
| @@ -1355,11 +1579,11 @@ function CredentialsPanel({ · 5 unmodified lines |
| 1355 | 1579 | <div className="flex items-center justify-between"> |
| 1356 | 1580 | <CardTitle className="text-sm font-medium flex items-center gap-2"> |
| 1357 | 1581 | <KeyRound className="h-4 w-4 text-muted-foreground" /> |
| 1358 | | − Credentials |
| 1582 | + Connections |
| 1359 | 1583 | </CardTitle> |
| 1360 | 1584 | <Button size="sm" className="h-8 text-xs" onClick={openForCreate}> |
| 1361 | 1585 | <Plus className="h-3.5 w-3.5 mr-1.5" /> |
| 1362 | | − Add Credential |
| 1586 | + Add Connection |
| 1363 | 1587 | </Button> |
| 1364 | 1588 | </div> |
| 1365 | 1589 | </CardHeader> |
| @@ -1370,82 +1594,71 @@ function CredentialsPanel({ · 4 unmodified lines |
| 1370 | 1594 | <Skeleton key={i} className="h-14" /> |
| 1371 | 1595 | ))} |
| 1372 | 1596 | </div> |
| 1373 | | − ) : credentials.length === 0 ? ( |
| 1597 | + ) : connectionOptions.length === 0 ? ( |
| 1374 | 1598 | <div className="flex flex-col items-center justify-center py-10 gap-2"> |
| 1375 | 1599 | <div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center"> |
| 1376 | 1600 | <KeyRound className="h-5 w-5 text-muted-foreground/50" /> |
| 1377 | 1601 | </div> |
| 1378 | | − <p className="text-sm text-muted-foreground">No credentials configured</p> |
| 1602 | + <p className="text-sm text-muted-foreground">No connections configured</p> |
| 1379 | 1603 | <p className="text-[11px] text-muted-foreground/70 text-center max-w-md"> |
| 1380 | | − Configure source auth on an OpenAPI or GraphQL source, then add workspace or actor credentials. |
| 1604 | + Add a source, then create or link a reusable connection. |
| 1381 | 1605 | </p> |
| 1382 | 1606 | </div> |
| 1383 | 1607 | ) : ( |
| 1384 | 1608 | <div className="space-y-2"> |
| 1385 | | − {credentials.map((credential) => ( |
| 1386 | | − <div |
| 1387 | | − key={`${credential.sourceKey}:${credential.scope}:${credential.actorId ?? "workspace"}`} |
| 1388 | | − className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40" |
| 1389 | | − > |
| 1390 | | − {(() => { |
| 1391 | | − const source = sourceForCredentialKey(sources, credential.sourceKey); |
| 1392 | | − const favicon = source ? getSourceFavicon(source) : null; |
| 1393 | | − return ( |
| 1394 | | − <div className="h-8 w-8 rounded bg-muted flex items-center justify-center shrink-0 overflow-hidden"> |
| 1395 | | − {favicon ? ( |
| 1396 | | − <img src={favicon} alt="" width={20} height={20} className="w-5 h-5" loading="lazy" /> |
| 1397 | | − ) : ( |
| 1398 | | − <KeyRound className="h-4 w-4 text-muted-foreground" /> |
| 1609 | + {connectionOptions.map((connection) => { |
| 1610 | + const representative = representativeCredentialByConnection.get(connection.id); |
| 1611 | + if (!representative) return null; |
| 1612 | + const firstSource = sourceForCredentialKey(sources, representative.sourceKey); |
| 1613 | + const favicon = firstSource ? getSourceFavicon(firstSource) : null; |
| 1614 | + |
| 1615 | + return ( |
| 1616 | + <div |
| 1617 | + key={connection.id} |
| 1618 | + className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40" |
| 1619 | + > |
| 1620 | + <div className="h-8 w-8 rounded bg-muted flex items-center justify-center shrink-0 overflow-hidden"> |
| 1621 | + {favicon ? ( |
| 1622 | + <img src={favicon} alt="" width={20} height={20} className="w-5 h-5" loading="lazy" /> |
| 1623 | + ) : ( |
| 1624 | + <KeyRound className="h-4 w-4 text-muted-foreground" /> |
| 1625 | + )} |
| 1626 | + </div> |
| 1627 | + <div className="flex-1 min-w-0"> |
| 1628 | + <div className="flex items-center gap-1.5 mb-1 flex-wrap"> |
| 1629 | + <span className="text-sm font-medium">{connectionDisplayName(sources, connection)}</span> |
| 1630 | + <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> |
| 1631 | + {connection.scope} |
| 1632 | + </Badge> |
| 1633 | + <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> |
| 1634 | + {providerLabel(connection.provider)} |
| 1635 | + </Badge> |
| 1636 | + {connection.scope === "actor" && connection.actorId && ( |
| 1637 | + <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> |
| 1638 | + {connection.actorId} |
| 1639 | + </span> |
| 1399 | 1640 | )} |
| 1400 | 1641 | </div> |
| 1401 | | − ); |
| 1402 | | − })()} |
| 1403 | | − <div className="flex-1 min-w-0"> |
| 1404 | | − {(() => { |
| 1405 | | − const source = sourceForCredentialKey(sources, credential.sourceKey); |
| 1406 | | − if (!source) { |
| 1407 | | − return ( |
| 1408 | | − <p className="text-[11px] text-muted-foreground/80 font-mono mb-1"> |
| 1409 | | − {credential.sourceKey} |
| 1410 | | − </p> |
| 1411 | | − ); |
| 1412 | | − } |
| 1413 | | − return ( |
| 1414 | | − <div className="flex items-center gap-1.5 mb-1"> |
| 1415 | | − <span className="text-sm font-mono font-medium">{source.name}</span> |
| 1416 | | − <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> |
| 1417 | | − {source.type} |
| 1418 | | − </Badge> |
| 1419 | | − </div> |
| 1420 | | − ); |
| 1421 | | − })()} |
| 1422 | | − <div className="flex items-center gap-2 flex-wrap"> |
| 1423 | | − <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> |
| 1424 | | − {credential.scope} |
| 1425 | | − </Badge> |
| 1426 | | − <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> |
| 1427 | | − {providerLabel(credential.provider === "workos-vault" ? "workos-vault" : "managed")} |
| 1428 | | − </Badge> |
| 1429 | | − {credential.scope === "actor" && credential.actorId && ( |
| 1430 | | − <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> |
| 1431 | | − {credential.actorId} |
| 1432 | | − </span> |
| 1433 | | − )} |
| 1642 | + <p className="text-[11px] text-muted-foreground"> |
| 1643 | + Linked to {connection.sourceKeys.size} source{connection.sourceKeys.size === 1 ? "" : "s"} • {storageCopy} |
| 1644 | + </p> |
| 1645 | + <p className="text-[11px] text-muted-foreground mt-0.5"> |
| 1646 | + ID: <span className="font-mono">{connection.id}</span> • |
| 1647 | + {" "} |
| 1648 | + Updated {new Date(connection.updatedAt).toLocaleString()} |
| 1649 | + </p> |
| 1434 | 1650 | </div> |
| 1435 | | − <p className="text-[11px] text-muted-foreground mt-0.5"> |
| 1436 | | − Updated {new Date(credential.updatedAt).toLocaleString()} |
| 1437 | | − </p> |
| 1651 | + <Button |
| 1652 | + variant="outline" |
| 1653 | + size="sm" |
| 1654 | + className="h-7 text-[11px]" |
| 1655 | + onClick={() => openForEdit(representative)} |
| 1656 | + > |
| 1657 | + Edit |
| 1658 | + </Button> |
| 1438 | 1659 | </div> |
| 1439 | | − <Button |
| 1440 | | − variant="outline" |
| 1441 | | − size="sm" |
| 1442 | | − className="h-7 text-[11px]" |
| 1443 | | − onClick={() => openForEdit(credential)} |
| 1444 | | − > |
| 1445 | | − Edit |
| 1446 | | − </Button> |
| 1447 | | − </div> |
| 1448 | | − ))} |
| 1660 | + ); |
| 1661 | + })} |
| 1449 | 1662 | </div> |
| 1450 | 1663 | )} |
| 1451 | 1664 | </CardContent> |
| @@ -1454,7 +1667,7 @@ function CredentialsPanel({ · 2 unmodified lines |
| 1454 | 1667 | <DialogContent className="bg-card border-border sm:max-w-lg"> |
| 1455 | 1668 | <DialogHeader> |
| 1456 | 1669 | <DialogTitle className="text-sm font-medium"> |
| 1457 | | − {editing ? "Edit Credential" : "Add Credential"} |
| 1670 | + {editing ? "Edit Connection" : "Add Connection"} |
| 1458 | 1671 | </DialogTitle> |
| 1459 | 1672 | </DialogHeader> |
| 1460 | 1673 | <div className="space-y-3"> |
| @@ -1491,6 +1704,11 @@ function CredentialsPanel({ · 30 unmodified lines |
| 1491 | 1704 | <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> |
| 1492 | 1705 | {selectedAuthBadge} |
| 1493 | 1706 | </Badge> |
| 1707 | + {selectedAuth.inferred && ( |
| 1708 | + <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> |
| 1709 | + inferred |
| 1710 | + </Badge> |
| 1711 | + )} |
| 1494 | 1712 | {selectedAuth.type === "apiKey" && selectedAuth.header && ( |
| 1495 | 1713 | <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> |
| 1496 | 1714 | header: {selectedAuth.header} |
| @@ -1511,18 +1729,9 @@ function CredentialsPanel({ · 14 unmodified lines |
| 1511 | 1729 | </SelectContent> |
| 1512 | 1730 | </Select> |
| 1513 | 1731 | </div> |
| 1514 | | − |
| 1515 | 1732 | <div className="space-y-1.5"> |
| 1516 | | − <Label className="text-xs text-muted-foreground">Provider</Label> |
| 1517 | | − <Select value={provider} onValueChange={(value) => handleProviderChange(value as "managed" | "workos-vault") }> |
| 1518 | | − <SelectTrigger className="h-8 text-xs bg-background"> |
| 1519 | | − <SelectValue /> |
| 1520 | | − </SelectTrigger> |
| 1521 | | − <SelectContent> |
| 1522 | | − <SelectItem value="managed" className="text-xs">Managed storage</SelectItem> |
| 1523 | | − <SelectItem value="workos-vault" className="text-xs">Encrypted storage</SelectItem> |
| 1524 | | − </SelectContent> |
| 1525 | | − </Select> |
| 1733 | + <Label className="text-xs text-muted-foreground">Storage</Label> |
| 1734 | + <Input value={storageCopy} readOnly className="h-8 text-xs bg-background" /> |
| 1526 | 1735 | </div> |
| 1527 | 1736 | </div> |
| 1528 | 1737 | |
| @@ -1538,79 +1747,114 @@ function CredentialsPanel({ · 9 unmodified lines |
| 1538 | 1747 | </div> |
| 1539 | 1748 | )} |
| 1540 | 1749 | |
| 1541 | | − {provider === "workos-vault" && editing && ( |
| 1542 | | − <p className="text-[11px] text-muted-foreground"> |
| 1543 | | − Stored secret is hidden. Enter a new value below to rotate, or leave fields blank to keep existing. |
| 1544 | | − </p> |
| 1545 | | − )} |
| 1546 | | − |
| 1547 | | − {selectedAuth.type === "none" ? ( |
| 1548 | | − <p className="text-[11px] text-terminal-amber"> |
| 1549 | | − This source has auth set to <code>none</code>. Configure source auth first. |
| 1550 | | − </p> |
| 1551 | | − ) : selectedAuth.type === "apiKey" ? ( |
| 1750 | + {!editing && connectionOptions.length > 0 && ( |
| 1552 | 1751 | <div className="space-y-1.5"> |
| 1553 | | − <Label className="text-xs text-muted-foreground">API Key Value</Label> |
| 1554 | | − <Input |
| 1555 | | − value={apiKeyValue} |
| 1556 | | − onChange={(e) => setApiKeyValue(e.target.value)} |
| 1557 | | − placeholder="sk_live_..." |
| 1558 | | − className="h-8 text-xs font-mono bg-background" |
| 1559 | | − /> |
| 1560 | | − </div> |
| 1561 | | − ) : selectedAuth.type === "basic" ? ( |
| 1562 | | − <div className="grid grid-cols-2 gap-3"> |
| 1563 | | − <div className="space-y-1.5"> |
| 1564 | | − <Label className="text-xs text-muted-foreground">Username</Label> |
| 1565 | | − <Input |
| 1566 | | − value={basicUsername} |
| 1567 | | − onChange={(e) => setBasicUsername(e.target.value)} |
| 1568 | | − className="h-8 text-xs font-mono bg-background" |
| 1569 | | − /> |
| 1570 | | − </div> |
| 1571 | | − <div className="space-y-1.5"> |
| 1572 | | − <Label className="text-xs text-muted-foreground">Password</Label> |
| 1573 | | − <Input |
| 1574 | | − type="password" |
| 1575 | | − value={basicPassword} |
| 1576 | | − onChange={(e) => setBasicPassword(e.target.value)} |
| 1577 | | − className="h-8 text-xs font-mono bg-background" |
| 1578 | | − /> |
| 1579 | | − </div> |
| 1752 | + <Label className="text-xs text-muted-foreground">Connection Mode</Label> |
| 1753 | + <Select value={connectionMode} onValueChange={(value) => setConnectionMode(value as "new" | "existing") }> |
| 1754 | + <SelectTrigger className="h-8 text-xs bg-background"> |
| 1755 | + <SelectValue /> |
| 1756 | + </SelectTrigger> |
| 1757 | + <SelectContent> |
| 1758 | + <SelectItem value="new" className="text-xs">Create new connection</SelectItem> |
| 1759 | + <SelectItem value="existing" className="text-xs">Use existing connection</SelectItem> |
| 1760 | + </SelectContent> |
| 1761 | + </Select> |
| 1580 | 1762 | </div> |
| 1581 | | − ) : ( |
| 1763 | + )} |
| 1764 | + |
| 1765 | + {!editing && connectionMode === "existing" && ( |
| 1582 | 1766 | <div className="space-y-1.5"> |
| 1583 | | − <Label className="text-xs text-muted-foreground">Bearer Token</Label> |
| 1584 | | − <Input |
| 1585 | | − type="password" |
| 1586 | | − value={managedToken} |
| 1587 | | − onChange={(e) => setManagedToken(e.target.value)} |
| 1588 | | − placeholder="ghp_..." |
| 1589 | | − className="h-8 text-xs font-mono bg-background" |
| 1590 | | − /> |
| 1767 | + <Label className="text-xs text-muted-foreground">Existing Connection</Label> |
| 1768 | + <Select value={existingConnectionId} onValueChange={setExistingConnectionId}> |
| 1769 | + <SelectTrigger className="h-8 text-xs bg-background"> |
| 1770 | + <SelectValue placeholder="Select a connection" /> |
| 1771 | + </SelectTrigger> |
| 1772 | + <SelectContent> |
| 1773 | + {compatibleConnectionOptions.map((connection) => ( |
| 1774 | + <SelectItem key={connection.id} value={connection.id} className="text-xs"> |
| 1775 | + {connectionDisplayName(sources, connection)} ({connection.sourceKeys.size} source{connection.sourceKeys.size === 1 ? "" : "s"}) |
| 1776 | + </SelectItem> |
| 1777 | + ))} |
| 1778 | + </SelectContent> |
| 1779 | + </Select> |
| 1780 | + {compatibleConnectionOptions.length === 0 && ( |
| 1781 | + <p className="text-[10px] text-muted-foreground">No compatible existing connections for this scope.</p> |
| 1782 | + )} |
| 1591 | 1783 | </div> |
| 1592 | 1784 | )} |
| 1593 | 1785 | |
| 1594 | | − <Collapsible open={advancedMode} onOpenChange={handleAdvancedModeChange}> |
| 1595 | | − <CollapsibleTrigger asChild> |
| 1596 | | − <Button variant="outline" size="sm" className="h-7 text-[11px]"> |
| 1597 | | − Advanced JSON |
| 1598 | | − <ChevronRight className={cn("ml-1.5 h-3 w-3 transition-transform", advancedMode && "rotate-90")} /> |
| 1599 | | − </Button> |
| 1600 | | − </CollapsibleTrigger> |
| 1601 | | − <CollapsibleContent className="mt-2 space-y-1.5"> |
| 1602 | | − <Label className="text-xs text-muted-foreground">Override Secret JSON</Label> |
| 1603 | | − <Textarea |
| 1604 | | − value={secretJsonText} |
| 1605 | | − onChange={(e) => setSecretJsonText(e.target.value)} |
| 1606 | | − rows={6} |
| 1607 | | − className="text-xs font-mono bg-background" |
| 1608 | | − /> |
| 1609 | | − </CollapsibleContent> |
| 1610 | | − </Collapsible> |
| 1786 | + {(editing || connectionMode === "new") && ( |
| 1787 | + <> |
| 1788 | + {editing && ( |
| 1789 | + <p className="text-[11px] text-muted-foreground"> |
| 1790 | + Stored secret is hidden. Enter a new value to rotate it, or leave fields blank to keep it. |
| 1791 | + </p> |
| 1792 | + )} |
| 1793 | + |
| 1794 | + {selectedAuth.type === "none" ? ( |
| 1795 | + <p className="text-[11px] text-terminal-amber">This source does not currently require auth.</p> |
| 1796 | + ) : selectedAuth.type === "mixed" ? ( |
| 1797 | + <p className="text-[11px] text-terminal-amber"> |
| 1798 | + This source has mixed auth requirements. Link an existing connection for now. |
| 1799 | + </p> |
| 1800 | + ) : selectedAuth.type === "apiKey" ? ( |
| 1801 | + <div className="space-y-1.5"> |
| 1802 | + <Label className="text-xs text-muted-foreground">API Key Value</Label> |
| 1803 | + <Input |
| 1804 | + value={apiKeyValue} |
| 1805 | + onChange={(e) => setApiKeyValue(e.target.value)} |
| 1806 | + placeholder="sk_live_..." |
| 1807 | + className="h-8 text-xs font-mono bg-background" |
| 1808 | + /> |
| 1809 | + </div> |
| 1810 | + ) : selectedAuth.type === "basic" ? ( |
| 1811 | + <div className="grid grid-cols-2 gap-3"> |
| 1812 | + <div className="space-y-1.5"> |
| 1813 | + <Label className="text-xs text-muted-foreground">Username</Label> |
| 1814 | + <Input |
| 1815 | + value={basicUsername} |
| 1816 | + onChange={(e) => setBasicUsername(e.target.value)} |
| 1817 | + className="h-8 text-xs font-mono bg-background" |
| 1818 | + /> |
| 1819 | + </div> |
| 1820 | + <div className="space-y-1.5"> |
| 1821 | + <Label className="text-xs text-muted-foreground">Password</Label> |
| 1822 | + <Input |
| 1823 | + type="password" |
| 1824 | + value={basicPassword} |
| 1825 | + onChange={(e) => setBasicPassword(e.target.value)} |
| 1826 | + className="h-8 text-xs font-mono bg-background" |
| 1827 | + /> |
| 1828 | + </div> |
| 1829 | + </div> |
| 1830 | + ) : ( |
| 1831 | + <div className="space-y-1.5"> |
| 1832 | + <Label className="text-xs text-muted-foreground">Bearer Token</Label> |
| 1833 | + <Input |
| 1834 | + type="password" |
| 1835 | + value={tokenValue} |
| 1836 | + onChange={(e) => setTokenValue(e.target.value)} |
| 1837 | + placeholder="ghp_..." |
| 1838 | + className="h-8 text-xs font-mono bg-background" |
| 1839 | + /> |
| 1840 | + </div> |
| 1841 | + )} |
| 1842 | + </> |
| 1843 | + )} |
| 1844 | + |
| 1845 | + <div className="space-y-1.5"> |
| 1846 | + <Label className="text-xs text-muted-foreground">Custom Headers (optional)</Label> |
| 1847 | + <Textarea |
| 1848 | + value={customHeadersText} |
| 1849 | + onChange={(e) => setCustomHeadersText(e.target.value)} |
| 1850 | + rows={4} |
| 1851 | + placeholder="x-tenant-id: acme\nx-env: staging" |
| 1852 | + className="text-xs font-mono bg-background" |
| 1853 | + /> |
| 1854 | + </div> |
| 1611 | 1855 | |
| 1612 | 1856 | <Button onClick={handleSave} disabled={saving} className="w-full h-9" size="sm"> |
| 1613 | | − {saving ? "Saving..." : editing ? "Update Credential" : "Save Credential"} |
| 1857 | + {saving ? "Saving..." : editing ? "Update Connection" : connectionMode === "existing" ? "Link Connection" : "Save Connection"} |
| 1614 | 1858 | </Button> |
| 1615 | 1859 | </div> |
| 1616 | 1860 | </DialogContent> |
| @@ -1625,6 +1869,9 @@ function CredentialsPanel({ · 8 unmodified lines |
| 1625 | 1869 | |
| 1626 | 1870 | export function ToolsView({ initialSource }: { initialSource?: string | null }) { |
| 1627 | 1871 | const { context, loading: sessionLoading } = useSession(); |
| 1872 | + const [selectedSource, setSelectedSource] = useState<string | null>(initialSource ?? null); |
| 1873 | + const [activeTab, setActiveTab] = useState<"catalog" | "credentials" | "mcp">("catalog"); |
| 1874 | + const [focusCredentialSourceKey, setFocusCredentialSourceKey] = useState<string | null>(null); |
| 1628 | 1875 | |
| 1629 | 1876 | const sources = useQuery( |
| 1630 | 1877 | convexApi.workspace.listToolSources, |
| @@ -1637,9 +1884,20 @@ export function ToolsView({ initialSource }: { initialSource?: string | null }) · 6 unmodified lines |
| 1637 | 1884 | convexApi.workspace.listCredentials, |
| 1638 | 1885 | context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip", |
| 1639 | 1886 | ); |
| 1887 | + const credentialItems: CredentialRecord[] = credentials ?? []; |
| 1640 | 1888 | const credentialsLoading = !!context && credentials === undefined; |
| 1641 | 1889 | |
| 1642 | | − const { tools, warnings, sourceQuality, loading: toolsLoading } = useWorkspaceTools(context ?? null); |
| 1890 | + const { |
| 1891 | + tools, |
| 1892 | + warnings, |
| 1893 | + sourceQuality, |
| 1894 | + sourceAuthProfiles, |
| 1895 | + loadingTools, |
| 1896 | + refreshingTools, |
| 1897 | + } = useWorkspaceTools(context ?? null); |
| 1898 | + const selectedSourceRecord = selectedSource |
| 1899 | + ? sourceItems.find((source) => source.name === selectedSource) ?? null |
| 1900 | + : null; |
| 1643 | 1901 | |
| 1644 | 1902 | if (sessionLoading) { |
| 1645 | 1903 | return ( |
| @@ -1654,108 +1912,113 @@ export function ToolsView({ initialSource }: { initialSource?: string | null }) · 8 unmodified lines |
| 1654 | 1912 | <div className="flex h-full min-h-0 flex-col gap-6"> |
| 1655 | 1913 | <PageHeader |
| 1656 | 1914 | title="Tools" |
| 1657 | | − description="Manage sources, auth, credentials, and available tools" |
| 1915 | + description="Manage sources, auth, connections, and available tools" |
| 1658 | 1916 | /> |
| 1659 | 1917 | |
| 1660 | 1918 | <Tabs |
| 1661 | | − defaultValue={initialSource ? "inventory" : "sources"} |
| 1919 | + value={activeTab} |
| 1920 | + onValueChange={(value) => setActiveTab(value as "catalog" | "credentials" | "mcp")} |
| 1662 | 1921 | className="w-full min-h-0 flex-1" |
| 1663 | 1922 | > |
| 1664 | 1923 | <TabsList className="bg-muted/50 h-9"> |
| 1665 | | − <TabsTrigger value="sources" className="text-xs data-[state=active]:bg-background"> |
| 1666 | | − Sources |
| 1667 | | − {sources && ( |
| 1668 | | − <span className="ml-1.5 text-[10px] font-mono text-muted-foreground"> |
| 1669 | | − {sourceItems.length} |
| 1670 | | − </span> |
| 1671 | | − )} |
| 1924 | + <TabsTrigger value="catalog" className="text-xs data-[state=active]:bg-background"> |
| 1925 | + Catalog |
| 1926 | + <span className="ml-1.5 text-[10px] font-mono text-muted-foreground"> |
| 1927 | + {loadingTools ? "..." : tools.length} |
| 1928 | + </span> |
| 1672 | 1929 | </TabsTrigger> |
| 1673 | 1930 | <TabsTrigger value="credentials" className="text-xs data-[state=active]:bg-background"> |
| 1674 | | − Credentials |
| 1931 | + Connections |
| 1675 | 1932 | {credentials && ( |
| 1676 | 1933 | <span className="ml-1.5 text-[10px] font-mono text-muted-foreground"> |
| 1677 | | − {credentials.length} |
| 1934 | + {new Set(credentialItems.map((credential) => credential.id)).size} |
| 1678 | 1935 | </span> |
| 1679 | 1936 | )} |
| 1680 | 1937 | </TabsTrigger> |
| 1681 | | − <TabsTrigger value="inventory" className="text-xs data-[state=active]:bg-background"> |
| 1682 | | − Inventory |
| 1683 | | − <span className="ml-1.5 text-[10px] font-mono text-muted-foreground"> |
| 1684 | | − {toolsLoading ? "…" : tools.length} |
| 1685 | | − </span> |
| 1686 | | − </TabsTrigger> |
| 1687 | 1938 | <TabsTrigger value="mcp" className="text-xs data-[state=active]:bg-background"> |
| 1688 | 1939 | MCP Setup |
| 1689 | 1940 | </TabsTrigger> |
| 1690 | 1941 | </TabsList> |
| 1691 | 1942 | |
| 1692 | | − <TabsContent value="sources" className="mt-4"> |
| 1693 | | − <Card className="bg-card border-border"> |
| 1943 | + <TabsContent value="catalog" className="mt-4 min-h-0"> |
| 1944 | + <Card className="bg-card border-border min-h-0 flex flex-col"> |
| 1694 | 1945 | <CardHeader className="pb-3"> |
| 1695 | | − <div className="flex items-center justify-between"> |
| 1946 | + <div className="flex items-center justify-between gap-3"> |
| 1696 | 1947 | <CardTitle className="text-sm font-medium flex items-center gap-2"> |
| 1697 | | − <Server className="h-4 w-4 text-muted-foreground" /> |
| 1698 | | − Tool Sources |
| 1948 | + <Globe className="h-4 w-4 text-muted-foreground" /> |
| 1949 | + Tools + Sources |
| 1950 | + <span className="text-[10px] font-mono bg-muted text-muted-foreground px-1.5 py-0.5 rounded"> |
| 1951 | + {loadingTools ? "..." : tools.length} |
| 1952 | + </span> |
| 1699 | 1953 | </CardTitle> |
| 1700 | | − <AddSourceDialog existingSourceNames={new Set(sourceItems.map((s) => s.name))} /> |
| 1954 | + <div className="flex items-center gap-2"> |
| 1955 | + {selectedSource ? ( |
| 1956 | + <Button variant="outline" size="sm" className="h-7 text-[11px]" onClick={() => setSelectedSource(null)}> |
| 1957 | + Clear source filter |
| 1958 | + </Button> |
| 1959 | + ) : null} |
| 1960 | + <AddSourceDialog |
| 1961 | + existingSourceNames={new Set(sourceItems.map((s) => s.name))} |
| 1962 | + onSourceAdded={(source) => { |
| 1963 | + setActiveTab("credentials"); |
| 1964 | + setSelectedSource(source.name); |
| 1965 | + const key = sourceKeyForSource(source); |
| 1966 | + if (key) { |
| 1967 | + setFocusCredentialSourceKey(key); |
| 1968 | + } |
| 1969 | + }} |
| 1970 | + /> |
| 1971 | + </div> |
| 1701 | 1972 | </div> |
| 1973 | + <p className="text-[11px] text-muted-foreground"> |
| 1974 | + {selectedSource |
| 1975 | + ? `Filtering and managing ${selectedSource}.` |
| 1976 | + : "Source management and tool inventory are unified here."} |
| 1977 | + </p> |
| 1702 | 1978 | </CardHeader> |
| 1703 | | − <CardContent className="pt-0"> |
| 1979 | + <CardContent className="pt-0 min-h-0 flex-1 flex flex-col gap-3"> |
| 1704 | 1980 | {sourcesLoading ? ( |
| 1705 | 1981 | <div className="space-y-2"> |
| 1706 | | − {Array.from({ length: 3 }).map((_, i) => ( |
| 1982 | + {Array.from({ length: 2 }).map((_, i) => ( |
| 1707 | 1983 | <Skeleton key={i} className="h-16" /> |
| 1708 | 1984 | ))} |
| 1709 | 1985 | </div> |
| 1710 | | − ) : sourceItems.length === 0 ? ( |
| 1711 | | − <div className="flex flex-col items-center justify-center py-12 gap-3"> |
| 1712 | | − <div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center"> |
| 1713 | | − <Wrench className="h-6 w-6 text-muted-foreground/40" /> |
| 1714 | | − </div> |
| 1715 | | − <p className="text-sm text-muted-foreground"> |
| 1716 | | − No external tool sources |
| 1717 | | − </p> |
| 1718 | | − <p className="text-[11px] text-muted-foreground/60"> |
| 1719 | | − Add MCP, OpenAPI, or GraphQL sources to extend available tools |
| 1986 | + ) : null} |
| 1987 | + |
| 1988 | + {!sourcesLoading && sourceItems.length === 0 ? ( |
| 1989 | + <div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2.5"> |
| 1990 | + <p className="text-[11px] text-muted-foreground"> |
| 1991 | + No external sources yet. Add MCP, OpenAPI, or GraphQL to expand available tools. |
| 1720 | 1992 | </p> |
| 1721 | 1993 | </div> |
| 1722 | | − ) : ( |
| 1723 | | − <div className="space-y-2"> |
| 1724 | | − {warnings.length > 0 && ( |
| 1725 | | − <div className="rounded-md border border-terminal-amber/30 bg-terminal-amber/10 px-3 py-2.5"> |
| 1726 | | − <div className="flex items-center gap-1.5 text-[11px] font-medium text-terminal-amber"> |
| 1727 | | − <AlertTriangle className="h-3.5 w-3.5" /> |
| 1728 | | − Source load warnings ({warnings.length}) |
| 1729 | | − </div> |
| 1730 | | − <div className="mt-1.5 space-y-1"> |
| 1731 | | − {warnings.slice(0, 3).map((warning: string, i: number) => ( |
| 1732 | | − <p key={`${warning}-${i}`} className="text-[11px] text-terminal-amber/90"> |
| 1733 | | − {warning} |
| 1734 | | − </p> |
| 1735 | | − ))} |
| 1736 | | − {warnings.length > 3 && ( |
| 1737 | | − <p className="text-[10px] text-terminal-amber/80"> |
| 1738 | | − +{warnings.length - 3} more warning{warnings.length - 3 === 1 ? "" : "s"} |
| 1739 | | − </p> |
| 1740 | | − )} |
| 1741 | | − </div> |
| 1742 | | − </div> |
| 1743 | | − )} |
| 1744 | | − {sourceItems.map((s) => { |
| 1745 | | − const quality = sourceQuality[toolSourceLabelForSource(s)]; |
| 1746 | | − const credentialStats = credentialStatsForSource(s, credentials ?? []); |
| 1747 | | − return ( |
| 1748 | | − <SourceCard |
| 1749 | | − key={s.id} |
| 1750 | | − source={s} |
| 1751 | | − quality={quality} |
| 1752 | | − qualityLoading={toolsLoading} |
| 1753 | | − credentialStats={credentialStats} |
| 1754 | | − /> |
| 1755 | | − ); |
| 1756 | | − })} |
| 1994 | + ) : null} |
| 1995 | + |
| 1996 | + {selectedSourceRecord ? ( |
| 1997 | + <div className="space-y-1.5"> |
| 1998 | + <p className="text-[10px] uppercase tracking-widest text-muted-foreground">Selected source</p> |
| 1999 | + <SourceCard |
| 2000 | + source={selectedSourceRecord} |
| 2001 | + quality={sourceQuality[toolSourceLabelForSource(selectedSourceRecord)]} |
| 2002 | + qualityLoading={selectedSourceRecord.type === "openapi" && !sourceQuality[toolSourceLabelForSource(selectedSourceRecord)] && refreshingTools} |
| 2003 | + credentialStats={credentialStatsForSource(selectedSourceRecord, credentialItems)} |
| 2004 | + sourceAuthProfiles={sourceAuthProfiles} |
| 2005 | + selected |
| 2006 | + onFocusSource={setSelectedSource} |
| 2007 | + /> |
| 1757 | 2008 | </div> |
| 1758 | | − )} |
| 2009 | + ) : null} |
| 2010 | + |
| 2011 | + <div className="min-h-0 flex-1"> |
| 2012 | + <ToolExplorer |
| 2013 | + tools={tools} |
| 2014 | + sources={sourceItems} |
| 2015 | + loading={loadingTools} |
| 2016 | + warnings={warnings} |
| 2017 | + initialSource={initialSource} |
| 2018 | + activeSource={selectedSource} |
| 2019 | + onActiveSourceChange={setSelectedSource} |
| 2020 | + /> |
| 2021 | + </div> |
| 1759 | 2022 | </CardContent> |
| 1760 | 2023 | </Card> |
| 1761 | 2024 | </TabsContent> |
| @@ -1763,18 +2026,11 @@ export function ToolsView({ initialSource }: { initialSource?: string | null }) · 1 unmodified line |
| 1763 | 2026 | <TabsContent value="credentials" className="mt-4"> |
| 1764 | 2027 | <CredentialsPanel |
| 1765 | 2028 | sources={sourceItems} |
| 1766 | | − credentials={credentials ?? []} |
| 2029 | + credentials={credentialItems} |
| 2030 | + sourceAuthProfiles={sourceAuthProfiles} |
| 1767 | 2031 | loading={credentialsLoading || sourcesLoading} |
| 1768 | | − /> |
| 1769 | | − </TabsContent> |
| 1770 | | − |
| 1771 | | − <TabsContent value="inventory" className="mt-4 min-h-0"> |
| 1772 | | − <ToolExplorer |
| 1773 | | − tools={tools} |
| 1774 | | − sources={sourceItems} |
| 1775 | | − loading={toolsLoading} |
| 1776 | | − warnings={warnings} |
| 1777 | | − initialSource={initialSource} |
| 2032 | + focusSourceKey={focusCredentialSourceKey} |
| 2033 | + onFocusHandled={() => setFocusCredentialSourceKey(null)} |
| 1778 | 2034 | /> |
| 1779 | 2035 | </TabsContent> |
| 1780 | 2036 | |