From fbad4433a440ac4ca37c88417bdc8b2c0b8b659c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 24 Jul 2026 13:56:27 -0700 Subject: [PATCH 1/8] feat(web): per-device provider settings Providers settings were hardwired to the primary backend, so remote boxes reached through T3 Connect or app.t3.codes could not be configured. - List every registered execution environment and let one be selected - Scope provider reads, settings writes, refreshes, updates, and instance creation to the selected environment - Gate controls on raw server config, connection phase, and operate scope - Keep shared model preferences intact when removing per-device config Co-Authored-By: Claude Opus 5 (1M context) --- ...roviderInstanceDialog.environment.test.tsx | 91 ++ .../settings/AddProviderInstanceDialog.tsx | 24 +- ...ProviderSettingsPanel.environment.test.tsx | 286 ++++++ .../ProviderSettingsPanel.logic.test.ts | 100 +++ .../settings/ProviderSettingsPanel.logic.ts | 76 ++ .../settings/ProviderSettingsPanel.tsx | 848 ++++++++++++++++++ .../components/settings/SettingsPanels.tsx | 601 +------------ apps/web/src/routes/settings.providers.tsx | 2 +- 8 files changed, 1425 insertions(+), 603 deletions(-) create mode 100644 apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx create mode 100644 apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx create mode 100644 apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts create mode 100644 apps/web/src/components/settings/ProviderSettingsPanel.logic.ts create mode 100644 apps/web/src/components/settings/ProviderSettingsPanel.tsx diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx new file mode 100644 index 00000000000..7326dac54f0 --- /dev/null +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx @@ -0,0 +1,91 @@ +import type { Dispatch, SetStateAction } from "react"; +import { EnvironmentId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const settingsHooks = vi.hoisted(() => ({ + read: vi.fn(() => ({ providerInstances: {} })), + update: vi.fn(() => vi.fn()), +})); + +const hooks = vi.hoisted(() => { + let cursor = 0; + let slots: unknown[] = []; + const nextIndex = () => cursor++; + + return { + beginRender() { + cursor = 0; + }, + reset() { + cursor = 0; + slots = []; + }, + useMemo(factory: () => T): T { + nextIndex(); + return factory(); + }, + useMemoCache(size: number): unknown[] { + const index = nextIndex(); + if (!slots[index]) { + slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel")); + } + return slots[index] as unknown[]; + }, + useState(initialValue: T | (() => T)): [T, Dispatch>] { + const index = nextIndex(); + if (index >= slots.length) { + slots[index] = + typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; + } + const setValue: Dispatch> = (nextValue) => { + const previous = slots[index] as T; + slots[index] = + typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue; + }; + return [slots[index] as T, setValue]; + }, + }; +}); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useMemo: hooks.useMemo, + useState: hooks.useState, + }; +}); + +vi.mock("react/compiler-runtime", () => ({ + c: hooks.useMemoCache, +})); + +vi.mock("../../hooks/useSettings", () => ({ + useEnvironmentSettings: settingsHooks.read, + useUpdateEnvironmentSettings: settingsHooks.update, +})); + +import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; + +const remoteEnvironmentId = EnvironmentId.make("remote-device"); + +describe("AddProviderInstanceDialog environment routing", () => { + beforeEach(() => { + hooks.reset(); + settingsHooks.read.mockClear(); + settingsHooks.update.mockClear(); + }); + + it("reads and writes settings through the supplied environment", () => { + hooks.beginRender(); + AddProviderInstanceDialog({ + open: true, + environmentId: remoteEnvironmentId, + environmentLabel: "Remote device", + onOpenChange: vi.fn(), + }); + + expect(settingsHooks.read).toHaveBeenCalledWith(remoteEnvironmentId); + expect(settingsHooks.update).toHaveBeenCalledWith(remoteEnvironmentId); + }); +}); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index a6da37c1551..158908b5e94 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -6,10 +6,11 @@ import { useMemo, useState } from "react"; import { ProviderInstanceId, ProviderDriverKind, + type EnvironmentId, type ProviderInstanceConfig, } from "@t3tools/contracts"; -import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Button } from "../ui/button"; @@ -115,13 +116,20 @@ function validateInstanceId(id: string, existing: ReadonlySet): string | } interface AddProviderInstanceDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; + readonly open: boolean; + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly onOpenChange: (open: boolean) => void; } -export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); +export function AddProviderInstanceDialog({ + open, + environmentId, + environmentLabel, + onOpenChange, +}: AddProviderInstanceDialogProps) { + const settings = useEnvironmentSettings(environmentId); + const updateSettings = useUpdateEnvironmentSettings(environmentId); const [wizardStep, setWizardStep] = useState(0); const [driver, setDriver] = useState(DEFAULT_DRIVER_KIND); @@ -227,8 +235,8 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns Add provider instance - Configure an additional provider instance — for example, a second Codex install - pointed at a different workspace. + Configure an additional provider instance on {environmentLabel} — for example, a + second Codex install pointed at a different workspace. ({ + providers: null as ReadonlyArray | null, + providersAtom: Symbol("providers"), + refreshProviders: Symbol("refreshProviders"), + updateProvider: Symbol("updateProvider"), +})); + +const commands = vi.hoisted(() => ({ + refresh: vi.fn(), + updateProvider: vi.fn(), +})); + +const settingsState = vi.hoisted(() => ({ + value: null as UnifiedSettings | null, + readEnvironmentIds: [] as EnvironmentId[], + updateEnvironmentIds: [] as EnvironmentId[], + updateSettings: vi.fn(), +})); + +const hooks = vi.hoisted(() => { + let cursor = 0; + let slots: unknown[] = []; + const nextIndex = () => cursor++; + + return { + beginRender() { + cursor = 0; + }, + reset() { + cursor = 0; + slots = []; + }, + useCallback(callback: T): T { + nextIndex(); + return callback; + }, + useMemo(factory: () => T): T { + nextIndex(); + return factory(); + }, + useMemoCache(size: number): unknown[] { + const index = nextIndex(); + if (!slots[index]) { + slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel")); + } + return slots[index] as unknown[]; + }, + useRef(initialValue: T): { current: T } { + const index = nextIndex(); + if (!slots[index]) { + slots[index] = { current: initialValue }; + } + return slots[index] as { current: T }; + }, + useState(initialValue: T | (() => T)): [T, Dispatch>] { + const index = nextIndex(); + if (index >= slots.length) { + slots[index] = + typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; + } + const setValue: Dispatch> = (nextValue) => { + const previous = slots[index] as T; + slots[index] = + typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue; + }; + return [slots[index] as T, setValue]; + }, + }; +}); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useCallback: hooks.useCallback, + useMemo: hooks.useMemo, + useRef: hooks.useRef, + useState: hooks.useState, + }; +}); + +vi.mock("react/compiler-runtime", () => ({ + c: hooks.useMemoCache, +})); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => atoms.providers, +})); + +vi.mock("../../state/server", () => ({ + serverEnvironment: { + providersValueAtom: () => atoms.providersAtom, + refreshProviders: atoms.refreshProviders, + updateProvider: atoms.updateProvider, + }, +})); + +vi.mock("../../state/use-atom-command", () => ({ + useAtomCommand: (atom: symbol) => + atom === atoms.refreshProviders ? commands.refresh : commands.updateProvider, +})); + +vi.mock("../../hooks/useSettings", () => ({ + useEnvironmentSettings: (environmentId: EnvironmentId) => { + settingsState.readEnvironmentIds.push(environmentId); + return settingsState.value; + }, + useUpdateEnvironmentSettings: (environmentId: EnvironmentId) => { + settingsState.updateEnvironmentIds.push(environmentId); + return settingsState.updateSettings; + }, +})); + +vi.mock("../../environments/primary", () => ({ + usePrimarySessionState: () => ({ data: null, error: null, isPending: false, refresh: vi.fn() }), +})); + +import { EnvironmentProviderSettings } from "./ProviderSettingsPanel"; + +const environmentId = EnvironmentId.make("remote-device"); +const codexId = ProviderInstanceId.make("codex"); +const customId = ProviderInstanceId.make("codex_work"); + +function provider(): ServerProvider { + return { + instanceId: codexId, + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-07-24T12:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + versionAdvisory: { + status: "behind_latest", + currentVersion: "1.0.0", + latestVersion: "1.1.0", + updateCommand: "pnpm add -g @openai/codex@latest", + canUpdate: true, + checkedAt: "2026-07-24T12:00:00.000Z", + message: "Update available.", + }, + }; +} + +function visitElements( + node: unknown, + visitor: (element: ReactElement>) => boolean, +): ReactElement> | null { + if (Array.isArray(node)) { + for (const child of node) { + const found = visitElements(child, visitor); + if (found) return found; + } + return null; + } + if (!isValidElement>(node)) return null; + if (visitor(node)) return node; + for (const value of Object.values(node.props)) { + const found = visitElements(value, visitor); + if (found) return found; + } + return null; +} + +function renderPanel(): ReactElement> { + hooks.beginRender(); + return EnvironmentProviderSettings({ + environmentId, + environmentLabel: "Remote device", + }) as ReactElement>; +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("EnvironmentProviderSettings routing", () => { + beforeEach(() => { + hooks.reset(); + atoms.providers = null; + settingsState.value = DEFAULT_UNIFIED_SETTINGS; + settingsState.readEnvironmentIds = []; + settingsState.updateEnvironmentIds = []; + settingsState.updateSettings.mockReset(); + commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" }); + commands.updateProvider.mockReset().mockResolvedValue({ _tag: "Success" }); + }); + + it("coalesces a nullable provider snapshot before rendering array-backed UI", () => { + expect(() => renderPanel()).not.toThrow(); + expect(settingsState.readEnvironmentIds).toEqual([environmentId]); + expect(settingsState.updateEnvironmentIds).toEqual([environmentId]); + }); + + it("routes refresh and provider update commands to the selected environment", async () => { + atoms.providers = [provider()]; + const panel = renderPanel(); + const refreshButton = visitElements( + panel, + (element) => element.props["aria-label"] === "Refresh provider status", + ); + expect(refreshButton).not.toBeNull(); + (refreshButton?.props.onClick as (() => void) | undefined)?.(); + await flushPromises(); + + expect(commands.refresh).toHaveBeenCalledWith({ environmentId, input: {} }); + + const providerCard = visitElements( + panel, + (element) => + element.props.instanceId === codexId && typeof element.props.onRunUpdate === "function", + ); + expect(providerCard).not.toBeNull(); + (providerCard?.props.onRunUpdate as (() => void) | undefined)?.(); + await flushPromises(); + + expect(commands.updateProvider).toHaveBeenCalledWith({ + environmentId, + input: { provider: ProviderDriverKind.make("codex"), instanceId: codexId }, + }); + }); + + it("deletes and resets provider configuration without erasing shared preferences", () => { + settingsState.value = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + }, + [customId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + }, + }, + providerModelPreferences: { + [customId]: { hiddenModels: ["hidden"], modelOrder: ["model"] }, + }, + favorites: [{ provider: customId, model: "favorite" }], + }; + const panel = renderPanel(); + const customCard = visitElements(panel, (element) => element.props.instanceId === customId); + expect(customCard).not.toBeNull(); + (customCard?.props.onDelete as (() => void) | undefined)?.(); + + expect(settingsState.updateSettings).toHaveBeenLastCalledWith({ + providerInstances: { + [codexId]: settingsState.value.providerInstances?.[codexId], + }, + }); + + settingsState.updateSettings.mockClear(); + const defaultCard = visitElements(panel, (element) => element.props.instanceId === codexId); + const resetAction = defaultCard?.props.headerAction; + const resetButton = visitElements( + resetAction, + (element) => typeof element.props.onClick === "function", + ); + expect(resetButton).not.toBeNull(); + (resetButton?.props.onClick as (() => void) | undefined)?.(); + + const resetPatch = settingsState.updateSettings.mock.lastCall?.[0] as + | Record + | undefined; + expect(Object.keys(resetPatch ?? {}).sort()).toEqual(["providerInstances", "providers"]); + expect(resetPatch).not.toHaveProperty("favorites"); + expect(resetPatch).not.toHaveProperty("providerModelPreferences"); + }); +}); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts new file mode 100644 index 00000000000..a8c5331c9a4 --- /dev/null +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts @@ -0,0 +1,100 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildProviderEnvironmentOptions, + classifyProviderEnvironmentAccess, + resolveSelectedProviderEnvironmentId, +} from "./ProviderSettingsPanel.logic"; + +const primaryId = EnvironmentId.make("primary"); +const relayId = EnvironmentId.make("relay"); +const sshId = EnvironmentId.make("ssh"); + +const environments = [ + { environmentId: sshId, label: "Zulu SSH" }, + { environmentId: relayId, label: "Alpha Relay" }, + { environmentId: primaryId, label: "This device" }, +] as const; + +describe("provider environment selection", () => { + it("sorts the primary environment first and the rest by label", () => { + expect( + buildProviderEnvironmentOptions(environments, primaryId).map( + (environment) => environment.environmentId, + ), + ).toEqual([primaryId, relayId, sshId]); + }); + + it("keeps a valid selection, then falls back to primary or the first environment", () => { + const options = buildProviderEnvironmentOptions(environments, primaryId); + + expect(resolveSelectedProviderEnvironmentId(options, sshId, primaryId)).toBe(sshId); + expect( + resolveSelectedProviderEnvironmentId( + options.filter((environment) => environment.environmentId !== sshId), + sshId, + primaryId, + ), + ).toBe(primaryId); + expect(resolveSelectedProviderEnvironmentId(options.slice(1), primaryId, primaryId)).toBe( + relayId, + ); + expect(resolveSelectedProviderEnvironmentId([], null, primaryId)).toBeNull(); + }); +}); + +describe("provider environment access", () => { + it("allows connected environments with config and operate access", () => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase: "connected", + hasServerConfig: true, + canOperate: true, + }), + ).toEqual({ kind: "editable" }); + }); + + it("waits for config before exposing controls", () => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase: "connected", + hasServerConfig: false, + canOperate: true, + }), + ).toEqual({ kind: "loading" }); + }); + + it("represents known missing operate access as read only", () => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase: "connected", + hasServerConfig: true, + canOperate: false, + }), + ).toEqual({ kind: "read-only" }); + }); + + it.each(["available", "offline", "connecting", "reconnecting"] as const)( + "keeps %s environments unavailable", + (connectionPhase) => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase, + hasServerConfig: true, + canOperate: true, + }), + ).toEqual({ kind: "unavailable" }); + }, + ); + + it("separates connection errors from other unavailable states", () => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase: "error", + hasServerConfig: true, + canOperate: true, + }), + ).toEqual({ kind: "error" }); + }); +}); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts new file mode 100644 index 00000000000..cd5e0d4fa42 --- /dev/null +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts @@ -0,0 +1,76 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +export interface ProviderEnvironmentOptionLike { + readonly environmentId: EnvironmentId; + readonly label: string; +} + +export function buildProviderEnvironmentOptions( + environments: ReadonlyArray, + primaryEnvironmentId: EnvironmentId | null, +): ReadonlyArray { + return environments.toSorted((left, right) => { + const leftIsPrimary = left.environmentId === primaryEnvironmentId; + const rightIsPrimary = right.environmentId === primaryEnvironmentId; + if (leftIsPrimary !== rightIsPrimary) { + return leftIsPrimary ? -1 : 1; + } + return ( + left.label.localeCompare(right.label) || + String(left.environmentId).localeCompare(String(right.environmentId)) + ); + }); +} + +export function resolveSelectedProviderEnvironmentId( + environments: ReadonlyArray, + selectedEnvironmentId: EnvironmentId | null, + primaryEnvironmentId: EnvironmentId | null, +): EnvironmentId | null { + if ( + selectedEnvironmentId !== null && + environments.some((environment) => environment.environmentId === selectedEnvironmentId) + ) { + return selectedEnvironmentId; + } + if ( + primaryEnvironmentId !== null && + environments.some((environment) => environment.environmentId === primaryEnvironmentId) + ) { + return primaryEnvironmentId; + } + return environments[0]?.environmentId ?? null; +} + +export type ProviderEnvironmentAccess = + | { readonly kind: "editable" } + | { readonly kind: "loading" } + | { readonly kind: "read-only" } + | { readonly kind: "unavailable" } + | { readonly kind: "error" }; + +export function classifyProviderEnvironmentAccess(input: { + readonly connectionPhase: + | "available" + | "offline" + | "connecting" + | "reconnecting" + | "connected" + | "error"; + readonly hasServerConfig: boolean; + readonly canOperate: boolean | null; +}): ProviderEnvironmentAccess { + if (input.connectionPhase === "error") { + return { kind: "error" }; + } + if (input.connectionPhase !== "connected") { + return { kind: "unavailable" }; + } + if (!input.hasServerConfig) { + return { kind: "loading" }; + } + if (input.canOperate === false) { + return { kind: "read-only" }; + } + return { kind: "editable" }; +} diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx new file mode 100644 index 00000000000..d98fb2e491a --- /dev/null +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -0,0 +1,848 @@ +import { useAtomValue } from "@effect/atom-react"; +import { connectionStatusText } from "@t3tools/client-runtime/connection"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + AuthOrchestrationOperateScope, + defaultInstanceIdForDriver, + type EnvironmentId, + PROVIDER_DISPLAY_NAMES, + ProviderDriverKind, + type ProviderInstanceConfig, + type ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { + getBackgroundActivityPresetSettings, + resolveServerBackgroundActivitySettings, +} from "@t3tools/shared/backgroundActivitySettings"; +import * as Arr from "effect/Array"; +import * as Duration from "effect/Duration"; +import * as Equal from "effect/Equal"; +import * as Result from "effect/Result"; +import { + CloudIcon, + LaptopIcon, + LoaderIcon, + MonitorIcon, + PlusIcon, + RefreshCwIcon, + TerminalIcon, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; +import { usePrimarySessionState } from "../../environments/primary"; +import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; +import { cn } from "../../lib/utils"; +import { resolveAppModelSelectionState } from "../../modelSelection"; +import { + useEnvironments, + usePrimaryEnvironmentId, + type EnvironmentPresentation, +} from "../../state/environments"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { getRelativeTimeState } from "../../timestampFormat"; +import { ConnectionStatusDot } from "../ConnectionStatusDot"; +import { + canOneClickUpdateProviderCandidate, + collectProviderUpdateCandidates, + hasOneClickUpdateProviderCandidate, + isProviderUpdateActive, + type ProviderUpdateCandidate, +} from "../ProviderUpdateLaunchNotification.logic"; +import { Button } from "../ui/button"; +import { + NumberField, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from "../ui/number-field"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; +import { ProviderInstanceCard } from "./ProviderInstanceCard"; +import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; +import { + backgroundActivityOverrideSettings, + durationToSeconds, + normalizeIntervalSeconds, + PolicyTooltip, + PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, +} from "./SettingsPanels"; +import { buildProviderInstanceUpdatePatch } from "./SettingsPanels.logic"; +import { + SettingResetButton, + SettingsPageContainer, + SettingsRow, + SettingsSection, + useRelativeTimeTick, +} from "./settingsLayout"; +import { + buildProviderEnvironmentOptions, + classifyProviderEnvironmentAccess, + resolveSelectedProviderEnvironmentId, +} from "./ProviderSettingsPanel.logic"; + +const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; + +function withoutProviderInstanceKey( + record: Readonly> | undefined, + key: ProviderInstanceId, +): Record { + const next = { ...record } as Record; + delete next[key]; + return next; +} + +function withoutProviderInstanceFavorites( + favorites: ReadonlyArray<{ readonly provider: ProviderInstanceId; readonly model: string }>, + instanceId: ProviderInstanceId, +) { + return favorites.filter((favorite) => favorite.provider !== instanceId); +} + +const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ + provider: definition.value, +})); + +function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { + useRelativeTimeTick(); + const lastCheckedRelative = getRelativeTimeState(lastCheckedAt); + + if (lastCheckedRelative.status === "missing") { + return null; + } + + if (lastCheckedRelative.status === "invalid") { + return Checked unavailable; + } + + return ( + + {lastCheckedRelative.suffix ? ( + <> + Checked {lastCheckedRelative.value}{" "} + {lastCheckedRelative.suffix} + + ) : ( + <>Checked {lastCheckedRelative.value} + )} + + ); +} + +function providerEnvironmentIcon(environment: EnvironmentPresentation) { + if (environment.entry.target._tag === "PrimaryConnectionTarget") return MonitorIcon; + if (environment.entry.target._tag === "RelayConnectionTarget") return CloudIcon; + if (environment.entry.target._tag === "SshConnectionTarget") return TerminalIcon; + if (isDesktopLocalConnectionTarget(environment.entry.target)) return LaptopIcon; + return CloudIcon; +} + +function providerEnvironmentDetail(environment: EnvironmentPresentation): string { + if (environment.entry.target._tag === "PrimaryConnectionTarget") return "Primary device"; + if (environment.relayManaged) return "T3 Connect"; + if (environment.entry.target._tag === "SshConnectionTarget") return "SSH"; + if (isDesktopLocalConnectionTarget(environment.entry.target)) return "Local device"; + return environment.displayUrl ?? "Remote device"; +} + +function connectionDotClassName(environment: EnvironmentPresentation): string { + switch (environment.connection.phase) { + case "connected": + return "bg-success"; + case "connecting": + case "reconnecting": + return "bg-warning"; + case "error": + return "bg-destructive"; + default: + return "bg-muted-foreground/40"; + } +} + +function EnvironmentUnavailableRow({ + environment, + accessKind, +}: { + readonly environment: EnvironmentPresentation; + readonly accessKind: "loading" | "read-only" | "unavailable" | "error"; +}) { + const title = + accessKind === "loading" + ? "Loading provider settings" + : accessKind === "read-only" + ? "Provider settings are read only" + : accessKind === "error" + ? "Could not connect to this device" + : "Provider settings are unavailable"; + const description = + accessKind === "loading" + ? `Waiting for ${environment.label}'s configuration.` + : accessKind === "read-only" + ? `This session can view ${environment.label}, but it cannot change provider configuration.` + : connectionStatusText(environment.connection); + return ( + + + {accessKind === "loading" ? ( + + ) : null} + {title} + + } + description={description} + /> + + ); +} + +export function ProviderSettingsPanel() { + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const options = useMemo( + () => buildProviderEnvironmentOptions(environments, primaryEnvironmentId), + [environments, primaryEnvironmentId], + ); + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState( + primaryEnvironmentId, + ); + const effectiveEnvironmentId = resolveSelectedProviderEnvironmentId( + options, + selectedEnvironmentId, + primaryEnvironmentId, + ); + useEffect(() => { + if (effectiveEnvironmentId !== selectedEnvironmentId) { + setSelectedEnvironmentId(effectiveEnvironmentId); + } + }, [effectiveEnvironmentId, selectedEnvironmentId]); + const selectedEnvironment = + options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null; + const showDeviceList = + options.length === 0 || + options.length > 1 || + options[0]?.entry.target._tag !== "PrimaryConnectionTarget"; + + return ( + + {showDeviceList ? ( + + {options.length === 0 ? ( + + ) : ( +
+ {options.map((environment) => { + const Icon = providerEnvironmentIcon(environment); + const selected = environment.environmentId === effectiveEnvironmentId; + const statusText = connectionStatusText(environment.connection); + const isPending = + environment.connection.phase === "connecting" || + environment.connection.phase === "reconnecting"; + return ( + + ); + })} +
+ )} +
+ ) : null} + + {selectedEnvironment ? ( + + ) : null} +
+ ); +} + +function SelectedEnvironmentProviderSettings({ + environment, +}: { + readonly environment: EnvironmentPresentation; +}) { + const primarySessionState = usePrimarySessionState(); + const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; + // Remote connection brokers request the standard client scopes, which include + // orchestration:operate. The environment RPC layer remains authoritative if a + // custom remote credential grants less access. + const canOperate = isPrimary + ? window.desktopBridge + ? true + : primarySessionState.data?.authenticated + ? (primarySessionState.data.scopes ?? []).includes(AuthOrchestrationOperateScope) + : null + : true; + const access = classifyProviderEnvironmentAccess({ + connectionPhase: environment.connection.phase, + hasServerConfig: environment.serverConfig !== null, + canOperate, + }); + if (access.kind !== "editable") { + return ; + } + return ( + + ); +} + +export function EnvironmentProviderSettings({ + environmentId, + environmentLabel, +}: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; +}) { + const settings = useEnvironmentSettings(environmentId); + const updateSettings = useUpdateEnvironmentSettings(environmentId); + const serverProviders = + useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; + const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { + reportFailure: false, + }); + const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); + const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); + const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< + ReadonlySet + >(() => new Set()); + const [openInstanceDetails, setOpenInstanceDetails] = useState>({}); + const refreshingRef = useRef(false); + + const providerUpdateCandidates = useMemo( + () => collectProviderUpdateCandidates(serverProviders), + [serverProviders], + ); + const providerUpdateCandidateByInstanceId = useMemo( + () => new Map(providerUpdateCandidates.map((candidate) => [candidate.instanceId, candidate])), + [providerUpdateCandidates], + ); + const visibleProviderSettings = PROVIDER_SETTINGS.filter( + (providerSettings) => + providerSettings.provider !== "cursor" || + serverProviders.some( + (provider) => + provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")), + ), + ); + const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); + const textGenInstanceId = textGenerationModelSelection.instanceId; + const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); + const providerHealthPreset = getBackgroundActivityPresetSettings( + resolvedBackgroundActivity.profile, + ).providerHealthRefreshInterval; + const providerHealthRefreshIntervalSeconds = durationToSeconds( + resolvedBackgroundActivity.providerHealthRefreshInterval, + ); + const defaultProviderHealthRefreshIntervalSeconds = durationToSeconds(providerHealthPreset); + const lastCheckedAt = + serverProviders.length > 0 + ? serverProviders.reduce( + (latest, provider) => (provider.checkedAt > latest ? provider.checkedAt : latest), + serverProviders[0]!.checkedAt, + ) + : null; + + const refreshProviders = useCallback(() => { + if (refreshingRef.current) return; + refreshingRef.current = true; + setIsRefreshingProviders(true); + void (async () => { + const result = await refreshServerProviders({ + environmentId, + input: {}, + }); + refreshingRef.current = false; + setIsRefreshingProviders(false); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + console.warn("Failed to refresh providers", { + operation: "refresh-providers", + environmentId, + ...safeErrorLogAttributes(squashAtomCommandFailure(result)), + }); + } + })(); + }, [environmentId, refreshServerProviders]); + + const runProviderUpdate = useCallback( + async (candidate: ProviderUpdateCandidate) => { + let started = false; + setUpdatingProviderDrivers((previous) => { + if (previous.has(candidate.driver)) { + return previous; + } + started = true; + const next = new Set(previous); + next.add(candidate.driver); + return next; + }); + if (!started) { + return; + } + + const result = await updateProvider({ + environmentId, + input: { + provider: candidate.driver, + instanceId: candidate.instanceId, + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`, + description: + error instanceof Error + ? error.message + : "The provider update command could not be started.", + }), + ); + } + setUpdatingProviderDrivers((previous) => { + if (!previous.has(candidate.driver)) { + return previous; + } + const next = new Set(previous); + next.delete(candidate.driver); + return next; + }); + }, + [environmentId, updateProvider], + ); + + interface InstanceRow { + readonly instanceId: ProviderInstanceId; + readonly instance: ProviderInstanceConfig; + readonly driver: ProviderDriverKind; + readonly isDefault: boolean; + readonly isDirty?: boolean; + } + + const instancesByDriver = new Map< + ProviderDriverKind, + Array<[ProviderInstanceId, ProviderInstanceConfig]> + >(); + for (const [rawId, instance] of Object.entries(settings.providerInstances ?? {})) { + const driver = instance.driver; + const list = instancesByDriver.get(driver) ?? []; + list.push([rawId as ProviderInstanceId, instance]); + instancesByDriver.set(driver, list); + } + + const defaultSlotIdsBySource = new Set( + visibleProviderSettings.map((providerSettings) => + String(defaultInstanceIdForDriver(providerSettings.provider)), + ), + ); + + const rows: InstanceRow[] = []; + const visibleDriverKinds = new Set( + visibleProviderSettings.map((providerSettings) => providerSettings.provider), + ); + + for (const providerSettings of visibleProviderSettings) { + type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; + const legacyProviders = settings.providers as Record; + const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< + string, + LegacyProviderSettings + >; + const driver = providerSettings.provider; + const defaultInstanceId = defaultInstanceIdForDriver(driver); + const explicitInstance = settings.providerInstances?.[defaultInstanceId]; + const legacyConfig = legacyProviders[providerSettings.provider]!; + const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]!; + const effectiveInstance: ProviderInstanceConfig = + explicitInstance ?? + ({ + driver, + enabled: legacyConfig.enabled, + config: legacyConfig, + } satisfies ProviderInstanceConfig); + const isDirty = + explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig); + rows.push({ + instanceId: defaultInstanceId, + instance: effectiveInstance, + driver, + isDefault: true, + isDirty, + }); + for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) { + if (id === defaultInstanceId) continue; + rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false }); + } + } + for (const [driver, list] of instancesByDriver) { + if (visibleDriverKinds.has(driver)) continue; + for (const [id, instance] of list) { + rows.push({ + instanceId: id, + instance, + driver: instance.driver, + isDefault: defaultSlotIdsBySource.has(String(id)), + }); + } + } + + const updateProviderInstance = ( + row: InstanceRow, + next: ProviderInstanceConfig, + options?: { + readonly textGenerationModelSelection?: Parameters< + typeof buildProviderInstanceUpdatePatch + >[0]["textGenerationModelSelection"]; + }, + ) => { + updateSettings( + buildProviderInstanceUpdatePatch({ + settings, + instanceId: row.instanceId, + instance: next, + driver: row.driver, + isDefault: row.isDefault, + textGenerationModelSelection: options?.textGenerationModelSelection, + }), + ); + }; + + const deleteProviderInstance = (id: ProviderInstanceId) => { + updateSettings({ + providerInstances: withoutProviderInstanceKey(settings.providerInstances, id), + }); + }; + + const updateProviderModelPreferences = ( + instanceId: ProviderInstanceId, + next: { + readonly hiddenModels: ReadonlyArray; + readonly modelOrder: ReadonlyArray; + }, + ) => { + const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))]; + const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))]; + const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId); + updateSettings({ + providerModelPreferences: + hiddenModels.length === 0 && modelOrder.length === 0 + ? rest + : { + ...rest, + [instanceId]: { + hiddenModels, + modelOrder, + }, + }, + }); + }; + + const updateProviderFavoriteModels = ( + instanceId: ProviderInstanceId, + nextFavoriteModels: ReadonlyArray, + ) => { + const favoriteModels = [ + ...new Set( + Arr.filterMap(nextFavoriteModels, (slug) => { + const trimmedSlug = slug.trim(); + return trimmedSlug.length > 0 ? Result.succeed(trimmedSlug) : Result.failVoid; + }), + ), + ]; + updateSettings({ + favorites: [ + ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId), + ...favoriteModels.map((model) => ({ provider: instanceId, model })), + ], + }); + }; + + const resetDefaultInstance = (driverKind: ProviderDriverKind) => { + type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; + const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< + string, + LegacyProviderSettings | undefined + >; + const defaultInstanceId = defaultInstanceIdForDriver(driverKind); + const defaultLegacyProvider = defaultLegacyProviders[driverKind]; + if (defaultLegacyProvider === undefined) return; + updateSettings({ + providers: { + ...settings.providers, + [driverKind]: defaultLegacyProvider, + } as typeof settings.providers, + providerInstances: withoutProviderInstanceKey(settings.providerInstances, defaultInstanceId), + }); + }; + + return ( + <> + + + + setIsAddInstanceDialogOpen(true)} + aria-label="Add provider instance" + > + + + } + /> + Add provider instance + + + void refreshProviders()} + aria-label="Refresh provider status" + > + {isRefreshingProviders ? ( + + ) : ( + + )} + + } + /> + Refresh provider status + + + } + > + + Health check interval + + This interval is configured here, then the shared Background activity policy decides + whether provider probes may run when the timer fires. Custom intervals appear as + Advanced in General settings. + + + } + description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes." + resetAction={ + providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? ( + + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + providerHealthRefreshInterval: undefined, + }, + ), + ) + } + /> + ) : null + } + control={ +
+ + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + providerHealthRefreshInterval: Duration.seconds( + normalizeIntervalSeconds(value), + ), + }, + ), + ) + } + > + + + + + + + seconds +
+ } + /> + + {rows.map((row) => { + const driverOption = getDriverOption(row.driver); + const liveProvider = serverProviders.find( + (candidate) => candidate.instanceId === row.instanceId, + ); + const updateCandidate = liveProvider + ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) + : undefined; + const isDriverUpdateRunning = + updateCandidate !== undefined && + (updatingProviderDrivers.has(updateCandidate.driver) || + serverProviders.some( + (provider) => + provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), + )); + const showInlineUpdateButton = + updateCandidate !== undefined && + hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); + const canRunInlineUpdate = + updateCandidate !== undefined && + canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && + !updatingProviderDrivers.has(updateCandidate.driver); + const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { + hiddenModels: [], + modelOrder: [], + }; + const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) => + favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid, + ); + const resetLabel = driverOption?.label ?? String(row.driver); + const headerAction = + row.isDefault && row.isDirty ? ( + resetDefaultInstance(row.driver)} + /> + ) : null; + return ( + + setOpenInstanceDetails((existing) => ({ + ...existing, + [row.instanceId]: open, + })) + } + onUpdate={(next) => { + const wasEnabled = row.instance.enabled ?? true; + const isDisabling = next.enabled === false && wasEnabled; + const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; + if (shouldClearTextGen) { + updateProviderInstance(row, next, { + textGenerationModelSelection: + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + }); + } else { + updateProviderInstance(row, next); + } + }} + onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)} + headerAction={headerAction} + hiddenModels={modelPreferences.hiddenModels} + favoriteModels={favoriteModels} + modelOrder={modelPreferences.modelOrder} + onHiddenModelsChange={(hiddenModels) => + updateProviderModelPreferences(row.instanceId, { + ...modelPreferences, + hiddenModels, + }) + } + onFavoriteModelsChange={(favoriteModels) => + updateProviderFavoriteModels(row.instanceId, favoriteModels) + } + onModelOrderChange={(modelOrder) => + updateProviderModelPreferences(row.instanceId, { + ...modelPreferences, + modelOrder, + }) + } + onRunUpdate={ + showInlineUpdateButton && updateCandidate + ? () => { + if (!canRunInlineUpdate) { + return; + } + void runProviderUpdate(updateCandidate); + } + : undefined + } + isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined} + /> + ); + })} +
+ + {isAddInstanceDialogOpen ? ( + + ) : null} + + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9a5fe319568..c9ac6fdab05 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,30 +1,17 @@ -import { - ArchiveIcon, - ArchiveX, - InfoIcon, - LoaderIcon, - PlusIcon, - RefreshCwIcon, - SettingsIcon, -} from "lucide-react"; +import { ArchiveIcon, ArchiveX, InfoIcon, LoaderIcon, SettingsIcon } from "lucide-react"; import { Link } from "@tanstack/react-router"; import type { CSSProperties, ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { - defaultInstanceIdForDriver, type BackgroundActivityProfile, type BackgroundActivitySettings, type DesktopUpdateChannel, - PROVIDER_DISPLAY_NAMES, ProviderDriverKind, - type ProviderInstanceConfig, - type ProviderInstanceId, type ScopedThreadRef, type SidebarProjectGroupingMode, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { isAtomCommandInterrupted, settlePromise, @@ -51,7 +38,6 @@ import { resolveServerBackgroundActivitySettings, } from "@t3tools/shared/backgroundActivitySettings"; import { createModelSelection } from "@t3tools/shared/model"; -import * as Arr from "effect/Array"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; import * as Result from "effect/Result"; @@ -96,7 +82,7 @@ import { import { usePrimaryEnvironment } from "../../state/environments"; import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; -import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; import { Dialog, @@ -131,19 +117,8 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; -import { - canOneClickUpdateProviderCandidate, - collectProviderUpdateCandidates, - hasOneClickUpdateProviderCandidate, - isProviderUpdateActive, - type ProviderUpdateCandidate, -} from "../ProviderUpdateLaunchNotification.logic"; -import { ProviderInstanceCard } from "./ProviderInstanceCard"; -import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { backgroundActivitySharedPolicySettings, - buildProviderInstanceUpdatePatch, formatDiagnosticsDescription, hasChangedBackgroundActivitySettings, isProjectGroupingEnabled, @@ -162,7 +137,6 @@ import { } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; -import { useAtomCommand } from "../../state/use-atom-command"; const THEME_OPTIONS = [ { @@ -219,7 +193,7 @@ const BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS: Record, overrides: BackgroundActivityOverridePatch, @@ -294,7 +268,7 @@ function backgroundActivityOverrideSettings( }; } -function PolicyTooltip({ children }: { readonly children: string }) { +export function PolicyTooltip({ children }: { readonly children: string }) { return ( ( - record: Readonly> | undefined, - key: ProviderInstanceId, -): Record { - const next = { ...record } as Record; - delete next[key]; - return next; -} - -function withoutProviderInstanceFavorites( - favorites: ReadonlyArray<{ readonly provider: ProviderInstanceId; readonly model: string }>, - instanceId: ProviderInstanceId, -) { - return favorites.filter((favorite) => favorite.provider !== instanceId); -} - -const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ - provider: definition.value, -})); - -function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { - useRelativeTimeTick(); - const lastCheckedRelative = getRelativeTimeState(lastCheckedAt); - - if (lastCheckedRelative.status === "missing") { - return null; - } - - if (lastCheckedRelative.status === "invalid") { - return Checked unavailable; - } - - return ( - - {lastCheckedRelative.suffix ? ( - <> - Checked {lastCheckedRelative.value}{" "} - {lastCheckedRelative.suffix} - - ) : ( - <>Checked {lastCheckedRelative.value} - )} - - ); -} function AboutVersionTitle() { return ( @@ -2177,522 +2106,6 @@ export function GeneralSettingsPanel() { ); } -export function ProviderSettingsPanel() { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); - const serverProviders = useAtomValue(primaryServerProvidersAtom); - const primaryEnvironment = usePrimaryEnvironment(); - const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { - reportFailure: false, - }); - const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { - reportFailure: false, - }); - const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); - const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); - const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< - ReadonlySet - >(() => new Set()); - const [openInstanceDetails, setOpenInstanceDetails] = useState>({}); - const refreshingRef = useRef(false); - - const providerUpdateCandidates = useMemo( - () => collectProviderUpdateCandidates(serverProviders), - [serverProviders], - ); - const providerUpdateCandidateByInstanceId = useMemo( - () => new Map(providerUpdateCandidates.map((candidate) => [candidate.instanceId, candidate])), - [providerUpdateCandidates], - ); - const visibleProviderSettings = PROVIDER_SETTINGS.filter( - (providerSettings) => - providerSettings.provider !== "cursor" || - serverProviders.some( - (provider) => - provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")), - ), - ); - const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); - const textGenInstanceId = textGenerationModelSelection.instanceId; - const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); - const providerHealthPreset = getBackgroundActivityPresetSettings( - resolvedBackgroundActivity.profile, - ).providerHealthRefreshInterval; - const providerHealthRefreshIntervalSeconds = durationToSeconds( - resolvedBackgroundActivity.providerHealthRefreshInterval, - ); - const defaultProviderHealthRefreshIntervalSeconds = durationToSeconds(providerHealthPreset); - const lastCheckedAt = - serverProviders.length > 0 - ? serverProviders.reduce( - (latest, provider) => (provider.checkedAt > latest ? provider.checkedAt : latest), - serverProviders[0]!.checkedAt, - ) - : null; - - const refreshProviders = useCallback(() => { - if (refreshingRef.current) return; - refreshingRef.current = true; - setIsRefreshingProviders(true); - if (!primaryEnvironment) { - refreshingRef.current = false; - setIsRefreshingProviders(false); - return; - } - void (async () => { - const result = await refreshServerProviders({ - environmentId: primaryEnvironment.environmentId, - input: {}, - }); - refreshingRef.current = false; - setIsRefreshingProviders(false); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - console.warn("Failed to refresh providers", { - operation: "refresh-providers", - environmentId: primaryEnvironment.environmentId, - ...safeErrorLogAttributes(squashAtomCommandFailure(result)), - }); - } - })(); - }, [primaryEnvironment, refreshServerProviders]); - - const runProviderUpdate = useCallback( - async (candidate: ProviderUpdateCandidate) => { - if (!primaryEnvironment) return; - let started = false; - setUpdatingProviderDrivers((previous) => { - if (previous.has(candidate.driver)) { - return previous; - } - started = true; - const next = new Set(previous); - next.add(candidate.driver); - return next; - }); - if (!started) { - return; - } - - const result = await updateProvider({ - environmentId: primaryEnvironment.environmentId, - input: { - provider: candidate.driver, - instanceId: candidate.instanceId, - }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`, - description: - error instanceof Error - ? error.message - : "The provider update command could not be started.", - }), - ); - } - setUpdatingProviderDrivers((previous) => { - if (!previous.has(candidate.driver)) { - return previous; - } - const next = new Set(previous); - next.delete(candidate.driver); - return next; - }); - }, - [primaryEnvironment, updateProvider], - ); - - interface InstanceRow { - readonly instanceId: ProviderInstanceId; - readonly instance: ProviderInstanceConfig; - readonly driver: ProviderDriverKind; - readonly isDefault: boolean; - readonly isDirty?: boolean; - } - - const instancesByDriver = new Map< - ProviderDriverKind, - Array<[ProviderInstanceId, ProviderInstanceConfig]> - >(); - for (const [rawId, instance] of Object.entries(settings.providerInstances ?? {})) { - const driver = instance.driver; - const list = instancesByDriver.get(driver) ?? []; - list.push([rawId as ProviderInstanceId, instance]); - instancesByDriver.set(driver, list); - } - - const defaultSlotIdsBySource = new Set( - visibleProviderSettings.map((providerSettings) => - String(defaultInstanceIdForDriver(providerSettings.provider)), - ), - ); - - const rows: InstanceRow[] = []; - const visibleDriverKinds = new Set( - visibleProviderSettings.map((providerSettings) => providerSettings.provider), - ); - - for (const providerSettings of visibleProviderSettings) { - type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; - const legacyProviders = settings.providers as Record; - const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< - string, - LegacyProviderSettings - >; - const driver = providerSettings.provider; - const defaultInstanceId = defaultInstanceIdForDriver(driver); - const explicitInstance = settings.providerInstances?.[defaultInstanceId]; - const legacyConfig = legacyProviders[providerSettings.provider]!; - const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]!; - const effectiveInstance: ProviderInstanceConfig = - explicitInstance ?? - ({ - driver, - enabled: legacyConfig.enabled, - config: legacyConfig, - } satisfies ProviderInstanceConfig); - const isDirty = - explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig); - rows.push({ - instanceId: defaultInstanceId, - instance: effectiveInstance, - driver, - isDefault: true, - isDirty, - }); - for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) { - if (id === defaultInstanceId) continue; - rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false }); - } - } - for (const [driver, list] of instancesByDriver) { - if (visibleDriverKinds.has(driver)) continue; - for (const [id, instance] of list) { - rows.push({ - instanceId: id, - instance, - driver: instance.driver, - isDefault: defaultSlotIdsBySource.has(String(id)), - }); - } - } - - const updateProviderInstance = ( - row: InstanceRow, - next: ProviderInstanceConfig, - options?: { - readonly textGenerationModelSelection?: Parameters< - typeof buildProviderInstanceUpdatePatch - >[0]["textGenerationModelSelection"]; - }, - ) => { - updateSettings( - buildProviderInstanceUpdatePatch({ - settings, - instanceId: row.instanceId, - instance: next, - driver: row.driver, - isDefault: row.isDefault, - textGenerationModelSelection: options?.textGenerationModelSelection, - }), - ); - }; - - const deleteProviderInstance = (id: ProviderInstanceId) => { - updateSettings({ - providerInstances: withoutProviderInstanceKey(settings.providerInstances, id), - providerModelPreferences: withoutProviderInstanceKey(settings.providerModelPreferences, id), - favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], id), - }); - }; - - const updateProviderModelPreferences = ( - instanceId: ProviderInstanceId, - next: { - readonly hiddenModels: ReadonlyArray; - readonly modelOrder: ReadonlyArray; - }, - ) => { - const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))]; - const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))]; - const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId); - updateSettings({ - providerModelPreferences: - hiddenModels.length === 0 && modelOrder.length === 0 - ? rest - : { - ...rest, - [instanceId]: { - hiddenModels, - modelOrder, - }, - }, - }); - }; - - const updateProviderFavoriteModels = ( - instanceId: ProviderInstanceId, - nextFavoriteModels: ReadonlyArray, - ) => { - const favoriteModels = [ - ...new Set( - Arr.filterMap(nextFavoriteModels, (slug) => { - const trimmedSlug = slug.trim(); - return trimmedSlug.length > 0 ? Result.succeed(trimmedSlug) : Result.failVoid; - }), - ), - ]; - updateSettings({ - favorites: [ - ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId), - ...favoriteModels.map((model) => ({ provider: instanceId, model })), - ], - }); - }; - - const resetDefaultInstance = (driverKind: ProviderDriverKind) => { - type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; - const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< - string, - LegacyProviderSettings | undefined - >; - const defaultInstanceId = defaultInstanceIdForDriver(driverKind); - const defaultLegacyProvider = defaultLegacyProviders[driverKind]; - if (defaultLegacyProvider === undefined) return; - updateSettings({ - providers: { - ...settings.providers, - [driverKind]: defaultLegacyProvider, - } as typeof settings.providers, - providerInstances: withoutProviderInstanceKey(settings.providerInstances, defaultInstanceId), - providerModelPreferences: withoutProviderInstanceKey( - settings.providerModelPreferences, - defaultInstanceId, - ), - favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], defaultInstanceId), - }); - }; - - return ( - - - - - setIsAddInstanceDialogOpen(true)} - aria-label="Add provider instance" - > - - - } - /> - Add provider instance - - - void refreshProviders()} - aria-label="Refresh provider status" - > - {isRefreshingProviders ? ( - - ) : ( - - )} - - } - /> - Refresh provider status - - - } - > - - Health check interval - - This interval is configured here, then the shared Background activity policy decides - whether provider probes may run when the timer fires. Custom intervals appear as - Advanced in General settings. - - - } - description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes." - resetAction={ - providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? ( - - updateSettings( - backgroundActivityOverrideSettings( - settings.backgroundActivity, - resolvedBackgroundActivity, - { - providerHealthRefreshInterval: undefined, - }, - ), - ) - } - /> - ) : null - } - control={ -
- - updateSettings( - backgroundActivityOverrideSettings( - settings.backgroundActivity, - resolvedBackgroundActivity, - { - providerHealthRefreshInterval: Duration.seconds( - normalizeIntervalSeconds(value), - ), - }, - ), - ) - } - > - - - - - - - seconds -
- } - /> - - {rows.map((row) => { - const driverOption = getDriverOption(row.driver); - const liveProvider = serverProviders.find( - (candidate) => candidate.instanceId === row.instanceId, - ); - const updateCandidate = liveProvider - ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) - : undefined; - const isDriverUpdateRunning = - updateCandidate !== undefined && - (updatingProviderDrivers.has(updateCandidate.driver) || - serverProviders.some( - (provider) => - provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), - )); - const showInlineUpdateButton = - updateCandidate !== undefined && - hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); - const canRunInlineUpdate = - updateCandidate !== undefined && - canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && - !updatingProviderDrivers.has(updateCandidate.driver); - const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { - hiddenModels: [], - modelOrder: [], - }; - const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) => - favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid, - ); - const resetLabel = driverOption?.label ?? String(row.driver); - const headerAction = - row.isDefault && row.isDirty ? ( - resetDefaultInstance(row.driver)} - /> - ) : null; - return ( - - setOpenInstanceDetails((existing) => ({ - ...existing, - [row.instanceId]: open, - })) - } - onUpdate={(next) => { - const wasEnabled = row.instance.enabled ?? true; - const isDisabling = next.enabled === false && wasEnabled; - const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; - if (shouldClearTextGen) { - updateProviderInstance(row, next, { - textGenerationModelSelection: - DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, - }); - } else { - updateProviderInstance(row, next); - } - }} - onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)} - headerAction={headerAction} - hiddenModels={modelPreferences.hiddenModels} - favoriteModels={favoriteModels} - modelOrder={modelPreferences.modelOrder} - onHiddenModelsChange={(hiddenModels) => - updateProviderModelPreferences(row.instanceId, { - ...modelPreferences, - hiddenModels, - }) - } - onFavoriteModelsChange={(favoriteModels) => - updateProviderFavoriteModels(row.instanceId, favoriteModels) - } - onModelOrderChange={(modelOrder) => - updateProviderModelPreferences(row.instanceId, { - ...modelPreferences, - modelOrder, - }) - } - onRunUpdate={ - showInlineUpdateButton && updateCandidate - ? () => { - if (!canRunInlineUpdate) { - return; - } - void runProviderUpdate(updateCandidate); - } - : undefined - } - isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined} - /> - ); - })} -
- - {isAddInstanceDialogOpen ? ( - - ) : null} -
- ); -} - export function ArchivedThreadsPanel() { const projects = useProjects(); const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); diff --git a/apps/web/src/routes/settings.providers.tsx b/apps/web/src/routes/settings.providers.tsx index a7a86c2b50b..deab014722d 100644 --- a/apps/web/src/routes/settings.providers.tsx +++ b/apps/web/src/routes/settings.providers.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { ProviderSettingsPanel } from "../components/settings/SettingsPanels"; +import { ProviderSettingsPanel } from "../components/settings/ProviderSettingsPanel"; function SettingsProvidersRoute() { return ; From ec47ac8ff43e2f73a55b764d245ce6a12cdf6355 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 24 Jul 2026 14:03:31 -0700 Subject: [PATCH 2/8] fix(web): honest provider access states for remote devices Addresses review findings on the per-device providers panel. - Treat unresolved primary session scopes as loading instead of editable, so controls are never offered before permissions are known - Render provider status rows for read-only sessions instead of replacing the whole panel with a blocking message - Distinguish a hydrating environment catalog from having no devices Co-Authored-By: Claude Opus 5 (1M context) --- .../ProviderSettingsPanel.logic.test.ts | 20 +++- .../settings/ProviderSettingsPanel.logic.ts | 14 ++- .../settings/ProviderSettingsPanel.tsx | 105 ++++++++++++++---- 3 files changed, 111 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts index a8c5331c9a4..c7d576235ff 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts @@ -50,7 +50,7 @@ describe("provider environment access", () => { classifyProviderEnvironmentAccess({ connectionPhase: "connected", hasServerConfig: true, - canOperate: true, + operateAccess: "granted", }), ).toEqual({ kind: "editable" }); }); @@ -60,7 +60,17 @@ describe("provider environment access", () => { classifyProviderEnvironmentAccess({ connectionPhase: "connected", hasServerConfig: false, - canOperate: true, + operateAccess: "granted", + }), + ).toEqual({ kind: "loading" }); + }); + + it("waits for unresolved operate access instead of assuming it is editable", () => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase: "connected", + hasServerConfig: true, + operateAccess: "pending", }), ).toEqual({ kind: "loading" }); }); @@ -70,7 +80,7 @@ describe("provider environment access", () => { classifyProviderEnvironmentAccess({ connectionPhase: "connected", hasServerConfig: true, - canOperate: false, + operateAccess: "denied", }), ).toEqual({ kind: "read-only" }); }); @@ -82,7 +92,7 @@ describe("provider environment access", () => { classifyProviderEnvironmentAccess({ connectionPhase, hasServerConfig: true, - canOperate: true, + operateAccess: "granted", }), ).toEqual({ kind: "unavailable" }); }, @@ -93,7 +103,7 @@ describe("provider environment access", () => { classifyProviderEnvironmentAccess({ connectionPhase: "error", hasServerConfig: true, - canOperate: true, + operateAccess: "granted", }), ).toEqual({ kind: "error" }); }); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts index cd5e0d4fa42..83d5a908a06 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts @@ -49,6 +49,14 @@ export type ProviderEnvironmentAccess = | { readonly kind: "unavailable" } | { readonly kind: "error" }; +/** + * Whether the session may change provider configuration on an environment. + * `pending` means the answer is still unknown, which must not be presented as + * editable: rendering controls we already know might be rejected only turns a + * permission problem into a failed write. + */ +export type ProviderOperateAccess = "granted" | "denied" | "pending"; + export function classifyProviderEnvironmentAccess(input: { readonly connectionPhase: | "available" @@ -58,7 +66,7 @@ export function classifyProviderEnvironmentAccess(input: { | "connected" | "error"; readonly hasServerConfig: boolean; - readonly canOperate: boolean | null; + readonly operateAccess: ProviderOperateAccess; }): ProviderEnvironmentAccess { if (input.connectionPhase === "error") { return { kind: "error" }; @@ -66,10 +74,10 @@ export function classifyProviderEnvironmentAccess(input: { if (input.connectionPhase !== "connected") { return { kind: "unavailable" }; } - if (!input.hasServerConfig) { + if (!input.hasServerConfig || input.operateAccess === "pending") { return { kind: "loading" }; } - if (input.canOperate === false) { + if (input.operateAccess === "denied") { return { kind: "read-only" }; } return { kind: "editable" }; diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index d98fb2e491a..ec5e10dbaac 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -69,6 +69,12 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; +import { + getProviderSummary, + getProviderVersionLabel, + PROVIDER_STATUS_STYLES, + type ProviderStatusKey, +} from "./providerStatus"; import { backgroundActivityOverrideSettings, durationToSeconds, @@ -87,6 +93,7 @@ import { import { buildProviderEnvironmentOptions, classifyProviderEnvironmentAccess, + type ProviderOperateAccess, resolveSelectedProviderEnvironmentId, } from "./ProviderSettingsPanel.logic"; @@ -173,22 +180,18 @@ function EnvironmentUnavailableRow({ accessKind, }: { readonly environment: EnvironmentPresentation; - readonly accessKind: "loading" | "read-only" | "unavailable" | "error"; + readonly accessKind: "loading" | "unavailable" | "error"; }) { const title = accessKind === "loading" ? "Loading provider settings" - : accessKind === "read-only" - ? "Provider settings are read only" - : accessKind === "error" - ? "Could not connect to this device" - : "Provider settings are unavailable"; + : accessKind === "error" + ? "Could not connect to this device" + : "Provider settings are unavailable"; const description = accessKind === "loading" ? `Waiting for ${environment.label}'s configuration.` - : accessKind === "read-only" - ? `This session can view ${environment.label}, but it cannot change provider configuration.` - : connectionStatusText(environment.connection); + : connectionStatusText(environment.connection); return ( buildProviderEnvironmentOptions(environments, primaryEnvironmentId), @@ -238,9 +241,15 @@ export function ProviderSettingsPanel() { {showDeviceList ? ( {options.length === 0 ? ( + // The catalog hydrates asynchronously, so an empty list before it is + // ready means "not loaded yet", not "nothing is connected". ) : (
@@ -310,18 +319,25 @@ function SelectedEnvironmentProviderSettings({ // Remote connection brokers request the standard client scopes, which include // orchestration:operate. The environment RPC layer remains authoritative if a // custom remote credential grants less access. - const canOperate = isPrimary - ? window.desktopBridge - ? true - : primarySessionState.data?.authenticated - ? (primarySessionState.data.scopes ?? []).includes(AuthOrchestrationOperateScope) - : null - : true; + const operateAccess: ProviderOperateAccess = !isPrimary + ? "granted" + : window.desktopBridge + ? "granted" + : primarySessionState.isPending + ? "pending" + : primarySessionState.data?.authenticated + ? (primarySessionState.data.scopes ?? []).includes(AuthOrchestrationOperateScope) + ? "granted" + : "denied" + : "denied"; const access = classifyProviderEnvironmentAccess({ connectionPhase: environment.connection.phase, hasServerConfig: environment.serverConfig !== null, - canOperate, + operateAccess, }); + if (access.kind === "read-only") { + return ; + } if (access.kind !== "editable") { return ; } @@ -333,6 +349,55 @@ function SelectedEnvironmentProviderSettings({ ); } +/** + * Connected devices this session may read but not reconfigure. The provider + * catalogue is still worth showing — knowing which providers a box has and + * whether they are authenticated is most of the value — so render each one as + * a status row and omit every mutation control. + */ +function ReadOnlyProviderSettings({ + environment, +}: { + readonly environment: EnvironmentPresentation; +}) { + const providers = environment.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS; + return ( + + + {providers.map((provider) => { + const driverOption = getDriverOption(provider.driver); + const summary = getProviderSummary(provider); + const versionLabel = getProviderVersionLabel(provider.version); + return ( + + + {driverOption?.label ?? String(provider.driver)} + {versionLabel ? ( + {versionLabel} + ) : null} + + } + description={summary.detail ?? summary.headline} + status={summary.detail ? summary.headline : null} + /> + ); + })} + + ); +} + export function EnvironmentProviderSettings({ environmentId, environmentLabel, From be5d1db8a273fef24f74de2a71fd7371db729b79 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 24 Jul 2026 14:14:51 -0700 Subject: [PATCH 3/8] fix(web): keep cached session scopes authoritative in providers panel - Extract resolvePrimaryOperateAccess so SWR revalidation no longer reports pending while cached session data is available, which was unmounting the provider editor and discarding in-progress edits - Carry a reason on the loading state so waiting on permissions is not described as waiting on device configuration - Name read-only provider rows with deriveProviderInstanceEntries so multiple instances of one driver stay distinguishable Co-Authored-By: Claude Opus 5 (1M context) --- .../ProviderSettingsPanel.logic.test.ts | 82 ++++++++++++++++++- .../settings/ProviderSettingsPanel.logic.ts | 49 ++++++++++- .../settings/ProviderSettingsPanel.tsx | 73 ++++++++--------- .../components/settings/SettingsPanels.tsx | 2 - 4 files changed, 159 insertions(+), 47 deletions(-) diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts index c7d576235ff..d343b0c5f0d 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts @@ -1,9 +1,10 @@ -import { EnvironmentId } from "@t3tools/contracts"; +import { AuthOrchestrationOperateScope, EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { buildProviderEnvironmentOptions, classifyProviderEnvironmentAccess, + resolvePrimaryOperateAccess, resolveSelectedProviderEnvironmentId, } from "./ProviderSettingsPanel.logic"; @@ -62,7 +63,7 @@ describe("provider environment access", () => { hasServerConfig: false, operateAccess: "granted", }), - ).toEqual({ kind: "loading" }); + ).toEqual({ kind: "loading", reason: "config" }); }); it("waits for unresolved operate access instead of assuming it is editable", () => { @@ -72,7 +73,7 @@ describe("provider environment access", () => { hasServerConfig: true, operateAccess: "pending", }), - ).toEqual({ kind: "loading" }); + ).toEqual({ kind: "loading", reason: "permissions" }); }); it("represents known missing operate access as read only", () => { @@ -108,3 +109,78 @@ describe("provider environment access", () => { ).toEqual({ kind: "error" }); }); }); + +describe("primary operate access", () => { + const authenticated = { + authenticated: true as const, + scopes: [AuthOrchestrationOperateScope], + }; + + it("keeps cached session data authoritative while SWR revalidates", () => { + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: authenticated, + isPending: true, + }), + ).toBe("granted"); + }); + + it("reports pending only before any session has resolved", () => { + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: null, + isPending: true, + }), + ).toBe("pending"); + }); + + it("denies unauthenticated sessions and sessions without the operate scope", () => { + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: { authenticated: false }, + isPending: false, + }), + ).toBe("denied"); + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: { authenticated: true, scopes: ["orchestration:read"] }, + isPending: false, + }), + ).toBe("denied"); + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: null, + isPending: false, + }), + ).toBe("denied"); + }); + + it("grants desktop bridge and remote environments without blocking on the primary session", () => { + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: true, + session: null, + isPending: true, + }), + ).toBe("granted"); + expect( + resolvePrimaryOperateAccess({ + isPrimary: false, + hasDesktopBridge: false, + session: null, + isPending: true, + }), + ).toBe("granted"); + }); +}); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts index 83d5a908a06..8bdef8d19ae 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts @@ -1,4 +1,8 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + type AuthSessionState, + type EnvironmentId, +} from "@t3tools/contracts"; export interface ProviderEnvironmentOptionLike { readonly environmentId: EnvironmentId; @@ -44,7 +48,8 @@ export function resolveSelectedProviderEnvironmentId( export type ProviderEnvironmentAccess = | { readonly kind: "editable" } - | { readonly kind: "loading" } + /** `reason` distinguishes waiting on the device from waiting on permissions. */ + | { readonly kind: "loading"; readonly reason: "config" | "permissions" } | { readonly kind: "read-only" } | { readonly kind: "unavailable" } | { readonly kind: "error" }; @@ -57,6 +62,39 @@ export type ProviderEnvironmentAccess = */ export type ProviderOperateAccess = "granted" | "denied" | "pending"; +/** + * Resolve whether the session may reconfigure providers on an environment. + * + * Only the primary environment exposes its own granted scopes to the client + * today, so remote sessions are optimistic: the connection brokers request + * `orchestration:operate`, and the environment RPC layer stays authoritative if + * a narrower credential was minted. + * + * Cached session data wins over an in-flight revalidation. The session atom is + * SWR-backed, so it reports `isPending` on every background refresh; treating + * that as unknown would flip a working panel back to loading and discard + * in-progress edits. + */ +export function resolvePrimaryOperateAccess(input: { + readonly isPrimary: boolean; + readonly hasDesktopBridge: boolean; + readonly session: Pick | null; + readonly isPending: boolean; +}): ProviderOperateAccess { + if (!input.isPrimary || input.hasDesktopBridge) { + return "granted"; + } + if (input.session === null) { + return input.isPending ? "pending" : "denied"; + } + if (!input.session.authenticated) { + return "denied"; + } + return (input.session.scopes ?? []).includes(AuthOrchestrationOperateScope) + ? "granted" + : "denied"; +} + export function classifyProviderEnvironmentAccess(input: { readonly connectionPhase: | "available" @@ -74,8 +112,11 @@ export function classifyProviderEnvironmentAccess(input: { if (input.connectionPhase !== "connected") { return { kind: "unavailable" }; } - if (!input.hasServerConfig || input.operateAccess === "pending") { - return { kind: "loading" }; + if (!input.hasServerConfig) { + return { kind: "loading", reason: "config" }; + } + if (input.operateAccess === "pending") { + return { kind: "loading", reason: "permissions" }; } if (input.operateAccess === "denied") { return { kind: "read-only" }; diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index ec5e10dbaac..7dc695299c4 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -6,7 +6,6 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { - AuthOrchestrationOperateScope, defaultInstanceIdForDriver, type EnvironmentId, PROVIDER_DISPLAY_NAMES, @@ -40,6 +39,7 @@ import { usePrimarySessionState } from "../../environments/primary"; import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { resolveAppModelSelectionState } from "../../modelSelection"; +import { deriveProviderInstanceEntries } from "../../providerInstances"; import { useEnvironments, usePrimaryEnvironmentId, @@ -93,7 +93,8 @@ import { import { buildProviderEnvironmentOptions, classifyProviderEnvironmentAccess, - type ProviderOperateAccess, + type ProviderEnvironmentAccess, + resolvePrimaryOperateAccess, resolveSelectedProviderEnvironmentId, } from "./ProviderSettingsPanel.logic"; @@ -177,27 +178,28 @@ function connectionDotClassName(environment: EnvironmentPresentation): string { function EnvironmentUnavailableRow({ environment, - accessKind, + access, }: { readonly environment: EnvironmentPresentation; - readonly accessKind: "loading" | "unavailable" | "error"; + readonly access: Exclude; }) { - const title = - accessKind === "loading" - ? "Loading provider settings" - : accessKind === "error" - ? "Could not connect to this device" - : "Provider settings are unavailable"; - const description = - accessKind === "loading" - ? `Waiting for ${environment.label}'s configuration.` - : connectionStatusText(environment.connection); + const isLoading = access.kind === "loading"; + const title = isLoading + ? "Loading provider settings" + : access.kind === "error" + ? "Could not connect to this device" + : "Provider settings are unavailable"; + const description = isLoading + ? access.reason === "permissions" + ? "Checking what this session is allowed to change." + : `Waiting for ${environment.label}'s configuration.` + : connectionStatusText(environment.connection); return ( - {accessKind === "loading" ? ( + {isLoading ? ( ) : null} {title} @@ -316,20 +318,12 @@ function SelectedEnvironmentProviderSettings({ }) { const primarySessionState = usePrimarySessionState(); const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; - // Remote connection brokers request the standard client scopes, which include - // orchestration:operate. The environment RPC layer remains authoritative if a - // custom remote credential grants less access. - const operateAccess: ProviderOperateAccess = !isPrimary - ? "granted" - : window.desktopBridge - ? "granted" - : primarySessionState.isPending - ? "pending" - : primarySessionState.data?.authenticated - ? (primarySessionState.data.scopes ?? []).includes(AuthOrchestrationOperateScope) - ? "granted" - : "denied" - : "denied"; + const operateAccess = resolvePrimaryOperateAccess({ + isPrimary, + hasDesktopBridge: Boolean(window.desktopBridge), + session: primarySessionState.data, + isPending: primarySessionState.isPending, + }); const access = classifyProviderEnvironmentAccess({ connectionPhase: environment.connection.phase, hasServerConfig: environment.serverConfig !== null, @@ -339,7 +333,7 @@ function SelectedEnvironmentProviderSettings({ return ; } if (access.kind !== "editable") { - return ; + return ; } return ( - {providers.map((provider) => { - const driverOption = getDriverOption(provider.driver); - const summary = getProviderSummary(provider); - const versionLabel = getProviderVersionLabel(provider.version); + {entries.map((entry) => { + const summary = getProviderSummary(entry.snapshot); + const versionLabel = getProviderVersionLabel(entry.snapshot.version); return ( - {driverOption?.label ?? String(provider.driver)} + {entry.displayName} {versionLabel ? ( {versionLabel} ) : null} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index c9ac6fdab05..c65471b0e5a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -34,7 +34,6 @@ import { } from "@t3tools/contracts/settings"; import { getBackgroundActivityBaseProfile, - getBackgroundActivityPresetSettings, resolveServerBackgroundActivitySettings, } from "@t3tools/shared/backgroundActivitySettings"; import { createModelSelection } from "@t3tools/shared/model"; @@ -289,7 +288,6 @@ export function PolicyTooltip({ children }: { readonly children: string }) { ); } - function AboutVersionTitle() { return ( From 9ae80a43de86299a4b24e2b8666d365ba5ca6602 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 30 Jul 2026 22:04:54 -0700 Subject: [PATCH 4/8] refactor(web): simplify per-device provider settings Cleanup pass over the providers panel from parallel reuse, simplification, efficiency, and altitude reviews: - Share the connection-phase dot/ping mapping from ConnectionStatusDot instead of a third inline copy - Extract the React Compiler hook harness and element-tree walker into src/test and reuse them from both new test files - Move interval/background-activity helpers and PolicyTooltip out of the SettingsPanels component file into SettingsPanels.logic and settingsLayout - Drop the setState-in-useEffect selection sync; the effective selection is derived, so a device that reconnects regains its prior selection - Only mount the primary-session atom when a browser session is actually gated by it; remote devices skip the SWR fetch entirely - Guard runProviderUpdate re-entry with a ref instead of a state-updater flag - Treat a failed session fetch as transport trouble rather than denied access - Reuse EnvironmentConnectionPhase, isElectron, and the shared EMPTY_SERVER_PROVIDERS constant; remove the indefinite loading spinner Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/ConnectionStatusDot.tsx | 22 ++++ ...roviderInstanceDialog.environment.test.tsx | 55 ++------ ...ProviderSettingsPanel.environment.test.tsx | 94 ++----------- .../ProviderSettingsPanel.logic.test.ts | 19 +++ .../settings/ProviderSettingsPanel.logic.ts | 18 +-- .../settings/ProviderSettingsPanel.tsx | 123 +++++++++--------- .../settings/SettingsPanels.logic.ts | 57 ++++++++ .../components/settings/SettingsPanels.tsx | 83 +----------- .../components/settings/settingsLayout.tsx | 24 +++- apps/web/src/state/server.ts | 2 +- apps/web/src/test/reactElementTree.ts | 27 ++++ apps/web/src/test/reactHookHarness.ts | 89 +++++++++++++ 12 files changed, 337 insertions(+), 276 deletions(-) create mode 100644 apps/web/src/test/reactElementTree.ts create mode 100644 apps/web/src/test/reactHookHarness.ts diff --git a/apps/web/src/components/ConnectionStatusDot.tsx b/apps/web/src/components/ConnectionStatusDot.tsx index 0c22f1702e5..2efddcfa736 100644 --- a/apps/web/src/components/ConnectionStatusDot.tsx +++ b/apps/web/src/components/ConnectionStatusDot.tsx @@ -1,6 +1,28 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; + import { cn } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +/** Canonical connection-phase → dot color mapping shared by every status dot. */ +export function connectionPhaseDotClassName(phase: EnvironmentConnectionPhase): string { + switch (phase) { + case "connected": + return "bg-success"; + case "connecting": + case "reconnecting": + return "bg-warning"; + case "error": + return "bg-destructive"; + default: + return "bg-muted-foreground/40"; + } +} + +/** Ping halo for transitional phases; null renders no ping. */ +export function connectionPhasePingClassName(phase: EnvironmentConnectionPhase): string | null { + return phase === "connecting" || phase === "reconnecting" ? "bg-warning/60 duration-2000" : null; +} + type ConnectionStatusDotProps = { tooltipText?: string | null; dotClassName: string; diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx index 7326dac54f0..3c502c624dd 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx @@ -1,64 +1,27 @@ -import type { Dispatch, SetStateAction } from "react"; import { EnvironmentId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { reactHookHarness as hooks } from "../../test/reactHookHarness"; + const settingsHooks = vi.hoisted(() => ({ read: vi.fn(() => ({ providerInstances: {} })), update: vi.fn(() => vi.fn()), })); -const hooks = vi.hoisted(() => { - let cursor = 0; - let slots: unknown[] = []; - const nextIndex = () => cursor++; - - return { - beginRender() { - cursor = 0; - }, - reset() { - cursor = 0; - slots = []; - }, - useMemo(factory: () => T): T { - nextIndex(); - return factory(); - }, - useMemoCache(size: number): unknown[] { - const index = nextIndex(); - if (!slots[index]) { - slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel")); - } - return slots[index] as unknown[]; - }, - useState(initialValue: T | (() => T)): [T, Dispatch>] { - const index = nextIndex(); - if (index >= slots.length) { - slots[index] = - typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; - } - const setValue: Dispatch> = (nextValue) => { - const previous = slots[index] as T; - slots[index] = - typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue; - }; - return [slots[index] as T, setValue]; - }, - }; -}); - vi.mock("react", async (importOriginal) => { const actual = await importOriginal(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); return { ...actual, - useMemo: hooks.useMemo, - useState: hooks.useState, + useMemo: reactHookHarness.useMemo, + useState: reactHookHarness.useState, }; }); -vi.mock("react/compiler-runtime", () => ({ - c: hooks.useMemoCache, -})); +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); vi.mock("../../hooks/useSettings", () => ({ useEnvironmentSettings: settingsHooks.read, diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx index 7ebaf6586b1..81e2c4ec5f1 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx @@ -1,5 +1,4 @@ -import type { Dispatch, ReactElement, SetStateAction } from "react"; -import { isValidElement } from "react"; +import type { ReactElement } from "react"; import { DEFAULT_UNIFIED_SETTINGS, EnvironmentId, @@ -10,6 +9,9 @@ import { } from "@t3tools/contracts"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { visitElements } from "../../test/reactElementTree"; +import { reactHookHarness as hooks } from "../../test/reactHookHarness"; + const atoms = vi.hoisted(() => ({ providers: null as ReadonlyArray | null, providersAtom: Symbol("providers"), @@ -29,77 +31,29 @@ const settingsState = vi.hoisted(() => ({ updateSettings: vi.fn(), })); -const hooks = vi.hoisted(() => { - let cursor = 0; - let slots: unknown[] = []; - const nextIndex = () => cursor++; - - return { - beginRender() { - cursor = 0; - }, - reset() { - cursor = 0; - slots = []; - }, - useCallback(callback: T): T { - nextIndex(); - return callback; - }, - useMemo(factory: () => T): T { - nextIndex(); - return factory(); - }, - useMemoCache(size: number): unknown[] { - const index = nextIndex(); - if (!slots[index]) { - slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel")); - } - return slots[index] as unknown[]; - }, - useRef(initialValue: T): { current: T } { - const index = nextIndex(); - if (!slots[index]) { - slots[index] = { current: initialValue }; - } - return slots[index] as { current: T }; - }, - useState(initialValue: T | (() => T)): [T, Dispatch>] { - const index = nextIndex(); - if (index >= slots.length) { - slots[index] = - typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; - } - const setValue: Dispatch> = (nextValue) => { - const previous = slots[index] as T; - slots[index] = - typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue; - }; - return [slots[index] as T, setValue]; - }, - }; -}); - vi.mock("react", async (importOriginal) => { const actual = await importOriginal(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); return { ...actual, - useCallback: hooks.useCallback, - useMemo: hooks.useMemo, - useRef: hooks.useRef, - useState: hooks.useState, + useCallback: reactHookHarness.useCallback, + useMemo: reactHookHarness.useMemo, + useRef: reactHookHarness.useRef, + useState: reactHookHarness.useState, }; }); -vi.mock("react/compiler-runtime", () => ({ - c: hooks.useMemoCache, -})); +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); vi.mock("@effect/atom-react", () => ({ useAtomValue: () => atoms.providers, })); vi.mock("../../state/server", () => ({ + EMPTY_SERVER_PROVIDERS: [], serverEnvironment: { providersValueAtom: () => atoms.providersAtom, refreshProviders: atoms.refreshProviders, @@ -158,26 +112,6 @@ function provider(): ServerProvider { }; } -function visitElements( - node: unknown, - visitor: (element: ReactElement>) => boolean, -): ReactElement> | null { - if (Array.isArray(node)) { - for (const child of node) { - const found = visitElements(child, visitor); - if (found) return found; - } - return null; - } - if (!isValidElement>(node)) return null; - if (visitor(node)) return node; - for (const value of Object.values(node.props)) { - const found = visitElements(value, visitor); - if (found) return found; - } - return null; -} - function renderPanel(): ReactElement> { hooks.beginRender(); return EnvironmentProviderSettings({ diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts index d343b0c5f0d..7dcc9611bb2 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts @@ -123,6 +123,7 @@ describe("primary operate access", () => { hasDesktopBridge: false, session: authenticated, isPending: true, + hasError: false, }), ).toBe("granted"); }); @@ -134,10 +135,23 @@ describe("primary operate access", () => { hasDesktopBridge: false, session: null, isPending: true, + hasError: false, }), ).toBe("pending"); }); + it("treats a failed session fetch as a transport problem, not a denial", () => { + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: null, + isPending: false, + hasError: true, + }), + ).toBe("granted"); + }); + it("denies unauthenticated sessions and sessions without the operate scope", () => { expect( resolvePrimaryOperateAccess({ @@ -145,6 +159,7 @@ describe("primary operate access", () => { hasDesktopBridge: false, session: { authenticated: false }, isPending: false, + hasError: false, }), ).toBe("denied"); expect( @@ -153,6 +168,7 @@ describe("primary operate access", () => { hasDesktopBridge: false, session: { authenticated: true, scopes: ["orchestration:read"] }, isPending: false, + hasError: false, }), ).toBe("denied"); expect( @@ -161,6 +177,7 @@ describe("primary operate access", () => { hasDesktopBridge: false, session: null, isPending: false, + hasError: false, }), ).toBe("denied"); }); @@ -172,6 +189,7 @@ describe("primary operate access", () => { hasDesktopBridge: true, session: null, isPending: true, + hasError: false, }), ).toBe("granted"); expect( @@ -180,6 +198,7 @@ describe("primary operate access", () => { hasDesktopBridge: false, session: null, isPending: true, + hasError: false, }), ).toBe("granted"); }); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts index 8bdef8d19ae..3b13b36037c 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts @@ -1,3 +1,4 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { AuthOrchestrationOperateScope, type AuthSessionState, @@ -80,12 +81,19 @@ export function resolvePrimaryOperateAccess(input: { readonly hasDesktopBridge: boolean; readonly session: Pick | null; readonly isPending: boolean; + readonly hasError: boolean; }): ProviderOperateAccess { if (!input.isPrimary || input.hasDesktopBridge) { return "granted"; } if (input.session === null) { - return input.isPending ? "pending" : "denied"; + if (input.isPending) { + return "pending"; + } + // A failed session fetch is a transport problem, not a permission + // decision — locking the panel read-only would misreport it. Stay + // optimistic; the environment RPC layer still rejects unauthorized writes. + return input.hasError ? "granted" : "denied"; } if (!input.session.authenticated) { return "denied"; @@ -96,13 +104,7 @@ export function resolvePrimaryOperateAccess(input: { } export function classifyProviderEnvironmentAccess(input: { - readonly connectionPhase: - | "available" - | "offline" - | "connecting" - | "reconnecting" - | "connected" - | "error"; + readonly connectionPhase: EnvironmentConnectionPhase; readonly hasServerConfig: boolean; readonly operateAccess: ProviderOperateAccess; }): ProviderEnvironmentAccess { diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 7dc695299c4..b98179c555d 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -12,7 +12,6 @@ import { ProviderDriverKind, type ProviderInstanceConfig, type ProviderInstanceId, - type ServerProvider, } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { @@ -32,9 +31,10 @@ import { RefreshCwIcon, TerminalIcon, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; +import { isElectron } from "../../env"; import { usePrimarySessionState } from "../../environments/primary"; import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; @@ -45,10 +45,14 @@ import { usePrimaryEnvironmentId, type EnvironmentPresentation, } from "../../state/environments"; -import { serverEnvironment } from "../../state/server"; +import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { getRelativeTimeState } from "../../timestampFormat"; -import { ConnectionStatusDot } from "../ConnectionStatusDot"; +import { + ConnectionStatusDot, + connectionPhaseDotClassName, + connectionPhasePingClassName, +} from "../ConnectionStatusDot"; import { canOneClickUpdateProviderCandidate, collectProviderUpdateCandidates, @@ -75,15 +79,16 @@ import { PROVIDER_STATUS_STYLES, type ProviderStatusKey, } from "./providerStatus"; +import { searchableSetting } from "./settingsSearch"; import { backgroundActivityOverrideSettings, + buildProviderInstanceUpdatePatch, durationToSeconds, normalizeIntervalSeconds, - PolicyTooltip, PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, -} from "./SettingsPanels"; -import { buildProviderInstanceUpdatePatch } from "./SettingsPanels.logic"; +} from "./SettingsPanels.logic"; import { + PolicyTooltip, SettingResetButton, SettingsPageContainer, SettingsRow, @@ -94,12 +99,11 @@ import { buildProviderEnvironmentOptions, classifyProviderEnvironmentAccess, type ProviderEnvironmentAccess, + type ProviderOperateAccess, resolvePrimaryOperateAccess, resolveSelectedProviderEnvironmentId, } from "./ProviderSettingsPanel.logic"; -const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; - function withoutProviderInstanceKey( record: Readonly> | undefined, key: ProviderInstanceId, @@ -162,20 +166,6 @@ function providerEnvironmentDetail(environment: EnvironmentPresentation): string return environment.displayUrl ?? "Remote device"; } -function connectionDotClassName(environment: EnvironmentPresentation): string { - switch (environment.connection.phase) { - case "connected": - return "bg-success"; - case "connecting": - case "reconnecting": - return "bg-warning"; - case "error": - return "bg-destructive"; - default: - return "bg-muted-foreground/40"; - } -} - function EnvironmentUnavailableRow({ environment, access, @@ -194,19 +184,11 @@ function EnvironmentUnavailableRow({ ? "Checking what this session is allowed to change." : `Waiting for ${environment.label}'s configuration.` : connectionStatusText(environment.connection); + // No spinner: this state can persist indefinitely for a wedged device, and a + // continuously repainting animation would run the whole time. return ( - - {isLoading ? ( - - ) : null} - {title} - - } - description={description} - /> + ); } @@ -218,6 +200,9 @@ export function ProviderSettingsPanel() { () => buildProviderEnvironmentOptions(environments, primaryEnvironmentId), [environments, primaryEnvironmentId], ); + // Raw user intent; the effective selection is re-derived every render so a + // device that drops out of the catalog falls back without erasing the pick — + // if it reappears (e.g. after a reconnect) the selection is restored. const [selectedEnvironmentId, setSelectedEnvironmentId] = useState( primaryEnvironmentId, ); @@ -226,21 +211,14 @@ export function ProviderSettingsPanel() { selectedEnvironmentId, primaryEnvironmentId, ); - useEffect(() => { - if (effectiveEnvironmentId !== selectedEnvironmentId) { - setSelectedEnvironmentId(effectiveEnvironmentId); - } - }, [effectiveEnvironmentId, selectedEnvironmentId]); const selectedEnvironment = options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null; - const showDeviceList = - options.length === 0 || - options.length > 1 || - options[0]?.entry.target._tag !== "PrimaryConnectionTarget"; + const onlyPrimaryDevice = + options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget"; return ( - {showDeviceList ? ( + {!onlyPrimaryDevice ? ( {options.length === 0 ? ( // The catalog hydrates asynchronously, so an empty list before it is @@ -259,9 +237,6 @@ export function ProviderSettingsPanel() { const Icon = providerEnvironmentIcon(environment); const selected = environment.environmentId === effectiveEnvironmentId; const statusText = connectionStatusText(environment.connection); - const isPending = - environment.connection.phase === "connecting" || - environment.connection.phase === "reconnecting"; return ( - } - /> - - {children} - - - ); -} - function AboutVersionTitle() { return ( diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 84fb95a4741..0bb1a1aa678 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -1,4 +1,4 @@ -import { Undo2Icon } from "lucide-react"; +import { InfoIcon, Undo2Icon } from "lucide-react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { createContext, @@ -83,6 +83,28 @@ function useSettingsSearchTarget(id: string | undefined) return targetRef; } +/** Info affordance explaining how a setting interacts with the shared background policy. */ +export function PolicyTooltip({ children }: { readonly children: string }) { + return ( + + + + + } + /> + + {children} + + + ); +} + /** Re-render every `intervalMs`; return a stable timestamp snapshot for render-time relative labels. */ export function useRelativeTimeTick(intervalMs = 1_000) { const [nowMs, setNowMs] = useState(() => Date.now()); diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 3271eefd1e1..1071d8209df 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -33,7 +33,7 @@ interface PrimaryServerState { } const EMPTY_AVAILABLE_EDITORS: ReadonlyArray = []; -const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; +export const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; const EMPTY_PRIMARY_SERVER_STATE: PrimaryServerState = { config: null, latestEvent: null, diff --git a/apps/web/src/test/reactElementTree.ts b/apps/web/src/test/reactElementTree.ts new file mode 100644 index 00000000000..33351c35eb1 --- /dev/null +++ b/apps/web/src/test/reactElementTree.ts @@ -0,0 +1,27 @@ +import { isValidElement, type ReactElement } from "react"; + +/** + * Depth-first search over a React element tree produced by calling a component + * as a plain function (see `reactHookHarness`). Descends through props so + * render-prop and slot-style children are reachable. Returns the first element + * the visitor accepts, or null. + */ +export function visitElements( + node: unknown, + visitor: (element: ReactElement>) => boolean, +): ReactElement> | null { + if (Array.isArray(node)) { + for (const child of node) { + const found = visitElements(child, visitor); + if (found) return found; + } + return null; + } + if (!isValidElement>(node)) return null; + if (visitor(node)) return node; + for (const value of Object.values(node.props)) { + const found = visitElements(value, visitor); + if (found) return found; + } + return null; +} diff --git a/apps/web/src/test/reactHookHarness.ts b/apps/web/src/test/reactHookHarness.ts new file mode 100644 index 00000000000..1b4b26fb698 --- /dev/null +++ b/apps/web/src/test/reactHookHarness.ts @@ -0,0 +1,89 @@ +import type { Dispatch, SetStateAction } from "react"; + +/** + * Minimal React hook shim for tests that call components as plain functions + * instead of mounting a renderer. Slots are keyed by call order, mirroring + * React's own rules-of-hooks contract, and `useMemoCache` emulates the React + * Compiler runtime so compiled components can execute unmodified. + * + * This module must stay free of runtime `react` imports: it is loaded from + * inside `vi.mock("react", ...)` factories, and a value import would recurse + * into the in-progress mock. Wire it up in each test file (mock calls cannot + * live here because vitest hoists them per test module): + * + * ```ts + * import { reactHookHarness } from "~/test/reactHookHarness"; + * + * vi.mock("react", async (importOriginal) => { + * const actual = await importOriginal(); + * const { reactHookHarness } = await import("~/test/reactHookHarness"); + * return { + * ...actual, + * useCallback: reactHookHarness.useCallback, + * useMemo: reactHookHarness.useMemo, + * useRef: reactHookHarness.useRef, + * useState: reactHookHarness.useState, + * }; + * }); + * vi.mock("react/compiler-runtime", async () => { + * const { reactHookHarness } = await import("~/test/reactHookHarness"); + * return { c: reactHookHarness.useMemoCache }; + * }); + * ``` + * + * Call `beginRender()` before each component invocation and `reset()` in + * `beforeEach` to drop persisted state between tests. + */ +export function createReactHookHarness() { + let cursor = 0; + let slots: unknown[] = []; + const nextIndex = () => cursor++; + + return { + beginRender() { + cursor = 0; + }, + reset() { + cursor = 0; + slots = []; + }, + useCallback(callback: T): T { + nextIndex(); + return callback; + }, + useMemo(factory: () => T): T { + nextIndex(); + return factory(); + }, + useMemoCache(size: number): unknown[] { + const index = nextIndex(); + if (!slots[index]) { + slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel")); + } + return slots[index] as unknown[]; + }, + useRef(initialValue: T): { current: T } { + const index = nextIndex(); + if (!slots[index]) { + slots[index] = { current: initialValue }; + } + return slots[index] as { current: T }; + }, + useState(initialValue: T | (() => T)): [T, Dispatch>] { + const index = nextIndex(); + if (index >= slots.length) { + slots[index] = + typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; + } + const setValue: Dispatch> = (nextValue) => { + const previous = slots[index] as T; + slots[index] = + typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue; + }; + return [slots[index] as T, setValue]; + }, + }; +} + +/** Shared instance so `vi.mock` factories and test bodies see the same slots. */ +export const reactHookHarness = createReactHookHarness(); From 0cc4d3935fc624e1ad90b9b6c5cb0a598d735a9c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 4 Aug 2026 14:28:08 -0700 Subject: [PATCH 5/8] fix(web): tolerate missing legacy provider config in default slots A remote device may run a server version whose settings predate a driver in this build's DRIVER_OPTIONS, so the legacy providers mirror can lack the entry. The default-slot loop asserted it non-null and would throw during render; skip the slot instead when neither an explicit instance nor a legacy blob exists, matching resetDefaultInstance's existing guard. Co-Authored-By: Claude Opus 5 (1M context) --- .../settings/ProviderSettingsPanel.tsx | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index b98179c555d..69bf9f3a2a5 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -558,15 +558,23 @@ export function EnvironmentProviderSettings({ const driver = providerSettings.provider; const defaultInstanceId = defaultInstanceIdForDriver(driver); const explicitInstance = settings.providerInstances?.[defaultInstanceId]; - const legacyConfig = legacyProviders[providerSettings.provider]!; - const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]!; - const effectiveInstance: ProviderInstanceConfig = + // A remote device may run a server version whose settings predate this + // driver, so the legacy mirror can be absent. Without either an explicit + // instance or a legacy blob there is nothing to render for the slot. + const legacyConfig = legacyProviders[providerSettings.provider]; + const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]; + const effectiveInstance: ProviderInstanceConfig | undefined = explicitInstance ?? - ({ - driver, - enabled: legacyConfig.enabled, - config: legacyConfig, - } satisfies ProviderInstanceConfig); + (legacyConfig !== undefined + ? ({ + driver, + enabled: legacyConfig.enabled, + config: legacyConfig, + } satisfies ProviderInstanceConfig) + : undefined); + if (effectiveInstance === undefined) { + continue; + } const isDirty = explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig); rows.push({ From 0b213fb825bf7dcbf4fda2fad808ec2f243eddf1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 4 Aug 2026 15:01:20 -0700 Subject: [PATCH 6/8] fix(web): keep custom instances visible when a default slot has no config The missing-legacy guard used continue, which also skipped the custom instance rows appended later in the same loop iteration. Only the default slot depends on the legacy blob, so skip just that row. Co-Authored-By: Claude Opus 5 (1M context) --- .../settings/ProviderSettingsPanel.tsx | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 69bf9f3a2a5..b29ebfc79f3 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -572,18 +572,19 @@ export function EnvironmentProviderSettings({ config: legacyConfig, } satisfies ProviderInstanceConfig) : undefined); - if (effectiveInstance === undefined) { - continue; + // Only the default slot depends on the legacy blob; custom instances for + // the driver must still render even when the slot has nothing to show. + if (effectiveInstance !== undefined) { + const isDirty = + explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig); + rows.push({ + instanceId: defaultInstanceId, + instance: effectiveInstance, + driver, + isDefault: true, + isDirty, + }); } - const isDirty = - explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig); - rows.push({ - instanceId: defaultInstanceId, - instance: effectiveInstance, - driver, - isDefault: true, - isDirty, - }); for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) { if (id === defaultInstanceId) continue; rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false }); From f43e38eda0871d8905621b4ad1fd69d1d0178d7c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 5 Aug 2026 19:15:37 -0700 Subject: [PATCH 7/8] chore(web): drop imports left unused by the rebase Co-Authored-By: Claude Fable 5 --- apps/web/src/components/settings/SettingsPanels.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 75efad8a467..bd16b88cc52 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -35,7 +35,6 @@ import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgro import { createModelSelection } from "@t3tools/shared/model"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; -import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; import { @@ -69,12 +68,7 @@ import { } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; import { isMacPlatform } from "../../lib/utils"; -import { - primaryServerObservabilityAtom, - primaryServerProvidersAtom, - serverEnvironment, -} from "../../state/server"; -import { usePrimaryEnvironment } from "../../state/environments"; +import { primaryServerObservabilityAtom, primaryServerProvidersAtom } from "../../state/server"; import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTimeLabel } from "../../timestampFormat"; @@ -132,7 +126,6 @@ import { SettingsPageContainer, SettingsRow, SettingsSection, - useRelativeTimeTick, useSettingsSearchTargetId, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; From 688524b3360e17afca2d047ea25ff7b46e12a105 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 5 Aug 2026 20:06:48 -0700 Subject: [PATCH 8/8] feat(web): derive remote provider access from real session scopes Non-primary environments previously assumed operate access, so a narrow-scope credential was offered edit controls whose writes the environment RPC would reject. Each environment's /api/auth/session now answers what this client may change, and sessions without orchestration:operate see the full provider layout greyed out and inert behind a limited-permissions notice instead of a separate stripped view. Co-Authored-By: Claude Fable 5 --- ...ProviderSettingsPanel.environment.test.tsx | 38 +- .../ProviderSettingsPanel.logic.test.ts | 55 ++ .../settings/ProviderSettingsPanel.logic.ts | 69 ++- .../settings/ProviderSettingsPanel.tsx | 510 +++++++++--------- apps/web/src/state/session.ts | 16 +- packages/client-runtime/src/state/session.ts | 71 ++- 6 files changed, 476 insertions(+), 283 deletions(-) diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx index 81e2c4ec5f1..2b304378ae9 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx @@ -81,6 +81,10 @@ vi.mock("../../environments/primary", () => ({ usePrimarySessionState: () => ({ data: null, error: null, isPending: false, refresh: vi.fn() }), })); +vi.mock("../../state/session", () => ({ + useEnvironmentSessionState: () => ({ data: null, hasError: false, isPending: true }), +})); + import { EnvironmentProviderSettings } from "./ProviderSettingsPanel"; const environmentId = EnvironmentId.make("remote-device"); @@ -112,11 +116,14 @@ function provider(): ServerProvider { }; } -function renderPanel(): ReactElement> { +function renderPanel(options?: { + readonly readOnly?: boolean; +}): ReactElement> { hooks.beginRender(); return EnvironmentProviderSettings({ environmentId, environmentLabel: "Remote device", + ...(options?.readOnly === undefined ? {} : { readOnly: options.readOnly }), }) as ReactElement>; } @@ -171,6 +178,35 @@ describe("EnvironmentProviderSettings routing", () => { }); }); + it("renders the provider layout inert with a limited-permissions notice when read only", () => { + atoms.providers = [provider()]; + const panel = renderPanel({ readOnly: true }); + + const inertWrapper = visitElements(panel, (element) => element.props.inert === true); + expect(inertWrapper).not.toBeNull(); + const providerCard = visitElements(panel, (element) => element.props.instanceId === codexId); + expect(providerCard).not.toBeNull(); + + const notice = visitElements(panel, (element) => element.props.title === "Limited permissions"); + expect(notice).not.toBeNull(); + + expect( + visitElements(panel, (element) => element.props["aria-label"] === "Add provider instance"), + ).toBeNull(); + expect( + visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"), + ).toBeNull(); + }); + + it("keeps the editable layout interactive when not read only", () => { + atoms.providers = [provider()]; + const panel = renderPanel(); + expect(visitElements(panel, (element) => element.props.inert === true)).toBeNull(); + expect( + visitElements(panel, (element) => element.props.title === "Limited permissions"), + ).toBeNull(); + }); + it("deletes and resets provider configuration without erasing shared preferences", () => { settingsState.value = { ...DEFAULT_UNIFIED_SETTINGS, diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts index 7dcc9611bb2..bf558f5a4d6 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts @@ -5,6 +5,7 @@ import { buildProviderEnvironmentOptions, classifyProviderEnvironmentAccess, resolvePrimaryOperateAccess, + resolveRemoteOperateAccess, resolveSelectedProviderEnvironmentId, } from "./ProviderSettingsPanel.logic"; @@ -203,3 +204,57 @@ describe("primary operate access", () => { ).toBe("granted"); }); }); + +describe("remote operate access", () => { + it("derives access from the environment session's granted scopes", () => { + expect( + resolveRemoteOperateAccess({ + session: { authenticated: true, scopes: [AuthOrchestrationOperateScope] }, + isPending: false, + hasError: false, + }), + ).toBe("granted"); + expect( + resolveRemoteOperateAccess({ + session: { authenticated: true, scopes: ["orchestration:read"] }, + isPending: false, + hasError: false, + }), + ).toBe("denied"); + expect( + resolveRemoteOperateAccess({ + session: { authenticated: false }, + isPending: false, + hasError: false, + }), + ).toBe("denied"); + }); + + it("reports pending before the first session resolve, then keeps cached data", () => { + expect(resolveRemoteOperateAccess({ session: null, isPending: true, hasError: false })).toBe( + "pending", + ); + expect( + resolveRemoteOperateAccess({ + session: { authenticated: true, scopes: [AuthOrchestrationOperateScope] }, + isPending: true, + hasError: false, + }), + ).toBe("granted"); + }); + + it("stays optimistic when the session fetch fails or an older server omits scopes", () => { + // Transport failures and pre-scope-reporting servers are not permission + // decisions; the environment RPC layer still rejects unauthorized writes. + expect(resolveRemoteOperateAccess({ session: null, isPending: false, hasError: true })).toBe( + "granted", + ); + expect( + resolveRemoteOperateAccess({ + session: { authenticated: true }, + isPending: false, + hasError: false, + }), + ).toBe("granted"); + }); +}); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts index 3b13b36037c..1c7dac391f6 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts @@ -64,28 +64,26 @@ export type ProviderEnvironmentAccess = export type ProviderOperateAccess = "granted" | "denied" | "pending"; /** - * Resolve whether the session may reconfigure providers on an environment. + * Resolve operate access from an environment's `/api/auth/session` answer. * - * Only the primary environment exposes its own granted scopes to the client - * today, so remote sessions are optimistic: the connection brokers request - * `orchestration:operate`, and the environment RPC layer stays authoritative if - * a narrower credential was minted. + * Cached session data wins over an in-flight revalidation. The session atoms + * are SWR-backed, so they report `isPending` on every background refresh; + * treating that as unknown would flip a working panel back to loading and + * discard in-progress edits. * - * Cached session data wins over an in-flight revalidation. The session atom is - * SWR-backed, so it reports `isPending` on every background refresh; treating - * that as unknown would flip a working panel back to loading and discard - * in-progress edits. + * `missingScopesAccess` decides the case where the session resolved but did + * not report scopes: the primary serves the web app itself so its server + * always reports them (absence means denial), while a remote device may run an + * older server version that predates scope reporting, where denial would lock + * out a legitimate session. The environment RPC layer stays authoritative + * either way. */ -export function resolvePrimaryOperateAccess(input: { - readonly isPrimary: boolean; - readonly hasDesktopBridge: boolean; +function resolveSessionOperateAccess(input: { readonly session: Pick | null; readonly isPending: boolean; readonly hasError: boolean; + readonly missingScopesAccess: "granted" | "denied"; }): ProviderOperateAccess { - if (!input.isPrimary || input.hasDesktopBridge) { - return "granted"; - } if (input.session === null) { if (input.isPending) { return "pending"; @@ -98,9 +96,44 @@ export function resolvePrimaryOperateAccess(input: { if (!input.session.authenticated) { return "denied"; } - return (input.session.scopes ?? []).includes(AuthOrchestrationOperateScope) - ? "granted" - : "denied"; + if (input.session.scopes === undefined) { + return input.missingScopesAccess; + } + return input.session.scopes.includes(AuthOrchestrationOperateScope) ? "granted" : "denied"; +} + +/** Operate access for the primary environment's own browser session. */ +export function resolvePrimaryOperateAccess(input: { + readonly isPrimary: boolean; + readonly hasDesktopBridge: boolean; + readonly session: Pick | null; + readonly isPending: boolean; + readonly hasError: boolean; +}): ProviderOperateAccess { + if (!input.isPrimary || input.hasDesktopBridge) { + return "granted"; + } + return resolveSessionOperateAccess({ + session: input.session, + isPending: input.isPending, + hasError: input.hasError, + missingScopesAccess: "denied", + }); +} + +/** + * Operate access for a non-primary environment, derived from the scopes its + * `/api/auth/session` endpoint reports for this client's credential. + */ +export function resolveRemoteOperateAccess(input: { + readonly session: Pick | null; + readonly isPending: boolean; + readonly hasError: boolean; +}): ProviderOperateAccess { + return resolveSessionOperateAccess({ + ...input, + missingScopesAccess: "granted", + }); } export function classifyProviderEnvironmentAccess(input: { diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index b29ebfc79f3..c06826d450d 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -39,13 +39,13 @@ import { usePrimarySessionState } from "../../environments/primary"; import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { resolveAppModelSelectionState } from "../../modelSelection"; -import { deriveProviderInstanceEntries } from "../../providerInstances"; import { useEnvironments, usePrimaryEnvironmentId, type EnvironmentPresentation, } from "../../state/environments"; import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; +import { useEnvironmentSessionState } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import { getRelativeTimeState } from "../../timestampFormat"; import { @@ -73,12 +73,6 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; -import { - getProviderSummary, - getProviderVersionLabel, - PROVIDER_STATUS_STYLES, - type ProviderStatusKey, -} from "./providerStatus"; import { searchableSetting } from "./settingsSearch"; import { backgroundActivityOverrideSettings, @@ -101,6 +95,7 @@ import { type ProviderEnvironmentAccess, type ProviderOperateAccess, resolvePrimaryOperateAccess, + resolveRemoteOperateAccess, resolveSelectedProviderEnvironmentId, } from "./ProviderSettingsPanel.logic"; @@ -292,12 +287,15 @@ function SelectedEnvironmentProviderSettings({ readonly environment: EnvironmentPresentation; }) { const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; - // Only a browser session against the primary needs its scopes checked; the - // session atom stays unmounted (no SWR fetch) for every other device. - if (isPrimary && !isElectron) { + if (isPrimary) { + // The desktop app owns its primary server outright; a browser session + // checks the scopes its cookie session was granted. + if (isElectron) { + return ; + } return ; } - return ; + return ; } function PrimarySessionGatedProviderSettings({ @@ -316,6 +314,20 @@ function PrimarySessionGatedProviderSettings({ return ; } +function RemoteSessionGatedProviderSettings({ + environment, +}: { + readonly environment: EnvironmentPresentation; +}) { + const sessionState = useEnvironmentSessionState(environment.environmentId); + const operateAccess = resolveRemoteOperateAccess({ + session: sessionState.data, + isPending: sessionState.isPending, + hasError: sessionState.hasError, + }); + return ; +} + function AccessGatedProviderSettings({ environment, operateAccess, @@ -328,78 +340,32 @@ function AccessGatedProviderSettings({ hasServerConfig: environment.serverConfig !== null, operateAccess, }); - if (access.kind === "read-only") { - return ; - } - if (access.kind !== "editable") { + if (access.kind !== "editable" && access.kind !== "read-only") { return ; } return ( ); } -/** - * Connected devices this session may read but not reconfigure. The provider - * catalogue is still worth showing — knowing which providers a box has and - * whether they are authenticated is most of the value — so render each one as - * a status row and omit every mutation control. - */ -function ReadOnlyProviderSettings({ - environment, -}: { - readonly environment: EnvironmentPresentation; -}) { - // `deriveProviderInstanceEntries` resolves the same per-instance display name - // the pickers use, so two instances of one driver stay distinguishable here. - const entries = deriveProviderInstanceEntries( - environment.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS, - ); - return ( - - - {entries.map((entry) => { - const summary = getProviderSummary(entry.snapshot); - const versionLabel = getProviderVersionLabel(entry.snapshot.version); - return ( - - - {entry.displayName} - {versionLabel ? ( - {versionLabel} - ) : null} - - } - description={summary.detail ?? summary.headline} - status={summary.detail ? summary.headline : null} - /> - ); - })} - - ); -} - export function EnvironmentProviderSettings({ environmentId, environmentLabel, + readOnly = false, }: { readonly environmentId: EnvironmentId; readonly environmentLabel: string; + /** + * Render the full provider layout, greyed out and inert, when this session's + * credential lacks `orchestration:operate` on the environment. Showing the + * real configuration keeps the view honest; disabling interaction keeps + * every one of its writes from being offered and then rejected. + */ + readonly readOnly?: boolean; }) { const settings = useEnvironmentSettings(environmentId); const updateSettings = useUpdateEnvironmentSettings(environmentId); @@ -698,207 +664,229 @@ export function EnvironmentProviderSettings({ headerAction={
- - setIsAddInstanceDialogOpen(true)} - aria-label="Add provider instance" - > - - - } - /> - Add provider instance - - - void refreshProviders()} - aria-label="Refresh provider status" - > - {isRefreshingProviders ? ( - - ) : ( - - )} - - } - /> - Refresh provider status - + {!readOnly ? ( + <> + + setIsAddInstanceDialogOpen(true)} + aria-label="Add provider instance" + > + + + } + /> + Add provider instance + + + void refreshProviders()} + aria-label="Refresh provider status" + > + {isRefreshingProviders ? ( + + ) : ( + + )} + + } + /> + Refresh provider status + + + ) : null}
} > - - Health check interval - - This interval is configured here, then the shared Background activity policy decides - whether provider probes may run when the timer fires. Custom intervals appear as - Advanced in General settings. - -
- } - description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes." - resetAction={ - providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? ( - - updateSettings( - backgroundActivityOverrideSettings( - settings.backgroundActivity, - resolvedBackgroundActivity, - { - providerHealthRefreshInterval: undefined, - }, - ), - ) + {readOnly ? ( + + ) : null} +
+ + Health check interval + + This interval is configured here, then the shared Background activity policy + decides whether provider probes may run when the timer fires. Custom intervals + appear as Advanced in General settings. + + + } + description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes." + resetAction={ + providerHealthRefreshIntervalSeconds !== + defaultProviderHealthRefreshIntervalSeconds ? ( + + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + providerHealthRefreshInterval: undefined, + }, + ), + ) + } + /> + ) : null + } + control={ +
+ + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + providerHealthRefreshInterval: Duration.seconds( + normalizeIntervalSeconds(value), + ), + }, + ), + ) + } + > + + + + + + + seconds +
+ } + /> + + {rows.map((row) => { + const driverOption = getDriverOption(row.driver); + const liveProvider = serverProviders.find( + (candidate) => candidate.instanceId === row.instanceId, + ); + const updateCandidate = liveProvider + ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) + : undefined; + const isDriverUpdateRunning = + updateCandidate !== undefined && + (updatingProviderDrivers.has(updateCandidate.driver) || + serverProviders.some( + (provider) => + provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), + )); + const showInlineUpdateButton = + updateCandidate !== undefined && + hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); + const canRunInlineUpdate = + updateCandidate !== undefined && + canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && + !updatingProviderDrivers.has(updateCandidate.driver); + const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { + hiddenModels: [], + modelOrder: [], + }; + const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) => + favorite.provider === row.instanceId + ? Result.succeed(favorite.model) + : Result.failVoid, + ); + const resetLabel = driverOption?.label ?? String(row.driver); + const headerAction = + row.isDefault && row.isDirty ? ( + resetDefaultInstance(row.driver)} + /> + ) : null; + return ( + + setOpenInstanceDetails((existing) => ({ + ...existing, + [row.instanceId]: open, + })) } - /> - ) : null - } - control={ -
- - updateSettings( - backgroundActivityOverrideSettings( - settings.backgroundActivity, - resolvedBackgroundActivity, - { - providerHealthRefreshInterval: Duration.seconds( - normalizeIntervalSeconds(value), - ), - }, - ), - ) + onUpdate={(next) => { + const wasEnabled = row.instance.enabled ?? true; + const isDisabling = next.enabled === false && wasEnabled; + const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; + if (shouldClearTextGen) { + updateProviderInstance(row, next, { + textGenerationModelSelection: + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + }); + } else { + updateProviderInstance(row, next); + } + }} + onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)} + headerAction={headerAction} + hiddenModels={modelPreferences.hiddenModels} + favoriteModels={favoriteModels} + modelOrder={modelPreferences.modelOrder} + onHiddenModelsChange={(hiddenModels) => + updateProviderModelPreferences(row.instanceId, { + ...modelPreferences, + hiddenModels, + }) } - > - - - - - - - seconds -
- } - /> - - {rows.map((row) => { - const driverOption = getDriverOption(row.driver); - const liveProvider = serverProviders.find( - (candidate) => candidate.instanceId === row.instanceId, - ); - const updateCandidate = liveProvider - ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) - : undefined; - const isDriverUpdateRunning = - updateCandidate !== undefined && - (updatingProviderDrivers.has(updateCandidate.driver) || - serverProviders.some( - (provider) => - provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), - )); - const showInlineUpdateButton = - updateCandidate !== undefined && - hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); - const canRunInlineUpdate = - updateCandidate !== undefined && - canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && - !updatingProviderDrivers.has(updateCandidate.driver); - const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { - hiddenModels: [], - modelOrder: [], - }; - const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) => - favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid, - ); - const resetLabel = driverOption?.label ?? String(row.driver); - const headerAction = - row.isDefault && row.isDirty ? ( - resetDefaultInstance(row.driver)} - /> - ) : null; - return ( - - setOpenInstanceDetails((existing) => ({ - ...existing, - [row.instanceId]: open, - })) - } - onUpdate={(next) => { - const wasEnabled = row.instance.enabled ?? true; - const isDisabling = next.enabled === false && wasEnabled; - const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; - if (shouldClearTextGen) { - updateProviderInstance(row, next, { - textGenerationModelSelection: - DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, - }); - } else { - updateProviderInstance(row, next); + onFavoriteModelsChange={(favoriteModels) => + updateProviderFavoriteModels(row.instanceId, favoriteModels) } - }} - onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)} - headerAction={headerAction} - hiddenModels={modelPreferences.hiddenModels} - favoriteModels={favoriteModels} - modelOrder={modelPreferences.modelOrder} - onHiddenModelsChange={(hiddenModels) => - updateProviderModelPreferences(row.instanceId, { - ...modelPreferences, - hiddenModels, - }) - } - onFavoriteModelsChange={(favoriteModels) => - updateProviderFavoriteModels(row.instanceId, favoriteModels) - } - onModelOrderChange={(modelOrder) => - updateProviderModelPreferences(row.instanceId, { - ...modelPreferences, - modelOrder, - }) - } - onRunUpdate={ - showInlineUpdateButton && updateCandidate - ? () => { - if (!canRunInlineUpdate) { - return; + onModelOrderChange={(modelOrder) => + updateProviderModelPreferences(row.instanceId, { + ...modelPreferences, + modelOrder, + }) + } + onRunUpdate={ + showInlineUpdateButton && updateCandidate + ? () => { + if (!canRunInlineUpdate) { + return; + } + void runProviderUpdate(updateCandidate); } - void runProviderUpdate(updateCandidate); - } - : undefined - } - isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined} - /> - ); - })} + : undefined + } + isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined} + /> + ); + })} +
{isAddInstanceDialogOpen ? ( diff --git a/apps/web/src/state/session.ts b/apps/web/src/state/session.ts index 37fed3b188f..a7d5a53d10d 100644 --- a/apps/web/src/state/session.ts +++ b/apps/web/src/state/session.ts @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import { Atom } from "effect/unstable/reactivity"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -26,3 +26,17 @@ export function readPreparedConnection(environmentId: EnvironmentId) { appAtomRegistry.get(environmentSession.preparedConnectionValueAtom(environmentId)), ); } + +/** + * This client's authenticated session on one environment, as reported by that + * environment's `/api/auth/session` endpoint. `data` stays populated across + * SWR revalidations; `isPending` is only meaningful before the first resolve. + */ +export function useEnvironmentSessionState(environmentId: EnvironmentId) { + const result = useAtomValue(environmentSession.sessionStateAtom(environmentId)); + return { + data: Option.getOrNull(AsyncResult.value(result)), + hasError: result._tag === "Failure", + isPending: result.waiting, + }; +} diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 3cb62009a20..31fd297da3f 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -1,14 +1,19 @@ -import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import type { HttpClient } from "effect/unstable/http"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { EnvironmentRegistry } from "../connection/registry.ts"; import type { PreparedConnection } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; +import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; +import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; import { followStreamInEnvironment } from "./runtime.ts"; export function initialConfigOption( @@ -25,8 +30,39 @@ export function initialConfigOption( ); } +// Bounded like the snapshot fetches: a wedged environment must not pin the +// permissions check (and with it the settings UI) in a loading state for long. +const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000; + +/** + * Read the granted scopes of this client's session on one environment via its + * `/api/auth/session` endpoint, authenticated with whatever credential the + * connection was prepared with (cookie, bearer, or DPoP). + */ +export const fetchEnvironmentSessionState = Effect.fn( + "clientRuntime.state.fetchEnvironmentSessionState", +)(function* (input: { + readonly prepared: PreparedConnection; + readonly signer: Option.Option; + readonly timeoutMs?: number; +}) { + const requestUrl = environmentEndpointUrl(input.prepared.httpBaseUrl, "/api/auth/session"); + const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); + const headers = yield* buildEnvironmentAuthHeaders( + input.prepared.httpAuthorization, + "GET", + requestUrl, + input.signer, + ); + return yield* executeEnvironmentHttpRequest( + requestUrl, + input.timeoutMs ?? DEFAULT_SESSION_STATE_TIMEOUT_MS, + withEnvironmentCredentials(input.prepared.httpAuthorization, client.auth.session({ headers })), + ); +}); + export function createEnvironmentSessionAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, ) { const initialConfigAtom = Atom.family((environmentId: EnvironmentId) => runtime.atom( @@ -86,10 +122,41 @@ export function createEnvironmentSessionAtoms( ).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)), ); + // Keyed on the prepared connection's identity: a reconnect (new credential, + // new base URL) swaps the prepared value, which re-runs the fetch, so scope + // changes from re-pairing are picked up without an explicit refresh. + const sessionStateAtom = Atom.family((environmentId: EnvironmentId) => + runtime + .atom((get) => { + const prepared = Option.getOrNull(get(preparedConnectionValueAtom(environmentId))); + if (prepared === null) { + return Effect.never; + } + return Effect.gen(function* () { + const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner); + return yield* fetchEnvironmentSessionState({ prepared, signer }); + }); + }) + .pipe( + Atom.swr({ staleTime: 30_000, revalidateOnMount: true }), + Atom.setIdleTTL(5 * 60_000), + Atom.withLabel(`environment-session-state:${environmentId}`), + ), + ); + + const sessionStateValueAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get): AuthSessionState | null => + Option.getOrNull(AsyncResult.value(get(sessionStateAtom(environmentId)))) ?? null, + ).pipe(Atom.withLabel(`environment-session-state-value:${environmentId}`)), + ); + return { initialConfigAtom, initialConfigValueAtom, preparedConnectionAtom, preparedConnectionValueAtom, + sessionStateAtom, + sessionStateValueAtom, }; }