| author | |
| committer | |
| log | 95305c36fa418301183e4750f13b6a525a20cafe |
| tree | 333599e14932c5344fd5c0dd277a1017814a080f |
| parent | 48aa875c0e6f8f2ee83d6972dd4357a4a083fe30 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>16 files changed, 1961 insertions(+), 688 deletions(-)
apps/web/src/components/ConnectionStatusDot.tsx+22| ... | ... | @@ -1,6 +1,28 @@ |
| 1 | import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | |
| 2 | ||
| 1 | 3 | import { cn } from "~/lib/utils"; |
| 2 | 4 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 3 | 5 | |
| 6 | /** Canonical connection-phase → dot color mapping shared by every status dot. */ | |
| 7 | export function connectionPhaseDotClassName(phase: EnvironmentConnectionPhase): string { | |
| 8 | switch (phase) { | |
| 9 | case "connected": | |
| 10 | return "bg-success"; | |
| 11 | case "connecting": | |
| 12 | case "reconnecting": | |
| 13 | return "bg-warning"; | |
| 14 | case "error": | |
| 15 | return "bg-destructive"; | |
| 16 | default: | |
| 17 | return "bg-muted-foreground/40"; | |
| 18 | } | |
| 19 | } | |
| 20 | ||
| 21 | /** Ping halo for transitional phases; null renders no ping. */ | |
| 22 | export function connectionPhasePingClassName(phase: EnvironmentConnectionPhase): string | null { | |
| 23 | return phase === "connecting" || phase === "reconnecting" ? "bg-warning/60 duration-2000" : null; | |
| 24 | } | |
| 25 | ||
| 4 | 26 | type ConnectionStatusDotProps = { |
| 5 | 27 | tooltipText?: string | null; |
| 6 | 28 | dotClassName: string; |
apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx created+54| ... | ... | @@ -0,0 +1,54 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { reactHookHarness as hooks } from "../../test/reactHookHarness"; | |
| 5 | ||
| 6 | const settingsHooks = vi.hoisted(() => ({ | |
| 7 | read: vi.fn(() => ({ providerInstances: {} })), | |
| 8 | update: vi.fn(() => vi.fn()), | |
| 9 | })); | |
| 10 | ||
| 11 | vi.mock("react", async (importOriginal) => { | |
| 12 | const actual = await importOriginal<typeof import("react")>(); | |
| 13 | const { reactHookHarness } = await import("../../test/reactHookHarness"); | |
| 14 | return { | |
| 15 | ...actual, | |
| 16 | useMemo: reactHookHarness.useMemo, | |
| 17 | useState: reactHookHarness.useState, | |
| 18 | }; | |
| 19 | }); | |
| 20 | ||
| 21 | vi.mock("react/compiler-runtime", async () => { | |
| 22 | const { reactHookHarness } = await import("../../test/reactHookHarness"); | |
| 23 | return { c: reactHookHarness.useMemoCache }; | |
| 24 | }); | |
| 25 | ||
| 26 | vi.mock("../../hooks/useSettings", () => ({ | |
| 27 | useEnvironmentSettings: settingsHooks.read, | |
| 28 | useUpdateEnvironmentSettings: settingsHooks.update, | |
| 29 | })); | |
| 30 | ||
| 31 | import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; | |
| 32 | ||
| 33 | const remoteEnvironmentId = EnvironmentId.make("remote-device"); | |
| 34 | ||
| 35 | describe("AddProviderInstanceDialog environment routing", () => { | |
| 36 | beforeEach(() => { | |
| 37 | hooks.reset(); | |
| 38 | settingsHooks.read.mockClear(); | |
| 39 | settingsHooks.update.mockClear(); | |
| 40 | }); | |
| 41 | ||
| 42 | it("reads and writes settings through the supplied environment", () => { | |
| 43 | hooks.beginRender(); | |
| 44 | AddProviderInstanceDialog({ | |
| 45 | open: true, | |
| 46 | environmentId: remoteEnvironmentId, | |
| 47 | environmentLabel: "Remote device", | |
| 48 | onOpenChange: vi.fn(), | |
| 49 | }); | |
| 50 | ||
| 51 | expect(settingsHooks.read).toHaveBeenCalledWith(remoteEnvironmentId); | |
| 52 | expect(settingsHooks.update).toHaveBeenCalledWith(remoteEnvironmentId); | |
| 53 | }); | |
| 54 | }); |
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+16-8| ... | ... | @@ -6,10 +6,11 @@ import { useMemo, useState } from "react"; |
| 6 | 6 | import { |
| 7 | 7 | ProviderInstanceId, |
| 8 | 8 | ProviderDriverKind, |
| 9 | type EnvironmentId, | |
| 9 | 10 | type ProviderInstanceConfig, |
| 10 | 11 | } from "@t3tools/contracts"; |
| 11 | 12 | |
| 12 | import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; | |
| 13 | import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; | |
| 13 | 14 | import { cn } from "../../lib/utils"; |
| 14 | 15 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| 15 | 16 | import { Button } from "../ui/button"; |
| ... | ... | @@ -115,13 +116,20 @@ function validateInstanceId(id: string, existing: ReadonlySet<string>): string | |
| 115 | 116 | } |
| 116 | 117 | |
| 117 | 118 | interface AddProviderInstanceDialogProps { |
| 118 | open: boolean; | |
| 119 | onOpenChange: (open: boolean) => void; | |
| 119 | readonly open: boolean; | |
| 120 | readonly environmentId: EnvironmentId; | |
| 121 | readonly environmentLabel: string; | |
| 122 | readonly onOpenChange: (open: boolean) => void; | |
| 120 | 123 | } |
| 121 | 124 | |
| 122 | export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) { | |
| 123 | const settings = usePrimarySettings(); | |
| 124 | const updateSettings = useUpdatePrimarySettings(); | |
| 125 | export function AddProviderInstanceDialog({ | |
| 126 | open, | |
| 127 | environmentId, | |
| 128 | environmentLabel, | |
| 129 | onOpenChange, | |
| 130 | }: AddProviderInstanceDialogProps) { | |
| 131 | const settings = useEnvironmentSettings(environmentId); | |
| 132 | const updateSettings = useUpdateEnvironmentSettings(environmentId); | |
| 125 | 133 | |
| 126 | 134 | const [wizardStep, setWizardStep] = useState(0); |
| 127 | 135 | const [driver, setDriver] = useState<ProviderDriverKind>(DEFAULT_DRIVER_KIND); |
| ... | ... | @@ -227,8 +235,8 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns |
| 227 | 235 | <DialogHeader> |
| 228 | 236 | <DialogTitle>Add provider instance</DialogTitle> |
| 229 | 237 | <DialogDescription> |
| 230 | Configure an additional provider instance — for example, a second Codex install | |
| 231 | pointed at a different workspace. | |
| 238 | Configure an additional provider instance on {environmentLabel} — for example, a | |
| 239 | second Codex install pointed at a different workspace. | |
| 232 | 240 | </DialogDescription> |
| 233 | 241 | <AddProviderInstanceWizardSteps |
| 234 | 242 | currentStep={wizardStep} |
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx created+256| ... | ... | @@ -0,0 +1,256 @@ |
| 1 | import type { ReactElement } from "react"; | |
| 2 | import { | |
| 3 | DEFAULT_UNIFIED_SETTINGS, | |
| 4 | EnvironmentId, | |
| 5 | ProviderDriverKind, | |
| 6 | ProviderInstanceId, | |
| 7 | type ServerProvider, | |
| 8 | type UnifiedSettings, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 11 | ||
| 12 | import { visitElements } from "../../test/reactElementTree"; | |
| 13 | import { reactHookHarness as hooks } from "../../test/reactHookHarness"; | |
| 14 | ||
| 15 | const atoms = vi.hoisted(() => ({ | |
| 16 | providers: null as ReadonlyArray<ServerProvider> | null, | |
| 17 | providersAtom: Symbol("providers"), | |
| 18 | refreshProviders: Symbol("refreshProviders"), | |
| 19 | updateProvider: Symbol("updateProvider"), | |
| 20 | })); | |
| 21 | ||
| 22 | const commands = vi.hoisted(() => ({ | |
| 23 | refresh: vi.fn(), | |
| 24 | updateProvider: vi.fn(), | |
| 25 | })); | |
| 26 | ||
| 27 | const settingsState = vi.hoisted(() => ({ | |
| 28 | value: null as UnifiedSettings | null, | |
| 29 | readEnvironmentIds: [] as EnvironmentId[], | |
| 30 | updateEnvironmentIds: [] as EnvironmentId[], | |
| 31 | updateSettings: vi.fn(), | |
| 32 | })); | |
| 33 | ||
| 34 | vi.mock("react", async (importOriginal) => { | |
| 35 | const actual = await importOriginal<typeof import("react")>(); | |
| 36 | const { reactHookHarness } = await import("../../test/reactHookHarness"); | |
| 37 | return { | |
| 38 | ...actual, | |
| 39 | useCallback: reactHookHarness.useCallback, | |
| 40 | useMemo: reactHookHarness.useMemo, | |
| 41 | useRef: reactHookHarness.useRef, | |
| 42 | useState: reactHookHarness.useState, | |
| 43 | }; | |
| 44 | }); | |
| 45 | ||
| 46 | vi.mock("react/compiler-runtime", async () => { | |
| 47 | const { reactHookHarness } = await import("../../test/reactHookHarness"); | |
| 48 | return { c: reactHookHarness.useMemoCache }; | |
| 49 | }); | |
| 50 | ||
| 51 | vi.mock("@effect/atom-react", () => ({ | |
| 52 | useAtomValue: () => atoms.providers, | |
| 53 | })); | |
| 54 | ||
| 55 | vi.mock("../../state/server", () => ({ | |
| 56 | EMPTY_SERVER_PROVIDERS: [], | |
| 57 | serverEnvironment: { | |
| 58 | providersValueAtom: () => atoms.providersAtom, | |
| 59 | refreshProviders: atoms.refreshProviders, | |
| 60 | updateProvider: atoms.updateProvider, | |
| 61 | }, | |
| 62 | })); | |
| 63 | ||
| 64 | vi.mock("../../state/use-atom-command", () => ({ | |
| 65 | useAtomCommand: (atom: symbol) => | |
| 66 | atom === atoms.refreshProviders ? commands.refresh : commands.updateProvider, | |
| 67 | })); | |
| 68 | ||
| 69 | vi.mock("../../hooks/useSettings", () => ({ | |
| 70 | useEnvironmentSettings: (environmentId: EnvironmentId) => { | |
| 71 | settingsState.readEnvironmentIds.push(environmentId); | |
| 72 | return settingsState.value; | |
| 73 | }, | |
| 74 | useUpdateEnvironmentSettings: (environmentId: EnvironmentId) => { | |
| 75 | settingsState.updateEnvironmentIds.push(environmentId); | |
| 76 | return settingsState.updateSettings; | |
| 77 | }, | |
| 78 | })); | |
| 79 | ||
| 80 | vi.mock("../../environments/primary", () => ({ | |
| 81 | usePrimarySessionState: () => ({ data: null, error: null, isPending: false, refresh: vi.fn() }), | |
| 82 | })); | |
| 83 | ||
| 84 | vi.mock("../../state/session", () => ({ | |
| 85 | useEnvironmentSessionState: () => ({ data: null, hasError: false, isPending: true }), | |
| 86 | })); | |
| 87 | ||
| 88 | import { EnvironmentProviderSettings } from "./ProviderSettingsPanel"; | |
| 89 | ||
| 90 | const environmentId = EnvironmentId.make("remote-device"); | |
| 91 | const codexId = ProviderInstanceId.make("codex"); | |
| 92 | const customId = ProviderInstanceId.make("codex_work"); | |
| 93 | ||
| 94 | function provider(): ServerProvider { | |
| 95 | return { | |
| 96 | instanceId: codexId, | |
| 97 | driver: ProviderDriverKind.make("codex"), | |
| 98 | enabled: true, | |
| 99 | installed: true, | |
| 100 | version: "1.0.0", | |
| 101 | status: "ready", | |
| 102 | auth: { status: "authenticated" }, | |
| 103 | checkedAt: "2026-07-24T12:00:00.000Z", | |
| 104 | models: [], | |
| 105 | slashCommands: [], | |
| 106 | skills: [], | |
| 107 | versionAdvisory: { | |
| 108 | status: "behind_latest", | |
| 109 | currentVersion: "1.0.0", | |
| 110 | latestVersion: "1.1.0", | |
| 111 | updateCommand: "pnpm add -g @openai/codex@latest", | |
| 112 | canUpdate: true, | |
| 113 | checkedAt: "2026-07-24T12:00:00.000Z", | |
| 114 | message: "Update available.", | |
| 115 | }, | |
| 116 | }; | |
| 117 | } | |
| 118 | ||
| 119 | function renderPanel(options?: { | |
| 120 | readonly readOnly?: boolean; | |
| 121 | }): ReactElement<Record<string, unknown>> { | |
| 122 | hooks.beginRender(); | |
| 123 | return EnvironmentProviderSettings({ | |
| 124 | environmentId, | |
| 125 | environmentLabel: "Remote device", | |
| 126 | ...(options?.readOnly === undefined ? {} : { readOnly: options.readOnly }), | |
| 127 | }) as ReactElement<Record<string, unknown>>; | |
| 128 | } | |
| 129 | ||
| 130 | async function flushPromises(): Promise<void> { | |
| 131 | await Promise.resolve(); | |
| 132 | await Promise.resolve(); | |
| 133 | } | |
| 134 | ||
| 135 | describe("EnvironmentProviderSettings routing", () => { | |
| 136 | beforeEach(() => { | |
| 137 | hooks.reset(); | |
| 138 | atoms.providers = null; | |
| 139 | settingsState.value = DEFAULT_UNIFIED_SETTINGS; | |
| 140 | settingsState.readEnvironmentIds = []; | |
| 141 | settingsState.updateEnvironmentIds = []; | |
| 142 | settingsState.updateSettings.mockReset(); | |
| 143 | commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" }); | |
| 144 | commands.updateProvider.mockReset().mockResolvedValue({ _tag: "Success" }); | |
| 145 | }); | |
| 146 | ||
| 147 | it("coalesces a nullable provider snapshot before rendering array-backed UI", () => { | |
| 148 | expect(() => renderPanel()).not.toThrow(); | |
| 149 | expect(settingsState.readEnvironmentIds).toEqual([environmentId]); | |
| 150 | expect(settingsState.updateEnvironmentIds).toEqual([environmentId]); | |
| 151 | }); | |
| 152 | ||
| 153 | it("routes refresh and provider update commands to the selected environment", async () => { | |
| 154 | atoms.providers = [provider()]; | |
| 155 | const panel = renderPanel(); | |
| 156 | const refreshButton = visitElements( | |
| 157 | panel, | |
| 158 | (element) => element.props["aria-label"] === "Refresh provider status", | |
| 159 | ); | |
| 160 | expect(refreshButton).not.toBeNull(); | |
| 161 | (refreshButton?.props.onClick as (() => void) | undefined)?.(); | |
| 162 | await flushPromises(); | |
| 163 | ||
| 164 | expect(commands.refresh).toHaveBeenCalledWith({ environmentId, input: {} }); | |
| 165 | ||
| 166 | const providerCard = visitElements( | |
| 167 | panel, | |
| 168 | (element) => | |
| 169 | element.props.instanceId === codexId && typeof element.props.onRunUpdate === "function", | |
| 170 | ); | |
| 171 | expect(providerCard).not.toBeNull(); | |
| 172 | (providerCard?.props.onRunUpdate as (() => void) | undefined)?.(); | |
| 173 | await flushPromises(); | |
| 174 | ||
| 175 | expect(commands.updateProvider).toHaveBeenCalledWith({ | |
| 176 | environmentId, | |
| 177 | input: { provider: ProviderDriverKind.make("codex"), instanceId: codexId }, | |
| 178 | }); | |
| 179 | }); | |
| 180 | ||
| 181 | it("renders the provider layout inert with a limited-permissions notice when read only", () => { | |
| 182 | atoms.providers = [provider()]; | |
| 183 | const panel = renderPanel({ readOnly: true }); | |
| 184 | ||
| 185 | const inertWrapper = visitElements(panel, (element) => element.props.inert === true); | |
| 186 | expect(inertWrapper).not.toBeNull(); | |
| 187 | const providerCard = visitElements(panel, (element) => element.props.instanceId === codexId); | |
| 188 | expect(providerCard).not.toBeNull(); | |
| 189 | ||
| 190 | const notice = visitElements(panel, (element) => element.props.title === "Limited permissions"); | |
| 191 | expect(notice).not.toBeNull(); | |
| 192 | ||
| 193 | expect( | |
| 194 | visitElements(panel, (element) => element.props["aria-label"] === "Add provider instance"), | |
| 195 | ).toBeNull(); | |
| 196 | expect( | |
| 197 | visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"), | |
| 198 | ).toBeNull(); | |
| 199 | }); | |
| 200 | ||
| 201 | it("keeps the editable layout interactive when not read only", () => { | |
| 202 | atoms.providers = [provider()]; | |
| 203 | const panel = renderPanel(); | |
| 204 | expect(visitElements(panel, (element) => element.props.inert === true)).toBeNull(); | |
| 205 | expect( | |
| 206 | visitElements(panel, (element) => element.props.title === "Limited permissions"), | |
| 207 | ).toBeNull(); | |
| 208 | }); | |
| 209 | ||
| 210 | it("deletes and resets provider configuration without erasing shared preferences", () => { | |
| 211 | settingsState.value = { | |
| 212 | ...DEFAULT_UNIFIED_SETTINGS, | |
| 213 | providerInstances: { | |
| 214 | [codexId]: { | |
| 215 | driver: ProviderDriverKind.make("codex"), | |
| 216 | enabled: false, | |
| 217 | }, | |
| 218 | [customId]: { | |
| 219 | driver: ProviderDriverKind.make("codex"), | |
| 220 | enabled: true, | |
| 221 | }, | |
| 222 | }, | |
| 223 | providerModelPreferences: { | |
| 224 | [customId]: { hiddenModels: ["hidden"], modelOrder: ["model"] }, | |
| 225 | }, | |
| 226 | favorites: [{ provider: customId, model: "favorite" }], | |
| 227 | }; | |
| 228 | const panel = renderPanel(); | |
| 229 | const customCard = visitElements(panel, (element) => element.props.instanceId === customId); | |
| 230 | expect(customCard).not.toBeNull(); | |
| 231 | (customCard?.props.onDelete as (() => void) | undefined)?.(); | |
| 232 | ||
| 233 | expect(settingsState.updateSettings).toHaveBeenLastCalledWith({ | |
| 234 | providerInstances: { | |
| 235 | [codexId]: settingsState.value.providerInstances?.[codexId], | |
| 236 | }, | |
| 237 | }); | |
| 238 | ||
| 239 | settingsState.updateSettings.mockClear(); | |
| 240 | const defaultCard = visitElements(panel, (element) => element.props.instanceId === codexId); | |
| 241 | const resetAction = defaultCard?.props.headerAction; | |
| 242 | const resetButton = visitElements( | |
| 243 | resetAction, | |
| 244 | (element) => typeof element.props.onClick === "function", | |
| 245 | ); | |
| 246 | expect(resetButton).not.toBeNull(); | |
| 247 | (resetButton?.props.onClick as (() => void) | undefined)?.(); | |
| 248 | ||
| 249 | const resetPatch = settingsState.updateSettings.mock.lastCall?.[0] as | |
| 250 | | Record<string, unknown> | |
| 251 | | undefined; | |
| 252 | expect(Object.keys(resetPatch ?? {}).sort()).toEqual(["providerInstances", "providers"]); | |
| 253 | expect(resetPatch).not.toHaveProperty("favorites"); | |
| 254 | expect(resetPatch).not.toHaveProperty("providerModelPreferences"); | |
| 255 | }); | |
| 256 | }); |
apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts created+260| ... | ... | @@ -0,0 +1,260 @@ |
| 1 | import { AuthOrchestrationOperateScope, EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | buildProviderEnvironmentOptions, | |
| 6 | classifyProviderEnvironmentAccess, | |
| 7 | resolvePrimaryOperateAccess, | |
| 8 | resolveRemoteOperateAccess, | |
| 9 | resolveSelectedProviderEnvironmentId, | |
| 10 | } from "./ProviderSettingsPanel.logic"; | |
| 11 | ||
| 12 | const primaryId = EnvironmentId.make("primary"); | |
| 13 | const relayId = EnvironmentId.make("relay"); | |
| 14 | const sshId = EnvironmentId.make("ssh"); | |
| 15 | ||
| 16 | const environments = [ | |
| 17 | { environmentId: sshId, label: "Zulu SSH" }, | |
| 18 | { environmentId: relayId, label: "Alpha Relay" }, | |
| 19 | { environmentId: primaryId, label: "This device" }, | |
| 20 | ] as const; | |
| 21 | ||
| 22 | describe("provider environment selection", () => { | |
| 23 | it("sorts the primary environment first and the rest by label", () => { | |
| 24 | expect( | |
| 25 | buildProviderEnvironmentOptions(environments, primaryId).map( | |
| 26 | (environment) => environment.environmentId, | |
| 27 | ), | |
| 28 | ).toEqual([primaryId, relayId, sshId]); | |
| 29 | }); | |
| 30 | ||
| 31 | it("keeps a valid selection, then falls back to primary or the first environment", () => { | |
| 32 | const options = buildProviderEnvironmentOptions(environments, primaryId); | |
| 33 | ||
| 34 | expect(resolveSelectedProviderEnvironmentId(options, sshId, primaryId)).toBe(sshId); | |
| 35 | expect( | |
| 36 | resolveSelectedProviderEnvironmentId( | |
| 37 | options.filter((environment) => environment.environmentId !== sshId), | |
| 38 | sshId, | |
| 39 | primaryId, | |
| 40 | ), | |
| 41 | ).toBe(primaryId); | |
| 42 | expect(resolveSelectedProviderEnvironmentId(options.slice(1), primaryId, primaryId)).toBe( | |
| 43 | relayId, | |
| 44 | ); | |
| 45 | expect(resolveSelectedProviderEnvironmentId([], null, primaryId)).toBeNull(); | |
| 46 | }); | |
| 47 | }); | |
| 48 | ||
| 49 | describe("provider environment access", () => { | |
| 50 | it("allows connected environments with config and operate access", () => { | |
| 51 | expect( | |
| 52 | classifyProviderEnvironmentAccess({ | |
| 53 | connectionPhase: "connected", | |
| 54 | hasServerConfig: true, | |
| 55 | operateAccess: "granted", | |
| 56 | }), | |
| 57 | ).toEqual({ kind: "editable" }); | |
| 58 | }); | |
| 59 | ||
| 60 | it("waits for config before exposing controls", () => { | |
| 61 | expect( | |
| 62 | classifyProviderEnvironmentAccess({ | |
| 63 | connectionPhase: "connected", | |
| 64 | hasServerConfig: false, | |
| 65 | operateAccess: "granted", | |
| 66 | }), | |
| 67 | ).toEqual({ kind: "loading", reason: "config" }); | |
| 68 | }); | |
| 69 | ||
| 70 | it("waits for unresolved operate access instead of assuming it is editable", () => { | |
| 71 | expect( | |
| 72 | classifyProviderEnvironmentAccess({ | |
| 73 | connectionPhase: "connected", | |
| 74 | hasServerConfig: true, | |
| 75 | operateAccess: "pending", | |
| 76 | }), | |
| 77 | ).toEqual({ kind: "loading", reason: "permissions" }); | |
| 78 | }); | |
| 79 | ||
| 80 | it("represents known missing operate access as read only", () => { | |
| 81 | expect( | |
| 82 | classifyProviderEnvironmentAccess({ | |
| 83 | connectionPhase: "connected", | |
| 84 | hasServerConfig: true, | |
| 85 | operateAccess: "denied", | |
| 86 | }), | |
| 87 | ).toEqual({ kind: "read-only" }); | |
| 88 | }); | |
| 89 | ||
| 90 | it.each(["available", "offline", "connecting", "reconnecting"] as const)( | |
| 91 | "keeps %s environments unavailable", | |
| 92 | (connectionPhase) => { | |
| 93 | expect( | |
| 94 | classifyProviderEnvironmentAccess({ | |
| 95 | connectionPhase, | |
| 96 | hasServerConfig: true, | |
| 97 | operateAccess: "granted", | |
| 98 | }), | |
| 99 | ).toEqual({ kind: "unavailable" }); | |
| 100 | }, | |
| 101 | ); | |
| 102 | ||
| 103 | it("separates connection errors from other unavailable states", () => { | |
| 104 | expect( | |
| 105 | classifyProviderEnvironmentAccess({ | |
| 106 | connectionPhase: "error", | |
| 107 | hasServerConfig: true, | |
| 108 | operateAccess: "granted", | |
| 109 | }), | |
| 110 | ).toEqual({ kind: "error" }); | |
| 111 | }); | |
| 112 | }); | |
| 113 | ||
| 114 | describe("primary operate access", () => { | |
| 115 | const authenticated = { | |
| 116 | authenticated: true as const, | |
| 117 | scopes: [AuthOrchestrationOperateScope], | |
| 118 | }; | |
| 119 | ||
| 120 | it("keeps cached session data authoritative while SWR revalidates", () => { | |
| 121 | expect( | |
| 122 | resolvePrimaryOperateAccess({ | |
| 123 | isPrimary: true, | |
| 124 | hasDesktopBridge: false, | |
| 125 | session: authenticated, | |
| 126 | isPending: true, | |
| 127 | hasError: false, | |
| 128 | }), | |
| 129 | ).toBe("granted"); | |
| 130 | }); | |
| 131 | ||
| 132 | it("reports pending only before any session has resolved", () => { | |
| 133 | expect( | |
| 134 | resolvePrimaryOperateAccess({ | |
| 135 | isPrimary: true, | |
| 136 | hasDesktopBridge: false, | |
| 137 | session: null, | |
| 138 | isPending: true, | |
| 139 | hasError: false, | |
| 140 | }), | |
| 141 | ).toBe("pending"); | |
| 142 | }); | |
| 143 | ||
| 144 | it("treats a failed session fetch as a transport problem, not a denial", () => { | |
| 145 | expect( | |
| 146 | resolvePrimaryOperateAccess({ | |
| 147 | isPrimary: true, | |
| 148 | hasDesktopBridge: false, | |
| 149 | session: null, | |
| 150 | isPending: false, | |
| 151 | hasError: true, | |
| 152 | }), | |
| 153 | ).toBe("granted"); | |
| 154 | }); | |
| 155 | ||
| 156 | it("denies unauthenticated sessions and sessions without the operate scope", () => { | |
| 157 | expect( | |
| 158 | resolvePrimaryOperateAccess({ | |
| 159 | isPrimary: true, | |
| 160 | hasDesktopBridge: false, | |
| 161 | session: { authenticated: false }, | |
| 162 | isPending: false, | |
| 163 | hasError: false, | |
| 164 | }), | |
| 165 | ).toBe("denied"); | |
| 166 | expect( | |
| 167 | resolvePrimaryOperateAccess({ | |
| 168 | isPrimary: true, | |
| 169 | hasDesktopBridge: false, | |
| 170 | session: { authenticated: true, scopes: ["orchestration:read"] }, | |
| 171 | isPending: false, | |
| 172 | hasError: false, | |
| 173 | }), | |
| 174 | ).toBe("denied"); | |
| 175 | expect( | |
| 176 | resolvePrimaryOperateAccess({ | |
| 177 | isPrimary: true, | |
| 178 | hasDesktopBridge: false, | |
| 179 | session: null, | |
| 180 | isPending: false, | |
| 181 | hasError: false, | |
| 182 | }), | |
| 183 | ).toBe("denied"); | |
| 184 | }); | |
| 185 | ||
| 186 | it("grants desktop bridge and remote environments without blocking on the primary session", () => { | |
| 187 | expect( | |
| 188 | resolvePrimaryOperateAccess({ | |
| 189 | isPrimary: true, | |
| 190 | hasDesktopBridge: true, | |
| 191 | session: null, | |
| 192 | isPending: true, | |
| 193 | hasError: false, | |
| 194 | }), | |
| 195 | ).toBe("granted"); | |
| 196 | expect( | |
| 197 | resolvePrimaryOperateAccess({ | |
| 198 | isPrimary: false, | |
| 199 | hasDesktopBridge: false, | |
| 200 | session: null, | |
| 201 | isPending: true, | |
| 202 | hasError: false, | |
| 203 | }), | |
| 204 | ).toBe("granted"); | |
| 205 | }); | |
| 206 | }); | |
| 207 | ||
| 208 | describe("remote operate access", () => { | |
| 209 | it("derives access from the environment session's granted scopes", () => { | |
| 210 | expect( | |
| 211 | resolveRemoteOperateAccess({ | |
| 212 | session: { authenticated: true, scopes: [AuthOrchestrationOperateScope] }, | |
| 213 | isPending: false, | |
| 214 | hasError: false, | |
| 215 | }), | |
| 216 | ).toBe("granted"); | |
| 217 | expect( | |
| 218 | resolveRemoteOperateAccess({ | |
| 219 | session: { authenticated: true, scopes: ["orchestration:read"] }, | |
| 220 | isPending: false, | |
| 221 | hasError: false, | |
| 222 | }), | |
| 223 | ).toBe("denied"); | |
| 224 | expect( | |
| 225 | resolveRemoteOperateAccess({ | |
| 226 | session: { authenticated: false }, | |
| 227 | isPending: false, | |
| 228 | hasError: false, | |
| 229 | }), | |
| 230 | ).toBe("denied"); | |
| 231 | }); | |
| 232 | ||
| 233 | it("reports pending before the first session resolve, then keeps cached data", () => { | |
| 234 | expect(resolveRemoteOperateAccess({ session: null, isPending: true, hasError: false })).toBe( | |
| 235 | "pending", | |
| 236 | ); | |
| 237 | expect( | |
| 238 | resolveRemoteOperateAccess({ | |
| 239 | session: { authenticated: true, scopes: [AuthOrchestrationOperateScope] }, | |
| 240 | isPending: true, | |
| 241 | hasError: false, | |
| 242 | }), | |
| 243 | ).toBe("granted"); | |
| 244 | }); | |
| 245 | ||
| 246 | it("stays optimistic when the session fetch fails or an older server omits scopes", () => { | |
| 247 | // Transport failures and pre-scope-reporting servers are not permission | |
| 248 | // decisions; the environment RPC layer still rejects unauthorized writes. | |
| 249 | expect(resolveRemoteOperateAccess({ session: null, isPending: false, hasError: true })).toBe( | |
| 250 | "granted", | |
| 251 | ); | |
| 252 | expect( | |
| 253 | resolveRemoteOperateAccess({ | |
| 254 | session: { authenticated: true }, | |
| 255 | isPending: false, | |
| 256 | hasError: false, | |
| 257 | }), | |
| 258 | ).toBe("granted"); | |
| 259 | }); | |
| 260 | }); |
apps/web/src/components/settings/ProviderSettingsPanel.logic.ts created+160| ... | ... | @@ -0,0 +1,160 @@ |
| 1 | import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | |
| 2 | import { | |
| 3 | AuthOrchestrationOperateScope, | |
| 4 | type AuthSessionState, | |
| 5 | type EnvironmentId, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | ||
| 8 | export interface ProviderEnvironmentOptionLike { | |
| 9 | readonly environmentId: EnvironmentId; | |
| 10 | readonly label: string; | |
| 11 | } | |
| 12 | ||
| 13 | export function buildProviderEnvironmentOptions<T extends ProviderEnvironmentOptionLike>( | |
| 14 | environments: ReadonlyArray<T>, | |
| 15 | primaryEnvironmentId: EnvironmentId | null, | |
| 16 | ): ReadonlyArray<T> { | |
| 17 | return environments.toSorted((left, right) => { | |
| 18 | const leftIsPrimary = left.environmentId === primaryEnvironmentId; | |
| 19 | const rightIsPrimary = right.environmentId === primaryEnvironmentId; | |
| 20 | if (leftIsPrimary !== rightIsPrimary) { | |
| 21 | return leftIsPrimary ? -1 : 1; | |
| 22 | } | |
| 23 | return ( | |
| 24 | left.label.localeCompare(right.label) || | |
| 25 | String(left.environmentId).localeCompare(String(right.environmentId)) | |
| 26 | ); | |
| 27 | }); | |
| 28 | } | |
| 29 | ||
| 30 | export function resolveSelectedProviderEnvironmentId( | |
| 31 | environments: ReadonlyArray<ProviderEnvironmentOptionLike>, | |
| 32 | selectedEnvironmentId: EnvironmentId | null, | |
| 33 | primaryEnvironmentId: EnvironmentId | null, | |
| 34 | ): EnvironmentId | null { | |
| 35 | if ( | |
| 36 | selectedEnvironmentId !== null && | |
| 37 | environments.some((environment) => environment.environmentId === selectedEnvironmentId) | |
| 38 | ) { | |
| 39 | return selectedEnvironmentId; | |
| 40 | } | |
| 41 | if ( | |
| 42 | primaryEnvironmentId !== null && | |
| 43 | environments.some((environment) => environment.environmentId === primaryEnvironmentId) | |
| 44 | ) { | |
| 45 | return primaryEnvironmentId; | |
| 46 | } | |
| 47 | return environments[0]?.environmentId ?? null; | |
| 48 | } | |
| 49 | ||
| 50 | export type ProviderEnvironmentAccess = | |
| 51 | | { readonly kind: "editable" } | |
| 52 | /** `reason` distinguishes waiting on the device from waiting on permissions. */ | |
| 53 | | { readonly kind: "loading"; readonly reason: "config" | "permissions" } | |
| 54 | | { readonly kind: "read-only" } | |
| 55 | | { readonly kind: "unavailable" } | |
| 56 | | { readonly kind: "error" }; | |
| 57 | ||
| 58 | /** | |
| 59 | * Whether the session may change provider configuration on an environment. | |
| 60 | * `pending` means the answer is still unknown, which must not be presented as | |
| 61 | * editable: rendering controls we already know might be rejected only turns a | |
| 62 | * permission problem into a failed write. | |
| 63 | */ | |
| 64 | export type ProviderOperateAccess = "granted" | "denied" | "pending"; | |
| 65 | ||
| 66 | /** | |
| 67 | * Resolve operate access from an environment's `/api/auth/session` answer. | |
| 68 | * | |
| 69 | * Cached session data wins over an in-flight revalidation. The session atoms | |
| 70 | * are SWR-backed, so they report `isPending` on every background refresh; | |
| 71 | * treating that as unknown would flip a working panel back to loading and | |
| 72 | * discard in-progress edits. | |
| 73 | * | |
| 74 | * `missingScopesAccess` decides the case where the session resolved but did | |
| 75 | * not report scopes: the primary serves the web app itself so its server | |
| 76 | * always reports them (absence means denial), while a remote device may run an | |
| 77 | * older server version that predates scope reporting, where denial would lock | |
| 78 | * out a legitimate session. The environment RPC layer stays authoritative | |
| 79 | * either way. | |
| 80 | */ | |
| 81 | function resolveSessionOperateAccess(input: { | |
| 82 | readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null; | |
| 83 | readonly isPending: boolean; | |
| 84 | readonly hasError: boolean; | |
| 85 | readonly missingScopesAccess: "granted" | "denied"; | |
| 86 | }): ProviderOperateAccess { | |
| 87 | if (input.session === null) { | |
| 88 | if (input.isPending) { | |
| 89 | return "pending"; | |
| 90 | } | |
| 91 | // A failed session fetch is a transport problem, not a permission | |
| 92 | // decision — locking the panel read-only would misreport it. Stay | |
| 93 | // optimistic; the environment RPC layer still rejects unauthorized writes. | |
| 94 | return input.hasError ? "granted" : "denied"; | |
| 95 | } | |
| 96 | if (!input.session.authenticated) { | |
| 97 | return "denied"; | |
| 98 | } | |
| 99 | if (input.session.scopes === undefined) { | |
| 100 | return input.missingScopesAccess; | |
| 101 | } | |
| 102 | return input.session.scopes.includes(AuthOrchestrationOperateScope) ? "granted" : "denied"; | |
| 103 | } | |
| 104 | ||
| 105 | /** Operate access for the primary environment's own browser session. */ | |
| 106 | export function resolvePrimaryOperateAccess(input: { | |
| 107 | readonly isPrimary: boolean; | |
| 108 | readonly hasDesktopBridge: boolean; | |
| 109 | readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null; | |
| 110 | readonly isPending: boolean; | |
| 111 | readonly hasError: boolean; | |
| 112 | }): ProviderOperateAccess { | |
| 113 | if (!input.isPrimary || input.hasDesktopBridge) { | |
| 114 | return "granted"; | |
| 115 | } | |
| 116 | return resolveSessionOperateAccess({ | |
| 117 | session: input.session, | |
| 118 | isPending: input.isPending, | |
| 119 | hasError: input.hasError, | |
| 120 | missingScopesAccess: "denied", | |
| 121 | }); | |
| 122 | } | |
| 123 | ||
| 124 | /** | |
| 125 | * Operate access for a non-primary environment, derived from the scopes its | |
| 126 | * `/api/auth/session` endpoint reports for this client's credential. | |
| 127 | */ | |
| 128 | export function resolveRemoteOperateAccess(input: { | |
| 129 | readonly session: Pick<AuthSessionState, "authenticated" | "scopes"> | null; | |
| 130 | readonly isPending: boolean; | |
| 131 | readonly hasError: boolean; | |
| 132 | }): ProviderOperateAccess { | |
| 133 | return resolveSessionOperateAccess({ | |
| 134 | ...input, | |
| 135 | missingScopesAccess: "granted", | |
| 136 | }); | |
| 137 | } | |
| 138 | ||
| 139 | export function classifyProviderEnvironmentAccess(input: { | |
| 140 | readonly connectionPhase: EnvironmentConnectionPhase; | |
| 141 | readonly hasServerConfig: boolean; | |
| 142 | readonly operateAccess: ProviderOperateAccess; | |
| 143 | }): ProviderEnvironmentAccess { | |
| 144 | if (input.connectionPhase === "error") { | |
| 145 | return { kind: "error" }; | |
| 146 | } | |
| 147 | if (input.connectionPhase !== "connected") { | |
| 148 | return { kind: "unavailable" }; | |
| 149 | } | |
| 150 | if (!input.hasServerConfig) { | |
| 151 | return { kind: "loading", reason: "config" }; | |
| 152 | } | |
| 153 | if (input.operateAccess === "pending") { | |
| 154 | return { kind: "loading", reason: "permissions" }; | |
| 155 | } | |
| 156 | if (input.operateAccess === "denied") { | |
| 157 | return { kind: "read-only" }; | |
| 158 | } | |
| 159 | return { kind: "editable" }; | |
| 160 | } |
apps/web/src/components/settings/ProviderSettingsPanel.tsx created+902| ... | ... | @@ -0,0 +1,902 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { connectionStatusText } from "@t3tools/client-runtime/connection"; | |
| 3 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; | |
| 4 | import { | |
| 5 | isAtomCommandInterrupted, | |
| 6 | squashAtomCommandFailure, | |
| 7 | } from "@t3tools/client-runtime/state/runtime"; | |
| 8 | import { | |
| 9 | defaultInstanceIdForDriver, | |
| 10 | type EnvironmentId, | |
| 11 | PROVIDER_DISPLAY_NAMES, | |
| 12 | ProviderDriverKind, | |
| 13 | type ProviderInstanceConfig, | |
| 14 | type ProviderInstanceId, | |
| 15 | } from "@t3tools/contracts"; | |
| 16 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | |
| 17 | import { | |
| 18 | getBackgroundActivityPresetSettings, | |
| 19 | resolveServerBackgroundActivitySettings, | |
| 20 | } from "@t3tools/shared/backgroundActivitySettings"; | |
| 21 | import * as Arr from "effect/Array"; | |
| 22 | import * as Duration from "effect/Duration"; | |
| 23 | import * as Equal from "effect/Equal"; | |
| 24 | import * as Result from "effect/Result"; | |
| 25 | import { | |
| 26 | CloudIcon, | |
| 27 | LaptopIcon, | |
| 28 | LoaderIcon, | |
| 29 | MonitorIcon, | |
| 30 | PlusIcon, | |
| 31 | RefreshCwIcon, | |
| 32 | TerminalIcon, | |
| 33 | } from "lucide-react"; | |
| 34 | import { useCallback, useMemo, useRef, useState } from "react"; | |
| 35 | ||
| 36 | import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; | |
| 37 | import { isElectron } from "../../env"; | |
| 38 | import { usePrimarySessionState } from "../../environments/primary"; | |
| 39 | import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; | |
| 40 | import { cn } from "../../lib/utils"; | |
| 41 | import { resolveAppModelSelectionState } from "../../modelSelection"; | |
| 42 | import { | |
| 43 | useEnvironments, | |
| 44 | usePrimaryEnvironmentId, | |
| 45 | type EnvironmentPresentation, | |
| 46 | } from "../../state/environments"; | |
| 47 | import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; | |
| 48 | import { useEnvironmentSessionState } from "../../state/session"; | |
| 49 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 50 | import { getRelativeTimeState } from "../../timestampFormat"; | |
| 51 | import { | |
| 52 | ConnectionStatusDot, | |
| 53 | connectionPhaseDotClassName, | |
| 54 | connectionPhasePingClassName, | |
| 55 | } from "../ConnectionStatusDot"; | |
| 56 | import { | |
| 57 | canOneClickUpdateProviderCandidate, | |
| 58 | collectProviderUpdateCandidates, | |
| 59 | hasOneClickUpdateProviderCandidate, | |
| 60 | isProviderUpdateActive, | |
| 61 | type ProviderUpdateCandidate, | |
| 62 | } from "../ProviderUpdateLaunchNotification.logic"; | |
| 63 | import { Button } from "../ui/button"; | |
| 64 | import { | |
| 65 | NumberField, | |
| 66 | NumberFieldDecrement, | |
| 67 | NumberFieldGroup, | |
| 68 | NumberFieldIncrement, | |
| 69 | NumberFieldInput, | |
| 70 | } from "../ui/number-field"; | |
| 71 | import { stackedThreadToast, toastManager } from "../ui/toast"; | |
| 72 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 73 | import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; | |
| 74 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; | |
| 75 | import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; | |
| 76 | import { searchableSetting } from "./settingsSearch"; | |
| 77 | import { | |
| 78 | backgroundActivityOverrideSettings, | |
| 79 | buildProviderInstanceUpdatePatch, | |
| 80 | durationToSeconds, | |
| 81 | normalizeIntervalSeconds, | |
| 82 | PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, | |
| 83 | } from "./SettingsPanels.logic"; | |
| 84 | import { | |
| 85 | PolicyTooltip, | |
| 86 | SettingResetButton, | |
| 87 | SettingsPageContainer, | |
| 88 | SettingsRow, | |
| 89 | SettingsSection, | |
| 90 | useRelativeTimeTick, | |
| 91 | } from "./settingsLayout"; | |
| 92 | import { | |
| 93 | buildProviderEnvironmentOptions, | |
| 94 | classifyProviderEnvironmentAccess, | |
| 95 | type ProviderEnvironmentAccess, | |
| 96 | type ProviderOperateAccess, | |
| 97 | resolvePrimaryOperateAccess, | |
| 98 | resolveRemoteOperateAccess, | |
| 99 | resolveSelectedProviderEnvironmentId, | |
| 100 | } from "./ProviderSettingsPanel.logic"; | |
| 101 | ||
| 102 | function withoutProviderInstanceKey<V>( | |
| 103 | record: Readonly<Record<ProviderInstanceId, V>> | undefined, | |
| 104 | key: ProviderInstanceId, | |
| 105 | ): Record<ProviderInstanceId, V> { | |
| 106 | const next = { ...record } as Record<ProviderInstanceId, V>; | |
| 107 | delete next[key]; | |
| 108 | return next; | |
| 109 | } | |
| 110 | ||
| 111 | function withoutProviderInstanceFavorites( | |
| 112 | favorites: ReadonlyArray<{ readonly provider: ProviderInstanceId; readonly model: string }>, | |
| 113 | instanceId: ProviderInstanceId, | |
| 114 | ) { | |
| 115 | return favorites.filter((favorite) => favorite.provider !== instanceId); | |
| 116 | } | |
| 117 | ||
| 118 | const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ | |
| 119 | provider: definition.value, | |
| 120 | })); | |
| 121 | ||
| 122 | function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { | |
| 123 | useRelativeTimeTick(); | |
| 124 | const lastCheckedRelative = getRelativeTimeState(lastCheckedAt); | |
| 125 | ||
| 126 | if (lastCheckedRelative.status === "missing") { | |
| 127 | return null; | |
| 128 | } | |
| 129 | ||
| 130 | if (lastCheckedRelative.status === "invalid") { | |
| 131 | return <span className="text-[11px] text-muted-foreground/50">Checked unavailable</span>; | |
| 132 | } | |
| 133 | ||
| 134 | return ( | |
| 135 | <span className="text-[11px] text-muted-foreground/60"> | |
| 136 | {lastCheckedRelative.suffix ? ( | |
| 137 | <> | |
| 138 | Checked <span className="font-mono tabular-nums">{lastCheckedRelative.value}</span>{" "} | |
| 139 | {lastCheckedRelative.suffix} | |
| 140 | </> | |
| 141 | ) : ( | |
| 142 | <>Checked {lastCheckedRelative.value}</> | |
| 143 | )} | |
| 144 | </span> | |
| 145 | ); | |
| 146 | } | |
| 147 | ||
| 148 | function providerEnvironmentIcon(environment: EnvironmentPresentation) { | |
| 149 | if (environment.entry.target._tag === "PrimaryConnectionTarget") return MonitorIcon; | |
| 150 | if (environment.entry.target._tag === "RelayConnectionTarget") return CloudIcon; | |
| 151 | if (environment.entry.target._tag === "SshConnectionTarget") return TerminalIcon; | |
| 152 | if (isDesktopLocalConnectionTarget(environment.entry.target)) return LaptopIcon; | |
| 153 | return CloudIcon; | |
| 154 | } | |
| 155 | ||
| 156 | function providerEnvironmentDetail(environment: EnvironmentPresentation): string { | |
| 157 | if (environment.entry.target._tag === "PrimaryConnectionTarget") return "Primary device"; | |
| 158 | if (environment.relayManaged) return "T3 Connect"; | |
| 159 | if (environment.entry.target._tag === "SshConnectionTarget") return "SSH"; | |
| 160 | if (isDesktopLocalConnectionTarget(environment.entry.target)) return "Local device"; | |
| 161 | return environment.displayUrl ?? "Remote device"; | |
| 162 | } | |
| 163 | ||
| 164 | function EnvironmentUnavailableRow({ | |
| 165 | environment, | |
| 166 | access, | |
| 167 | }: { | |
| 168 | readonly environment: EnvironmentPresentation; | |
| 169 | readonly access: Exclude<ProviderEnvironmentAccess, { kind: "editable" | "read-only" }>; | |
| 170 | }) { | |
| 171 | const isLoading = access.kind === "loading"; | |
| 172 | const title = isLoading | |
| 173 | ? "Loading provider settings" | |
| 174 | : access.kind === "error" | |
| 175 | ? "Could not connect to this device" | |
| 176 | : "Provider settings are unavailable"; | |
| 177 | const description = isLoading | |
| 178 | ? access.reason === "permissions" | |
| 179 | ? "Checking what this session is allowed to change." | |
| 180 | : `Waiting for ${environment.label}'s configuration.` | |
| 181 | : connectionStatusText(environment.connection); | |
| 182 | // No spinner: this state can persist indefinitely for a wedged device, and a | |
| 183 | // continuously repainting animation would run the whole time. | |
| 184 | return ( | |
| 185 | <SettingsSection title="Providers"> | |
| 186 | <SettingsRow title={title} description={description} /> | |
| 187 | </SettingsSection> | |
| 188 | ); | |
| 189 | } | |
| 190 | ||
| 191 | export function ProviderSettingsPanel() { | |
| 192 | const { environments, isReady } = useEnvironments(); | |
| 193 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | |
| 194 | const options = useMemo( | |
| 195 | () => buildProviderEnvironmentOptions(environments, primaryEnvironmentId), | |
| 196 | [environments, primaryEnvironmentId], | |
| 197 | ); | |
| 198 | // Raw user intent; the effective selection is re-derived every render so a | |
| 199 | // device that drops out of the catalog falls back without erasing the pick — | |
| 200 | // if it reappears (e.g. after a reconnect) the selection is restored. | |
| 201 | const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>( | |
| 202 | primaryEnvironmentId, | |
| 203 | ); | |
| 204 | const effectiveEnvironmentId = resolveSelectedProviderEnvironmentId( | |
| 205 | options, | |
| 206 | selectedEnvironmentId, | |
| 207 | primaryEnvironmentId, | |
| 208 | ); | |
| 209 | const selectedEnvironment = | |
| 210 | options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null; | |
| 211 | const onlyPrimaryDevice = | |
| 212 | options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget"; | |
| 213 | ||
| 214 | return ( | |
| 215 | <SettingsPageContainer> | |
| 216 | {!onlyPrimaryDevice ? ( | |
| 217 | <SettingsSection title="Devices"> | |
| 218 | {options.length === 0 ? ( | |
| 219 | // The catalog hydrates asynchronously, so an empty list before it is | |
| 220 | // ready means "not loaded yet", not "nothing is connected". | |
| 221 | <SettingsRow | |
| 222 | title={isReady ? "No connected devices" : "Loading devices"} | |
| 223 | description={ | |
| 224 | isReady | |
| 225 | ? "Connect an execution environment before configuring providers." | |
| 226 | : "Reading connected execution environments." | |
| 227 | } | |
| 228 | /> | |
| 229 | ) : ( | |
| 230 | <div className="grid gap-1 sm:grid-cols-2"> | |
| 231 | {options.map((environment) => { | |
| 232 | const Icon = providerEnvironmentIcon(environment); | |
| 233 | const selected = environment.environmentId === effectiveEnvironmentId; | |
| 234 | const statusText = connectionStatusText(environment.connection); | |
| 235 | return ( | |
| 236 | <button | |
| 237 | key={environment.environmentId} | |
| 238 | type="button" | |
| 239 | aria-pressed={selected} | |
| 240 | className={cn( | |
| 241 | "flex min-w-0 items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors sm:px-4", | |
| 242 | selected | |
| 243 | ? "bg-primary/8 ring-1 ring-primary/25 dark:bg-primary/12" | |
| 244 | : "hover:bg-muted/40", | |
| 245 | )} | |
| 246 | onClick={() => setSelectedEnvironmentId(environment.environmentId)} | |
| 247 | > | |
| 248 | <span className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-background text-muted-foreground"> | |
| 249 | <Icon className="size-4" aria-hidden /> | |
| 250 | </span> | |
| 251 | <span className="min-w-0 flex-1"> | |
| 252 | <span className="flex items-center gap-1.5"> | |
| 253 | <ConnectionStatusDot | |
| 254 | tooltipText={statusText} | |
| 255 | dotClassName={connectionPhaseDotClassName(environment.connection.phase)} | |
| 256 | pingClassName={connectionPhasePingClassName(environment.connection.phase)} | |
| 257 | /> | |
| 258 | <span className="truncate text-sm font-medium text-foreground"> | |
| 259 | {environment.label} | |
| 260 | </span> | |
| 261 | </span> | |
| 262 | <span className="block truncate pl-[18px] text-xs text-muted-foreground"> | |
| 263 | {providerEnvironmentDetail(environment)} · {statusText} | |
| 264 | </span> | |
| 265 | </span> | |
| 266 | </button> | |
| 267 | ); | |
| 268 | })} | |
| 269 | </div> | |
| 270 | )} | |
| 271 | </SettingsSection> | |
| 272 | ) : null} | |
| 273 | ||
| 274 | {selectedEnvironment ? ( | |
| 275 | <SelectedEnvironmentProviderSettings | |
| 276 | key={selectedEnvironment.environmentId} | |
| 277 | environment={selectedEnvironment} | |
| 278 | /> | |
| 279 | ) : null} | |
| 280 | </SettingsPageContainer> | |
| 281 | ); | |
| 282 | } | |
| 283 | ||
| 284 | function SelectedEnvironmentProviderSettings({ | |
| 285 | environment, | |
| 286 | }: { | |
| 287 | readonly environment: EnvironmentPresentation; | |
| 288 | }) { | |
| 289 | const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; | |
| 290 | if (isPrimary) { | |
| 291 | // The desktop app owns its primary server outright; a browser session | |
| 292 | // checks the scopes its cookie session was granted. | |
| 293 | if (isElectron) { | |
| 294 | return <AccessGatedProviderSettings environment={environment} operateAccess="granted" />; | |
| 295 | } | |
| 296 | return <PrimarySessionGatedProviderSettings environment={environment} />; | |
| 297 | } | |
| 298 | return <RemoteSessionGatedProviderSettings environment={environment} />; | |
| 299 | } | |
| 300 | ||
| 301 | function PrimarySessionGatedProviderSettings({ | |
| 302 | environment, | |
| 303 | }: { | |
| 304 | readonly environment: EnvironmentPresentation; | |
| 305 | }) { | |
| 306 | const primarySessionState = usePrimarySessionState(); | |
| 307 | const operateAccess = resolvePrimaryOperateAccess({ | |
| 308 | isPrimary: true, | |
| 309 | hasDesktopBridge: false, | |
| 310 | session: primarySessionState.data, | |
| 311 | isPending: primarySessionState.isPending, | |
| 312 | hasError: primarySessionState.error !== null, | |
| 313 | }); | |
| 314 | return <AccessGatedProviderSettings environment={environment} operateAccess={operateAccess} />; | |
| 315 | } | |
| 316 | ||
| 317 | function RemoteSessionGatedProviderSettings({ | |
| 318 | environment, | |
| 319 | }: { | |
| 320 | readonly environment: EnvironmentPresentation; | |
| 321 | }) { | |
| 322 | const sessionState = useEnvironmentSessionState(environment.environmentId); | |
| 323 | const operateAccess = resolveRemoteOperateAccess({ | |
| 324 | session: sessionState.data, | |
| 325 | isPending: sessionState.isPending, | |
| 326 | hasError: sessionState.hasError, | |
| 327 | }); | |
| 328 | return <AccessGatedProviderSettings environment={environment} operateAccess={operateAccess} />; | |
| 329 | } | |
| 330 | ||
| 331 | function AccessGatedProviderSettings({ | |
| 332 | environment, | |
| 333 | operateAccess, | |
| 334 | }: { | |
| 335 | readonly environment: EnvironmentPresentation; | |
| 336 | readonly operateAccess: ProviderOperateAccess; | |
| 337 | }) { | |
| 338 | const access = classifyProviderEnvironmentAccess({ | |
| 339 | connectionPhase: environment.connection.phase, | |
| 340 | hasServerConfig: environment.serverConfig !== null, | |
| 341 | operateAccess, | |
| 342 | }); | |
| 343 | if (access.kind !== "editable" && access.kind !== "read-only") { | |
| 344 | return <EnvironmentUnavailableRow environment={environment} access={access} />; | |
| 345 | } | |
| 346 | return ( | |
| 347 | <EnvironmentProviderSettings | |
| 348 | environmentId={environment.environmentId} | |
| 349 | environmentLabel={environment.label} | |
| 350 | readOnly={access.kind === "read-only"} | |
| 351 | /> | |
| 352 | ); | |
| 353 | } | |
| 354 | ||
| 355 | export function EnvironmentProviderSettings({ | |
| 356 | environmentId, | |
| 357 | environmentLabel, | |
| 358 | readOnly = false, | |
| 359 | }: { | |
| 360 | readonly environmentId: EnvironmentId; | |
| 361 | readonly environmentLabel: string; | |
| 362 | /** | |
| 363 | * Render the full provider layout, greyed out and inert, when this session's | |
| 364 | * credential lacks `orchestration:operate` on the environment. Showing the | |
| 365 | * real configuration keeps the view honest; disabling interaction keeps | |
| 366 | * every one of its writes from being offered and then rejected. | |
| 367 | */ | |
| 368 | readonly readOnly?: boolean; | |
| 369 | }) { | |
| 370 | const settings = useEnvironmentSettings(environmentId); | |
| 371 | const updateSettings = useUpdateEnvironmentSettings(environmentId); | |
| 372 | const serverProviders = | |
| 373 | useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; | |
| 374 | const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { | |
| 375 | reportFailure: false, | |
| 376 | }); | |
| 377 | const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { | |
| 378 | reportFailure: false, | |
| 379 | }); | |
| 380 | const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); | |
| 381 | const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); | |
| 382 | const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< | |
| 383 | ReadonlySet<ProviderDriverKind> | |
| 384 | >(() => new Set()); | |
| 385 | const [openInstanceDetails, setOpenInstanceDetails] = useState<Record<string, boolean>>({}); | |
| 386 | const refreshingRef = useRef(false); | |
| 387 | const updatingDriversRef = useRef<Set<ProviderDriverKind>>(new Set()); | |
| 388 | ||
| 389 | const providerUpdateCandidates = useMemo( | |
| 390 | () => collectProviderUpdateCandidates(serverProviders), | |
| 391 | [serverProviders], | |
| 392 | ); | |
| 393 | const providerUpdateCandidateByInstanceId = useMemo( | |
| 394 | () => new Map(providerUpdateCandidates.map((candidate) => [candidate.instanceId, candidate])), | |
| 395 | [providerUpdateCandidates], | |
| 396 | ); | |
| 397 | const visibleProviderSettings = PROVIDER_SETTINGS.filter( | |
| 398 | (providerSettings) => | |
| 399 | providerSettings.provider !== "cursor" || | |
| 400 | serverProviders.some( | |
| 401 | (provider) => | |
| 402 | provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")), | |
| 403 | ), | |
| 404 | ); | |
| 405 | const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); | |
| 406 | const textGenInstanceId = textGenerationModelSelection.instanceId; | |
| 407 | const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); | |
| 408 | const providerHealthPreset = getBackgroundActivityPresetSettings( | |
| 409 | resolvedBackgroundActivity.profile, | |
| 410 | ).providerHealthRefreshInterval; | |
| 411 | const providerHealthRefreshIntervalSeconds = durationToSeconds( | |
| 412 | resolvedBackgroundActivity.providerHealthRefreshInterval, | |
| 413 | ); | |
| 414 | const defaultProviderHealthRefreshIntervalSeconds = durationToSeconds(providerHealthPreset); | |
| 415 | const lastCheckedAt = | |
| 416 | serverProviders.length > 0 | |
| 417 | ? serverProviders.reduce( | |
| 418 | (latest, provider) => (provider.checkedAt > latest ? provider.checkedAt : latest), | |
| 419 | serverProviders[0]!.checkedAt, | |
| 420 | ) | |
| 421 | : null; | |
| 422 | ||
| 423 | const refreshProviders = useCallback(() => { | |
| 424 | if (refreshingRef.current) return; | |
| 425 | refreshingRef.current = true; | |
| 426 | setIsRefreshingProviders(true); | |
| 427 | void (async () => { | |
| 428 | const result = await refreshServerProviders({ | |
| 429 | environmentId, | |
| 430 | input: {}, | |
| 431 | }); | |
| 432 | refreshingRef.current = false; | |
| 433 | setIsRefreshingProviders(false); | |
| 434 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | |
| 435 | console.warn("Failed to refresh providers", { | |
| 436 | operation: "refresh-providers", | |
| 437 | environmentId, | |
| 438 | ...safeErrorLogAttributes(squashAtomCommandFailure(result)), | |
| 439 | }); | |
| 440 | } | |
| 441 | })(); | |
| 442 | }, [environmentId, refreshServerProviders]); | |
| 443 | ||
| 444 | const runProviderUpdate = useCallback( | |
| 445 | async (candidate: ProviderUpdateCandidate) => { | |
| 446 | // Ref-based re-entry guard, mirroring refreshProviders: a state updater | |
| 447 | // may run after this function returns, so it cannot gate the dispatch. | |
| 448 | if (updatingDriversRef.current.has(candidate.driver)) { | |
| 449 | return; | |
| 450 | } | |
| 451 | updatingDriversRef.current.add(candidate.driver); | |
| 452 | setUpdatingProviderDrivers((previous) => new Set(previous).add(candidate.driver)); | |
| 453 | ||
| 454 | const result = await updateProvider({ | |
| 455 | environmentId, | |
| 456 | input: { | |
| 457 | provider: candidate.driver, | |
| 458 | instanceId: candidate.instanceId, | |
| 459 | }, | |
| 460 | }); | |
| 461 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | |
| 462 | const error = squashAtomCommandFailure(result); | |
| 463 | toastManager.add( | |
| 464 | stackedThreadToast({ | |
| 465 | type: "error", | |
| 466 | title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`, | |
| 467 | description: | |
| 468 | error instanceof Error | |
| 469 | ? error.message | |
| 470 | : "The provider update command could not be started.", | |
| 471 | }), | |
| 472 | ); | |
| 473 | } | |
| 474 | updatingDriversRef.current.delete(candidate.driver); | |
| 475 | setUpdatingProviderDrivers((previous) => { | |
| 476 | if (!previous.has(candidate.driver)) { | |
| 477 | return previous; | |
| 478 | } | |
| 479 | const next = new Set(previous); | |
| 480 | next.delete(candidate.driver); | |
| 481 | return next; | |
| 482 | }); | |
| 483 | }, | |
| 484 | [environmentId, updateProvider], | |
| 485 | ); | |
| 486 | ||
| 487 | interface InstanceRow { | |
| 488 | readonly instanceId: ProviderInstanceId; | |
| 489 | readonly instance: ProviderInstanceConfig; | |
| 490 | readonly driver: ProviderDriverKind; | |
| 491 | readonly isDefault: boolean; | |
| 492 | readonly isDirty?: boolean; | |
| 493 | } | |
| 494 | ||
| 495 | const instancesByDriver = new Map< | |
| 496 | ProviderDriverKind, | |
| 497 | Array<[ProviderInstanceId, ProviderInstanceConfig]> | |
| 498 | >(); | |
| 499 | for (const [rawId, instance] of Object.entries(settings.providerInstances ?? {})) { | |
| 500 | const driver = instance.driver; | |
| 501 | const list = instancesByDriver.get(driver) ?? []; | |
| 502 | list.push([rawId as ProviderInstanceId, instance]); | |
| 503 | instancesByDriver.set(driver, list); | |
| 504 | } | |
| 505 | ||
| 506 | const defaultSlotIdsBySource = new Set<string>( | |
| 507 | visibleProviderSettings.map((providerSettings) => | |
| 508 | String(defaultInstanceIdForDriver(providerSettings.provider)), | |
| 509 | ), | |
| 510 | ); | |
| 511 | ||
| 512 | const rows: InstanceRow[] = []; | |
| 513 | const visibleDriverKinds = new Set<ProviderDriverKind>( | |
| 514 | visibleProviderSettings.map((providerSettings) => providerSettings.provider), | |
| 515 | ); | |
| 516 | ||
| 517 | for (const providerSettings of visibleProviderSettings) { | |
| 518 | type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; | |
| 519 | const legacyProviders = settings.providers as Record<string, LegacyProviderSettings>; | |
| 520 | const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< | |
| 521 | string, | |
| 522 | LegacyProviderSettings | |
| 523 | >; | |
| 524 | const driver = providerSettings.provider; | |
| 525 | const defaultInstanceId = defaultInstanceIdForDriver(driver); | |
| 526 | const explicitInstance = settings.providerInstances?.[defaultInstanceId]; | |
| 527 | // A remote device may run a server version whose settings predate this | |
| 528 | // driver, so the legacy mirror can be absent. Without either an explicit | |
| 529 | // instance or a legacy blob there is nothing to render for the slot. | |
| 530 | const legacyConfig = legacyProviders[providerSettings.provider]; | |
| 531 | const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]; | |
| 532 | const effectiveInstance: ProviderInstanceConfig | undefined = | |
| 533 | explicitInstance ?? | |
| 534 | (legacyConfig !== undefined | |
| 535 | ? ({ | |
| 536 | driver, | |
| 537 | enabled: legacyConfig.enabled, | |
| 538 | config: legacyConfig, | |
| 539 | } satisfies ProviderInstanceConfig) | |
| 540 | : undefined); | |
| 541 | // Only the default slot depends on the legacy blob; custom instances for | |
| 542 | // the driver must still render even when the slot has nothing to show. | |
| 543 | if (effectiveInstance !== undefined) { | |
| 544 | const isDirty = | |
| 545 | explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig); | |
| 546 | rows.push({ | |
| 547 | instanceId: defaultInstanceId, | |
| 548 | instance: effectiveInstance, | |
| 549 | driver, | |
| 550 | isDefault: true, | |
| 551 | isDirty, | |
| 552 | }); | |
| 553 | } | |
| 554 | for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) { | |
| 555 | if (id === defaultInstanceId) continue; | |
| 556 | rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false }); | |
| 557 | } | |
| 558 | } | |
| 559 | for (const [driver, list] of instancesByDriver) { | |
| 560 | if (visibleDriverKinds.has(driver)) continue; | |
| 561 | for (const [id, instance] of list) { | |
| 562 | rows.push({ | |
| 563 | instanceId: id, | |
| 564 | instance, | |
| 565 | driver: instance.driver, | |
| 566 | isDefault: defaultSlotIdsBySource.has(String(id)), | |
| 567 | }); | |
| 568 | } | |
| 569 | } | |
| 570 | ||
| 571 | const updateProviderInstance = ( | |
| 572 | row: InstanceRow, | |
| 573 | next: ProviderInstanceConfig, | |
| 574 | options?: { | |
| 575 | readonly textGenerationModelSelection?: Parameters< | |
| 576 | typeof buildProviderInstanceUpdatePatch | |
| 577 | >[0]["textGenerationModelSelection"]; | |
| 578 | }, | |
| 579 | ) => { | |
| 580 | updateSettings( | |
| 581 | buildProviderInstanceUpdatePatch({ | |
| 582 | settings, | |
| 583 | instanceId: row.instanceId, | |
| 584 | instance: next, | |
| 585 | driver: row.driver, | |
| 586 | isDefault: row.isDefault, | |
| 587 | textGenerationModelSelection: options?.textGenerationModelSelection, | |
| 588 | }), | |
| 589 | ); | |
| 590 | }; | |
| 591 | ||
| 592 | const deleteProviderInstance = (id: ProviderInstanceId) => { | |
| 593 | updateSettings({ | |
| 594 | providerInstances: withoutProviderInstanceKey(settings.providerInstances, id), | |
| 595 | }); | |
| 596 | }; | |
| 597 | ||
| 598 | const updateProviderModelPreferences = ( | |
| 599 | instanceId: ProviderInstanceId, | |
| 600 | next: { | |
| 601 | readonly hiddenModels: ReadonlyArray<string>; | |
| 602 | readonly modelOrder: ReadonlyArray<string>; | |
| 603 | }, | |
| 604 | ) => { | |
| 605 | const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))]; | |
| 606 | const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))]; | |
| 607 | const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId); | |
| 608 | updateSettings({ | |
| 609 | providerModelPreferences: | |
| 610 | hiddenModels.length === 0 && modelOrder.length === 0 | |
| 611 | ? rest | |
| 612 | : { | |
| 613 | ...rest, | |
| 614 | [instanceId]: { | |
| 615 | hiddenModels, | |
| 616 | modelOrder, | |
| 617 | }, | |
| 618 | }, | |
| 619 | }); | |
| 620 | }; | |
| 621 | ||
| 622 | const updateProviderFavoriteModels = ( | |
| 623 | instanceId: ProviderInstanceId, | |
| 624 | nextFavoriteModels: ReadonlyArray<string>, | |
| 625 | ) => { | |
| 626 | const favoriteModels = [ | |
| 627 | ...new Set( | |
| 628 | Arr.filterMap(nextFavoriteModels, (slug) => { | |
| 629 | const trimmedSlug = slug.trim(); | |
| 630 | return trimmedSlug.length > 0 ? Result.succeed(trimmedSlug) : Result.failVoid; | |
| 631 | }), | |
| 632 | ), | |
| 633 | ]; | |
| 634 | updateSettings({ | |
| 635 | favorites: [ | |
| 636 | ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId), | |
| 637 | ...favoriteModels.map((model) => ({ provider: instanceId, model })), | |
| 638 | ], | |
| 639 | }); | |
| 640 | }; | |
| 641 | ||
| 642 | const resetDefaultInstance = (driverKind: ProviderDriverKind) => { | |
| 643 | type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; | |
| 644 | const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< | |
| 645 | string, | |
| 646 | LegacyProviderSettings | undefined | |
| 647 | >; | |
| 648 | const defaultInstanceId = defaultInstanceIdForDriver(driverKind); | |
| 649 | const defaultLegacyProvider = defaultLegacyProviders[driverKind]; | |
| 650 | if (defaultLegacyProvider === undefined) return; | |
| 651 | updateSettings({ | |
| 652 | providers: { | |
| 653 | ...settings.providers, | |
| 654 | [driverKind]: defaultLegacyProvider, | |
| 655 | } as typeof settings.providers, | |
| 656 | providerInstances: withoutProviderInstanceKey(settings.providerInstances, defaultInstanceId), | |
| 657 | }); | |
| 658 | }; | |
| 659 | ||
| 660 | return ( | |
| 661 | <> | |
| 662 | <SettingsSection | |
| 663 | {...searchableSetting("providers")} | |
| 664 | headerAction={ | |
| 665 | <div className="flex items-center gap-1.5"> | |
| 666 | <ProviderLastChecked lastCheckedAt={lastCheckedAt} /> | |
| 667 | {!readOnly ? ( | |
| 668 | <> | |
| 669 | <Tooltip> | |
| 670 | <TooltipTrigger | |
| 671 | render={ | |
| 672 | <Button | |
| 673 | size="icon-xs" | |
| 674 | variant="ghost" | |
| 675 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | |
| 676 | onClick={() => setIsAddInstanceDialogOpen(true)} | |
| 677 | aria-label="Add provider instance" | |
| 678 | > | |
| 679 | <PlusIcon className="size-3" /> | |
| 680 | </Button> | |
| 681 | } | |
| 682 | /> | |
| 683 | <TooltipPopup side="top">Add provider instance</TooltipPopup> | |
| 684 | </Tooltip> | |
| 685 | <Tooltip> | |
| 686 | <TooltipTrigger | |
| 687 | render={ | |
| 688 | <Button | |
| 689 | size="icon-xs" | |
| 690 | variant="ghost" | |
| 691 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | |
| 692 | disabled={isRefreshingProviders} | |
| 693 | onClick={() => void refreshProviders()} | |
| 694 | aria-label="Refresh provider status" | |
| 695 | > | |
| 696 | {isRefreshingProviders ? ( | |
| 697 | <LoaderIcon className="size-3 animate-spin" /> | |
| 698 | ) : ( | |
| 699 | <RefreshCwIcon className="size-3" /> | |
| 700 | )} | |
| 701 | </Button> | |
| 702 | } | |
| 703 | /> | |
| 704 | <TooltipPopup side="top">Refresh provider status</TooltipPopup> | |
| 705 | </Tooltip> | |
| 706 | </> | |
| 707 | ) : null} | |
| 708 | </div> | |
| 709 | } | |
| 710 | > | |
| 711 | {readOnly ? ( | |
| 712 | <SettingsRow | |
| 713 | title="Limited permissions" | |
| 714 | description={`This session can view ${environmentLabel}'s providers, but its credential does not allow changing their configuration.`} | |
| 715 | /> | |
| 716 | ) : null} | |
| 717 | <div | |
| 718 | // `inert` blocks focus and interaction in one attribute, so the | |
| 719 | // read-only view stays byte-for-byte the editable layout without | |
| 720 | // threading a disabled flag through every control. | |
| 721 | inert={readOnly} | |
| 722 | aria-disabled={readOnly || undefined} | |
| 723 | className={readOnly ? "space-y-1 opacity-50 select-none" : "space-y-1"} | |
| 724 | > | |
| 725 | <SettingsRow | |
| 726 | title={ | |
| 727 | <span className="inline-flex items-center gap-1.5"> | |
| 728 | Health check interval | |
| 729 | <PolicyTooltip> | |
| 730 | This interval is configured here, then the shared Background activity policy | |
| 731 | decides whether provider probes may run when the timer fires. Custom intervals | |
| 732 | appear as Advanced in General settings. | |
| 733 | </PolicyTooltip> | |
| 734 | </span> | |
| 735 | } | |
| 736 | description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes." | |
| 737 | resetAction={ | |
| 738 | providerHealthRefreshIntervalSeconds !== | |
| 739 | defaultProviderHealthRefreshIntervalSeconds ? ( | |
| 740 | <SettingResetButton | |
| 741 | label="provider health check interval" | |
| 742 | onClick={() => | |
| 743 | updateSettings( | |
| 744 | backgroundActivityOverrideSettings( | |
| 745 | settings.backgroundActivity, | |
| 746 | resolvedBackgroundActivity, | |
| 747 | { | |
| 748 | providerHealthRefreshInterval: undefined, | |
| 749 | }, | |
| 750 | ), | |
| 751 | ) | |
| 752 | } | |
| 753 | /> | |
| 754 | ) : null | |
| 755 | } | |
| 756 | control={ | |
| 757 | <div className="flex shrink-0 items-center gap-2"> | |
| 758 | <NumberField | |
| 759 | value={providerHealthRefreshIntervalSeconds} | |
| 760 | min={0} | |
| 761 | step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS} | |
| 762 | size="sm" | |
| 763 | className="w-32" | |
| 764 | onValueChange={(value) => | |
| 765 | updateSettings( | |
| 766 | backgroundActivityOverrideSettings( | |
| 767 | settings.backgroundActivity, | |
| 768 | resolvedBackgroundActivity, | |
| 769 | { | |
| 770 | providerHealthRefreshInterval: Duration.seconds( | |
| 771 | normalizeIntervalSeconds(value), | |
| 772 | ), | |
| 773 | }, | |
| 774 | ), | |
| 775 | ) | |
| 776 | } | |
| 777 | > | |
| 778 | <NumberFieldGroup> | |
| 779 | <NumberFieldDecrement aria-label="Decrease provider health check interval" /> | |
| 780 | <NumberFieldInput aria-label="Provider health check interval in seconds" /> | |
| 781 | <NumberFieldIncrement aria-label="Increase provider health check interval" /> | |
| 782 | </NumberFieldGroup> | |
| 783 | </NumberField> | |
| 784 | <span className="text-xs text-muted-foreground">seconds</span> | |
| 785 | </div> | |
| 786 | } | |
| 787 | /> | |
| 788 | ||
| 789 | {rows.map((row) => { | |
| 790 | const driverOption = getDriverOption(row.driver); | |
| 791 | const liveProvider = serverProviders.find( | |
| 792 | (candidate) => candidate.instanceId === row.instanceId, | |
| 793 | ); | |
| 794 | const updateCandidate = liveProvider | |
| 795 | ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) | |
| 796 | : undefined; | |
| 797 | const isDriverUpdateRunning = | |
| 798 | updateCandidate !== undefined && | |
| 799 | (updatingProviderDrivers.has(updateCandidate.driver) || | |
| 800 | serverProviders.some( | |
| 801 | (provider) => | |
| 802 | provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), | |
| 803 | )); | |
| 804 | const showInlineUpdateButton = | |
| 805 | updateCandidate !== undefined && | |
| 806 | hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); | |
| 807 | const canRunInlineUpdate = | |
| 808 | updateCandidate !== undefined && | |
| 809 | canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && | |
| 810 | !updatingProviderDrivers.has(updateCandidate.driver); | |
| 811 | const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { | |
| 812 | hiddenModels: [], | |
| 813 | modelOrder: [], | |
| 814 | }; | |
| 815 | const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) => | |
| 816 | favorite.provider === row.instanceId | |
| 817 | ? Result.succeed(favorite.model) | |
| 818 | : Result.failVoid, | |
| 819 | ); | |
| 820 | const resetLabel = driverOption?.label ?? String(row.driver); | |
| 821 | const headerAction = | |
| 822 | row.isDefault && row.isDirty ? ( | |
| 823 | <SettingResetButton | |
| 824 | label={`${resetLabel} provider settings`} | |
| 825 | onClick={() => resetDefaultInstance(row.driver)} | |
| 826 | /> | |
| 827 | ) : null; | |
| 828 | return ( | |
| 829 | <ProviderInstanceCard | |
| 830 | key={row.instanceId} | |
| 831 | instanceId={row.instanceId} | |
| 832 | instance={row.instance} | |
| 833 | driverOption={driverOption} | |
| 834 | liveProvider={liveProvider} | |
| 835 | isExpanded={openInstanceDetails[row.instanceId] ?? false} | |
| 836 | onExpandedChange={(open) => | |
| 837 | setOpenInstanceDetails((existing) => ({ | |
| 838 | ...existing, | |
| 839 | [row.instanceId]: open, | |
| 840 | })) | |
| 841 | } | |
| 842 | onUpdate={(next) => { | |
| 843 | const wasEnabled = row.instance.enabled ?? true; | |
| 844 | const isDisabling = next.enabled === false && wasEnabled; | |
| 845 | const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; | |
| 846 | if (shouldClearTextGen) { | |
| 847 | updateProviderInstance(row, next, { | |
| 848 | textGenerationModelSelection: | |
| 849 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, | |
| 850 | }); | |
| 851 | } else { | |
| 852 | updateProviderInstance(row, next); | |
| 853 | } | |
| 854 | }} | |
| 855 | onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)} | |
| 856 | headerAction={headerAction} | |
| 857 | hiddenModels={modelPreferences.hiddenModels} | |
| 858 | favoriteModels={favoriteModels} | |
| 859 | modelOrder={modelPreferences.modelOrder} | |
| 860 | onHiddenModelsChange={(hiddenModels) => | |
| 861 | updateProviderModelPreferences(row.instanceId, { | |
| 862 | ...modelPreferences, | |
| 863 | hiddenModels, | |
| 864 | }) | |
| 865 | } | |
| 866 | onFavoriteModelsChange={(favoriteModels) => | |
| 867 | updateProviderFavoriteModels(row.instanceId, favoriteModels) | |
| 868 | } | |
| 869 | onModelOrderChange={(modelOrder) => | |
| 870 | updateProviderModelPreferences(row.instanceId, { | |
| 871 | ...modelPreferences, | |
| 872 | modelOrder, | |
| 873 | }) | |
| 874 | } | |
| 875 | onRunUpdate={ | |
| 876 | showInlineUpdateButton && updateCandidate | |
| 877 | ? () => { | |
| 878 | if (!canRunInlineUpdate) { | |
| 879 | return; | |
| 880 | } | |
| 881 | void runProviderUpdate(updateCandidate); | |
| 882 | } | |
| 883 | : undefined | |
| 884 | } | |
| 885 | isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined} | |
| 886 | /> | |
| 887 | ); | |
| 888 | })} | |
| 889 | </div> | |
| 890 | </SettingsSection> | |
| 891 | ||
| 892 | {isAddInstanceDialogOpen ? ( | |
| 893 | <AddProviderInstanceDialog | |
| 894 | open | |
| 895 | environmentId={environmentId} | |
| 896 | environmentLabel={environmentLabel} | |
| 897 | onOpenChange={setIsAddInstanceDialogOpen} | |
| 898 | /> | |
| 899 | ) : null} | |
| 900 | </> | |
| 901 | ); | |
| 902 | } |
apps/web/src/components/settings/SettingsPanels.logic.ts+57| ... | ... | @@ -10,10 +10,12 @@ import type { |
| 10 | 10 | } from "@t3tools/contracts"; |
| 11 | 11 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| 12 | 12 | import { |
| 13 | getBackgroundActivityBaseProfile, | |
| 13 | 14 | normalizeBackgroundActivitySettings, |
| 14 | 15 | normalizeServerBackgroundActivitySettings, |
| 15 | 16 | resolveServerBackgroundActivitySettings, |
| 16 | 17 | } from "@t3tools/shared/backgroundActivitySettings"; |
| 18 | import * as Duration from "effect/Duration"; | |
| 17 | 19 | import * as Equal from "effect/Equal"; |
| 18 | 20 | |
| 19 | 21 | export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean { |
| ... | ... | @@ -190,3 +192,58 @@ export function buildProviderInstanceUpdatePatch(input: { |
| 190 | 192 | : {}), |
| 191 | 193 | }; |
| 192 | 194 | } |
| 195 | ||
| 196 | // ── Background-activity interval helpers ───────────────────────────── | |
| 197 | // Shared by the General panel's interval rows and the Providers panel's | |
| 198 | // health-check row. | |
| 199 | ||
| 200 | export const PROVIDER_HEALTH_INTERVAL_STEP_SECONDS = 30; | |
| 201 | ||
| 202 | type BackgroundActivityOverridePatch = Partial<{ | |
| 203 | [K in keyof BackgroundActivitySettings["overrides"]]: | |
| 204 | | BackgroundActivitySettings["overrides"][K] | |
| 205 | | undefined; | |
| 206 | }>; | |
| 207 | ||
| 208 | export function durationToSeconds(duration: Duration.Duration): number { | |
| 209 | return Math.round(Duration.toMillis(duration) / 1_000); | |
| 210 | } | |
| 211 | ||
| 212 | export function normalizeIntervalSeconds(value: number | null, minimum = 0): number { | |
| 213 | if (value === null || !Number.isFinite(value)) { | |
| 214 | return minimum; | |
| 215 | } | |
| 216 | return Math.max(minimum, Math.round(value)); | |
| 217 | } | |
| 218 | ||
| 219 | export function backgroundActivityOverrideSettings( | |
| 220 | current: BackgroundActivitySettings, | |
| 221 | resolved: ReturnType<typeof resolveServerBackgroundActivitySettings>, | |
| 222 | overrides: BackgroundActivityOverridePatch, | |
| 223 | ) { | |
| 224 | const nextOverrides: BackgroundActivityOverridePatch = { | |
| 225 | automaticGitFetchInterval: resolved.automaticGitFetchInterval, | |
| 226 | providerHealthRefreshInterval: resolved.providerHealthRefreshInterval, | |
| 227 | hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval, | |
| 228 | hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval, | |
| 229 | idleClientTtl: resolved.idleClientTtl, | |
| 230 | pauseWhenHostLocked: resolved.pauseWhenHostLocked, | |
| 231 | pauseWhenHostLowPower: resolved.pauseWhenHostLowPower, | |
| 232 | pauseWhenClientLowPower: resolved.pauseWhenClientLowPower, | |
| 233 | pauseWhenOnBattery: resolved.pauseWhenOnBattery, | |
| 234 | ...overrides, | |
| 235 | }; | |
| 236 | for (const [key, value] of Object.entries(nextOverrides)) { | |
| 237 | if (value === undefined) { | |
| 238 | delete nextOverrides[key as keyof typeof nextOverrides]; | |
| 239 | } | |
| 240 | } | |
| 241 | return { | |
| 242 | backgroundActivity: { | |
| 243 | schemaVersion: 1 as const, | |
| 244 | profile: "custom" as const, | |
| 245 | baseProfile: getBackgroundActivityBaseProfile(current), | |
| 246 | overrides: nextOverrides as BackgroundActivitySettings["overrides"], | |
| 247 | }, | |
| 248 | }; | |
| 249 | } |
apps/web/src/components/settings/SettingsPanels.tsx+9-674| ... | ... | @@ -1,30 +1,16 @@ |
| 1 | import { | |
| 2 | ArchiveIcon, | |
| 3 | ArchiveX, | |
| 4 | InfoIcon, | |
| 5 | LoaderIcon, | |
| 6 | PlusIcon, | |
| 7 | RefreshCwIcon, | |
| 8 | SettingsIcon, | |
| 9 | } from "lucide-react"; | |
| 1 | import { ArchiveIcon, ArchiveX, LoaderIcon, SettingsIcon } from "lucide-react"; | |
| 10 | 2 | import { Link } from "@tanstack/react-router"; |
| 11 | 3 | import type { CSSProperties, ReactNode } from "react"; |
| 12 | 4 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 13 | 5 | import { useAtomValue } from "@effect/atom-react"; |
| 14 | 6 | import { |
| 15 | defaultInstanceIdForDriver, | |
| 16 | 7 | type BackgroundActivityProfile, |
| 17 | type BackgroundActivitySettings, | |
| 18 | 8 | type DesktopUpdateChannel, |
| 19 | PROVIDER_DISPLAY_NAMES, | |
| 20 | 9 | ProviderDriverKind, |
| 21 | type ProviderInstanceConfig, | |
| 22 | type ProviderInstanceId, | |
| 23 | 10 | type ScopedThreadRef, |
| 24 | 11 | type SidebarProjectGroupingMode, |
| 25 | 12 | } from "@t3tools/contracts"; |
| 26 | 13 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 27 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; | |
| 28 | 14 | import { |
| 29 | 15 | isAtomCommandInterrupted, |
| 30 | 16 | settlePromise, |
| ... | ... | @@ -45,16 +31,10 @@ import { |
| 45 | 31 | MIN_PROMPT_FONT_SIZE, |
| 46 | 32 | MIN_TERMINAL_FONT_SIZE, |
| 47 | 33 | } from "@t3tools/contracts/settings"; |
| 48 | import { | |
| 49 | getBackgroundActivityBaseProfile, | |
| 50 | getBackgroundActivityPresetSettings, | |
| 51 | resolveServerBackgroundActivitySettings, | |
| 52 | } from "@t3tools/shared/backgroundActivitySettings"; | |
| 34 | import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; | |
| 53 | 35 | import { createModelSelection } from "@t3tools/shared/model"; |
| 54 | import * as Arr from "effect/Array"; | |
| 55 | 36 | import * as Duration from "effect/Duration"; |
| 56 | 37 | import * as Equal from "effect/Equal"; |
| 57 | import * as Result from "effect/Result"; | |
| 58 | 38 | import * as Schema from "effect/Schema"; |
| 59 | 39 | import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; |
| 60 | 40 | import { |
| ... | ... | @@ -88,15 +68,10 @@ import { |
| 88 | 68 | } from "../../providerInstances"; |
| 89 | 69 | import { ensureLocalApi, readLocalApi } from "../../localApi"; |
| 90 | 70 | import { isMacPlatform } from "../../lib/utils"; |
| 91 | import { | |
| 92 | primaryServerObservabilityAtom, | |
| 93 | primaryServerProvidersAtom, | |
| 94 | serverEnvironment, | |
| 95 | } from "../../state/server"; | |
| 96 | import { usePrimaryEnvironment } from "../../state/environments"; | |
| 71 | import { primaryServerObservabilityAtom, primaryServerProvidersAtom } from "../../state/server"; | |
| 97 | 72 | import { useProjects } from "../../state/entities"; |
| 98 | 73 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; |
| 99 | import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; | |
| 74 | import { formatRelativeTimeLabel } from "../../timestampFormat"; | |
| 100 | 75 | import { Button } from "../ui/button"; |
| 101 | 76 | import { |
| 102 | 77 | Dialog, |
| ... | ... | @@ -131,20 +106,13 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ |
| 131 | 106 | import { Switch } from "../ui/switch"; |
| 132 | 107 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 133 | 108 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 134 | import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; | |
| 135 | import { | |
| 136 | canOneClickUpdateProviderCandidate, | |
| 137 | collectProviderUpdateCandidates, | |
| 138 | hasOneClickUpdateProviderCandidate, | |
| 139 | isProviderUpdateActive, | |
| 140 | type ProviderUpdateCandidate, | |
| 141 | } from "../ProviderUpdateLaunchNotification.logic"; | |
| 142 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; | |
| 143 | import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; | |
| 144 | 109 | import { |
| 110 | backgroundActivityOverrideSettings, | |
| 145 | 111 | backgroundActivitySharedPolicySettings, |
| 146 | buildProviderInstanceUpdatePatch, | |
| 112 | durationToSeconds, | |
| 147 | 113 | formatDiagnosticsDescription, |
| 114 | normalizeIntervalSeconds, | |
| 115 | PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, | |
| 148 | 116 | hasChangedBackgroundActivitySettings, |
| 149 | 117 | isProjectGroupingEnabled, |
| 150 | 118 | projectGroupingModeFromToggle, |
| ... | ... | @@ -153,16 +121,15 @@ import { |
| 153 | 121 | resolveBackgroundActivityProfileOption, |
| 154 | 122 | } from "./SettingsPanels.logic"; |
| 155 | 123 | import { |
| 124 | PolicyTooltip, | |
| 156 | 125 | SettingResetButton, |
| 157 | 126 | SettingsPageContainer, |
| 158 | 127 | SettingsRow, |
| 159 | 128 | SettingsSection, |
| 160 | useRelativeTimeTick, | |
| 161 | 129 | useSettingsSearchTargetId, |
| 162 | 130 | } from "./settingsLayout"; |
| 163 | 131 | import { searchableSetting } from "./settingsSearch"; |
| 164 | 132 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 165 | import { useAtomCommand } from "../../state/use-atom-command"; | |
| 166 | 133 | |
| 167 | 134 | const THEME_OPTIONS = [ |
| 168 | 135 | { |
| ... | ... | @@ -198,11 +165,6 @@ const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record<BackgroundActivityProfile, stri |
| 198 | 165 | }; |
| 199 | 166 | |
| 200 | 167 | type BackgroundActivityProfileOption = BackgroundActivityProfile | "advanced"; |
| 201 | type BackgroundActivityOverridePatch = Partial<{ | |
| 202 | [K in keyof BackgroundActivitySettings["overrides"]]: | |
| 203 | | BackgroundActivitySettings["overrides"][K] | |
| 204 | | undefined; | |
| 205 | }>; | |
| 206 | 168 | |
| 207 | 169 | const BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS: Record<BackgroundActivityProfileOption, string> = { |
| 208 | 170 | ...BACKGROUND_ACTIVITY_PROFILE_LABELS, |
| ... | ... | @@ -219,7 +181,6 @@ const BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS: Record<BackgroundActivityProfile |
| 219 | 181 | const ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION = |
| 220 | 182 | "Uses custom background intervals with the selected shared power policy."; |
| 221 | 183 | |
| 222 | const PROVIDER_HEALTH_INTERVAL_STEP_SECONDS = 30; | |
| 223 | 184 | const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); |
| 224 | 185 | const BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES: ReadonlyArray<{ |
| 225 | 186 | readonly key: |
| ... | ... | @@ -235,17 +196,6 @@ const BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES: ReadonlyArray<{ |
| 235 | 196 | { key: "pauseWhenOnBattery", label: "Pause on battery" }, |
| 236 | 197 | ]; |
| 237 | 198 | |
| 238 | function durationToSeconds(duration: Duration.Duration): number { | |
| 239 | return Math.round(Duration.toMillis(duration) / 1_000); | |
| 240 | } | |
| 241 | ||
| 242 | function normalizeIntervalSeconds(value: number | null, minimum = 0): number { | |
| 243 | if (value === null || !Number.isFinite(value)) { | |
| 244 | return minimum; | |
| 245 | } | |
| 246 | return Math.max(minimum, Math.round(value)); | |
| 247 | } | |
| 248 | ||
| 249 | 199 | function resetBackgroundActivitySettings() { |
| 250 | 200 | return { |
| 251 | 201 | backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, |
| ... | ... | @@ -262,105 +212,6 @@ function backgroundActivityProfileSettings(profile: BackgroundActivityProfile) { |
| 262 | 212 | }; |
| 263 | 213 | } |
| 264 | 214 | |
| 265 | function backgroundActivityOverrideSettings( | |
| 266 | current: BackgroundActivitySettings, | |
| 267 | resolved: ReturnType<typeof resolveServerBackgroundActivitySettings>, | |
| 268 | overrides: BackgroundActivityOverridePatch, | |
| 269 | ) { | |
| 270 | const nextOverrides: BackgroundActivityOverridePatch = { | |
| 271 | automaticGitFetchInterval: resolved.automaticGitFetchInterval, | |
| 272 | providerHealthRefreshInterval: resolved.providerHealthRefreshInterval, | |
| 273 | hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval, | |
| 274 | hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval, | |
| 275 | idleClientTtl: resolved.idleClientTtl, | |
| 276 | pauseWhenHostLocked: resolved.pauseWhenHostLocked, | |
| 277 | pauseWhenHostLowPower: resolved.pauseWhenHostLowPower, | |
| 278 | pauseWhenClientLowPower: resolved.pauseWhenClientLowPower, | |
| 279 | pauseWhenOnBattery: resolved.pauseWhenOnBattery, | |
| 280 | ...overrides, | |
| 281 | }; | |
| 282 | for (const [key, value] of Object.entries(nextOverrides)) { | |
| 283 | if (value === undefined) { | |
| 284 | delete nextOverrides[key as keyof typeof nextOverrides]; | |
| 285 | } | |
| 286 | } | |
| 287 | return { | |
| 288 | backgroundActivity: { | |
| 289 | schemaVersion: 1 as const, | |
| 290 | profile: "custom" as const, | |
| 291 | baseProfile: getBackgroundActivityBaseProfile(current), | |
| 292 | overrides: nextOverrides as BackgroundActivitySettings["overrides"], | |
| 293 | }, | |
| 294 | }; | |
| 295 | } | |
| 296 | ||
| 297 | function PolicyTooltip({ children }: { readonly children: string }) { | |
| 298 | return ( | |
| 299 | <Tooltip> | |
| 300 | <TooltipTrigger | |
| 301 | render={ | |
| 302 | <button | |
| 303 | type="button" | |
| 304 | className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground" | |
| 305 | aria-label="Background policy details" | |
| 306 | > | |
| 307 | <InfoIcon className="size-3.5" /> | |
| 308 | </button> | |
| 309 | } | |
| 310 | /> | |
| 311 | <TooltipPopup side="top" className="max-w-72"> | |
| 312 | {children} | |
| 313 | </TooltipPopup> | |
| 314 | </Tooltip> | |
| 315 | ); | |
| 316 | } | |
| 317 | ||
| 318 | function withoutProviderInstanceKey<V>( | |
| 319 | record: Readonly<Record<ProviderInstanceId, V>> | undefined, | |
| 320 | key: ProviderInstanceId, | |
| 321 | ): Record<ProviderInstanceId, V> { | |
| 322 | const next = { ...record } as Record<ProviderInstanceId, V>; | |
| 323 | delete next[key]; | |
| 324 | return next; | |
| 325 | } | |
| 326 | ||
| 327 | function withoutProviderInstanceFavorites( | |
| 328 | favorites: ReadonlyArray<{ readonly provider: ProviderInstanceId; readonly model: string }>, | |
| 329 | instanceId: ProviderInstanceId, | |
| 330 | ) { | |
| 331 | return favorites.filter((favorite) => favorite.provider !== instanceId); | |
| 332 | } | |
| 333 | ||
| 334 | const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ | |
| 335 | provider: definition.value, | |
| 336 | })); | |
| 337 | ||
| 338 | function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { | |
| 339 | useRelativeTimeTick(); | |
| 340 | const lastCheckedRelative = getRelativeTimeState(lastCheckedAt); | |
| 341 | ||
| 342 | if (lastCheckedRelative.status === "missing") { | |
| 343 | return null; | |
| 344 | } | |
| 345 | ||
| 346 | if (lastCheckedRelative.status === "invalid") { | |
| 347 | return <span className="text-[11px] text-muted-foreground/50">Checked unavailable</span>; | |
| 348 | } | |
| 349 | ||
| 350 | return ( | |
| 351 | <span className="text-[11px] text-muted-foreground/60"> | |
| 352 | {lastCheckedRelative.suffix ? ( | |
| 353 | <> | |
| 354 | Checked <span className="font-mono tabular-nums">{lastCheckedRelative.value}</span>{" "} | |
| 355 | {lastCheckedRelative.suffix} | |
| 356 | </> | |
| 357 | ) : ( | |
| 358 | <>Checked {lastCheckedRelative.value}</> | |
| 359 | )} | |
| 360 | </span> | |
| 361 | ); | |
| 362 | } | |
| 363 | ||
| 364 | 215 | function AboutVersionTitle() { |
| 365 | 216 | return ( |
| 366 | 217 | <span className="inline-flex items-center gap-2"> |
| ... | ... | @@ -2146,522 +1997,6 @@ export function GeneralSettingsPanel() { |
| 2146 | 1997 | ); |
| 2147 | 1998 | } |
| 2148 | 1999 | |
| 2149 | export function ProviderSettingsPanel() { | |
| 2150 | const settings = usePrimarySettings(); | |
| 2151 | const updateSettings = useUpdatePrimarySettings(); | |
| 2152 | const serverProviders = useAtomValue(primaryServerProvidersAtom); | |
| 2153 | const primaryEnvironment = usePrimaryEnvironment(); | |
| 2154 | const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { | |
| 2155 | reportFailure: false, | |
| 2156 | }); | |
| 2157 | const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { | |
| 2158 | reportFailure: false, | |
| 2159 | }); | |
| 2160 | const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); | |
| 2161 | const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); | |
| 2162 | const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< | |
| 2163 | ReadonlySet<ProviderDriverKind> | |
| 2164 | >(() => new Set()); | |
| 2165 | const [openInstanceDetails, setOpenInstanceDetails] = useState<Record<string, boolean>>({}); | |
| 2166 | const refreshingRef = useRef(false); | |
| 2167 | ||
| 2168 | const providerUpdateCandidates = useMemo( | |
| 2169 | () => collectProviderUpdateCandidates(serverProviders), | |
| 2170 | [serverProviders], | |
| 2171 | ); | |
| 2172 | const providerUpdateCandidateByInstanceId = useMemo( | |
| 2173 | () => new Map(providerUpdateCandidates.map((candidate) => [candidate.instanceId, candidate])), | |
| 2174 | [providerUpdateCandidates], | |
| 2175 | ); | |
| 2176 | const visibleProviderSettings = PROVIDER_SETTINGS.filter( | |
| 2177 | (providerSettings) => | |
| 2178 | providerSettings.provider !== "cursor" || | |
| 2179 | serverProviders.some( | |
| 2180 | (provider) => | |
| 2181 | provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")), | |
| 2182 | ), | |
| 2183 | ); | |
| 2184 | const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); | |
| 2185 | const textGenInstanceId = textGenerationModelSelection.instanceId; | |
| 2186 | const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); | |
| 2187 | const providerHealthPreset = getBackgroundActivityPresetSettings( | |
| 2188 | resolvedBackgroundActivity.profile, | |
| 2189 | ).providerHealthRefreshInterval; | |
| 2190 | const providerHealthRefreshIntervalSeconds = durationToSeconds( | |
| 2191 | resolvedBackgroundActivity.providerHealthRefreshInterval, | |
| 2192 | ); | |
| 2193 | const defaultProviderHealthRefreshIntervalSeconds = durationToSeconds(providerHealthPreset); | |
| 2194 | const lastCheckedAt = | |
| 2195 | serverProviders.length > 0 | |
| 2196 | ? serverProviders.reduce( | |
| 2197 | (latest, provider) => (provider.checkedAt > latest ? provider.checkedAt : latest), | |
| 2198 | serverProviders[0]!.checkedAt, | |
| 2199 | ) | |
| 2200 | : null; | |
| 2201 | ||
| 2202 | const refreshProviders = useCallback(() => { | |
| 2203 | if (refreshingRef.current) return; | |
| 2204 | refreshingRef.current = true; | |
| 2205 | setIsRefreshingProviders(true); | |
| 2206 | if (!primaryEnvironment) { | |
| 2207 | refreshingRef.current = false; | |
| 2208 | setIsRefreshingProviders(false); | |
| 2209 | return; | |
| 2210 | } | |
| 2211 | void (async () => { | |
| 2212 | const result = await refreshServerProviders({ | |
| 2213 | environmentId: primaryEnvironment.environmentId, | |
| 2214 | input: {}, | |
| 2215 | }); | |
| 2216 | refreshingRef.current = false; | |
| 2217 | setIsRefreshingProviders(false); | |
| 2218 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | |
| 2219 | console.warn("Failed to refresh providers", { | |
| 2220 | operation: "refresh-providers", | |
| 2221 | environmentId: primaryEnvironment.environmentId, | |
| 2222 | ...safeErrorLogAttributes(squashAtomCommandFailure(result)), | |
| 2223 | }); | |
| 2224 | } | |
| 2225 | })(); | |
| 2226 | }, [primaryEnvironment, refreshServerProviders]); | |
| 2227 | ||
| 2228 | const runProviderUpdate = useCallback( | |
| 2229 | async (candidate: ProviderUpdateCandidate) => { | |
| 2230 | if (!primaryEnvironment) return; | |
| 2231 | let started = false; | |
| 2232 | setUpdatingProviderDrivers((previous) => { | |
| 2233 | if (previous.has(candidate.driver)) { | |
| 2234 | return previous; | |
| 2235 | } | |
| 2236 | started = true; | |
| 2237 | const next = new Set(previous); | |
| 2238 | next.add(candidate.driver); | |
| 2239 | return next; | |
| 2240 | }); | |
| 2241 | if (!started) { | |
| 2242 | return; | |
| 2243 | } | |
| 2244 | ||
| 2245 | const result = await updateProvider({ | |
| 2246 | environmentId: primaryEnvironment.environmentId, | |
| 2247 | input: { | |
| 2248 | provider: candidate.driver, | |
| 2249 | instanceId: candidate.instanceId, | |
| 2250 | }, | |
| 2251 | }); | |
| 2252 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | |
| 2253 | const error = squashAtomCommandFailure(result); | |
| 2254 | toastManager.add( | |
| 2255 | stackedThreadToast({ | |
| 2256 | type: "error", | |
| 2257 | title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`, | |
| 2258 | description: | |
| 2259 | error instanceof Error | |
| 2260 | ? error.message | |
| 2261 | : "The provider update command could not be started.", | |
| 2262 | }), | |
| 2263 | ); | |
| 2264 | } | |
| 2265 | setUpdatingProviderDrivers((previous) => { | |
| 2266 | if (!previous.has(candidate.driver)) { | |
| 2267 | return previous; | |
| 2268 | } | |
| 2269 | const next = new Set(previous); | |
| 2270 | next.delete(candidate.driver); | |
| 2271 | return next; | |
| 2272 | }); | |
| 2273 | }, | |
| 2274 | [primaryEnvironment, updateProvider], | |
| 2275 | ); | |
| 2276 | ||
| 2277 | interface InstanceRow { | |
| 2278 | readonly instanceId: ProviderInstanceId; | |
| 2279 | readonly instance: ProviderInstanceConfig; | |
| 2280 | readonly driver: ProviderDriverKind; | |
| 2281 | readonly isDefault: boolean; | |
| 2282 | readonly isDirty?: boolean; | |
| 2283 | } | |
| 2284 | ||
| 2285 | const instancesByDriver = new Map< | |
| 2286 | ProviderDriverKind, | |
| 2287 | Array<[ProviderInstanceId, ProviderInstanceConfig]> | |
| 2288 | >(); | |
| 2289 | for (const [rawId, instance] of Object.entries(settings.providerInstances ?? {})) { | |
| 2290 | const driver = instance.driver; | |
| 2291 | const list = instancesByDriver.get(driver) ?? []; | |
| 2292 | list.push([rawId as ProviderInstanceId, instance]); | |
| 2293 | instancesByDriver.set(driver, list); | |
| 2294 | } | |
| 2295 | ||
| 2296 | const defaultSlotIdsBySource = new Set<string>( | |
| 2297 | visibleProviderSettings.map((providerSettings) => | |
| 2298 | String(defaultInstanceIdForDriver(providerSettings.provider)), | |
| 2299 | ), | |
| 2300 | ); | |
| 2301 | ||
| 2302 | const rows: InstanceRow[] = []; | |
| 2303 | const visibleDriverKinds = new Set<ProviderDriverKind>( | |
| 2304 | visibleProviderSettings.map((providerSettings) => providerSettings.provider), | |
| 2305 | ); | |
| 2306 | ||
| 2307 | for (const providerSettings of visibleProviderSettings) { | |
| 2308 | type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; | |
| 2309 | const legacyProviders = settings.providers as Record<string, LegacyProviderSettings>; | |
| 2310 | const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< | |
| 2311 | string, | |
| 2312 | LegacyProviderSettings | |
| 2313 | >; | |
| 2314 | const driver = providerSettings.provider; | |
| 2315 | const defaultInstanceId = defaultInstanceIdForDriver(driver); | |
| 2316 | const explicitInstance = settings.providerInstances?.[defaultInstanceId]; | |
| 2317 | const legacyConfig = legacyProviders[providerSettings.provider]!; | |
| 2318 | const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]!; | |
| 2319 | const effectiveInstance: ProviderInstanceConfig = | |
| 2320 | explicitInstance ?? | |
| 2321 | ({ | |
| 2322 | driver, | |
| 2323 | enabled: legacyConfig.enabled, | |
| 2324 | config: legacyConfig, | |
| 2325 | } satisfies ProviderInstanceConfig); | |
| 2326 | const isDirty = | |
| 2327 | explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig); | |
| 2328 | rows.push({ | |
| 2329 | instanceId: defaultInstanceId, | |
| 2330 | instance: effectiveInstance, | |
| 2331 | driver, | |
| 2332 | isDefault: true, | |
| 2333 | isDirty, | |
| 2334 | }); | |
| 2335 | for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) { | |
| 2336 | if (id === defaultInstanceId) continue; | |
| 2337 | rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false }); | |
| 2338 | } | |
| 2339 | } | |
| 2340 | for (const [driver, list] of instancesByDriver) { | |
| 2341 | if (visibleDriverKinds.has(driver)) continue; | |
| 2342 | for (const [id, instance] of list) { | |
| 2343 | rows.push({ | |
| 2344 | instanceId: id, | |
| 2345 | instance, | |
| 2346 | driver: instance.driver, | |
| 2347 | isDefault: defaultSlotIdsBySource.has(String(id)), | |
| 2348 | }); | |
| 2349 | } | |
| 2350 | } | |
| 2351 | ||
| 2352 | const updateProviderInstance = ( | |
| 2353 | row: InstanceRow, | |
| 2354 | next: ProviderInstanceConfig, | |
| 2355 | options?: { | |
| 2356 | readonly textGenerationModelSelection?: Parameters< | |
| 2357 | typeof buildProviderInstanceUpdatePatch | |
| 2358 | >[0]["textGenerationModelSelection"]; | |
| 2359 | }, | |
| 2360 | ) => { | |
| 2361 | updateSettings( | |
| 2362 | buildProviderInstanceUpdatePatch({ | |
| 2363 | settings, | |
| 2364 | instanceId: row.instanceId, | |
| 2365 | instance: next, | |
| 2366 | driver: row.driver, | |
| 2367 | isDefault: row.isDefault, | |
| 2368 | textGenerationModelSelection: options?.textGenerationModelSelection, | |
| 2369 | }), | |
| 2370 | ); | |
| 2371 | }; | |
| 2372 | ||
| 2373 | const deleteProviderInstance = (id: ProviderInstanceId) => { | |
| 2374 | updateSettings({ | |
| 2375 | providerInstances: withoutProviderInstanceKey(settings.providerInstances, id), | |
| 2376 | providerModelPreferences: withoutProviderInstanceKey(settings.providerModelPreferences, id), | |
| 2377 | favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], id), | |
| 2378 | }); | |
| 2379 | }; | |
| 2380 | ||
| 2381 | const updateProviderModelPreferences = ( | |
| 2382 | instanceId: ProviderInstanceId, | |
| 2383 | next: { | |
| 2384 | readonly hiddenModels: ReadonlyArray<string>; | |
| 2385 | readonly modelOrder: ReadonlyArray<string>; | |
| 2386 | }, | |
| 2387 | ) => { | |
| 2388 | const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))]; | |
| 2389 | const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))]; | |
| 2390 | const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId); | |
| 2391 | updateSettings({ | |
| 2392 | providerModelPreferences: | |
| 2393 | hiddenModels.length === 0 && modelOrder.length === 0 | |
| 2394 | ? rest | |
| 2395 | : { | |
| 2396 | ...rest, | |
| 2397 | [instanceId]: { | |
| 2398 | hiddenModels, | |
| 2399 | modelOrder, | |
| 2400 | }, | |
| 2401 | }, | |
| 2402 | }); | |
| 2403 | }; | |
| 2404 | ||
| 2405 | const updateProviderFavoriteModels = ( | |
| 2406 | instanceId: ProviderInstanceId, | |
| 2407 | nextFavoriteModels: ReadonlyArray<string>, | |
| 2408 | ) => { | |
| 2409 | const favoriteModels = [ | |
| 2410 | ...new Set( | |
| 2411 | Arr.filterMap(nextFavoriteModels, (slug) => { | |
| 2412 | const trimmedSlug = slug.trim(); | |
| 2413 | return trimmedSlug.length > 0 ? Result.succeed(trimmedSlug) : Result.failVoid; | |
| 2414 | }), | |
| 2415 | ), | |
| 2416 | ]; | |
| 2417 | updateSettings({ | |
| 2418 | favorites: [ | |
| 2419 | ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId), | |
| 2420 | ...favoriteModels.map((model) => ({ provider: instanceId, model })), | |
| 2421 | ], | |
| 2422 | }); | |
| 2423 | }; | |
| 2424 | ||
| 2425 | const resetDefaultInstance = (driverKind: ProviderDriverKind) => { | |
| 2426 | type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; | |
| 2427 | const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< | |
| 2428 | string, | |
| 2429 | LegacyProviderSettings | undefined | |
| 2430 | >; | |
| 2431 | const defaultInstanceId = defaultInstanceIdForDriver(driverKind); | |
| 2432 | const defaultLegacyProvider = defaultLegacyProviders[driverKind]; | |
| 2433 | if (defaultLegacyProvider === undefined) return; | |
| 2434 | updateSettings({ | |
| 2435 | providers: { | |
| 2436 | ...settings.providers, | |
| 2437 | [driverKind]: defaultLegacyProvider, | |
| 2438 | } as typeof settings.providers, | |
| 2439 | providerInstances: withoutProviderInstanceKey(settings.providerInstances, defaultInstanceId), | |
| 2440 | providerModelPreferences: withoutProviderInstanceKey( | |
| 2441 | settings.providerModelPreferences, | |
| 2442 | defaultInstanceId, | |
| 2443 | ), | |
| 2444 | favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], defaultInstanceId), | |
| 2445 | }); | |
| 2446 | }; | |
| 2447 | ||
| 2448 | return ( | |
| 2449 | <SettingsPageContainer> | |
| 2450 | <SettingsSection | |
| 2451 | {...searchableSetting("providers")} | |
| 2452 | headerAction={ | |
| 2453 | <div className="flex items-center gap-1.5"> | |
| 2454 | <ProviderLastChecked lastCheckedAt={lastCheckedAt} /> | |
| 2455 | <Tooltip> | |
| 2456 | <TooltipTrigger | |
| 2457 | render={ | |
| 2458 | <Button | |
| 2459 | size="icon-xs" | |
| 2460 | variant="ghost" | |
| 2461 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | |
| 2462 | onClick={() => setIsAddInstanceDialogOpen(true)} | |
| 2463 | aria-label="Add provider instance" | |
| 2464 | > | |
| 2465 | <PlusIcon className="size-3" /> | |
| 2466 | </Button> | |
| 2467 | } | |
| 2468 | /> | |
| 2469 | <TooltipPopup side="top">Add provider instance</TooltipPopup> | |
| 2470 | </Tooltip> | |
| 2471 | <Tooltip> | |
| 2472 | <TooltipTrigger | |
| 2473 | render={ | |
| 2474 | <Button | |
| 2475 | size="icon-xs" | |
| 2476 | variant="ghost" | |
| 2477 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | |
| 2478 | disabled={isRefreshingProviders} | |
| 2479 | onClick={() => void refreshProviders()} | |
| 2480 | aria-label="Refresh provider status" | |
| 2481 | > | |
| 2482 | {isRefreshingProviders ? ( | |
| 2483 | <LoaderIcon className="size-3 animate-spin" /> | |
| 2484 | ) : ( | |
| 2485 | <RefreshCwIcon className="size-3" /> | |
| 2486 | )} | |
| 2487 | </Button> | |
| 2488 | } | |
| 2489 | /> | |
| 2490 | <TooltipPopup side="top">Refresh provider status</TooltipPopup> | |
| 2491 | </Tooltip> | |
| 2492 | </div> | |
| 2493 | } | |
| 2494 | > | |
| 2495 | <SettingsRow | |
| 2496 | title={ | |
| 2497 | <span className="inline-flex items-center gap-1.5"> | |
| 2498 | Health check interval | |
| 2499 | <PolicyTooltip> | |
| 2500 | This interval is configured here, then the shared Background activity policy decides | |
| 2501 | whether provider probes may run when the timer fires. Custom intervals appear as | |
| 2502 | Advanced in General settings. | |
| 2503 | </PolicyTooltip> | |
| 2504 | </span> | |
| 2505 | } | |
| 2506 | description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes." | |
| 2507 | resetAction={ | |
| 2508 | providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? ( | |
| 2509 | <SettingResetButton | |
| 2510 | label="provider health check interval" | |
| 2511 | onClick={() => | |
| 2512 | updateSettings( | |
| 2513 | backgroundActivityOverrideSettings( | |
| 2514 | settings.backgroundActivity, | |
| 2515 | resolvedBackgroundActivity, | |
| 2516 | { | |
| 2517 | providerHealthRefreshInterval: undefined, | |
| 2518 | }, | |
| 2519 | ), | |
| 2520 | ) | |
| 2521 | } | |
| 2522 | /> | |
| 2523 | ) : null | |
| 2524 | } | |
| 2525 | control={ | |
| 2526 | <div className="flex shrink-0 items-center gap-2"> | |
| 2527 | <NumberField | |
| 2528 | value={providerHealthRefreshIntervalSeconds} | |
| 2529 | min={0} | |
| 2530 | step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS} | |
| 2531 | size="sm" | |
| 2532 | className="w-32" | |
| 2533 | onValueChange={(value) => | |
| 2534 | updateSettings( | |
| 2535 | backgroundActivityOverrideSettings( | |
| 2536 | settings.backgroundActivity, | |
| 2537 | resolvedBackgroundActivity, | |
| 2538 | { | |
| 2539 | providerHealthRefreshInterval: Duration.seconds( | |
| 2540 | normalizeIntervalSeconds(value), | |
| 2541 | ), | |
| 2542 | }, | |
| 2543 | ), | |
| 2544 | ) | |
| 2545 | } | |
| 2546 | > | |
| 2547 | <NumberFieldGroup> | |
| 2548 | <NumberFieldDecrement aria-label="Decrease provider health check interval" /> | |
| 2549 | <NumberFieldInput aria-label="Provider health check interval in seconds" /> | |
| 2550 | <NumberFieldIncrement aria-label="Increase provider health check interval" /> | |
| 2551 | </NumberFieldGroup> | |
| 2552 | </NumberField> | |
| 2553 | <span className="text-xs text-muted-foreground">seconds</span> | |
| 2554 | </div> | |
| 2555 | } | |
| 2556 | /> | |
| 2557 | ||
| 2558 | {rows.map((row) => { | |
| 2559 | const driverOption = getDriverOption(row.driver); | |
| 2560 | const liveProvider = serverProviders.find( | |
| 2561 | (candidate) => candidate.instanceId === row.instanceId, | |
| 2562 | ); | |
| 2563 | const updateCandidate = liveProvider | |
| 2564 | ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) | |
| 2565 | : undefined; | |
| 2566 | const isDriverUpdateRunning = | |
| 2567 | updateCandidate !== undefined && | |
| 2568 | (updatingProviderDrivers.has(updateCandidate.driver) || | |
| 2569 | serverProviders.some( | |
| 2570 | (provider) => | |
| 2571 | provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), | |
| 2572 | )); | |
| 2573 | const showInlineUpdateButton = | |
| 2574 | updateCandidate !== undefined && | |
| 2575 | hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); | |
| 2576 | const canRunInlineUpdate = | |
| 2577 | updateCandidate !== undefined && | |
| 2578 | canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && | |
| 2579 | !updatingProviderDrivers.has(updateCandidate.driver); | |
| 2580 | const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { | |
| 2581 | hiddenModels: [], | |
| 2582 | modelOrder: [], | |
| 2583 | }; | |
| 2584 | const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) => | |
| 2585 | favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid, | |
| 2586 | ); | |
| 2587 | const resetLabel = driverOption?.label ?? String(row.driver); | |
| 2588 | const headerAction = | |
| 2589 | row.isDefault && row.isDirty ? ( | |
| 2590 | <SettingResetButton | |
| 2591 | label={`${resetLabel} provider settings`} | |
| 2592 | onClick={() => resetDefaultInstance(row.driver)} | |
| 2593 | /> | |
| 2594 | ) : null; | |
| 2595 | return ( | |
| 2596 | <ProviderInstanceCard | |
| 2597 | key={row.instanceId} | |
| 2598 | instanceId={row.instanceId} | |
| 2599 | instance={row.instance} | |
| 2600 | driverOption={driverOption} | |
| 2601 | liveProvider={liveProvider} | |
| 2602 | isExpanded={openInstanceDetails[row.instanceId] ?? false} | |
| 2603 | onExpandedChange={(open) => | |
| 2604 | setOpenInstanceDetails((existing) => ({ | |
| 2605 | ...existing, | |
| 2606 | [row.instanceId]: open, | |
| 2607 | })) | |
| 2608 | } | |
| 2609 | onUpdate={(next) => { | |
| 2610 | const wasEnabled = row.instance.enabled ?? true; | |
| 2611 | const isDisabling = next.enabled === false && wasEnabled; | |
| 2612 | const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; | |
| 2613 | if (shouldClearTextGen) { | |
| 2614 | updateProviderInstance(row, next, { | |
| 2615 | textGenerationModelSelection: | |
| 2616 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, | |
| 2617 | }); | |
| 2618 | } else { | |
| 2619 | updateProviderInstance(row, next); | |
| 2620 | } | |
| 2621 | }} | |
| 2622 | onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)} | |
| 2623 | headerAction={headerAction} | |
| 2624 | hiddenModels={modelPreferences.hiddenModels} | |
| 2625 | favoriteModels={favoriteModels} | |
| 2626 | modelOrder={modelPreferences.modelOrder} | |
| 2627 | onHiddenModelsChange={(hiddenModels) => | |
| 2628 | updateProviderModelPreferences(row.instanceId, { | |
| 2629 | ...modelPreferences, | |
| 2630 | hiddenModels, | |
| 2631 | }) | |
| 2632 | } | |
| 2633 | onFavoriteModelsChange={(favoriteModels) => | |
| 2634 | updateProviderFavoriteModels(row.instanceId, favoriteModels) | |
| 2635 | } | |
| 2636 | onModelOrderChange={(modelOrder) => | |
| 2637 | updateProviderModelPreferences(row.instanceId, { | |
| 2638 | ...modelPreferences, | |
| 2639 | modelOrder, | |
| 2640 | }) | |
| 2641 | } | |
| 2642 | onRunUpdate={ | |
| 2643 | showInlineUpdateButton && updateCandidate | |
| 2644 | ? () => { | |
| 2645 | if (!canRunInlineUpdate) { | |
| 2646 | return; | |
| 2647 | } | |
| 2648 | void runProviderUpdate(updateCandidate); | |
| 2649 | } | |
| 2650 | : undefined | |
| 2651 | } | |
| 2652 | isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined} | |
| 2653 | /> | |
| 2654 | ); | |
| 2655 | })} | |
| 2656 | </SettingsSection> | |
| 2657 | ||
| 2658 | {isAddInstanceDialogOpen ? ( | |
| 2659 | <AddProviderInstanceDialog open onOpenChange={setIsAddInstanceDialogOpen} /> | |
| 2660 | ) : null} | |
| 2661 | </SettingsPageContainer> | |
| 2662 | ); | |
| 2663 | } | |
| 2664 | ||
| 2665 | 2000 | export function ArchivedThreadsPanel() { |
| 2666 | 2001 | const projects = useProjects(); |
| 2667 | 2002 | const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); |
apps/web/src/components/settings/settingsLayout.tsx+23-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Undo2Icon } from "lucide-react"; | |
| 1 | import { InfoIcon, Undo2Icon } from "lucide-react"; | |
| 2 | 2 | import { useLocation, useNavigate } from "@tanstack/react-router"; |
| 3 | 3 | import { |
| 4 | 4 | createContext, |
| ... | ... | @@ -83,6 +83,28 @@ function useSettingsSearchTarget<T extends HTMLElement>(id: string | undefined) |
| 83 | 83 | return targetRef; |
| 84 | 84 | } |
| 85 | 85 | |
| 86 | /** Info affordance explaining how a setting interacts with the shared background policy. */ | |
| 87 | export function PolicyTooltip({ children }: { readonly children: string }) { | |
| 88 | return ( | |
| 89 | <Tooltip> | |
| 90 | <TooltipTrigger | |
| 91 | render={ | |
| 92 | <button | |
| 93 | type="button" | |
| 94 | className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground" | |
| 95 | aria-label="Background policy details" | |
| 96 | > | |
| 97 | <InfoIcon className="size-3.5" /> | |
| 98 | </button> | |
| 99 | } | |
| 100 | /> | |
| 101 | <TooltipPopup side="top" className="max-w-72"> | |
| 102 | {children} | |
| 103 | </TooltipPopup> | |
| 104 | </Tooltip> | |
| 105 | ); | |
| 106 | } | |
| 107 | ||
| 86 | 108 | /** Re-render every `intervalMs`; return a stable timestamp snapshot for render-time relative labels. */ |
| 87 | 109 | export function useRelativeTimeTick(intervalMs = 1_000) { |
| 88 | 110 | const [nowMs, setNowMs] = useState(() => Date.now()); |
apps/web/src/routes/settings.providers.tsx+1-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | 2 | |
| 3 | import { ProviderSettingsPanel } from "../components/settings/SettingsPanels"; | |
| 3 | import { ProviderSettingsPanel } from "../components/settings/ProviderSettingsPanel"; | |
| 4 | 4 | |
| 5 | 5 | function SettingsProvidersRoute() { |
| 6 | 6 | return <ProviderSettingsPanel />; |
apps/web/src/state/server.ts+1-1| ... | ... | @@ -33,7 +33,7 @@ interface PrimaryServerState { |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | 35 | const EMPTY_AVAILABLE_EDITORS: ReadonlyArray<EditorId> = []; |
| 36 | const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = []; | |
| 36 | export const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = []; | |
| 37 | 37 | const EMPTY_PRIMARY_SERVER_STATE: PrimaryServerState = { |
| 38 | 38 | config: null, |
| 39 | 39 | latestEvent: null, |
apps/web/src/state/session.ts+15-1| ... | ... | @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; |
| 3 | 3 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 4 | 4 | import * as Option from "effect/Option"; |
| 5 | import { Atom } from "effect/unstable/reactivity"; | |
| 5 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | |
| 6 | 6 | |
| 7 | 7 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 8 | 8 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| ... | ... | @@ -26,3 +26,17 @@ export function readPreparedConnection(environmentId: EnvironmentId) { |
| 26 | 26 | appAtomRegistry.get(environmentSession.preparedConnectionValueAtom(environmentId)), |
| 27 | 27 | ); |
| 28 | 28 | } |
| 29 | ||
| 30 | /** | |
| 31 | * This client's authenticated session on one environment, as reported by that | |
| 32 | * environment's `/api/auth/session` endpoint. `data` stays populated across | |
| 33 | * SWR revalidations; `isPending` is only meaningful before the first resolve. | |
| 34 | */ | |
| 35 | export function useEnvironmentSessionState(environmentId: EnvironmentId) { | |
| 36 | const result = useAtomValue(environmentSession.sessionStateAtom(environmentId)); | |
| 37 | return { | |
| 38 | data: Option.getOrNull(AsyncResult.value(result)), | |
| 39 | hasError: result._tag === "Failure", | |
| 40 | isPending: result.waiting, | |
| 41 | }; | |
| 42 | } |
apps/web/src/test/reactElementTree.ts created+27| ... | ... | @@ -0,0 +1,27 @@ |
| 1 | import { isValidElement, type ReactElement } from "react"; | |
| 2 | ||
| 3 | /** | |
| 4 | * Depth-first search over a React element tree produced by calling a component | |
| 5 | * as a plain function (see `reactHookHarness`). Descends through props so | |
| 6 | * render-prop and slot-style children are reachable. Returns the first element | |
| 7 | * the visitor accepts, or null. | |
| 8 | */ | |
| 9 | export function visitElements( | |
| 10 | node: unknown, | |
| 11 | visitor: (element: ReactElement<Record<string, unknown>>) => boolean, | |
| 12 | ): ReactElement<Record<string, unknown>> | null { | |
| 13 | if (Array.isArray(node)) { | |
| 14 | for (const child of node) { | |
| 15 | const found = visitElements(child, visitor); | |
| 16 | if (found) return found; | |
| 17 | } | |
| 18 | return null; | |
| 19 | } | |
| 20 | if (!isValidElement<Record<string, unknown>>(node)) return null; | |
| 21 | if (visitor(node)) return node; | |
| 22 | for (const value of Object.values(node.props)) { | |
| 23 | const found = visitElements(value, visitor); | |
| 24 | if (found) return found; | |
| 25 | } | |
| 26 | return null; | |
| 27 | } |
apps/web/src/test/reactHookHarness.ts created+89| ... | ... | @@ -0,0 +1,89 @@ |
| 1 | import type { Dispatch, SetStateAction } from "react"; | |
| 2 | ||
| 3 | /** | |
| 4 | * Minimal React hook shim for tests that call components as plain functions | |
| 5 | * instead of mounting a renderer. Slots are keyed by call order, mirroring | |
| 6 | * React's own rules-of-hooks contract, and `useMemoCache` emulates the React | |
| 7 | * Compiler runtime so compiled components can execute unmodified. | |
| 8 | * | |
| 9 | * This module must stay free of runtime `react` imports: it is loaded from | |
| 10 | * inside `vi.mock("react", ...)` factories, and a value import would recurse | |
| 11 | * into the in-progress mock. Wire it up in each test file (mock calls cannot | |
| 12 | * live here because vitest hoists them per test module): | |
| 13 | * | |
| 14 | * ```ts | |
| 15 | * import { reactHookHarness } from "~/test/reactHookHarness"; | |
| 16 | * | |
| 17 | * vi.mock("react", async (importOriginal) => { | |
| 18 | * const actual = await importOriginal<typeof import("react")>(); | |
| 19 | * const { reactHookHarness } = await import("~/test/reactHookHarness"); | |
| 20 | * return { | |
| 21 | * ...actual, | |
| 22 | * useCallback: reactHookHarness.useCallback, | |
| 23 | * useMemo: reactHookHarness.useMemo, | |
| 24 | * useRef: reactHookHarness.useRef, | |
| 25 | * useState: reactHookHarness.useState, | |
| 26 | * }; | |
| 27 | * }); | |
| 28 | * vi.mock("react/compiler-runtime", async () => { | |
| 29 | * const { reactHookHarness } = await import("~/test/reactHookHarness"); | |
| 30 | * return { c: reactHookHarness.useMemoCache }; | |
| 31 | * }); | |
| 32 | * ``` | |
| 33 | * | |
| 34 | * Call `beginRender()` before each component invocation and `reset()` in | |
| 35 | * `beforeEach` to drop persisted state between tests. | |
| 36 | */ | |
| 37 | export function createReactHookHarness() { | |
| 38 | let cursor = 0; | |
| 39 | let slots: unknown[] = []; | |
| 40 | const nextIndex = () => cursor++; | |
| 41 | ||
| 42 | return { | |
| 43 | beginRender() { | |
| 44 | cursor = 0; | |
| 45 | }, | |
| 46 | reset() { | |
| 47 | cursor = 0; | |
| 48 | slots = []; | |
| 49 | }, | |
| 50 | useCallback<T>(callback: T): T { | |
| 51 | nextIndex(); | |
| 52 | return callback; | |
| 53 | }, | |
| 54 | useMemo<T>(factory: () => T): T { | |
| 55 | nextIndex(); | |
| 56 | return factory(); | |
| 57 | }, | |
| 58 | useMemoCache(size: number): unknown[] { | |
| 59 | const index = nextIndex(); | |
| 60 | if (!slots[index]) { | |
| 61 | slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel")); | |
| 62 | } | |
| 63 | return slots[index] as unknown[]; | |
| 64 | }, | |
| 65 | useRef<T>(initialValue: T): { current: T } { | |
| 66 | const index = nextIndex(); | |
| 67 | if (!slots[index]) { | |
| 68 | slots[index] = { current: initialValue }; | |
| 69 | } | |
| 70 | return slots[index] as { current: T }; | |
| 71 | }, | |
| 72 | useState<T>(initialValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>] { | |
| 73 | const index = nextIndex(); | |
| 74 | if (index >= slots.length) { | |
| 75 | slots[index] = | |
| 76 | typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; | |
| 77 | } | |
| 78 | const setValue: Dispatch<SetStateAction<T>> = (nextValue) => { | |
| 79 | const previous = slots[index] as T; | |
| 80 | slots[index] = | |
| 81 | typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue; | |
| 82 | }; | |
| 83 | return [slots[index] as T, setValue]; | |
| 84 | }, | |
| 85 | }; | |
| 86 | } | |
| 87 | ||
| 88 | /** Shared instance so `vi.mock` factories and test bodies see the same slots. */ | |
| 89 | export const reactHookHarness = createReactHookHarness(); |
packages/client-runtime/src/state/session.ts+69-2| ... | ... | @@ -1,14 +1,19 @@ |
| 1 | import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; | |
| 1 | import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts"; | |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as Option from "effect/Option"; |
| 4 | 4 | import * as Stream from "effect/Stream"; |
| 5 | 5 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 6 | import type { HttpClient } from "effect/unstable/http"; | |
| 6 | 7 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 7 | 8 | |
| 8 | 9 | import { EnvironmentRegistry } from "../connection/registry.ts"; |
| 9 | 10 | import type { PreparedConnection } from "../connection/model.ts"; |
| 10 | 11 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; |
| 12 | import { environmentEndpointUrl } from "../environment/endpoint.ts"; | |
| 13 | import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; | |
| 11 | 14 | import { safeErrorLogAttributes } from "../errors/safeLog.ts"; |
| 15 | import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; | |
| 16 | import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; | |
| 12 | 17 | import { followStreamInEnvironment } from "./runtime.ts"; |
| 13 | 18 | |
| 14 | 19 | export function initialConfigOption<E>( |
| ... | ... | @@ -25,8 +30,39 @@ export function initialConfigOption<E>( |
| 25 | 30 | ); |
| 26 | 31 | } |
| 27 | 32 | |
| 33 | // Bounded like the snapshot fetches: a wedged environment must not pin the | |
| 34 | // permissions check (and with it the settings UI) in a loading state for long. | |
| 35 | const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000; | |
| 36 | ||
| 37 | /** | |
| 38 | * Read the granted scopes of this client's session on one environment via its | |
| 39 | * `/api/auth/session` endpoint, authenticated with whatever credential the | |
| 40 | * connection was prepared with (cookie, bearer, or DPoP). | |
| 41 | */ | |
| 42 | export const fetchEnvironmentSessionState = Effect.fn( | |
| 43 | "clientRuntime.state.fetchEnvironmentSessionState", | |
| 44 | )(function* (input: { | |
| 45 | readonly prepared: PreparedConnection; | |
| 46 | readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>; | |
| 47 | readonly timeoutMs?: number; | |
| 48 | }) { | |
| 49 | const requestUrl = environmentEndpointUrl(input.prepared.httpBaseUrl, "/api/auth/session"); | |
| 50 | const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); | |
| 51 | const headers = yield* buildEnvironmentAuthHeaders( | |
| 52 | input.prepared.httpAuthorization, | |
| 53 | "GET", | |
| 54 | requestUrl, | |
| 55 | input.signer, | |
| 56 | ); | |
| 57 | return yield* executeEnvironmentHttpRequest( | |
| 58 | requestUrl, | |
| 59 | input.timeoutMs ?? DEFAULT_SESSION_STATE_TIMEOUT_MS, | |
| 60 | withEnvironmentCredentials(input.prepared.httpAuthorization, client.auth.session({ headers })), | |
| 61 | ); | |
| 62 | }); | |
| 63 | ||
| 28 | 64 | export function createEnvironmentSessionAtoms<R, E>( |
| 29 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | |
| 65 | runtime: Atom.AtomRuntime<EnvironmentRegistry | HttpClient.HttpClient | R, E>, | |
| 30 | 66 | ) { |
| 31 | 67 | const initialConfigAtom = Atom.family((environmentId: EnvironmentId) => |
| 32 | 68 | runtime.atom( |
| ... | ... | @@ -86,10 +122,41 @@ export function createEnvironmentSessionAtoms<R, E>( |
| 86 | 122 | ).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)), |
| 87 | 123 | ); |
| 88 | 124 | |
| 125 | // Keyed on the prepared connection's identity: a reconnect (new credential, | |
| 126 | // new base URL) swaps the prepared value, which re-runs the fetch, so scope | |
| 127 | // changes from re-pairing are picked up without an explicit refresh. | |
| 128 | const sessionStateAtom = Atom.family((environmentId: EnvironmentId) => | |
| 129 | runtime | |
| 130 | .atom((get) => { | |
| 131 | const prepared = Option.getOrNull(get(preparedConnectionValueAtom(environmentId))); | |
| 132 | if (prepared === null) { | |
| 133 | return Effect.never; | |
| 134 | } | |
| 135 | return Effect.gen(function* () { | |
| 136 | const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner); | |
| 137 | return yield* fetchEnvironmentSessionState({ prepared, signer }); | |
| 138 | }); | |
| 139 | }) | |
| 140 | .pipe( | |
| 141 | Atom.swr({ staleTime: 30_000, revalidateOnMount: true }), | |
| 142 | Atom.setIdleTTL(5 * 60_000), | |
| 143 | Atom.withLabel(`environment-session-state:${environmentId}`), | |
| 144 | ), | |
| 145 | ); | |
| 146 | ||
| 147 | const sessionStateValueAtom = Atom.family((environmentId: EnvironmentId) => | |
| 148 | Atom.make( | |
| 149 | (get): AuthSessionState | null => | |
| 150 | Option.getOrNull(AsyncResult.value(get(sessionStateAtom(environmentId)))) ?? null, | |
| 151 | ).pipe(Atom.withLabel(`environment-session-state-value:${environmentId}`)), | |
| 152 | ); | |
| 153 | ||
| 89 | 154 | return { |
| 90 | 155 | initialConfigAtom, |
| 91 | 156 | initialConfigValueAtom, |
| 92 | 157 | preparedConnectionAtom, |
| 93 | 158 | preparedConnectionValueAtom, |
| 159 | sessionStateAtom, | |
| 160 | sessionStateValueAtom, | |
| 94 | 161 | }; |
| 95 | 162 | } |