| 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,6 +1,28 @@ |
| 1 | import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; | ||
| 2 | |||
| 1 | import { cn } from "~/lib/utils"; | 3 | import { cn } from "~/lib/utils"; |
| 2 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | 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 | type ConnectionStatusDotProps = { | 26 | type ConnectionStatusDotProps = { |
| 5 | tooltipText?: string | null; | 27 | tooltipText?: string | null; |
| 6 | dotClassName: string; | 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,10 +6,11 @@ import { useMemo, useState } from "react"; |
| 6 | import { | 6 | import { |
| 7 | ProviderInstanceId, | 7 | ProviderInstanceId, |
| 8 | ProviderDriverKind, | 8 | ProviderDriverKind, |
| 9 | type EnvironmentId, | ||
| 9 | type ProviderInstanceConfig, | 10 | type ProviderInstanceConfig, |
| 10 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 11 | 12 | ||
| 12 | import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; | 13 | import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; |
| 13 | import { cn } from "../../lib/utils"; | 14 | import { cn } from "../../lib/utils"; |
| 14 | import { normalizeProviderAccentColor } from "../../providerInstances"; | 15 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| 15 | import { Button } from "../ui/button"; | 16 | import { Button } from "../ui/button"; |
| ... | @@ -115,13 +116,20 @@ function validateInstanceId(id: string, existing: ReadonlySet<string>): string | | ... | @@ -115,13 +116,20 @@ function validateInstanceId(id: string, existing: ReadonlySet<string>): string | |
| 115 | } | 116 | } |
| 116 | 117 | ||
| 117 | interface AddProviderInstanceDialogProps { | 118 | interface AddProviderInstanceDialogProps { |
| 118 | open: boolean; | 119 | readonly open: boolean; |
| 119 | onOpenChange: (open: boolean) => void; | 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) { | 125 | export function AddProviderInstanceDialog({ |
| 123 | const settings = usePrimarySettings(); | 126 | open, |
| 124 | const updateSettings = useUpdatePrimarySettings(); | 127 | environmentId, |
| 128 | environmentLabel, | ||
| 129 | onOpenChange, | ||
| 130 | }: AddProviderInstanceDialogProps) { | ||
| 131 | const settings = useEnvironmentSettings(environmentId); | ||
| 132 | const updateSettings = useUpdateEnvironmentSettings(environmentId); | ||
| 125 | 133 | ||
| 126 | const [wizardStep, setWizardStep] = useState(0); | 134 | const [wizardStep, setWizardStep] = useState(0); |
| 127 | const [driver, setDriver] = useState<ProviderDriverKind>(DEFAULT_DRIVER_KIND); | 135 | const [driver, setDriver] = useState<ProviderDriverKind>(DEFAULT_DRIVER_KIND); |
| ... | @@ -227,8 +235,8 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns | ... | @@ -227,8 +235,8 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns |
| 227 | <DialogHeader> | 235 | <DialogHeader> |
| 228 | <DialogTitle>Add provider instance</DialogTitle> | 236 | <DialogTitle>Add provider instance</DialogTitle> |
| 229 | <DialogDescription> | 237 | <DialogDescription> |
| 230 | Configure an additional provider instance — for example, a second Codex install | 238 | Configure an additional provider instance on {environmentLabel} — for example, a |
| 231 | pointed at a different workspace. | 239 | second Codex install pointed at a different workspace. |
| 232 | </DialogDescription> | 240 | </DialogDescription> |
| 233 | <AddProviderInstanceWizardSteps | 241 | <AddProviderInstanceWizardSteps |
| 234 | currentStep={wizardStep} | 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 +10,12 @@ import type { |
| 10 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 11 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | 11 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| 12 | import { | 12 | import { |
| 13 | getBackgroundActivityBaseProfile, | ||
| 13 | normalizeBackgroundActivitySettings, | 14 | normalizeBackgroundActivitySettings, |
| 14 | normalizeServerBackgroundActivitySettings, | 15 | normalizeServerBackgroundActivitySettings, |
| 15 | resolveServerBackgroundActivitySettings, | 16 | resolveServerBackgroundActivitySettings, |
| 16 | } from "@t3tools/shared/backgroundActivitySettings"; | 17 | } from "@t3tools/shared/backgroundActivitySettings"; |
| 18 | import * as Duration from "effect/Duration"; | ||
| 17 | import * as Equal from "effect/Equal"; | 19 | import * as Equal from "effect/Equal"; |
| 18 | 20 | ||
| 19 | export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean { | 21 | export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean { |
| ... | @@ -190,3 +192,58 @@ export function buildProviderInstanceUpdatePatch(input: { | ... | @@ -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,30 +1,16 @@ |
| 1 | import { | 1 | import { ArchiveIcon, ArchiveX, LoaderIcon, SettingsIcon } from "lucide-react"; |
| 2 | ArchiveIcon, | ||
| 3 | ArchiveX, | ||
| 4 | InfoIcon, | ||
| 5 | LoaderIcon, | ||
| 6 | PlusIcon, | ||
| 7 | RefreshCwIcon, | ||
| 8 | SettingsIcon, | ||
| 9 | } from "lucide-react"; | ||
| 10 | import { Link } from "@tanstack/react-router"; | 2 | import { Link } from "@tanstack/react-router"; |
| 11 | import type { CSSProperties, ReactNode } from "react"; | 3 | import type { CSSProperties, ReactNode } from "react"; |
| 12 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 4 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 13 | import { useAtomValue } from "@effect/atom-react"; | 5 | import { useAtomValue } from "@effect/atom-react"; |
| 14 | import { | 6 | import { |
| 15 | defaultInstanceIdForDriver, | ||
| 16 | type BackgroundActivityProfile, | 7 | type BackgroundActivityProfile, |
| 17 | type BackgroundActivitySettings, | ||
| 18 | type DesktopUpdateChannel, | 8 | type DesktopUpdateChannel, |
| 19 | PROVIDER_DISPLAY_NAMES, | ||
| 20 | ProviderDriverKind, | 9 | ProviderDriverKind, |
| 21 | type ProviderInstanceConfig, | ||
| 22 | type ProviderInstanceId, | ||
| 23 | type ScopedThreadRef, | 10 | type ScopedThreadRef, |
| 24 | type SidebarProjectGroupingMode, | 11 | type SidebarProjectGroupingMode, |
| 25 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 26 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | 13 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 27 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; | ||
| 28 | import { | 14 | import { |
| 29 | isAtomCommandInterrupted, | 15 | isAtomCommandInterrupted, |
| 30 | settlePromise, | 16 | settlePromise, |
| ... | @@ -45,16 +31,10 @@ import { | ... | @@ -45,16 +31,10 @@ import { |
| 45 | MIN_PROMPT_FONT_SIZE, | 31 | MIN_PROMPT_FONT_SIZE, |
| 46 | MIN_TERMINAL_FONT_SIZE, | 32 | MIN_TERMINAL_FONT_SIZE, |
| 47 | } from "@t3tools/contracts/settings"; | 33 | } from "@t3tools/contracts/settings"; |
| 48 | import { | 34 | import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; |
| 49 | getBackgroundActivityBaseProfile, | ||
| 50 | getBackgroundActivityPresetSettings, | ||
| 51 | resolveServerBackgroundActivitySettings, | ||
| 52 | } from "@t3tools/shared/backgroundActivitySettings"; | ||
| 53 | import { createModelSelection } from "@t3tools/shared/model"; | 35 | import { createModelSelection } from "@t3tools/shared/model"; |
| 54 | import * as Arr from "effect/Array"; | ||
| 55 | import * as Duration from "effect/Duration"; | 36 | import * as Duration from "effect/Duration"; |
| 56 | import * as Equal from "effect/Equal"; | 37 | import * as Equal from "effect/Equal"; |
| 57 | import * as Result from "effect/Result"; | ||
| 58 | import * as Schema from "effect/Schema"; | 38 | import * as Schema from "effect/Schema"; |
| 59 | import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; | 39 | import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; |
| 60 | import { | 40 | import { |
| ... | @@ -88,15 +68,10 @@ import { | ... | @@ -88,15 +68,10 @@ import { |
| 88 | } from "../../providerInstances"; | 68 | } from "../../providerInstances"; |
| 89 | import { ensureLocalApi, readLocalApi } from "../../localApi"; | 69 | import { ensureLocalApi, readLocalApi } from "../../localApi"; |
| 90 | import { isMacPlatform } from "../../lib/utils"; | 70 | import { isMacPlatform } from "../../lib/utils"; |
| 91 | import { | 71 | import { primaryServerObservabilityAtom, primaryServerProvidersAtom } from "../../state/server"; |
| 92 | primaryServerObservabilityAtom, | ||
| 93 | primaryServerProvidersAtom, | ||
| 94 | serverEnvironment, | ||
| 95 | } from "../../state/server"; | ||
| 96 | import { usePrimaryEnvironment } from "../../state/environments"; | ||
| 97 | import { useProjects } from "../../state/entities"; | 72 | import { useProjects } from "../../state/entities"; |
| 98 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; | 73 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; |
| 99 | import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; | 74 | import { formatRelativeTimeLabel } from "../../timestampFormat"; |
| 100 | import { Button } from "../ui/button"; | 75 | import { Button } from "../ui/button"; |
| 101 | import { | 76 | import { |
| 102 | Dialog, | 77 | Dialog, |
| ... | @@ -131,20 +106,13 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ | ... | @@ -131,20 +106,13 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ |
| 131 | import { Switch } from "../ui/switch"; | 106 | import { Switch } from "../ui/switch"; |
| 132 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 107 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 133 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 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 | import { | 109 | import { |
| 110 | backgroundActivityOverrideSettings, | ||
| 145 | backgroundActivitySharedPolicySettings, | 111 | backgroundActivitySharedPolicySettings, |
| 146 | buildProviderInstanceUpdatePatch, | 112 | durationToSeconds, |
| 147 | formatDiagnosticsDescription, | 113 | formatDiagnosticsDescription, |
| 114 | normalizeIntervalSeconds, | ||
| 115 | PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, | ||
| 148 | hasChangedBackgroundActivitySettings, | 116 | hasChangedBackgroundActivitySettings, |
| 149 | isProjectGroupingEnabled, | 117 | isProjectGroupingEnabled, |
| 150 | projectGroupingModeFromToggle, | 118 | projectGroupingModeFromToggle, |
| ... | @@ -153,16 +121,15 @@ import { | ... | @@ -153,16 +121,15 @@ import { |
| 153 | resolveBackgroundActivityProfileOption, | 121 | resolveBackgroundActivityProfileOption, |
| 154 | } from "./SettingsPanels.logic"; | 122 | } from "./SettingsPanels.logic"; |
| 155 | import { | 123 | import { |
| 124 | PolicyTooltip, | ||
| 156 | SettingResetButton, | 125 | SettingResetButton, |
| 157 | SettingsPageContainer, | 126 | SettingsPageContainer, |
| 158 | SettingsRow, | 127 | SettingsRow, |
| 159 | SettingsSection, | 128 | SettingsSection, |
| 160 | useRelativeTimeTick, | ||
| 161 | useSettingsSearchTargetId, | 129 | useSettingsSearchTargetId, |
| 162 | } from "./settingsLayout"; | 130 | } from "./settingsLayout"; |
| 163 | import { searchableSetting } from "./settingsSearch"; | 131 | import { searchableSetting } from "./settingsSearch"; |
| 164 | import { ProjectFavicon } from "../ProjectFavicon"; | 132 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 165 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 166 | 133 | ||
| 167 | const THEME_OPTIONS = [ | 134 | const THEME_OPTIONS = [ |
| 168 | { | 135 | { |
| ... | @@ -198,11 +165,6 @@ const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record<BackgroundActivityProfile, stri | ... | @@ -198,11 +165,6 @@ const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record<BackgroundActivityProfile, stri |
| 198 | }; | 165 | }; |
| 199 | 166 | ||
| 200 | type BackgroundActivityProfileOption = BackgroundActivityProfile | "advanced"; | 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 | const BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS: Record<BackgroundActivityProfileOption, string> = { | 169 | const BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS: Record<BackgroundActivityProfileOption, string> = { |
| 208 | ...BACKGROUND_ACTIVITY_PROFILE_LABELS, | 170 | ...BACKGROUND_ACTIVITY_PROFILE_LABELS, |
| ... | @@ -219,7 +181,6 @@ const BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS: Record<BackgroundActivityProfile | ... | @@ -219,7 +181,6 @@ const BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS: Record<BackgroundActivityProfile |
| 219 | const ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION = | 181 | const ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION = |
| 220 | "Uses custom background intervals with the selected shared power policy."; | 182 | "Uses custom background intervals with the selected shared power policy."; |
| 221 | 183 | ||
| 222 | const PROVIDER_HEALTH_INTERVAL_STEP_SECONDS = 30; | ||
| 223 | const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); | 184 | const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); |
| 224 | const BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES: ReadonlyArray<{ | 185 | const BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES: ReadonlyArray<{ |
| 225 | readonly key: | 186 | readonly key: |
| ... | @@ -235,17 +196,6 @@ const BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES: ReadonlyArray<{ | ... | @@ -235,17 +196,6 @@ const BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES: ReadonlyArray<{ |
| 235 | { key: "pauseWhenOnBattery", label: "Pause on battery" }, | 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 | function resetBackgroundActivitySettings() { | 199 | function resetBackgroundActivitySettings() { |
| 250 | return { | 200 | return { |
| 251 | backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, | 201 | backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, |
| ... | @@ -262,105 +212,6 @@ function backgroundActivityProfileSettings(profile: BackgroundActivityProfile) { | ... | @@ -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 | function AboutVersionTitle() { | 215 | function AboutVersionTitle() { |
| 365 | return ( | 216 | return ( |
| 366 | <span className="inline-flex items-center gap-2"> | 217 | <span className="inline-flex items-center gap-2"> |
| ... | @@ -2146,522 +1997,6 @@ export function GeneralSettingsPanel() { | ... | @@ -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 | export function ArchivedThreadsPanel() { | 2000 | export function ArchivedThreadsPanel() { |
| 2666 | const projects = useProjects(); | 2001 | const projects = useProjects(); |
| 2667 | const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); | 2002 | const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); |
apps/web/src/components/settings/settingsLayout.tsx+23-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Undo2Icon } from "lucide-react"; | 1 | import { InfoIcon, Undo2Icon } from "lucide-react"; |
| 2 | import { useLocation, useNavigate } from "@tanstack/react-router"; | 2 | import { useLocation, useNavigate } from "@tanstack/react-router"; |
| 3 | import { | 3 | import { |
| 4 | createContext, | 4 | createContext, |
| ... | @@ -83,6 +83,28 @@ function useSettingsSearchTarget<T extends HTMLElement>(id: string | undefined) | ... | @@ -83,6 +83,28 @@ function useSettingsSearchTarget<T extends HTMLElement>(id: string | undefined) |
| 83 | return targetRef; | 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 | /** Re-render every `intervalMs`; return a stable timestamp snapshot for render-time relative labels. */ | 108 | /** Re-render every `intervalMs`; return a stable timestamp snapshot for render-time relative labels. */ |
| 87 | export function useRelativeTimeTick(intervalMs = 1_000) { | 109 | export function useRelativeTimeTick(intervalMs = 1_000) { |
| 88 | const [nowMs, setNowMs] = useState(() => Date.now()); | 110 | const [nowMs, setNowMs] = useState(() => Date.now()); |
apps/web/src/routes/settings.providers.tsx+1-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { createFileRoute } from "@tanstack/react-router"; | 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 | function SettingsProvidersRoute() { | 5 | function SettingsProvidersRoute() { |
| 6 | return <ProviderSettingsPanel />; | 6 | return <ProviderSettingsPanel />; |
apps/web/src/state/server.ts+1-1| ... | @@ -33,7 +33,7 @@ interface PrimaryServerState { | ... | @@ -33,7 +33,7 @@ interface PrimaryServerState { |
| 33 | } | 33 | } |
| 34 | 34 | ||
| 35 | const EMPTY_AVAILABLE_EDITORS: ReadonlyArray<EditorId> = []; | 35 | const EMPTY_AVAILABLE_EDITORS: ReadonlyArray<EditorId> = []; |
| 36 | const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = []; | 36 | export const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = []; |
| 37 | const EMPTY_PRIMARY_SERVER_STATE: PrimaryServerState = { | 37 | const EMPTY_PRIMARY_SERVER_STATE: PrimaryServerState = { |
| 38 | config: null, | 38 | config: null, |
| 39 | latestEvent: null, | 39 | latestEvent: null, |
apps/web/src/state/session.ts+15-1| ... | @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; | ... | @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; | 2 | import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; |
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; | 3 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 4 | import * as Option from "effect/Option"; | 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 | import { connectionAtomRuntime } from "../connection/runtime"; | 7 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 8 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 8 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| ... | @@ -26,3 +26,17 @@ export function readPreparedConnection(environmentId: EnvironmentId) { | ... | @@ -26,3 +26,17 @@ export function readPreparedConnection(environmentId: EnvironmentId) { |
| 26 | appAtomRegistry.get(environmentSession.preparedConnectionValueAtom(environmentId)), | 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,14 +1,19 @@ |
| 1 | import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; | 1 | import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Option from "effect/Option"; | 3 | import * as Option from "effect/Option"; |
| 4 | import * as Stream from "effect/Stream"; | 4 | import * as Stream from "effect/Stream"; |
| 5 | import * as SubscriptionRef from "effect/SubscriptionRef"; | 5 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 6 | import type { HttpClient } from "effect/unstable/http"; | ||
| 6 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 7 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 7 | 8 | ||
| 8 | import { EnvironmentRegistry } from "../connection/registry.ts"; | 9 | import { EnvironmentRegistry } from "../connection/registry.ts"; |
| 9 | import type { PreparedConnection } from "../connection/model.ts"; | 10 | import type { PreparedConnection } from "../connection/model.ts"; |
| 10 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; | 11 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; |
| 12 | import { environmentEndpointUrl } from "../environment/endpoint.ts"; | ||
| 13 | import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; | ||
| 11 | import { safeErrorLogAttributes } from "../errors/safeLog.ts"; | 14 | import { safeErrorLogAttributes } from "../errors/safeLog.ts"; |
| 15 | import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; | ||
| 16 | import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; | ||
| 12 | import { followStreamInEnvironment } from "./runtime.ts"; | 17 | import { followStreamInEnvironment } from "./runtime.ts"; |
| 13 | 18 | ||
| 14 | export function initialConfigOption<E>( | 19 | export function initialConfigOption<E>( |
| ... | @@ -25,8 +30,39 @@ 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 | export function createEnvironmentSessionAtoms<R, E>( | 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 | const initialConfigAtom = Atom.family((environmentId: EnvironmentId) => | 67 | const initialConfigAtom = Atom.family((environmentId: EnvironmentId) => |
| 32 | runtime.atom( | 68 | runtime.atom( |
| ... | @@ -86,10 +122,41 @@ export function createEnvironmentSessionAtoms<R, E>( | ... | @@ -86,10 +122,41 @@ export function createEnvironmentSessionAtoms<R, E>( |
| 86 | ).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)), | 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 | return { | 154 | return { |
| 90 | initialConfigAtom, | 155 | initialConfigAtom, |
| 91 | initialConfigValueAtom, | 156 | initialConfigValueAtom, |
| 92 | preparedConnectionAtom, | 157 | preparedConnectionAtom, |
| 93 | preparedConnectionValueAtom, | 158 | preparedConnectionValueAtom, |
| 159 | sessionStateAtom, | ||
| 160 | sessionStateValueAtom, | ||
| 94 | }; | 161 | }; |
| 95 | } | 162 | } |