diff --git a/package-lock.json b/package-lock.json index 9ad73bef8..109090361 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2863,19 +2863,19 @@ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, - "node_modules/@fontsource-variable/geist": { + "node_modules/@fontsource-variable/ibm-plex-sans": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz", - "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==", + "resolved": "https://registry.npmjs.org/@fontsource-variable/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz", + "integrity": "sha512-agG8tXFEo0hD9+J7npa4vbbWult52eMLVaQ6WQRlhs/iCAojrMAoejru85W9HTVXHfyUj96KM7gp/KGAS87XaQ==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" } }, - "node_modules/@fontsource-variable/space-grotesk": { + "node_modules/@fontsource/ibm-plex-mono": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource-variable/space-grotesk/-/space-grotesk-5.3.0.tgz", - "integrity": "sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw==", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", + "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -19089,8 +19089,8 @@ "name": "@lightspeed-ai/platform-web", "dependencies": { "@base-ui/react": "^1.8.0", - "@fontsource-variable/geist": "^5.3.0", - "@fontsource-variable/space-grotesk": "^5.3.0", + "@fontsource-variable/ibm-plex-sans": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", "@lightspeed-ai/platform-shared": "*", "@lightspeed-ai/sdk": "*", "@shadcn/react": "^0.3.1", diff --git a/platform/web/package.json b/platform/web/package.json index e30cdbe09..9a9cfe633 100644 --- a/platform/web/package.json +++ b/platform/web/package.json @@ -12,8 +12,8 @@ }, "dependencies": { "@base-ui/react": "^1.8.0", - "@fontsource-variable/geist": "^5.3.0", - "@fontsource-variable/space-grotesk": "^5.3.0", + "@fontsource-variable/ibm-plex-sans": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", "@lightspeed-ai/sdk": "*", "@lightspeed-ai/platform-shared": "*", "@shadcn/react": "^0.3.1", diff --git a/platform/web/public/fonts/ibm-plex-mono.LICENSE.txt b/platform/web/public/fonts/ibm-plex-mono.LICENSE.txt new file mode 100644 index 000000000..83eac8278 --- /dev/null +++ b/platform/web/public/fonts/ibm-plex-mono.LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ThinItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLight.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Light.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-LightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Regular.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Italic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Medium.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-MediumItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Bold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-BoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/platform/web/public/fonts/ibm-plex-sans.LICENSE.txt b/platform/web/public/fonts/ibm-plex-sans.LICENSE.txt new file mode 100644 index 000000000..2ae8ef3cc --- /dev/null +++ b/platform/web/public/fonts/ibm-plex-sans.LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2019 IBM Corp. All rights reserved. IBMPlexSans-Italic[wdth,wght].ttf: Copyright 2019 IBM Corp. All rights reserved. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/platform/web/src/components/app-shell.test.tsx b/platform/web/src/components/app-shell.test.tsx index 4e69e0f04..f2767734d 100644 --- a/platform/web/src/components/app-shell.test.tsx +++ b/platform/web/src/components/app-shell.test.tsx @@ -19,7 +19,7 @@ vi.mock("@/components/universe-switcher", () => ({ UniverseSwitcher: () => null vi.mock("@/components/user-menu", () => ({ UserMenu: () => null })); const WORK = ["Bots", "Sessions"]; -const SETUP = ["Profiles", "Workspaces", "Models", "Environments", "MCP servers"]; +const SETUP = ["Profiles", "Workspaces", "Environments", "MCP servers", "Models"]; let root: Root; let container: HTMLDivElement; @@ -111,7 +111,7 @@ it("links each page at its flat route", async () => { await sidebarFor("admin"); expect([...container.querySelectorAll("a")].map((link) => link.getAttribute("href"))).toEqual([ "/u/test/bots", "/u/test/sessions", - "/u/test/profiles", "/u/test/workspaces", "/u/test/models", "/u/test/environments", "/u/test/mcp-servers", + "/u/test/profiles", "/u/test/workspaces", "/u/test/environments", "/u/test/mcp-servers", "/u/test/models", "/u/test/credentials", "/u/test/api-keys", "/u/test/members", "/u/test/settings/general", "/u/test/settings/channels", "/u/test/settings/templates", ]); diff --git a/platform/web/src/components/confirm-danger-button.tsx b/platform/web/src/components/confirm-danger-button.tsx index 0bafcfda6..47376dce9 100644 --- a/platform/web/src/components/confirm-danger-button.tsx +++ b/platform/web/src/components/confirm-danger-button.tsx @@ -29,7 +29,7 @@ export function ConfirmDangerButton({ }) { return ( - }> + }> {label} diff --git a/platform/web/src/components/environment/power-controls.tsx b/platform/web/src/components/environment/power-controls.tsx index c8ed32165..a20bfd437 100644 --- a/platform/web/src/components/environment/power-controls.tsx +++ b/platform/web/src/components/environment/power-controls.tsx @@ -54,10 +54,12 @@ export function EnvironmentPowerControls({ universeId, environment, onChanged, + size = "xs", }: { universeId: string; environment: Environment; onChanged?: () => void; + size?: "xs" | "sm"; }) { const queryClient = useQueryClient(); const power = useMutation({ @@ -91,7 +93,7 @@ export function EnvironmentPowerControls({ - + + )} ); @@ -564,13 +551,11 @@ export function OpenAiCompatibleForm({ export function OpenAiCompatibleDetails({ universeId, provider, - oauthGrant, onChanged, onRemoved, }: { universeId: string; provider: SecretProvider; - oauthGrant?: SecretGrant; onChanged: () => void; onRemoved: () => void; }) { @@ -600,30 +585,6 @@ export function OpenAiCompatibleDetails({ ); return (
-
-
Provider
-
{provider.providerId}
-
Credential ID
-
- {provider.credentialId} -
-
Status
-
- -
-
Authentication
-
- {provider.config.type === "modelOAuth" ? ( - <> - OAuth · {provider.config.grantId} - - ) : provider.hasCredential ? ( - "API key" - ) : ( - "None" - )} -
-
{endpoint && } {remove.error.message}

)} {writable && ( - + remove.mutate()} /> - {endpoint && } - + {endpoint && } + )}
); } -/// Why a provider row is or is not used for model calls. -function ProviderStatusBadge({ - provider, - oauthGrant, -}: { - provider: SecretProvider; - oauthGrant?: SecretGrant; -}) { - const attention = (label: string) => ( - - {label} - - ); - if (!provider.usableForModels) return attention("legacy id — replace"); - if (provider.status === "disabled") return disabled; - if (provider.config.type === "modelOAuth") { - if (!oauthGrant || oauthGrant.status === "revoked") return attention("OAuth grant missing"); - if (oauthGrant.status !== "active") { - return attention(oauthGrant.status === "needsReauth" ? "needs reauth" : oauthGrant.status); - } - } else if (provider.config.type !== "modelEndpoint" && !provider.hasCredential) { - return attention("needs key"); - } - if (provider.status !== "active") return attention("needs configuration"); - return active; -} export interface ProviderModelCatalogEntry { model: string; diff --git a/platform/web/src/components/models/model-provider-details-dialog.tsx b/platform/web/src/components/models/model-provider-details-dialog.tsx index aa6eeb7a6..560ce1557 100644 --- a/platform/web/src/components/models/model-provider-details-dialog.tsx +++ b/platform/web/src/components/models/model-provider-details-dialog.tsx @@ -1,4 +1,9 @@ -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import type { RefObject } from "react"; +import { ResourceDetail, ResourceDetailsDialog, ResourceDetailsGrid } from "@/components/resource-details-dialog"; +import { ResourceStatus } from "@/components/resource-table"; +import { resourceTimestamp } from "@/lib/resource-status"; +import { formatExpiry } from "@/lib/integrations"; +import { subscriptionAccountLabel, subscriptionBinding } from "@/lib/subscriptions"; import { modelProviderDefinition } from "./catalog"; import { ModelApiKeyDetails, OpenAiCompatibleDetails } from "./model-api-key"; import { SubscriptionDetails } from "./subscription"; @@ -11,34 +16,43 @@ export function ModelProviderDetailsDialog({ provider, onOpenChange, onChanged, + finalFocus, }: { universeId: string; provider: ConnectedModelProvider | null; onOpenChange: (open: boolean) => void; onChanged: () => void; + finalFocus?: RefObject; }) { const definition = provider ? modelProviderDefinition(provider.kind) : null; return ( - - - {provider && definition && ( - <> - - - - {provider.title} - - - onOpenChange(false)} - /> - - )} - - + } finalFocus={finalFocus}> + {provider && ( + <> + + {"provider" in provider + ? {provider.provider.providerId} + : {subscriptionAccountLabel(provider.grant) || "—"}} + + {"provider" in provider ? provider.provider.credentialId : provider.grant.grantId} + {provider.connectionDescription ? `${provider.connection} · ${provider.connectionDescription}` : provider.connection} + {"provider" in provider && provider.provider.config.type === "modelOAuth" && ( + {provider.provider.config.grantId} + )} + {"grant" in provider && <> + {subscriptionBinding(provider.grant)?.envName ?? "—"} + {formatExpiry(provider.grant.expiresAtMs)} + } + {resourceTimestamp(provider.createdAtMs)} + {resourceTimestamp(provider.updatedAtMs)} + + onOpenChange(false)} /> + + )} + ); } @@ -73,7 +87,6 @@ function ModelProviderDetails({ diff --git a/platform/web/src/components/models/model-provider-list.tsx b/platform/web/src/components/models/model-provider-list.tsx index 959be3868..60c3f6da3 100644 --- a/platform/web/src/components/models/model-provider-list.tsx +++ b/platform/web/src/components/models/model-provider-list.tsx @@ -1,99 +1,34 @@ import { EmptyState } from "@/components/page"; -import { BrainCircuit, ChevronRight } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { - Table, - TableBody, - TableCard, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; +import { BrainCircuit } from "lucide-react"; +import { TableCell } from "@/components/ui/table"; +import { ResourceIdentityCell, ResourceRow, ResourceStatus, ResourceTable, ResourceValue } from "@/components/resource-table"; import { modelProviderDefinition } from "./catalog"; -import type { ConnectedModelProvider, ModelProviderStatus } from "./use-model-providers"; +import type { ConnectedModelProvider } from "./use-model-providers"; -/// Connected model providers; a row opens the details dialog. -export function ModelProviderList({ - providers, - onSelect, -}: { +export function ModelProviderList({ providers, onSelect, showEmpty = true }: { providers: ConnectedModelProvider[]; - onSelect: (provider: ConnectedModelProvider) => void; + onSelect: (provider: ConnectedModelProvider, trigger: HTMLButtonElement | null) => void; + showEmpty?: boolean; }) { if (providers.length === 0) { - return ( + return showEmpty ? ( - Provider API keys, compatible endpoints and coding-agent subscriptions that sessions call - models through. + Provider API keys, compatible endpoints and coding-agent subscriptions that sessions call models through. - ); + ) : null; } return ( - - - - - Provider - Type - Status - Updated - - - - - {providers.map((provider) => { - const { Logo } = modelProviderDefinition(provider.kind); - return ( - onSelect(provider)} - > - -
- - - -
- {provider.title} - {provider.subtitle} -
-
-
- {provider.type} - - - - - {formatTimestamp(provider.updatedAtMs)} - - - - -
- ); - })} -
-
-
+ + {providers.map((provider) => { + const { Logo } = modelProviderDefinition(provider.kind); + return ( + onSelect(provider, trigger)}> + } compactDescription={[provider.connection, provider.connectionDescription, provider.subtitle.trim().toLocaleLowerCase() !== provider.title.trim().toLocaleLowerCase() ? provider.subtitle : null].filter(Boolean).join(" · ")} /> + {provider.connection} + + + ); + })} + ); } - -function StatusBadge({ status }: { status: ModelProviderStatus }) { - if (status === "active") return active; - if (status === "disabled") return disabled; - return ( - - needs attention - - ); -} - -function formatTimestamp(timestampMs: number): string { - if (!timestampMs) return "—"; - return new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", - }).format(new Date(timestampMs)); -} diff --git a/platform/web/src/components/models/subscription.tsx b/platform/web/src/components/models/subscription.tsx index 660bdf20c..b1058d363 100644 --- a/platform/web/src/components/models/subscription.tsx +++ b/platform/web/src/components/models/subscription.tsx @@ -1,21 +1,17 @@ +import { ResourceActions } from "@/components/resource-details-dialog"; import { useActionPermissions } from "@/lib/permissions"; import { useState, type FormEvent } from "react"; import { useMutation } from "@tanstack/react-query"; import { Copy } from "lucide-react"; import { api, type SecretGrant, type SubscriptionImportResult } from "@/api"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DialogFooter } from "@/components/ui/dialog"; import { Field, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; -import { IdText } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; -import { formatExpiry } from "@/lib/integrations"; import { CODEX_AUTH_JSON_BOOTSTRAP, isCodexTokenSet, - subscriptionAccountLabel, - subscriptionBinding, type SubscriptionProvider, } from "@/lib/subscriptions"; import { ConfirmDangerButton } from "@/components/confirm-danger-button"; @@ -155,7 +151,6 @@ export function SubscriptionDetails({ onDisconnected: () => void; }) { const writable = useActionPermissions(universeId).can("configure_resource"); - const binding = subscriptionBinding(grant); const disconnect = useMutation({ mutationFn: () => api( @@ -167,22 +162,6 @@ export function SubscriptionDetails({ return (
-
-
Account
-
{subscriptionAccountLabel(grant) || "—"}
-
Bind as
-
{binding ? {binding.envName} : "—"}
-
Expires
-
{formatExpiry(grant.expiresAtMs)}
-
Status
-
- -
-
Credential ID
-
- {grant.grantId} -
-

Used only inside environments (Environments → Assign credential; the variable name above is suggested automatically). Lightspeed's own sessions do not use this credential; they use @@ -191,7 +170,7 @@ export function SubscriptionDetails({ {isCodexTokenSet(grant) && } {disconnect.error &&

{disconnect.error.message}

} {writable && ( - + disconnect.mutate()} /> - + )}
); @@ -240,15 +219,3 @@ export function CodexBootstrapNote() { ); } - -function SubscriptionStatusBadge({ status }: { status: SecretGrant["status"] }) { - if (status === "active") return connected; - if (status === "needsReauth" || status === "failed") { - return ( - - {status === "needsReauth" ? "reconnect" : status} - - ); - } - return revoked; -} diff --git a/platform/web/src/components/models/use-model-providers.test.ts b/platform/web/src/components/models/use-model-providers.test.ts index 8f1181be0..5ae5f79f0 100644 --- a/platform/web/src/components/models/use-model-providers.test.ts +++ b/platform/web/src/components/models/use-model-providers.test.ts @@ -35,14 +35,14 @@ it("lists every model provider row, including legacy and OAuth rows no add form grant("plain-token"), ], ); - expect(rows.map((row) => [row.kind, row.title, row.type, row.status])).toEqual([ - ["anthropicApiKey", "Anthropic (API key)", "Anthropic (API key)", "attention"], - ["openAiCompatible", "deepseek", "OpenAI-compatible provider", "active"], - ["openAiCompatible", "ollama", "OpenAI-compatible provider", "active"], - ["openAiApiKey", "OpenAI (API key)", "OpenAI (API key)", "active"], - ["openAiCompatible", "vendor", "OAuth connection", "attention"], - ["anthropicSubscription", "Lukas · Max", "Claude Code (subscription)", "active"], + expect(rows.map((row) => [row.kind, row.title, row.connection, row.status.label])).toEqual([ + ["anthropicApiKey", "Anthropic", "API key", "Needs attention"], + ["openAiCompatible", "deepseek", "API key", "Active"], + ["anthropicSubscription", "Lukas · Max", "Subscription", "Connected"], + ["openAiCompatible", "ollama", "No authentication", "Active"], + ["openAiApiKey", "Production", "API key", "Active"], + ["openAiCompatible", "vendor", "OAuth", "Needs sign-in"], ]); - expect(rows[3]).toMatchObject({ subtitle: "Production", updatedAtMs: 2 }); - expect(rows[4]).toMatchObject({ oauthGrant: { grantId: "vendor-login" } }); + expect(rows.find((row) => row.title === "Production")).toMatchObject({ subtitle: "openai", createdAtMs: 1, updatedAtMs: 2 }); + expect(rows.find((row) => row.title === "vendor")).toMatchObject({ oauthGrant: { grantId: "vendor-login" } }); }); diff --git a/platform/web/src/components/models/use-model-providers.ts b/platform/web/src/components/models/use-model-providers.ts index 2de7427f0..a4c6ca7e1 100644 --- a/platform/web/src/components/models/use-model-providers.ts +++ b/platform/web/src/components/models/use-model-providers.ts @@ -1,7 +1,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { api, type SecretGrant, type SecretProvider, type SecretsInventory } from "@/api"; import { subscriptionAccountLabel, subscriptionProviderOf } from "@/lib/subscriptions"; -import { modelProviderDefinition, type ModelProviderKind } from "./catalog"; +import type { ModelProviderKind } from "./catalog"; +import { modelProviderStatus, subscriptionStatus, type ResourceStatusInfo } from "@/lib/resource-status"; /// One connected model provider as the list shows it. Each variant keeps its /// source record so the details dialog can render kind-specific content. @@ -9,9 +10,11 @@ export type ConnectedModelProvider = { id: string; title: string; subtitle: string; - /// The list's Type column. - type: string; - status: ModelProviderStatus; + subtitleMono: boolean; + connection: string; + connectionDescription?: string; + status: ResourceStatusInfo; + createdAtMs: number; updatedAtMs: number; } & ( | { @@ -26,11 +29,9 @@ export type ConnectedModelProvider = { } ); -export type ModelProviderStatus = "active" | "attention" | "disabled"; - /// Every model provider row in the universe inventory, including legacy and -/// OAuth-configured rows no add form creates, followed by coding-agent -/// subscriptions that are still connected. +/// OAuth-configured rows no add form creates, and connected coding-agent +/// subscriptions, sorted by their displayed name. export function connectedModelProviders( inventory: SecretsInventory | undefined, subscriptions: SecretGrant[] | undefined, @@ -38,7 +39,6 @@ export function connectedModelProviders( const grants = inventory?.grants ?? []; const providers = (inventory?.providers ?? []) .slice() - .sort((a, b) => a.providerId.localeCompare(b.providerId) || a.credentialId.localeCompare(b.credentialId)) .map((provider): ConnectedModelProvider => { const builtIn = provider.providerId === "openai" || provider.providerId === "anthropic"; const config = provider.config; @@ -47,26 +47,24 @@ export function connectedModelProviders( : undefined; const common = { id: `model-provider:${provider.credentialId}`, - status: providerStatus(provider, oauthGrant), + status: modelProviderStatus(provider, oauthGrant), + connection: config.type === "modelOAuth" ? "OAuth" : config.type === "modelEndpoint" ? "No authentication" : "API key", + subtitleMono: true, + createdAtMs: provider.createdAtMs, updatedAtMs: provider.updatedAtMs, provider, oauthGrant, }; if (builtIn && config.type === "modelApiKey") { const kind = provider.providerId === "openai" ? "openAiApiKey" : "anthropicApiKey"; - const name = modelProviderDefinition(kind).name; - return { ...common, kind, title: name, subtitle: provider.displayName ?? provider.credentialId, type: name }; + const name = provider.providerId === "openai" ? "OpenAI" : "Anthropic"; + return { ...common, kind, title: provider.displayName || name, subtitle: provider.providerId }; } return { ...common, kind: "openAiCompatible", title: provider.displayName ?? provider.providerId, subtitle: provider.providerId, - type: providerEndpoint(provider) - ? modelProviderDefinition("openAiCompatible").name - : config.type === "modelOAuth" - ? "OAuth connection" - : "API key", }; }); const connectedSubscriptions = (subscriptions ?? []) @@ -76,29 +74,23 @@ export function connectedModelProviders( if (!provider) return []; const kind: ModelProviderKind = provider === "anthropic" ? "anthropicSubscription" : "openAiSubscription"; - const name = modelProviderDefinition(kind).name; + const name = provider === "anthropic" ? "Claude Code" : "Codex"; + const account = subscriptionAccountLabel(grant); return [{ kind, id: `subscription:${grant.grantId}`, - title: grant.displayName ?? name, - subtitle: subscriptionAccountLabel(grant) || grant.grantId, - type: name, - status: grant.status === "active" ? "active" : "attention", + title: grant.displayName || name, + subtitle: account || grant.grantId, + subtitleMono: !account, + connection: "Subscription", + connectionDescription: name, + status: subscriptionStatus(grant), + createdAtMs: grant.createdAtMs, updatedAtMs: grant.updatedAtMs, grant, }]; }); - return [...providers, ...connectedSubscriptions]; -} - -/// Usable for model calls: an active row under the id the runtime reads, -/// with its credential when it needs one. -function providerStatus(provider: SecretProvider, oauthGrant: SecretGrant | undefined): ModelProviderStatus { - if (provider.status === "disabled") return "disabled"; - const credentialed = - provider.config.type === "modelEndpoint" - || (provider.config.type === "modelOAuth" ? oauthGrant?.status === "active" : provider.hasCredential); - return provider.status === "active" && provider.usableForModels && credentialed ? "active" : "attention"; + return [...providers, ...connectedSubscriptions].sort((a, b) => a.title.localeCompare(b.title) || a.id.localeCompare(b.id)); } export function providerEndpoint(provider: SecretProvider) { @@ -118,6 +110,8 @@ export function useModelProviders(universeId: string) { return { connected: connectedModelProviders(secrets.data, subscriptions.data), isLoading: subscriptions.isLoading || secrets.isLoading, + hasData: subscriptions.data !== undefined || secrets.data !== undefined, + isComplete: subscriptions.data !== undefined && secrets.data !== undefined, error: subscriptions.error ?? secrets.error ?? null, }; } diff --git a/platform/web/src/components/page.tsx b/platform/web/src/components/page.tsx index 24030a621..8d7eb0c57 100644 --- a/platform/web/src/components/page.tsx +++ b/platform/web/src/components/page.tsx @@ -35,7 +35,7 @@ export function SectionHeader({ return (
-

{title}

+

{title}

{description &&

{description}

}
{actions} diff --git a/platform/web/src/components/resource-details-dialog.tsx b/platform/web/src/components/resource-details-dialog.tsx new file mode 100644 index 000000000..2a84720e9 --- /dev/null +++ b/platform/web/src/components/resource-details-dialog.tsx @@ -0,0 +1,47 @@ +import { useRef, type ReactNode, type RefObject } from "react"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; + +export function ResourceDetailsDialog({ open, onOpenChange, title, description, icon, finalFocus, children }: { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: ReactNode; + icon?: ReactNode; + finalFocus?: RefObject; + children: ReactNode; +}) { + const contentRef = useRef(null); + return ( + + + + + {icon && } + {title} + + {description} + +
{children}
+
+
+ ); +} + +export function ResourceDetailsGrid({ children }: { children: ReactNode }) { + return
{children}
; +} + +export function ResourceDetail({ label, children, mono = false }: { label: string; children: ReactNode; mono?: boolean }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +export function ResourceActions({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/platform/web/src/components/resource-table.tsx b/platform/web/src/components/resource-table.tsx new file mode 100644 index 000000000..7fd22d39c --- /dev/null +++ b/platform/web/src/components/resource-table.tsx @@ -0,0 +1,105 @@ +import { useRef, type ReactNode } from "react"; +import { ChevronRight, LoaderCircle } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Table, TableActionsCell, TableBody, TableCard, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import type { ResourceStatusInfo } from "@/lib/resource-status"; +import { cn } from "@/lib/utils"; + +/** Inventory rows share their hierarchy and interaction; columns keep their domain meaning. */ +export function ResourceTable({ label, columns, children }: { + label: string; + columns: string[]; + children: ReactNode; +}) { + return ( + + + + + {columns.map((column, index) => 0 && index < columns.length - 1 ? "hidden sm:table-cell" : undefined}>{column})} + Details + + + {children} +
+
+ ); +} + +export function ResourceRow({ name, onOpen, children }: { + name: string; + onOpen: (trigger: HTMLButtonElement | null) => void; + children: ReactNode; +}) { + const triggerRef = useRef(null); + return ( + onOpen(triggerRef.current)}> + {children} + + + + + ); +} + +export function ResourceIdentityCell({ name, subtitle, mono = false, icon, compactDescription }: { + name: string; + subtitle?: string; + mono?: boolean; + icon?: ReactNode; + compactDescription?: string; +}) { + const secondary = subtitle?.trim().toLocaleLowerCase() !== name.trim().toLocaleLowerCase() ? subtitle : undefined; + return ( + +
+ {icon && } +
+ {name} + {secondary && {secondary}} + {compactDescription && {compactDescription}} +
+
+
+ ); +} + +export function ResourceValue({ children, description, mono = false }: { + children: string; + description?: string; + mono?: boolean; +}) { + return ( +
+ {children} + {description && {description}} +
+ ); +} + +export function ResourceStatusBadge({ status }: { status: ResourceStatusInfo }) { + return ( + + {status.tone === "pending" && + ); +} + +export function ResourceStatus({ status }: { status: ResourceStatusInfo }) { + return ( +
+ + {status.description &&

{status.description}

} +
+ ); +} diff --git a/platform/web/src/components/session/run-section.test.tsx b/platform/web/src/components/session/run-section.test.tsx index f7c198903..8371cfc98 100644 --- a/platform/web/src/components/session/run-section.test.tsx +++ b/platform/web/src/components/session/run-section.test.tsx @@ -25,7 +25,7 @@ afterEach(async () => { }); const read = (callId: string, over: Partial = {}): TranscriptToolCall => ({ - callId, toolName: "read_file", status: "succeeded", isError: false, + callId, toolId: "env.read_file", toolName: "read_file", status: "succeeded", isError: false, argumentsJson: '{"path":"/w/a.ts"}', output: "contents", display: { group: "explore", verb: "Read", target: "/w/a.ts" }, startedAtMs: 1_700_000_000_000, completedAtMs: 1_700_000_000_340, durationMs: 340, outputBytes: 412, @@ -38,7 +38,7 @@ function finished(status: "completed" | "failed" = "failed"): RunSection { { kind: "reasoning", key: "think", text: "**Look at the reaper**", runId: "r1" }, { kind: "tool-group", key: "batch", runId: "r1", status: "completedWithErrors", calls: [ read("c1"), - read("c2", { toolName: "exec_command", status: "failed", isError: true, error: "exit 101", + read("c2", { toolId: "env.run_process", toolName: "exec_command", status: "failed", isError: true, error: "exit 101", display: { group: "execute", verb: "Run", target: "cargo clippy" } }), ] }, { kind: "message", key: "note", role: "assistant", text: "Interim progress note.", runId: "r1" }, @@ -101,7 +101,8 @@ it("folds a finished run behind one strip that names the outcome, keeps the repl expect(container.textContent).toContain("All fixed."); expect(strip().textContent).toContain("Failed after 38s"); expect(strip().textContent).toContain("2 tool calls"); - expect(strip().textContent).toContain("1 failed"); + expect(strip().textContent).not.toContain("1 failed"); + expect(strip().querySelector(".text-destructive")?.textContent).toBe("Failed after 38s"); expect(container.textContent).toContain("Run failed: retries exhausted"); expect(container.textContent).not.toContain("Interim progress note."); expect(container.textContent).not.toContain("cargo clippy"); @@ -124,7 +125,7 @@ it("opens on click, then reveals a row's details with the meta line, and follows expect(opened.firstElementChild?.className).toContain("md:hidden"); expect(container.querySelectorAll('[aria-label="Run statistics"]')).toHaveLength(2); expect(container.textContent).toContain("cargo clippy"); - expect(container.textContent).toContain("exit 101"); + expect(container.textContent).not.toContain("exit 101"); expect(container.textContent).not.toContain("contents"); const row = container.querySelector('button[aria-label="Read /w/a.ts"]')!; @@ -137,6 +138,18 @@ it("opens on click, then reveals a row's details with the meta line, and follows expect(meta).toContain("412 B"); expect(meta).toMatch(/started \d\d:\d\d:\d\d\.\d\d\d/); + const command = container.querySelector('button[aria-label="Run cargo clippy"]')!; + expect(command.parentElement!.parentElement!.className).not.toContain("bg-destructive"); + const failedLabel = command.parentElement!.querySelector("span.text-amber-700")!; + expect(failedLabel.textContent).toBe("failed"); + expect(failedLabel.className).toContain("dark:text-amber-300"); + expect(failedLabel.parentElement!.className).toContain("text-muted-foreground"); + const failedIcon = command.parentElement!.querySelector('svg[aria-label="Failed"]')!; + expect(failedIcon.getAttribute("class")).toContain("text-amber-700 dark:text-amber-300"); + await act(async () => command.click()); + expect(container.textContent).toContain("exit 101"); + expect(container.querySelector('pre[class*="border-destructive"]')).toBeNull(); + await act(async () => strip().click()); expect(container.textContent).not.toContain("cargo clippy"); await render(finished("completed"), { collapse: false }); @@ -145,6 +158,36 @@ it("opens on click, then reveals a row's details with the meta line, and follows expect(container.textContent).not.toContain("cargo clippy"); }); +it.each(["env.read_file", "env.run_process", "env.job_run"])( + "omits the failure badge when the run's only call is %s", async (toolId) => { + const section = finished("completed"); + section.work = [{ kind: "tool-group", key: "batch", status: "completedWithErrors", calls: [ + read("only", { toolId, toolName: "custom_name", status: "failed", isError: true }), + ] }]; + await render(section); + expect(strip().textContent).toContain("Worked for 38s"); + expect(strip().textContent).toContain("1 tool call"); + expect(strip().textContent).not.toContain("failed"); + expect(strip().querySelector(".text-destructive")).toBeNull(); + }, +); + +it("counts only other tool failures in a mixed run's badge", async () => { + const section = finished("completed"); + const batch = section.work.find((entry) => entry.kind === "tool-group")!; + batch.calls.push(read("send", { + toolId: "channel.send", toolName: "send_message", status: "failed", isError: true, + error: "Delivery failed", display: { group: "message", verb: "Send", target: "Alice" }, + })); + await render(section); + expect(strip().textContent).toContain("3 tool calls"); + expect(strip().querySelector(".text-destructive")?.textContent).toBe("1 failed"); + await act(async () => strip().click()); + expect(container.textContent).toContain("Delivery failed"); + const send = container.querySelector('button[aria-label="Send Alice"]')!; + expect(send.parentElement!.parentElement!.className).toContain("bg-destructive"); +}); + it("drops the statistics from the strip and the opened run when the preference is off", async () => { await render(finished("completed"), { stats: false }); expect(container.querySelector('[aria-label="Run statistics"]')).toBeNull(); diff --git a/platform/web/src/components/session/tool-trace.test.ts b/platform/web/src/components/session/tool-trace.test.ts index 0a4adb1d0..60c4b50ac 100644 --- a/platform/web/src/components/session/tool-trace.test.ts +++ b/platform/web/src/components/session/tool-trace.test.ts @@ -54,11 +54,48 @@ describe("step rows", () => { it("marks a failed call with its first error line while the details stay closed", () => { const html = render(group([call({ - status: "failed", isError: true, error: "exit 101\nerror: unused variable", - display: { group: "execute", verb: "Run", target: "cargo clippy" }, durationMs: 12_000, + toolId: "channel.send", status: "failed", isError: true, error: "Delivery failed\nconnection reset", + display: { group: "message", verb: "Send", target: "Alice" }, durationMs: 12_000, })], "completedWithErrors")); - expect(text(html)).toBe("Run cargo clippy failed · 12s exit 101"); - expect(html).not.toContain("unused variable"); + expect(text(html)).toBe("Send Alice failed · 12s Delivery failed"); + expect(html).toContain("text-destructive"); + expect(html).toContain("bg-destructive/5"); + expect(html).not.toContain("connection reset"); + }); + + it.each(["env.read_file", "env.run_process", "env.job_run"])( + "gives a failed %s call an amber status with details closed", (toolId) => { + const html = render(group([call({ + toolId, status: "failed", isError: true, error: "exit 101\nerror: unused variable", + display: { group: "execute", verb: "Run", target: "cargo clippy" }, durationMs: 12_000, + })], "completedWithErrors")); + expect(text(html)).toBe("Run cargo clippy failed · 12s"); + expect(html).toContain('class="text-amber-700 dark:text-amber-300">failed'); + expect(html).not.toContain("destructive"); + expect(html).toContain("lucide-circle-alert"); + expect(html).not.toContain("exit 101"); + }, + ); + + it("shows environment batch failures in amber", () => { + const calls = ["env.run_process", "env.job_read"].map((toolId, index) => call({ + callId: `c${index}`, toolId, status: "failed", isError: true, + })); + const html = render(group(calls, "completedWithErrors")); + expect(text(html)).toContain("2 failed"); + expect(html).toContain("text-amber-700 dark:text-amber-300"); + expect(html).not.toContain("destructive"); + expect(html).toContain("lucide-circle-alert"); + }); + + it("emphasizes a mixed batch when an external tool fails", () => { + const html = render(group([ + call({ toolId: "env.run_process", status: "failed", isError: true }), + call({ callId: "external", toolId: "channel.send", status: "failed", isError: true }), + ], "completedWithErrors")); + expect(text(html)).toContain("2 failed"); + expect(html).toContain("text-destructive"); + expect(html).toContain("lucide-circle-alert"); }); it("shows cancelled and waiting states without inventing a duration", () => { diff --git a/platform/web/src/components/session/tool-trace.tsx b/platform/web/src/components/session/tool-trace.tsx index c7d4de126..995f209f3 100644 --- a/platform/web/src/components/session/tool-trace.tsx +++ b/platform/web/src/components/session/tool-trace.tsx @@ -41,6 +41,7 @@ import { type TranscriptToolGroup, } from "@/lib/sessions/transcript"; import { cn } from "@/lib/utils"; +import { isEmphasizedToolFailure } from "@/lib/sessions/tool-failures"; /// Every step a run takes — thinking, a tool call, a sub-agent, a bot emit, /// a channel send — is one row: icon · verb · target · detail · duration. @@ -132,7 +133,7 @@ export function StepRow({ targetTitle, detail, right, - failed = false, + highlightFailure = false, cancelled = false, note, extra, @@ -146,7 +147,7 @@ export function StepRow({ targetTitle?: string; detail?: string | null; right?: ReactNode; - failed?: boolean; + highlightFailure?: boolean; cancelled?: boolean; /// A line under the row: the first line of an error, or a link. note?: ReactNode; @@ -157,7 +158,7 @@ export function StepRow({ }) { const [open, setOpen] = useState(false); return ( -
+
- )} - {template?.publicIngress && ( - - )} - -
- )} - {configurable && registered && !gone && ( -
- - - Closing spends the daemon identity; the machine itself is untouched. - -
+ {source.type === "provisioned" && ( + + )} + {source.type === "provisioned" && ( + + )} + + + + + {configurable && source.type === "provisioned" && ( + + +
+ + {!gone && ( + )} - {configurable && source.type === "provisioned" && !gone && ( - + {template?.publicIngress && ( + )}
- - - +
+ )} + {configurable && source.type === "provisioned" && !gone && ( + + )} +
); } @@ -432,13 +426,11 @@ function EnvironmentCredentials({ universeId, environment, secrets, - enabled, writable, }: { universeId: string; environment: Environment; secrets: SecretsInventory | undefined; - enabled: boolean; writable: boolean; }) { const queryClient = useQueryClient(); @@ -449,7 +441,6 @@ function EnvironmentCredentials({ "GET", `/api/v1/universes/${universeId}/environments/${encodeURIComponent(environment.environmentId)}/credentials`, ), - enabled, }); const unbind = useMutation({ mutationFn: (envName: string) => @@ -466,7 +457,7 @@ function EnvironmentCredentials({ const canAssign = environment.status !== "closed" && environment.status !== "closing"; return ( -
+

Secret environment variables

@@ -706,35 +697,8 @@ function AssignCredentialDialog({ ); } -function EnvironmentStatusBadge({ environment }: { environment: Environment }) { - if (environment.status === "ready") { - return ready; - } - if (environment.status === "paused" || environment.status === "suspended") { - return ( - - {environment.status} - {powerDiverges(environment) ? " · waking" : ""} - - ); - } - if (environment.status === "failed") { - return ( - - failed - - ); - } - return {environment.status}; -} - function Detail({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { - return ( -
-
{label}
-
{value}
-
- ); + return {value}; } /// Every registration key of the universe: the policy each pool of @@ -746,58 +710,66 @@ function RegistrationKeys({ universeId: string; keys: EnvironmentRegistrationKey[]; }) { + const [open, setOpen] = useState(false); const [showInactive, setShowInactive] = useState(false); const inactiveCount = keys.filter((key) => key.status !== "active").length; const rows = showInactive ? keys : keys.filter((key) => key.status === "active"); return ( -
-

Registration keys

-

- A key lets lightspeed-envd daemons dial in and register as - environments; each key is the group its environments belong to. Revoking stops new daemons - while registered ones keep reconnecting. -

-
- {rows.map((key) => ( -
-
-

{key.displayName}

-

- {key.keyPrefix}… · {key.registrationKeyId} -

-
- {key.identityMode} - - {key.activeEnvironmentCount} active / {key.registeredEnvironmentCount} total - {key.maxActiveEnvironments ? ` · limit ${key.maxActiveEnvironments}` : ""} - {key.identityMode === "ephemeral" - ? ` · grace ${Math.round(key.ephemeralDisconnectGraceMs / 60_000)} min` - : ""} - {key.expiresAtMs ? ` · expires ${relativeTime(key.expiresAtMs).replace(" ago", "")}` : ""} - - {key.status === "active" ? ( - active - ) : ( - {key.status} - )} - {key.status === "active" && ( - + +
+ + Registration keys ({keys.length}) + + +

+ A key lets lightspeed-envd daemons dial in and register as + environments. Revoking stops new daemons + while registered ones keep reconnecting. +

+
+ {rows.map((key) => ( +
+
+

{key.displayName}

+

+ {key.keyPrefix}… · {key.registrationKeyId} +

+
+ {key.identityMode} + + {key.activeEnvironmentCount} active / {key.registeredEnvironmentCount} total + {key.maxActiveEnvironments ? ` · limit ${key.maxActiveEnvironments}` : ""} + {key.identityMode === "ephemeral" + ? ` · grace ${Math.round(key.ephemeralDisconnectGraceMs / 60_000)} min` + : ""} + {key.expiresAtMs ? ` · expires ${relativeTime(key.expiresAtMs).replace(" ago", "")}` : ""} + + {key.status === "active" ? ( + active + ) : ( + {key.status} + )} + {key.status === "active" && ( + + )} +
+ ))} + {rows.length === 0 && ( +

Every registration key here is revoked or expired.

)}
- ))} - {rows.length === 0 && ( -

Every registration key here is revoked or expired.

- )} -
-
- -
-
+
+ +
+ + + ); } @@ -1050,7 +1022,7 @@ function ProviderBindings({ bindings }: { bindings: EnvironmentProviderBinding[]
- Provider bindings + Provider configuration {enabled} enabled · {bindings.length} total @@ -1111,7 +1083,7 @@ function EnvironmentIngressButton({ <> - {environment.publicEndpoint && ( - - {environment.publicEndpoint} - - )} {ingress.error && {ingress.error.message}} ); @@ -1156,40 +1118,14 @@ function CloseEnvironmentButton({ } const label = environment.displayName ?? environment.environmentId; return ( -
- - - } - > - - {close.isPending ? "Closing…" : "Close environment"} - - - - Close {label}? - - This permanently destroys the provider target - {environment.incarnation.providerTargetId - ? <> {environment.incarnation.providerTargetId} - : null} - {" "}and everything stored on it. A closed environment cannot be - reopened; sessions that still select it will see it as - unavailable and its public endpoint stops working. - - - - Keep environment - close.mutate()} - > - Close permanently - - - - +
+ This permanently destroys the provider target and everything stored on it. A closed environment cannot be reopened; sessions that select it will lose access.} + pending={close.isPending} + onConfirm={() => close.mutate()} + /> {close.error && {close.error.message}}
); @@ -1246,6 +1182,7 @@ function CreateEnvironmentDialog({ setOpen(true); }} > + New environment diff --git a/platform/web/src/pages/McpServersPage.dialog.test.tsx b/platform/web/src/pages/McpServersPage.dialog.test.tsx index 36d20d70f..ee47c9e25 100644 --- a/platform/web/src/pages/McpServersPage.dialog.test.tsx +++ b/platform/web/src/pages/McpServersPage.dialog.test.tsx @@ -7,9 +7,9 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { PermissionIdentityProvider } from "@/lib/permissions"; import { McpServersPage, mcpConnectionSummary } from "./McpServersPage"; -const mocks = vi.hoisted(() => ({ api: vi.fn() })); +const mocks = vi.hoisted(() => ({ api: vi.fn(), role: "operator" })); vi.mock("@/api", async (original) => ({ ...await original(), api: mocks.api })); -vi.mock("@/lib/universes", () => ({ useActiveUniverse: () => ({ universe: { id: "universe", role: "operator", slug: "test", name: "Test" }, slug: "test", isLoading: false }) })); +vi.mock("@/lib/universes", () => ({ useActiveUniverse: () => ({ universe: { id: "universe", role: mocks.role, slug: "test", name: "Test" }, slug: "test", isLoading: false }) })); // Every select becomes a native control named by its trigger; jsdom cannot lay out the popup. vi.mock("@/components/ui/select", async () => { const React = await import("react"); @@ -40,22 +40,32 @@ vi.mock("@/components/ui/select", async () => { const saved = { serverId: "tools", displayName: "Research tools", serverUrl: "https://example.test/mcp", defaultServerLabel: "tools", execution: "native", exposure: "search", approval: "never", allowPrivateNetwork: false, revision: 4, - authPolicy: { type: "none" }, status: "disabled", allowedTools: null, + authPolicy: { type: "none" }, status: "disabled", allowedTools: null, createdAtMs: 1, updatedAtMs: 2, }; +let servers: typeof saved[]; let root: Root; let container: HTMLDivElement; let client: QueryClient; beforeEach(() => { vi.useFakeTimers(); + servers = [saved]; + mocks.role = "operator"; vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("PointerEvent", MouseEvent); - mocks.api.mockReset().mockImplementation(async (method: string, path: string) => { - if (method === "GET" && path.endsWith("/mcp-servers")) return [saved]; + mocks.api.mockReset().mockImplementation(async (method: string, path: string, body?: Partial) => { + if (method === "GET" && path.endsWith("/mcp-servers")) return servers; if (path.endsWith("/auth-grants")) return []; if (path.endsWith("/discover-auth")) return { oauth: null }; if (path.endsWith("/tools/discover")) return { status: "success", tools: [] }; if (method === "POST" && path.endsWith("/mcp-servers")) return { ...saved, serverId: "github" }; - if (method === "PUT") return saved; + if (method === "PUT") { + const next = { ...saved, ...body }; + servers = [next]; + return next; + } + if (method === "DELETE") { servers = []; return saved; } + if (path.endsWith("/oauth/start")) return { flowId: "flow", serverRevision: 4, authorizeUrl: "https://example.test/authorize", expiresAtMs: Date.now() + 60_000 }; + if (path.endsWith("/oauth/flows/flow")) return { flowId: "flow", status: "pending" }; throw new Error(`Unexpected request: ${method} ${path}`); }); client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); @@ -81,7 +91,7 @@ async function show() { )); await settle(); } -const dialog = () => document.body.querySelector('[role="dialog"]')!; +const dialog = () => [...document.body.querySelectorAll('[role="dialog"]')].at(-1)!; const field = (selector: string) => dialog().querySelector(selector); const button = (text: string) => [...document.body.querySelectorAll("button")].find((item) => item.textContent?.trim() === text); @@ -162,7 +172,8 @@ it("names the server first, then confirms the connection with everything else be it("edits on one page with the status switch as the first row", async () => { await show(); - await click(container.querySelector('[aria-label="Edit tools"]')); + await click(container.querySelector('[aria-label="Details for Research tools"]')); + await click(button("Edit server")); expect(dialog().querySelector('[aria-label="MCP server creation progress"]')).toBeNull(); const scroller = dialog().querySelector("form")!.firstElementChild!; const first = scroller.firstElementChild!; @@ -189,3 +200,81 @@ it("summarizes OAuth details and private-network access only when they differ", execution: "provider", exposure: "inject", approval: "never", allowPrivateNetwork: true, customOAuthSettings: 2, })).toBe("Model provider connects · no approval · private network allowed · 2 custom OAuth settings"); }); + +it("sorts by display name and gives readers inspectable details without management controls", async () => { + mocks.role = "viewer"; + servers = [{ ...saved, serverId: "a", displayName: "Zulu" }, { ...saved, serverId: "z", displayName: "Alpha" }]; + await show(); + const table = container.querySelector('table[aria-label="MCP servers"]')!; + expect([...table.querySelectorAll("thead th")].map((cell) => cell.textContent)).toEqual(["Server", "URL", "Authentication", "Status", "Details"]); + expect([...table.querySelectorAll("tbody tr")].map((row) => row.querySelector("td [title]")?.getAttribute("title"))).toEqual(["Alpha", "Zulu"]); + expect(table.querySelectorAll("button")).toHaveLength(2); + const trigger = table.querySelector('[aria-label="Details for Alpha"]')!; + await click(trigger); + expect(dialog().textContent).toContain("https://example.test/mcp"); + expect(dialog().textContent).toContain("All advertised tools"); + expect(button("Edit server")).toBeUndefined(); + expect(button("Delete server")).toBeUndefined(); + expect(button("Connect")).toBeUndefined(); + await click(dialog().querySelector('[data-slot="dialog-close"]')); + expect(document.activeElement).toBe(trigger); + expect(mocks.api.mock.calls.every(([method]) => method === "GET")).toBe(true); +}); + +it("keeps deletion in details, confirms it, and removes the selected server on success", async () => { + await show(); + const table = container.querySelector("table")!; + expect(table.querySelectorAll("button")).toHaveLength(1); + await click(table.querySelector("button")); + await click(button("Delete server")); + expect(mocks.api.mock.calls.some(([method]) => method === "DELETE")).toBe(false); + const confirmation = document.body.querySelector('[role="alertdialog"]')!; + expect(confirmation.textContent).toContain("Profiles referencing"); + await click([...confirmation.querySelectorAll("button")].find((item) => item.textContent === "Delete server")); + expect(mocks.api.mock.calls.filter(([method]) => method === "DELETE")).toHaveLength(1); + expect(document.body.querySelector('[role="dialog"]')).toBeNull(); + expect(container.textContent).toContain("No MCP servers yet"); +}); + +it("retains details and shows the error when deletion fails", async () => { + const implementation = mocks.api.getMockImplementation()!; + mocks.api.mockImplementation(async (method, path, body) => { + if (method === "DELETE") throw new Error("Server is still in use"); + return implementation(method, path, body); + }); + await show(); + await click(container.querySelector("tbody button")); + await click(button("Delete server")); + const confirmation = document.body.querySelector('[role="alertdialog"]')!; + await click([...confirmation.querySelectorAll("button")].find((item) => item.textContent === "Delete server")); + expect(dialog().querySelector('[role="alert"]')?.textContent).toBe("Server is still in use"); + expect(container.querySelectorAll("tbody tr")).toHaveLength(1); +}); + +it("connects OAuth from details and returns there after dismissing sign-in", async () => { + servers = [{ ...saved, authPolicy: { type: "requiredOAuth" }, status: "needsAuthConfig" }]; + await show(); + expect(button("Connect")).toBeUndefined(); + expect(container.textContent).toContain("Needs sign-in"); + await click(container.querySelector("tbody button")); + await click(button("Connect")); + expect(dialog().textContent).toContain("Connect Research tools with OAuth"); + expect(mocks.api.mock.calls.filter(([, path]) => path.endsWith("/oauth/start"))).toHaveLength(1); + await click(dialog().querySelector('[data-slot="dialog-close"]')); + expect(document.body.querySelectorAll('[role="dialog"]')).toHaveLength(1); + expect(dialog().textContent).toContain("Edit server"); +}); + +it("reflects refreshed server data and revokes management controls when permissions change", async () => { + await show(); + await click(container.querySelector("tbody button")); + await act(async () => client.setQueryData(["mcp-servers", "universe"], [{ ...saved, displayName: "Renamed server", status: "active" }])); + await settle(); + expect(dialog().textContent).toContain("Renamed server"); + expect(dialog().textContent).toContain("Active"); + mocks.role = "viewer"; + await show(); + expect(dialog().textContent).toContain("Renamed server"); + expect(button("Edit server")).toBeUndefined(); + expect(button("Delete server")).toBeUndefined(); +}); diff --git a/platform/web/src/pages/McpServersPage.tsx b/platform/web/src/pages/McpServersPage.tsx index dd169f4bc..31bfac2b9 100644 --- a/platform/web/src/pages/McpServersPage.tsx +++ b/platform/web/src/pages/McpServersPage.tsx @@ -1,11 +1,15 @@ import { useActionPermissions } from "@/lib/permissions"; +import { ConfirmDangerButton } from "@/components/confirm-danger-button"; +import { ResourceIdentityCell, ResourceRow, ResourceStatus, ResourceTable, ResourceValue } from "@/components/resource-table"; +import { ResourceActions, ResourceDetail, ResourceDetailsDialog, ResourceDetailsGrid } from "@/components/resource-details-dialog"; +import { mcpServerStatus, resourceTimestamp } from "@/lib/resource-status"; import { ReadError } from "@/components/read-error"; import { McpToolPicker } from "@/components/mcp/tool-picker"; import { useMcpToolDiscoverySource } from "@/lib/mcp/tool-discovery"; import { useEffect, useRef, useState, type FormEvent } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { slugify } from "@lightspeed-ai/platform-shared"; -import { CheckCircle2, ExternalLink, Loader2, LogIn, Pencil, Plus, RotateCcw, Server, Trash2 } from "lucide-react"; +import { CheckCircle2, ExternalLink, Loader2, LogIn, Plus, RotateCcw, Server } from "lucide-react"; import { api, type AuthGrantOption, @@ -14,18 +18,6 @@ import { type McpServer, type McpServerAuthDiscovery, } from "@/api"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -48,17 +40,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { - IdText, - Table, - TableActionsCell, - TableBody, - TableCard, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; +import { TableCell } from "@/components/ui/table"; import { EmptyState, LoadingNote, PageHeader, UniverseNotFound } from "@/components/page"; import { ProgressSteps } from "@/components/ui/progress-steps"; import { useActiveUniverse } from "@/lib/universes"; @@ -82,14 +64,12 @@ export function McpServersPage({ admin: _admin }: { admin: boolean }) { return ; } - return ; + return ; } const APPROVALS = ["always", "never"] as const; function ServerList({ universeId }: { universeId: string }) { - // Adding servers and changing credentials are universe configuration; - // everything else on a row is decided for that server. const writable = useActionPermissions(universeId).can("configure_resource"); const queryClient = useQueryClient(); const servers = useQuery({ @@ -98,221 +78,140 @@ function ServerList({ universeId }: { universeId: string }) { }); const authGrants = useQuery({ queryKey: ["auth-grants", universeId], - queryFn: () => - api("GET", `/api/v1/universes/${universeId}/auth-grants`), + queryFn: () => api("GET", `/api/v1/universes/${universeId}/auth-grants`), }); - const invalidate = () => - queryClient.invalidateQueries({ queryKey: ["mcp-servers", universeId] }); - + const invalidate = () => queryClient.invalidateQueries({ queryKey: ["mcp-servers", universeId] }); const [createOpen, setCreateOpen] = useState(false); - const [editing, setEditing] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const detailsTriggerRef = useRef(null); const [oauthServer, setOAuthServer] = useState(null); - - const remove = useMutation({ - mutationFn: (serverId: string) => - api("DELETE", `/api/v1/universes/${universeId}/mcp-servers/${serverId}`), - onSuccess: invalidate, - }); - - const rows = (servers.data ?? []) - .slice() - .sort((a, b) => a.serverId.localeCompare(b.serverId)); - const configurable = useActionPermissions(universeId).can("configure_resource"); - // Open dialogs follow current permissions, like the controls that open them. - const editable = editing && configurable ? editing : null; - const connectable = oauthServer && writable && configurable ? oauthServer : null; - const grantLabels = new Map( - (authGrants.data ?? []).map((grant) => [grant.grantId, authGrantLabel(grant)]), + const selected = servers.data?.find((server) => server.serverId === selectedId); + const rows = (servers.data ?? []).slice().sort((a, b) => + serverName(a).localeCompare(serverName(b)) || a.serverId.localeCompare(b.serverId), ); + const changed = () => { + void invalidate(); + void queryClient.invalidateQueries({ queryKey: ["auth-grants", universeId] }); + }; return ( <> - setCreateOpen(true)}> - - Add server - - )} - /> + setCreateOpen(true)}>Add server} /> {servers.isLoading && } - {servers.error && ( - - )} - {authGrants.error && ( - - )} + {servers.error && } + {authGrants.error && } {servers.data && rows.length === 0 && ( - Remote tool servers that profiles and sessions link, each with its own credential and - tool allowance. + Connect a remote tool server, then make its tools available to profiles and sessions. )} {rows.length > 0 && ( - - - - - Server - URL - Authentication - Status - - - - - {rows.map((server) => ( - - -
- {server.displayName ?? server.serverId} - {server.serverId} -
-
- - {server.serverUrl} - - -
- {authPolicyLabel(server.authPolicy.type)} - {server.credential && ( - - Connected · {grantLabels.get(server.credential.grantId) ?? server.credential.grantId} - - )} -
-
- -
- - {/* The one thing to do on a row that needs auth is right here, not behind an icon. */} - {writable && configurable && isOAuthPolicy(server.authPolicy.type) && !server.credential && ( - - )} -
-
- - {configurable && ( - <> - {writable && isOAuthPolicy(server.authPolicy.type) && server.credential && ( - - )} - - - - } - > - - - - - - Delete {server.displayName ?? server.serverId}? - - - Profiles referencing{" "} - {server.serverId}{" "} - will fail to link it into new sessions. - - - - Cancel - remove.mutate(server.serverId)} - > - Delete - - - - - - )} - -
- ))} -
-
-
- )} -

- Authentication is configured once on the universe server. Sessions and profiles only - select its id. OAuth servers discover their authorization metadata and store a brokered - universe credential after you approve access. -

- {writable && ( - { - invalidate(); - if (connectOAuth) setOAuthServer(server); - }} - /> + + {rows.map((server) => ( + { + detailsTriggerRef.current = trigger; + setSelectedId(server.serverId); + }}> + + {server.serverUrl} + + {mcpAuthKind(server.authPolicy.type) === "oauth" ? "OAuth" : mcpAuthKind(server.authPolicy.type) === "bearer" ? "Bearer token" : "No authentication"} + + + + ))} + )} - { - if (!open) { - setEditing(null); - } - }} - onDone={(server, connectOAuth) => { - invalidate(); + { if (!open) setSelectedId(null); }} + title={selected ? serverName(selected) : "MCP server"} + description={selected?.description || "Connection settings and tool access."} finalFocus={detailsTriggerRef}> + {selected && setSelectedId(null)} />} + + {writable && { + changed(); if (connectOAuth) setOAuthServer(server); - }} - /> - { - if (!open) setOAuthServer(null); - }} - onDone={() => { - invalidate(); - queryClient.invalidateQueries({ queryKey: ["auth-grants", universeId] }); - }} - /> + }} />} + { if (!open) setOAuthServer(null); }} onDone={changed} /> + + ); +} + +function serverName(server: McpServer) { + return server.displayName?.trim() || server.serverId; +} + +function ServerDetails({ universeId, server, authGrants, authGrantsLoading, onChanged, onDeleted }: { + universeId: string; + server: McpServer; + authGrants: AuthGrantOption[]; + authGrantsLoading: boolean; + onChanged: () => void; + onDeleted: () => void; +}) { + const writable = useActionPermissions(universeId).can("configure_resource"); + const [editing, setEditing] = useState(null); + const [oauthOpen, setOAuthOpen] = useState(false); + const remove = useMutation({ + mutationFn: () => api("DELETE", `/api/v1/universes/${universeId}/mcp-servers/${encodeURIComponent(server.serverId)}`), + onSuccess: () => { onChanged(); onDeleted(); }, + }); + const grant = authGrants.find((candidate) => candidate.grantId === server.credential?.grantId); + return ( + <> + + {server.serverId} + + {server.serverUrl} + {authPolicyLabel(server.authPolicy.type)} + {server.authPolicy.type !== "none" && + {grant ? authGrantLabel(grant) : server.credential?.grantId ?? "No credential connected"} + } + {server.execution === "native" ? "Lightspeed" : "Model provider"} + {server.allowedTools?.length ? `${server.allowedTools.length} selected tools` : "All advertised tools"} + {server.approval === "always" ? "Ask before each call" : "No approval required"} + {server.execution === "native" && {server.exposure === "search" ? "Search when needed" : "Available directly"}} + {server.allowPrivateNetwork ? "Allowed" : "Blocked"} + {resourceTimestamp(server.createdAtMs)} + {resourceTimestamp(server.updatedAtMs)} + + {Boolean(server.allowedTools?.length) && ( +
+ View selected tools +
    + {server.allowedTools!.slice().sort((a, b) => a.localeCompare(b)).map((tool) =>
  • {tool}
  • )} +
+
+ )} + {remove.error &&

{remove.error.message}

} + {writable && + Profiles referencing {server.serverId} will lose access to this server in new sessions.} + pending={remove.isPending} onConfirm={() => remove.mutate()} /> +
+ {isOAuthPolicy(server.authPolicy.type) && } + +
+
} + { if (!open) setEditing(null); }} onDone={(_saved, connectOAuth) => { + onChanged(); + if (connectOAuth) setOAuthOpen(true); + }} /> + ); } @@ -334,24 +233,6 @@ function authPolicyLabel(type: string): string { } } -function StatusBadge({ status }: { status: McpServer["status"] }) { - if (status === "active") { - return active; - } - if (status === "needsAuthConfig") { - return ( - - needs auth - - ); - } - return {status}; -} - /// One dialog for both modes: `server === null` creates; otherwise edits by /// replacing the loaded document with its revision as the CAS guard. type McpAuthKind = "none" | "bearer" | "oauth"; diff --git a/platform/web/src/pages/ModelsPage.test.tsx b/platform/web/src/pages/ModelsPage.test.tsx index 487cb2771..b97752a27 100644 --- a/platform/web/src/pages/ModelsPage.test.tsx +++ b/platform/web/src/pages/ModelsPage.test.tsx @@ -80,14 +80,57 @@ function button(label: string) { it("lists model providers and subscriptions with the legacy-credential warning", async () => { await show(); expect(container.querySelector("h1")?.textContent).toBe("Models"); - expect(container.textContent).toContain("Operators manage them; keys and logins are never shown again."); + expect(container.textContent).toContain("Provider connections and default models for agents in this universe."); expect(container.textContent).toContain("A legacy credential below has an incorrect internal ID"); const rows = [...container.querySelectorAll("tbody tr")].map((row) => row.textContent); expect(rows).toHaveLength(2); - expect(rows[0]).toContain("Anthropic (API key)"); - expect(rows[0]).toContain("needs attention"); + expect(rows[0]).toContain("Anthropic"); + expect(rows[0]).toContain("Needs attention"); expect(rows[1]).toContain("Team Max"); - expect(rows[1]).toContain("Claude Code (subscription)"); + expect(rows[1]).toContain("Subscription"); + expect([...container.querySelectorAll("table thead th")].map((cell) => cell.textContent)).toEqual(["Provider", "Connection", "Status", "Details"]); +}); + +it("reports failed inventory reads without claiming there are no providers", async () => { + const implementation = mocks.api.getMockImplementation()!; + mocks.api.mockImplementation(async (method, path, body) => { + if (path.endsWith("/secrets")) throw new Error("Inventory unavailable"); + if (path.endsWith("/integrations/subscriptions")) return []; + return implementation(method, path, body); + }); + await show(); + expect(container.textContent).toContain("Model providers unavailable"); + expect(container.textContent).not.toContain("No model providers yet"); +}); + +it("retains providers when the subscription inventory fails", async () => { + const implementation = mocks.api.getMockImplementation()!; + mocks.api.mockImplementation(async (method, path, body) => { + if (path.endsWith("/integrations/subscriptions")) throw new Error("Subscriptions unavailable"); + return implementation(method, path, body); + }); + await show(); + expect(container.querySelector("table")?.textContent).toContain("Anthropic"); + expect(container.textContent).toContain("Subscriptions unavailable"); + expect(container.textContent).not.toContain("No model providers yet"); +}); + +it("lets readers inspect model connection details and restores focus on dismissal", async () => { + mocks.role = "viewer"; + await show(); + const trigger = container.querySelector('[aria-label="Details for Anthropic"]')!; + expect(trigger.getAttribute("aria-haspopup")).toBe("dialog"); + await act(async () => trigger.click()); + for (let step = 0; step < 5; step++) await act(async () => { await vi.advanceTimersByTimeAsync(5); }); + const dialog = document.body.querySelector('[role="dialog"]')!; + expect(dialog.textContent).toContain("Credential ID"); + expect(dialog.textContent).toContain("Updated"); + expect(dialog.textContent).toContain("Needs attention"); + expect(button("Replace key")).toBeUndefined(); + expect(button("Remove key")).toBeUndefined(); + await act(async () => dialog.querySelector('[data-slot="dialog-close"]')!.click()); + for (let step = 0; step < 5; step++) await act(async () => { await vi.advanceTimersByTimeAsync(5); }); + expect(document.activeElement).toBe(trigger); }); async function click(label: string) { diff --git a/platform/web/src/pages/ModelsPage.tsx b/platform/web/src/pages/ModelsPage.tsx index ca3660960..b0422c575 100644 --- a/platform/web/src/pages/ModelsPage.tsx +++ b/platform/web/src/pages/ModelsPage.tsx @@ -1,8 +1,9 @@ import { useActionPermissions } from "@/lib/permissions"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { ReadError } from "@/components/read-error"; import { LoadingNote, PageHeader, UniverseNotFound } from "@/components/page"; import { AddModelProviderDialog } from "@/components/models/add-model-provider-dialog"; import { ModelProviderDetailsDialog } from "@/components/models/model-provider-details-dialog"; @@ -10,7 +11,6 @@ import { ModelProviderList } from "@/components/models/model-provider-list"; import { useInvalidateModelProviders, useModelProviders, - type ConnectedModelProvider, } from "@/components/models/use-model-providers"; import { MODEL_PROVIDER_CATALOG, type ModelProviderKind } from "@/components/models/catalog"; import { DefaultModelDialog, ModelDefaultsSection, type DefaultSlot } from "@/components/models/model-defaults"; @@ -45,19 +45,20 @@ function Models({ universeId }: { universeId: string }) { setSearchParams(next, { replace: true }); } }, [requestedKind]); - const [selected, setSelected] = useState(null); - const { connected, isLoading, error } = useModelProviders(universeId); + const [selectedId, setSelectedId] = useState(null); + const detailsTriggerRef = useRef(null); + const { connected, isLoading, hasData, isComplete, error } = useModelProviders(universeId); const invalidate = useInvalidateModelProviders(universeId); // Keep the details dialog on the freshest copy of its provider. - const current = selected ? connected.find((i) => i.id === selected.id) ?? selected : null; + const current = connected.find((provider) => provider.id === selectedId) ?? null; const legacy = connected.some((entry) => "provider" in entry && !entry.provider.usableForModels); return ( <> setAddOpen(true)}> @@ -72,9 +73,12 @@ function Models({ universeId }: { universeId: string }) { Remove it and add the provider again.

)} - {isLoading && } - {error &&

{error.message}

} - {!isLoading && } + {isLoading && connected.length === 0 && } + {error && } + { + detailsTriggerRef.current = trigger; + setSelectedId(provider.id); + }} /> setEditingDefaults({ defaults, slot })} /> {writable && ( { - if (!open) setSelected(null); + if (!open) setSelectedId(null); }} onChanged={() => void invalidate()} />