From 1aaccc681f6ba8cf8e959188851f07d9606bf194 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Sat, 25 Jul 2026 17:00:15 -0700 Subject: [PATCH 1/5] feat(web): configure providers per environment --- .../settings/AddProviderInstanceDialog.tsx | 25 ++-- .../settings/ProviderEnvironmentSelector.tsx | 77 ++++++++++ .../settings/SettingsPanels.logic.test.ts | 62 ++++++++ .../settings/SettingsPanels.logic.ts | 24 ++++ .../components/settings/SettingsPanels.tsx | 136 +++++++++++++++--- 5 files changed, 296 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/components/settings/ProviderEnvironmentSelector.tsx diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index a6da37c1551..71d00ec3bed 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -4,12 +4,13 @@ import { Radio as RadioPrimitive } from "@base-ui/react/radio"; import { CheckIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { + type EnvironmentId, ProviderInstanceId, ProviderDriverKind, 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); @@ -208,7 +216,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns toastManager.add({ type: "success", title: "Provider instance added", - description: `${driverOption.label} instance '${instanceId}' was added.`, + description: `${driverOption.label} instance '${instanceId}' was added to ${environmentLabel}.`, }); onOpenChange(false); } catch (error) { @@ -227,8 +235,7 @@ 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}. ; + readonly primaryEnvironmentId: EnvironmentId | null; + readonly onEnvironmentChange: (environmentId: EnvironmentId) => void; +} + +export function ProviderEnvironmentSelector({ + environmentId, + environments, + primaryEnvironmentId, + onEnvironmentChange, +}: ProviderEnvironmentSelectorProps) { + const selectedEnvironment = + environments.find((environment) => environment.environmentId === environmentId) ?? null; + const items = useMemo( + () => + environments.map((environment) => ({ + value: environment.environmentId, + label: environment.label, + })), + [environments], + ); + + return ( + + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index 077991f8de0..f37a2d65d4e 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_SERVER_SETTINGS, + EnvironmentId, ProviderDriverKind, ProviderInstanceId, type ProviderInstanceConfig, @@ -10,8 +11,69 @@ import { formatDiagnosticsDescription, isProjectGroupingEnabled, projectGroupingModeFromToggle, + resolveProviderSettingsEnvironmentId, } from "./SettingsPanels.logic"; +const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("00000000-0000-4000-8000-000000000001"); +const REMOTE_ENVIRONMENT_ID = EnvironmentId.make("00000000-0000-4000-8000-000000000002"); + +describe("provider settings environment selection", () => { + it("preserves an explicit selected environment", () => { + expect( + resolveProviderSettingsEnvironmentId({ + availableEnvironmentIds: [LOCAL_ENVIRONMENT_ID, REMOTE_ENVIRONMENT_ID], + selectedEnvironmentId: REMOTE_ENVIRONMENT_ID, + primaryEnvironmentId: LOCAL_ENVIRONMENT_ID, + activeEnvironmentId: LOCAL_ENVIRONMENT_ID, + }), + ).toBe(REMOTE_ENVIRONMENT_ID); + }); + + it("defaults to the primary environment in managed mode", () => { + expect( + resolveProviderSettingsEnvironmentId({ + availableEnvironmentIds: [REMOTE_ENVIRONMENT_ID, LOCAL_ENVIRONMENT_ID], + selectedEnvironmentId: null, + primaryEnvironmentId: LOCAL_ENVIRONMENT_ID, + activeEnvironmentId: REMOTE_ENVIRONMENT_ID, + }), + ).toBe(LOCAL_ENVIRONMENT_ID); + }); + + it("defaults to the active remote environment in client-only mode", () => { + expect( + resolveProviderSettingsEnvironmentId({ + availableEnvironmentIds: [LOCAL_ENVIRONMENT_ID, REMOTE_ENVIRONMENT_ID], + selectedEnvironmentId: null, + primaryEnvironmentId: null, + activeEnvironmentId: REMOTE_ENVIRONMENT_ID, + }), + ).toBe(REMOTE_ENVIRONMENT_ID); + }); + + it("falls back when the selected environment is no longer available", () => { + expect( + resolveProviderSettingsEnvironmentId({ + availableEnvironmentIds: [LOCAL_ENVIRONMENT_ID], + selectedEnvironmentId: REMOTE_ENVIRONMENT_ID, + primaryEnvironmentId: LOCAL_ENVIRONMENT_ID, + activeEnvironmentId: REMOTE_ENVIRONMENT_ID, + }), + ).toBe(LOCAL_ENVIRONMENT_ID); + }); + + it("returns null when no environments are available", () => { + expect( + resolveProviderSettingsEnvironmentId({ + availableEnvironmentIds: [], + selectedEnvironmentId: null, + primaryEnvironmentId: null, + activeEnvironmentId: null, + }), + ).toBeNull(); + }); +}); + describe("project grouping toggle", () => { it("enables repository grouping and disables into separate projects", () => { expect(isProjectGroupingEnabled("repository")).toBe(true); diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 51e318225ae..d61cf894047 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -1,4 +1,5 @@ import type { + EnvironmentId, ProviderDriverKind, ProviderInstanceConfig, ProviderInstanceId, @@ -8,6 +9,29 @@ import type { } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +export function resolveProviderSettingsEnvironmentId(input: { + readonly availableEnvironmentIds: ReadonlyArray; + readonly selectedEnvironmentId: EnvironmentId | null; + readonly primaryEnvironmentId: EnvironmentId | null; + readonly activeEnvironmentId: EnvironmentId | null; +}): EnvironmentId | null { + const availableIds = new Set(input.availableEnvironmentIds); + const candidates = [ + input.selectedEnvironmentId, + input.primaryEnvironmentId, + input.activeEnvironmentId, + input.availableEnvironmentIds[0] ?? null, + ]; + + for (const candidate of candidates) { + if (candidate !== null && availableIds.has(candidate)) { + return candidate; + } + } + + return null; +} + export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean { return mode !== "separate"; } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index fa7c7299667..ece7e520ab2 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -6,6 +6,7 @@ import { useAtomValue } from "@effect/atom-react"; import { defaultInstanceIdForDriver, type DesktopUpdateChannel, + type EnvironmentId, PROVIDER_DISPLAY_NAMES, ProviderDriverKind, type ProviderInstanceConfig, @@ -43,7 +44,12 @@ import { TraitsPicker } from "../chat/TraitsPicker"; import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; import { useTheme } from "../../hooks/useTheme"; -import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { + useEnvironmentSettings, + usePrimarySettings, + useUpdateEnvironmentSettings, + useUpdatePrimarySettings, +} from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { @@ -61,8 +67,12 @@ import { primaryServerProvidersAtom, serverEnvironment, } from "../../state/server"; -import { usePrimaryEnvironment } from "../../state/environments"; -import { useProjects } from "../../state/entities"; +import { + type EnvironmentPresentation, + useEnvironments, + usePrimaryEnvironmentId, +} from "../../state/environments"; +import { useActiveEnvironmentId, useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; import { Button } from "../ui/button"; @@ -80,6 +90,7 @@ import { type ProviderUpdateCandidate, } from "../ProviderUpdateLaunchNotification.logic"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; +import { ProviderEnvironmentSelector } from "./ProviderEnvironmentSelector"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { buildProviderInstanceUpdatePatch, @@ -88,6 +99,7 @@ import { projectGroupingModeFromToggle, readLastEnabledProjectGroupingMode, rememberEnabledProjectGroupingMode, + resolveProviderSettingsEnvironmentId, } from "./SettingsPanels.logic"; import { SettingResetButton, @@ -1097,10 +1109,74 @@ export function GeneralSettingsPanel() { } export function ProviderSettingsPanel() { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); - const serverProviders = useAtomValue(primaryServerProvidersAtom); - const primaryEnvironment = usePrimaryEnvironment(); + const { isReady, environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const activeEnvironmentId = useActiveEnvironmentId(); + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); + const environmentId = resolveProviderSettingsEnvironmentId({ + availableEnvironmentIds: environments.map((environment) => environment.environmentId), + selectedEnvironmentId, + primaryEnvironmentId, + activeEnvironmentId, + }); + const environment = + environmentId === null + ? null + : (environments.find((candidate) => candidate.environmentId === environmentId) ?? null); + + if (environmentId === null || environment === null) { + return ( + + + } size="xs" variant="outline"> + Open connections + + ) : null + } + /> + + + ); + } + + return ( + + ); +} + +function ProviderSettingsEnvironmentPanel({ + environmentId, + environmentLabel, + environments, + primaryEnvironmentId, + onEnvironmentChange, +}: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly environments: ReadonlyArray; + readonly primaryEnvironmentId: EnvironmentId | null; + readonly onEnvironmentChange: (environmentId: EnvironmentId) => void; +}) { + const settings = useEnvironmentSettings(environmentId); + const updateSettings = useUpdateEnvironmentSettings(environmentId); + const serverSettings = useAtomValue(serverEnvironment.settingsValueAtom(environmentId)); + const serverProviders = useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? []; const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); @@ -1115,6 +1191,15 @@ export function ProviderSettingsPanel() { const [openInstanceDetails, setOpenInstanceDetails] = useState>({}); const refreshingRef = useRef(false); + const environmentSelector = ( + + ); + const providerUpdateCandidates = useMemo( () => collectProviderUpdateCandidates(serverProviders), [serverProviders], @@ -1145,14 +1230,9 @@ export function ProviderSettingsPanel() { 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, + environmentId, input: {}, }); refreshingRef.current = false; @@ -1160,16 +1240,15 @@ export function ProviderSettingsPanel() { if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { console.warn("Failed to refresh providers", { operation: "refresh-providers", - environmentId: primaryEnvironment.environmentId, + environmentId, ...safeErrorLogAttributes(squashAtomCommandFailure(result)), }); } })(); - }, [primaryEnvironment, refreshServerProviders]); + }, [environmentId, refreshServerProviders]); const runProviderUpdate = useCallback( async (candidate: ProviderUpdateCandidate) => { - if (!primaryEnvironment) return; let started = false; setUpdatingProviderDrivers((previous) => { if (previous.has(candidate.driver)) { @@ -1185,7 +1264,7 @@ export function ProviderSettingsPanel() { } const result = await updateProvider({ - environmentId: primaryEnvironment.environmentId, + environmentId, input: { provider: candidate.driver, instanceId: candidate.instanceId, @@ -1213,9 +1292,22 @@ export function ProviderSettingsPanel() { return next; }); }, - [primaryEnvironment, updateProvider], + [environmentId, updateProvider], ); + if (serverSettings === null) { + return ( + + + + + + ); + } + interface InstanceRow { readonly instanceId: ProviderInstanceId; readonly instance: ProviderInstanceConfig; @@ -1393,6 +1485,7 @@ export function ProviderSettingsPanel() { title="Providers" headerAction={
+ {environmentSelector} {isAddInstanceDialogOpen ? ( - + ) : null} ); From 2b6ab9609f3820fb71e2269ca3e51d381f6f4628 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 16:41:05 -0700 Subject: [PATCH 2/5] feat(web): select diagnostics environment --- .../DiagnosticsSettings.logic.test.ts | 81 +++++++++++ .../settings/DiagnosticsSettings.logic.ts | 30 ++++ .../settings/DiagnosticsSettings.tsx | 133 ++++++++++++++++-- 3 files changed, 233 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/settings/DiagnosticsSettings.logic.test.ts create mode 100644 apps/web/src/components/settings/DiagnosticsSettings.logic.ts diff --git a/apps/web/src/components/settings/DiagnosticsSettings.logic.test.ts b/apps/web/src/components/settings/DiagnosticsSettings.logic.test.ts new file mode 100644 index 00000000000..55c8d7f68e4 --- /dev/null +++ b/apps/web/src/components/settings/DiagnosticsSettings.logic.test.ts @@ -0,0 +1,81 @@ +import { assert, describe, it } from "@effect/vitest"; +import { EnvironmentId } from "@t3tools/contracts"; + +import { resolveDiagnosticsEnvironmentId } from "./DiagnosticsSettings.logic"; + +const PRIMARY = EnvironmentId.make("primary"); +const ACTIVE = EnvironmentId.make("active"); +const SELECTED = EnvironmentId.make("selected"); + +describe("resolveDiagnosticsEnvironmentId", () => { + it("preserves an explicit available selection", () => { + assert.equal( + resolveDiagnosticsEnvironmentId({ + selectedEnvironmentId: SELECTED, + primaryEnvironmentId: PRIMARY, + activeEnvironmentId: ACTIVE, + availableEnvironmentIds: [PRIMARY, ACTIVE, SELECTED], + }), + SELECTED, + ); + }); + + it("defaults to the primary environment when one is available", () => { + assert.equal( + resolveDiagnosticsEnvironmentId({ + selectedEnvironmentId: null, + primaryEnvironmentId: PRIMARY, + activeEnvironmentId: ACTIVE, + availableEnvironmentIds: [ACTIVE, PRIMARY], + }), + PRIMARY, + ); + }); + + it("uses the active environment when there is no primary environment", () => { + assert.equal( + resolveDiagnosticsEnvironmentId({ + selectedEnvironmentId: null, + primaryEnvironmentId: null, + activeEnvironmentId: ACTIVE, + availableEnvironmentIds: [SELECTED, ACTIVE], + }), + ACTIVE, + ); + }); + + it("falls back to the first available environment", () => { + assert.equal( + resolveDiagnosticsEnvironmentId({ + selectedEnvironmentId: null, + primaryEnvironmentId: null, + activeEnvironmentId: null, + availableEnvironmentIds: [SELECTED, ACTIVE], + }), + SELECTED, + ); + }); + + it("recovers when the explicit selection is no longer available", () => { + assert.equal( + resolveDiagnosticsEnvironmentId({ + selectedEnvironmentId: SELECTED, + primaryEnvironmentId: PRIMARY, + activeEnvironmentId: ACTIVE, + availableEnvironmentIds: [PRIMARY, ACTIVE], + }), + PRIMARY, + ); + }); + + it("returns null when no environments are available", () => { + assert.isNull( + resolveDiagnosticsEnvironmentId({ + selectedEnvironmentId: SELECTED, + primaryEnvironmentId: PRIMARY, + activeEnvironmentId: ACTIVE, + availableEnvironmentIds: [], + }), + ); + }); +}); diff --git a/apps/web/src/components/settings/DiagnosticsSettings.logic.ts b/apps/web/src/components/settings/DiagnosticsSettings.logic.ts new file mode 100644 index 00000000000..1166cf5865d --- /dev/null +++ b/apps/web/src/components/settings/DiagnosticsSettings.logic.ts @@ -0,0 +1,30 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +export function resolveDiagnosticsEnvironmentId(input: { + readonly selectedEnvironmentId: EnvironmentId | null; + readonly primaryEnvironmentId: EnvironmentId | null; + readonly activeEnvironmentId: EnvironmentId | null; + readonly availableEnvironmentIds: ReadonlyArray; +}): EnvironmentId | null { + const availableEnvironmentIds = new Set(input.availableEnvironmentIds); + + if ( + input.selectedEnvironmentId !== null && + availableEnvironmentIds.has(input.selectedEnvironmentId) + ) { + return input.selectedEnvironmentId; + } + if ( + input.primaryEnvironmentId !== null && + availableEnvironmentIds.has(input.primaryEnvironmentId) + ) { + return input.primaryEnvironmentId; + } + if ( + input.activeEnvironmentId !== null && + availableEnvironmentIds.has(input.activeEnvironmentId) + ) { + return input.activeEnvironmentId; + } + return input.availableEnvironmentIds[0] ?? null; +} diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index b1a54feb718..f3535d545fa 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -2,18 +2,22 @@ import { AlertTriangleIcon, ChevronDownIcon, ChevronRightIcon, + CloudIcon, CopyIcon, FolderOpenIcon, InfoIcon, + MonitorIcon, RefreshCwIcon, } from "lucide-react"; -import { useAtomValue } from "@effect/atom-react"; +import { Link } from "@tanstack/react-router"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { useCallback, useMemo, useState, type ReactNode } from "react"; import type { + EnvironmentId, ServerProcessDiagnosticsEntry, ServerProcessResourceHistorySummary, ServerProcessSignal, @@ -25,19 +29,31 @@ import { cn } from "../../lib/utils"; import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; import { useEnvironmentQuery } from "../../state/query"; -import { - primaryServerAvailableEditorsAtom, - primaryServerObservabilityAtom, - serverEnvironment, -} from "../../state/server"; +import { serverEnvironment } from "../../state/server"; import { shellEnvironment } from "../../state/shell"; -import { usePrimaryEnvironment } from "../../state/environments"; +import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; +import { useActiveEnvironmentId } from "../../state/entities"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { Button } from "../ui/button"; import { ScrollArea } from "../ui/scroll-area"; +import { + Select, + SelectGroup, + SelectGroupLabel, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "../ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; -import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout"; +import { + SettingsPageContainer, + SettingsRow, + SettingsSection, + useRelativeTimeTick, +} from "./settingsLayout"; +import { resolveDiagnosticsEnvironmentId } from "./DiagnosticsSettings.logic"; import { useAtomCommand } from "../../state/use-atom-command"; const NUMBER_FORMAT = new Intl.NumberFormat(); @@ -808,10 +824,28 @@ function DiagnosticsRefreshButton({ } export function DiagnosticsSettingsPanel() { - const observability = useAtomValue(primaryServerObservabilityAtom); - const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); + const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); - const environmentId = primaryEnvironment?.environmentId ?? null; + const activeEnvironmentId = useActiveEnvironmentId(); + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); + const environmentId = resolveDiagnosticsEnvironmentId({ + selectedEnvironmentId, + primaryEnvironmentId: primaryEnvironment?.environmentId ?? null, + activeEnvironmentId, + availableEnvironmentIds: environments.map((environment) => environment.environmentId), + }); + const diagnosticsEnvironment = + environments.find((environment) => environment.environmentId === environmentId) ?? null; + const observability = diagnosticsEnvironment?.serverConfig?.observability ?? null; + const availableEditors = diagnosticsEnvironment?.serverConfig?.availableEditors ?? []; + const environmentItems = useMemo( + () => + environments.map((environment) => ({ + value: environment.environmentId, + label: environment.label, + })), + [environments], + ); const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { reportFailure: false, }); @@ -956,8 +990,85 @@ export function DiagnosticsSettingsPanel() { ? Option.getOrElse(data.partialFailure, () => false) : false; + if (environmentId === null) { + return ( + + + } size="xs" variant="outline"> + Manage connections + + } + /> + + + ); + } + return ( + + + {connectionStatusText(diagnosticsEnvironment.connection)} + {diagnosticsEnvironment.displayUrl + ? ` · ${diagnosticsEnvironment.displayUrl}` + : null} + + ) : ( + "Connect an environment to view diagnostics." + ) + } + control={ + diagnosticsEnvironment ? ( + + ) : ( + + ) + } + /> + + Date: Fri, 24 Jul 2026 19:25:13 -0700 Subject: [PATCH 3/5] fix(web): scope diagnostics panel state to the selected environment The diagnostics environment picker made three pieces of panel-local state lie about which machine they belong to: - `openLogsDirectoryError` / `isOpeningLogsDirectory` kept showing an environment A failure (or spinner) after switching to environment B. They are now stored together with the environment they belong to, so they only render for that environment and a late completion can only update its own request. - `signalingPid` was a single shared value, so completing a signal on environment A re-enabled the signal controls for an in-flight signal on environment B and allowed a duplicate SIGKILL. Pending signals are now identified by environment id plus pid. - Diagnostics queries only run while the selected environment's supervisor is connected, so a disconnected machine showed permanent "Loading..." placeholders with the refresh buttons disabled. The panel now derives a connection notice and renders it instead of the loading state. Co-Authored-By: Claude Opus 5 (1M context) --- .../DiagnosticsSettings.logic.test.ts | 110 +++++++++++- .../settings/DiagnosticsSettings.logic.ts | 74 ++++++++ .../settings/DiagnosticsSettings.tsx | 168 +++++++++++++----- 3 files changed, 305 insertions(+), 47 deletions(-) diff --git a/apps/web/src/components/settings/DiagnosticsSettings.logic.test.ts b/apps/web/src/components/settings/DiagnosticsSettings.logic.test.ts index 55c8d7f68e4..f336f037225 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.logic.test.ts +++ b/apps/web/src/components/settings/DiagnosticsSettings.logic.test.ts @@ -1,7 +1,14 @@ import { assert, describe, it } from "@effect/vitest"; import { EnvironmentId } from "@t3tools/contracts"; -import { resolveDiagnosticsEnvironmentId } from "./DiagnosticsSettings.logic"; +import { + addPendingProcessSignal, + diagnosticsConnectionNotice, + pendingProcessSignalPids, + removePendingProcessSignal, + resolveDiagnosticsEnvironmentId, + type PendingProcessSignal, +} from "./DiagnosticsSettings.logic"; const PRIMARY = EnvironmentId.make("primary"); const ACTIVE = EnvironmentId.make("active"); @@ -79,3 +86,104 @@ describe("resolveDiagnosticsEnvironmentId", () => { ); }); }); + +describe("diagnosticsConnectionNotice", () => { + it("returns null while the environment is connected", () => { + assert.isNull( + diagnosticsConnectionNotice({ phase: "connected", label: "Laptop", error: null }), + ); + }); + + it("explains that an offline environment cannot report diagnostics", () => { + assert.equal( + diagnosticsConnectionNotice({ phase: "offline", label: "Laptop", error: null }), + "Laptop is offline. Diagnostics load once it reconnects.", + ); + }); + + it("explains that a never-connected environment cannot report diagnostics", () => { + assert.equal( + diagnosticsConnectionNotice({ phase: "available", label: "Laptop", error: null }), + "Laptop is not connected. Diagnostics load once it connects.", + ); + }); + + it("includes the failure reason while reconnecting", () => { + assert.equal( + diagnosticsConnectionNotice({ + phase: "reconnecting", + label: "Laptop", + error: "socket hang up", + }), + "Reconnecting to Laptop... Reason: socket hang up", + ); + }); + + it("reports a blocked connection", () => { + assert.equal( + diagnosticsConnectionNotice({ phase: "error", label: "Laptop", error: "unauthorized" }), + "Could not connect to Laptop. Reason: unauthorized", + ); + }); + + it("reports an initial connection attempt", () => { + assert.equal( + diagnosticsConnectionNotice({ phase: "connecting", label: "Laptop", error: null }), + "Connecting to Laptop...", + ); + }); +}); + +describe("pending process signals", () => { + it("tracks pending signals per environment and pid", () => { + const pending = addPendingProcessSignal( + addPendingProcessSignal([], { environmentId: PRIMARY, pid: 100 }), + { environmentId: ACTIVE, pid: 200 }, + ); + + assert.deepEqual([...pendingProcessSignalPids(pending, PRIMARY)], [100]); + assert.deepEqual([...pendingProcessSignalPids(pending, ACTIVE)], [200]); + assert.deepEqual([...pendingProcessSignalPids(pending, SELECTED)], []); + assert.deepEqual([...pendingProcessSignalPids(pending, null)], []); + }); + + it("does not duplicate a signal that is already pending", () => { + const pending = addPendingProcessSignal([{ environmentId: PRIMARY, pid: 100 }], { + environmentId: PRIMARY, + pid: 100, + }); + + assert.equal(pending.length, 1); + }); + + it("keeps the same pid pending in another environment", () => { + const pending = addPendingProcessSignal( + addPendingProcessSignal([], { environmentId: PRIMARY, pid: 100 }), + { environmentId: ACTIVE, pid: 100 }, + ); + + assert.deepEqual([...pendingProcessSignalPids(pending, PRIMARY)], [100]); + assert.deepEqual([...pendingProcessSignalPids(pending, ACTIVE)], [100]); + }); + + it("only clears the completed request, leaving other environments pending", () => { + const pending = addPendingProcessSignal( + addPendingProcessSignal([], { environmentId: PRIMARY, pid: 100 }), + { environmentId: ACTIVE, pid: 200 }, + ); + + const remaining = removePendingProcessSignal(pending, { environmentId: PRIMARY, pid: 100 }); + + assert.deepEqual([...pendingProcessSignalPids(remaining, PRIMARY)], []); + assert.deepEqual([...pendingProcessSignalPids(remaining, ACTIVE)], [200]); + }); + + it("ignores completions for signals that are no longer pending", () => { + const pending: ReadonlyArray = [{ environmentId: PRIMARY, pid: 100 }]; + + assert.strictEqual( + removePendingProcessSignal(pending, { environmentId: ACTIVE, pid: 100 }), + pending, + ); + }); +}); diff --git a/apps/web/src/components/settings/DiagnosticsSettings.logic.ts b/apps/web/src/components/settings/DiagnosticsSettings.logic.ts index 1166cf5865d..a5dc5bcd5f9 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.logic.ts +++ b/apps/web/src/components/settings/DiagnosticsSettings.logic.ts @@ -1,3 +1,4 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import type { EnvironmentId } from "@t3tools/contracts"; export function resolveDiagnosticsEnvironmentId(input: { @@ -28,3 +29,76 @@ export function resolveDiagnosticsEnvironmentId(input: { } return input.availableEnvironmentIds[0] ?? null; } + +/** + * Diagnostics queries only run while the selected environment has a connected + * supervisor generation; otherwise they stay pending forever. Returns the + * message to show instead of a loading state, or `null` when diagnostics can + * actually be collected. + */ +export function diagnosticsConnectionNotice(input: { + readonly phase: EnvironmentConnectionPhase; + readonly label: string; + readonly error: string | null; +}): string | null { + switch (input.phase) { + case "connected": + return null; + case "connecting": + return `Connecting to ${input.label}...`; + case "reconnecting": + return input.error + ? `Reconnecting to ${input.label}... Reason: ${input.error}` + : `Reconnecting to ${input.label}...`; + case "offline": + return `${input.label} is offline. Diagnostics load once it reconnects.`; + case "available": + return `${input.label} is not connected. Diagnostics load once it connects.`; + case "error": + return input.error + ? `Could not connect to ${input.label}. Reason: ${input.error}` + : `Could not connect to ${input.label}.`; + } +} + +/** + * An in-flight process signal. Signals are identified by environment as well as + * pid so that a completion in one environment cannot clear pending state that + * belongs to another one. + */ +export interface PendingProcessSignal { + readonly environmentId: EnvironmentId; + readonly pid: number; +} + +function isSamePendingProcessSignal(left: PendingProcessSignal, right: PendingProcessSignal) { + return left.environmentId === right.environmentId && left.pid === right.pid; +} + +export function addPendingProcessSignal( + pending: ReadonlyArray, + signal: PendingProcessSignal, +): ReadonlyArray { + return pending.some((entry) => isSamePendingProcessSignal(entry, signal)) + ? pending + : [...pending, signal]; +} + +export function removePendingProcessSignal( + pending: ReadonlyArray, + signal: PendingProcessSignal, +): ReadonlyArray { + const next = pending.filter((entry) => !isSamePendingProcessSignal(entry, signal)); + return next.length === pending.length ? pending : next; +} + +export function pendingProcessSignalPids( + pending: ReadonlyArray, + environmentId: EnvironmentId | null, +): ReadonlySet { + return new Set( + pending + .filter((entry) => environmentId !== null && entry.environmentId === environmentId) + .map((entry) => entry.pid), + ); +} diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index f3535d545fa..e08049ff600 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -53,7 +53,14 @@ import { SettingsSection, useRelativeTimeTick, } from "./settingsLayout"; -import { resolveDiagnosticsEnvironmentId } from "./DiagnosticsSettings.logic"; +import { + addPendingProcessSignal, + diagnosticsConnectionNotice, + pendingProcessSignalPids, + removePendingProcessSignal, + resolveDiagnosticsEnvironmentId, + type PendingProcessSignal, +} from "./DiagnosticsSettings.logic"; import { useAtomCommand } from "../../state/use-atom-command"; const NUMBER_FORMAT = new Intl.NumberFormat(); @@ -410,12 +417,12 @@ function ProcessSignalActions({ function ProcessDiagnosticsTable({ processes, - signalingPid, + signalingPids, onSignal, emptyLabel, }: { processes: ReadonlyArray; - signalingPid: number | null; + signalingPids: ReadonlySet; onSignal: (pid: number, signal: ServerProcessSignal) => void; emptyLabel?: string; }) { @@ -524,7 +531,7 @@ function ProcessDiagnosticsTable({ @@ -823,6 +830,12 @@ function DiagnosticsRefreshButton({ ); } +interface LogsDirectoryState { + readonly environmentId: EnvironmentId | null; + readonly isOpening: boolean; + readonly error: string | null; +} + export function DiagnosticsSettingsPanel() { const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); @@ -887,9 +900,20 @@ export function DiagnosticsSettingsPanel() { }, }), ); - const [isOpeningLogsDirectory, setIsOpeningLogsDirectory] = useState(false); - const [openLogsDirectoryError, setOpenLogsDirectoryError] = useState(null); - const [signalingPid, setSignalingPid] = useState(null); + // Panel-local state is keyed by environment so that a result produced for one + // environment is never shown for (or applied to) another one. + const [logsDirectoryState, setLogsDirectoryState] = useState(null); + const [pendingSignals, setPendingSignals] = useState>([]); + const logsDirectory = + logsDirectoryState !== null && logsDirectoryState.environmentId === environmentId + ? logsDirectoryState + : null; + const isOpeningLogsDirectory = logsDirectory?.isOpening ?? false; + const openLogsDirectoryError = logsDirectory?.error ?? null; + const signalingPids = useMemo( + () => pendingProcessSignalPids(pendingSignals, environmentId), + [environmentId, pendingSignals], + ); const openLogsDirectory = useCallback(() => { const logsDirectoryPath = observability?.logsDirectoryPath ?? null; @@ -897,16 +921,23 @@ export function DiagnosticsSettingsPanel() { const editor = resolveAndPersistPreferredEditor(availableEditors ?? []); if (!editor) { - setOpenLogsDirectoryError("No available editors found."); + setLogsDirectoryState({ + environmentId, + isOpening: false, + error: "No available editors found.", + }); return; } if (environmentId === null) { - setOpenLogsDirectoryError("No environment is selected."); + setLogsDirectoryState({ + environmentId, + isOpening: false, + error: "No environment is selected.", + }); return; } - setIsOpeningLogsDirectory(true); - setOpenLogsDirectoryError(null); + setLogsDirectoryState({ environmentId, isOpening: true, error: null }); void (async () => { const result = await openInEditor({ environmentId, @@ -915,13 +946,22 @@ export function DiagnosticsSettingsPanel() { editor, }, }); - setIsOpeningLogsDirectory(false); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setOpenLogsDirectoryError( - error instanceof Error ? error.message : "Unable to open logs folder.", - ); - } + const failure = + result._tag === "Failure" && !isAtomCommandInterrupted(result) + ? squashAtomCommandFailure(result) + : null; + const error = + failure === null + ? null + : failure instanceof Error + ? failure.message + : "Unable to open logs folder."; + // Only the request that owns the current pending state may clear it. + setLogsDirectoryState((current) => + current !== null && current.environmentId === environmentId + ? { environmentId, isOpening: false, error } + : current, + ); })(); }, [availableEditors, environmentId, observability?.logsDirectoryPath, openInEditor]); @@ -939,13 +979,14 @@ export function DiagnosticsSettingsPanel() { return; } - setSignalingPid(pid); + const pendingSignal: PendingProcessSignal = { environmentId, pid }; + setPendingSignals((current) => addPendingProcessSignal(current, pendingSignal)); void (async () => { const result = await signalServerProcess({ environmentId, input: { pid, signal }, }); - setSignalingPid(null); + setPendingSignals((current) => removePendingProcessSignal(current, pendingSignal)); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); @@ -983,6 +1024,16 @@ export function DiagnosticsSettingsPanel() { [environmentId, refreshProcesses, signalServerProcess], ); + // Diagnostics RPCs only run while the environment's supervisor is connected, + // so anything else has to surface as a state instead of an endless spinner. + const connectionNotice = diagnosticsConnectionNotice({ + phase: diagnosticsEnvironment?.connection.phase ?? "available", + label: diagnosticsEnvironment?.label ?? "This environment", + error: diagnosticsEnvironment?.connection.error ?? null, + }); + const isConnected = connectionNotice === null; + const statPlaceholder = isConnected ? "..." : "—"; + const processDiagnosticsError = processData ? Option.getOrNull(processData.error) : null; const processResourceError = resourceData ? Option.getOrNull(resourceData.error) : null; const traceDiagnosticsError = data ? Option.getOrNull(data.error) : null; @@ -1075,7 +1126,7 @@ export function DiagnosticsSettingsPanel() {
@@ -1085,21 +1136,21 @@ export function DiagnosticsSettingsPanel() { {processDiagnosticsError || processError ? ( @@ -1120,12 +1171,13 @@ export function DiagnosticsSettingsPanel() { ) : null} @@ -1140,7 +1192,7 @@ export function DiagnosticsSettingsPanel() { /> @@ -1150,21 +1202,23 @@ export function DiagnosticsSettingsPanel() { {processResourceError || resourceError ? ( @@ -1187,9 +1241,10 @@ export function DiagnosticsSettingsPanel() { @@ -1206,7 +1261,9 @@ export function DiagnosticsSettingsPanel() { size="icon-xs" variant="ghost" className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" - disabled={!observability?.logsDirectoryPath || isOpeningLogsDirectory} + disabled={ + !isConnected || !observability?.logsDirectoryPath || isOpeningLogsDirectory + } onClick={openLogsDirectory} aria-label="Open logs folder" > @@ -1217,7 +1274,7 @@ export function DiagnosticsSettingsPanel() { Open logs folder @@ -1225,15 +1282,15 @@ export function DiagnosticsSettingsPanel() { } > - + 0 ? "danger" : "default"} /> 0 ? "warning" : "default"} /> @@ -1303,7 +1360,12 @@ export function DiagnosticsSettingsPanel() { ))} ) : ( - + )} @@ -1332,7 +1394,10 @@ export function DiagnosticsSettingsPanel() { ) : ( )} @@ -1362,7 +1427,11 @@ export function DiagnosticsSettingsPanel() { ))} ) : ( - + )} @@ -1425,7 +1494,10 @@ export function DiagnosticsSettingsPanel() { ) : ( )} @@ -1458,7 +1530,11 @@ export function DiagnosticsSettingsPanel() { ))} ) : ( - + )} From f08526b07ef166d251231a465a6b04c11033c2a1 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 19:31:22 -0700 Subject: [PATCH 4/5] fix(web): keep diagnostics refresh disabled while disconnected Follow-up to the review feedback on the previous commit: - `DiagnosticsRefreshButton` used a single `isPending` prop for both the spinner and `disabled`, so suppressing the spinner while the selected environment is disconnected also made the button clickable even though the query cannot run. It now takes an explicit `isDisabled` prop. - The open-logs completion matched on environment id alone, so returning to an environment and starting a second open while the first was still in flight let the older completion clear the newer pending state. The completion now matches the request by identity. Co-Authored-By: Claude Opus 5 (1M context) --- .../settings/DiagnosticsSettings.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index e08049ff600..f4f3413e204 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -802,10 +802,12 @@ function DiagnosticsLastChecked({ checkedAt }: { checkedAt: DateTime.Utc | null function DiagnosticsRefreshButton({ isPending, + isDisabled = false, label, onClick, }: { isPending: boolean; + isDisabled?: boolean; label: string; onClick: () => void; }) { @@ -817,7 +819,7 @@ function DiagnosticsRefreshButton({ size="icon-xs" variant="ghost" className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" - disabled={isPending} + disabled={isPending || isDisabled} onClick={onClick} aria-label={label} > @@ -937,7 +939,8 @@ export function DiagnosticsSettingsPanel() { return; } - setLogsDirectoryState({ environmentId, isOpening: true, error: null }); + const request: LogsDirectoryState = { environmentId, isOpening: true, error: null }; + setLogsDirectoryState(request); void (async () => { const result = await openInEditor({ environmentId, @@ -950,17 +953,16 @@ export function DiagnosticsSettingsPanel() { result._tag === "Failure" && !isAtomCommandInterrupted(result) ? squashAtomCommandFailure(result) : null; - const error = + const failureMessage = failure === null ? null : failure instanceof Error ? failure.message : "Unable to open logs folder."; - // Only the request that owns the current pending state may clear it. + // Identity check: only the request that still owns the slot may clear it, + // so a later request (in this or another environment) is never disturbed. setLogsDirectoryState((current) => - current !== null && current.environmentId === environmentId - ? { environmentId, isOpening: false, error } - : current, + current === request ? { environmentId, isOpening: false, error: failureMessage } : current, ); })(); }, [availableEditors, environmentId, observability?.logsDirectoryPath, openInEditor]); @@ -1127,6 +1129,7 @@ export function DiagnosticsSettingsPanel() { @@ -1193,6 +1196,7 @@ export function DiagnosticsSettingsPanel() { @@ -1275,6 +1279,7 @@ export function DiagnosticsSettingsPanel() { From bf1d0eeaef8f4e173d3ff43345e3582fad1cd230 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Sat, 25 Jul 2026 20:44:51 -0700 Subject: [PATCH 5/5] fix(web): target server settings by environment --- apps/web/src/components/AppSidebarLayout.tsx | 6 +- apps/web/src/components/ChatView.tsx | 31 +- apps/web/src/components/CommandPalette.tsx | 11 +- .../ProviderUpdatePrimaryNotification.tsx | 22 +- apps/web/src/components/Sidebar.tsx | 6 +- .../src/components/SidebarStageBackdrop.tsx | 6 +- apps/web/src/components/SidebarV2.tsx | 10 +- .../settings/DiagnosticsSettings.tsx | 80 +---- .../settings/KeybindingsSettings.tsx | 284 ++++++++++-------- ...or.tsx => SettingsEnvironmentSelector.tsx} | 14 +- .../settings/SettingsPanels.logic.test.ts | 25 +- .../settings/SettingsPanels.logic.ts | 2 +- .../components/settings/SettingsPanels.tsx | 133 +++++--- .../settings/SourceControlSettings.tsx | 70 ++++- .../src/components/sidebar/SidebarChrome.tsx | 6 +- .../sidebar/SidebarProviderUpdatePill.tsx | 5 +- apps/web/src/hooks/useDefaultServerConfig.ts | 24 ++ apps/web/src/hooks/useHandleNewThread.ts | 29 +- apps/web/src/hooks/useSettings.ts | 12 +- apps/web/src/hooks/useSettingsEnvironment.ts | 56 ++++ apps/web/src/routes/__root.tsx | 6 +- apps/web/src/routes/_chat.tsx | 6 +- apps/web/src/state/settingsEnvironment.ts | 13 + 23 files changed, 514 insertions(+), 343 deletions(-) rename apps/web/src/components/settings/{ProviderEnvironmentSelector.tsx => SettingsEnvironmentSelector.tsx} (86%) create mode 100644 apps/web/src/hooks/useDefaultServerConfig.ts create mode 100644 apps/web/src/hooks/useSettingsEnvironment.ts create mode 100644 apps/web/src/state/settingsEnvironment.ts diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 3a70390d0c4..2ddb223334e 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,4 +1,3 @@ -import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; import { useLocation, useNavigate } from "@tanstack/react-router"; @@ -7,7 +6,8 @@ import { isElectron } from "../env"; import { getLocalStorageItem } from "../hooks/useLocalStorage"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; -import { primaryServerKeybindingsAtom } from "../state/server"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { useDefaultServerConfig } from "../hooks/useDefaultServerConfig"; import { useClientSettings } from "../hooks/useSettings"; import ThreadSidebar from "./Sidebar"; import ThreadSidebarV2 from "./SidebarV2"; @@ -44,7 +44,7 @@ function readInitialThreadSidebarWidth(): number { } function SidebarControl() { - const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const keybindings = useDefaultServerConfig()?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; const { toggleSidebar } = useSidebar(); const isSidebarVisible = useSidebarVisibility(); const stageBackdropVariant = useSidebarStageBackdropVariant(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ab1256cddb3..4d15143dfba 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -42,7 +42,6 @@ import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/proje import { truncate } from "@t3tools/shared/String"; import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { Debouncer } from "@tanstack/react-pacer"; -import { useAtomValue } from "@effect/atom-react"; import { lazy, memo, @@ -195,12 +194,8 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; -import { - primaryServerAvailableEditorsAtom, - primaryServerKeybindingsAtom, - primaryServerSettingsAtom, - serverEnvironment, -} from "../state/server"; +import { serverEnvironment } from "../state/server"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { terminalEnvironment } from "../state/terminal"; import { threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; @@ -1179,10 +1174,6 @@ function ChatViewContent(props: ChatViewProps) { (store) => store.threadLastVisitedAtById[routeThreadKey], ); const settings = useEnvironmentSettings(environmentId); - // New-thread defaults live in the primary environment's settings.json (the - // settings UI never writes to remote environments), so read them from the - // primary server rather than the thread's environment. - const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); @@ -1804,11 +1795,9 @@ function ChatViewContent(props: ChatViewProps) { selectedProvider: selectedProviderByThreadId, threadProvider, }); - // Once a thread selects an environment, never substitute the primary - // environment's config while the selected environment is still loading. - const serverConfig = activeThread - ? (activeEnvironment?.serverConfig ?? null) - : (primaryEnvironment?.serverConfig ?? null); + // Once a thread or draft selects an environment, never substitute another + // environment's config while the selected one is still loading. + const serverConfig = environmentById.get(environmentId)?.serverConfig ?? null; const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -2330,8 +2319,8 @@ function ChatViewContent(props: ChatViewProps) { input: { cwd: gitStatusCwd }, }), ); - const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); + const keybindings = serverConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; + const availableEditors = serverConfig?.availableEditors ?? []; // Prefer an instance-id match so a custom Codex instance (e.g. // `codex_personal`) surfaces its own status/message in the banner rather // than the default Codex's. Falls back to first-match-by-kind when no @@ -3816,7 +3805,7 @@ function ChatViewContent(props: ChatViewProps) { ? (draftThread?.startFromOrigin ?? false) : canOverrideServerThreadEnvMode ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? - primaryServerSettings.newWorktreesStartFromOrigin) + settings.newWorktreesStartFromOrigin) : false; const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, @@ -5441,7 +5430,7 @@ function ChatViewContent(props: ChatViewProps) { envMode: mode, startFromOrigin: resolveNewDraftStartFromOrigin({ envMode: mode, - newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, + newWorktreesStartFromOrigin: settings.newWorktreesStartFromOrigin, }), ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), }); @@ -5453,7 +5442,7 @@ function ChatViewContent(props: ChatViewProps) { composerDraftTarget, draftThread?.worktreePath, isLocalDraftThread, - primaryServerSettings.newWorktreesStartFromOrigin, + settings.newWorktreesStartFromOrigin, setPendingServerThreadEnvMode, scheduleComposerFocus, setDraftThreadContext, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index b0f21b281f6..0232fce4219 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -42,7 +42,6 @@ import { type KeyboardEvent, type ReactNode, } from "react"; -import { useAtomValue } from "@effect/atom-react"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; @@ -113,7 +112,8 @@ import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; import { ProjectFavicon } from "./ProjectFavicon"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; -import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { useDefaultServerConfig } from "../hooks/useDefaultServerConfig"; import { resolveDefaultProviderModelSelection } from "../providerInstances"; import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; import { @@ -390,7 +390,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); - const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const keybindings = useDefaultServerConfig()?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; const composerHandleRef = useRef(null); const routeTarget = useParams({ strict: false, @@ -502,8 +502,9 @@ function OpenCommandPaletteDialog(props: { const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); - const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const providers = useAtomValue(primaryServerProvidersAtom); + const defaultServerConfig = useDefaultServerConfig(); + const keybindings = defaultServerConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; + const providers = defaultServerConfig?.providers ?? []; const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; const [browseGeneration, setBrowseGeneration] = useState(0); diff --git a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx index a7f7a2c1e69..71944ad2a51 100644 --- a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx +++ b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx @@ -1,11 +1,14 @@ import { useNavigate } from "@tanstack/react-router"; -import { useAtomValue } from "@effect/atom-react"; import { DownloadIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef } from "react"; -import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; +import { + type ProviderDriverKind, + type ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; -import { primaryServerProvidersAtom, serverEnvironment } from "../state/server"; -import { usePrimaryEnvironment } from "../state/environments"; +import { serverEnvironment } from "../state/server"; +import { useDefaultEnvironmentId, useDefaultServerConfig } from "../hooks/useDefaultServerConfig"; import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal"; import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils"; import { @@ -24,6 +27,7 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { useAtomCommand } from "../state/use-atom-command"; const seenProviderUpdateNotificationKeys = new Set(); +const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; type ProviderUpdateToastId = ReturnType; type ActiveProviderUpdateToast = @@ -108,8 +112,8 @@ function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) { */ export function ProviderUpdatePrimaryNotification() { const navigate = useNavigate(); - const providers = useAtomValue(primaryServerProvidersAtom); - const primaryEnvironment = usePrimaryEnvironment(); + const defaultEnvironmentId = useDefaultEnvironmentId(); + const providers = useDefaultServerConfig()?.providers ?? EMPTY_SERVER_PROVIDERS; const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { reportFailure: false, }); @@ -208,7 +212,7 @@ export function ProviderUpdatePrimaryNotification() { }; const runUpdates = () => { - if (updateStarted || oneClickProviders.length === 0 || !primaryEnvironment) { + if (updateStarted || oneClickProviders.length === 0 || defaultEnvironmentId === null) { return; } updateStarted = true; @@ -234,7 +238,7 @@ export function ProviderUpdatePrimaryNotification() { for (const provider of oneClickProviders) { results.push( await updateProvider({ - environmentId: primaryEnvironment.environmentId, + environmentId: defaultEnvironmentId, input: { provider: provider.driver, instanceId: provider.instanceId, @@ -323,7 +327,7 @@ export function ProviderUpdatePrimaryNotification() { notificationKey, oneClickProviders, openProviderSettings, - primaryEnvironment, + defaultEnvironmentId, updateProviders, ]); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b814edce7fa..94ec3250644 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -22,7 +22,6 @@ import { ThreadWorktreeIndicator, } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; -import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; @@ -198,7 +197,8 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; -import { primaryServerKeybindingsAtom } from "../state/server"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { useDefaultServerConfig } from "../hooks/useDefaultServerConfig"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, @@ -3025,7 +3025,7 @@ export default function Sidebar() { ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen : false, ); - const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const keybindings = useDefaultServerConfig()?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; const openAddProjectCommandPalette = useCallback( () => openCommandPalette({ open: "add-project" }), [], diff --git a/apps/web/src/components/SidebarStageBackdrop.tsx b/apps/web/src/components/SidebarStageBackdrop.tsx index ba3de64de15..7bdb4863f5d 100644 --- a/apps/web/src/components/SidebarStageBackdrop.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.tsx @@ -1,9 +1,8 @@ -import { useAtomValue } from "@effect/atom-react"; import { useId } from "react"; import { APP_STAGE_LABEL } from "../branding"; import { resolveServerBackedAppStageLabel } from "../branding.logic"; -import { primaryServerConfigAtom } from "../state/server"; +import { useDefaultServerConfig } from "../hooks/useDefaultServerConfig"; export type SidebarStageBackdropVariant = "nightly" | "dev"; @@ -21,8 +20,7 @@ export function resolveSidebarStageBackdropVariant( } export function useSidebarStageBackdropVariant(): SidebarStageBackdropVariant | null { - const primaryServerVersion = - useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; + const primaryServerVersion = useDefaultServerConfig()?.environment.serverVersion ?? null; return resolveSidebarStageBackdropVariant( resolveServerBackedAppStageLabel({ diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index c312a9533fa..439309010eb 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -91,7 +91,7 @@ import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironmentPresenceScope, useEnvironments } from "../state/environments"; import { isRemoteEnvironmentId, type EnvironmentPresenceScope } from "../environmentPresence"; import { useProjects, useThreadShells } from "../state/entities"; -import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; +import { environmentServerConfigsAtom } from "../state/server"; import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; import { projectEnvironment } from "../state/projects"; @@ -132,7 +132,8 @@ import { ProjectFavicon } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; -import { primaryServerProvidersAtom } from "../state/server"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { useDefaultServerConfig } from "../hooks/useDefaultServerConfig"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { CommandDialogTrigger } from "./ui/command"; import { Button } from "./ui/button"; @@ -1001,7 +1002,8 @@ export default function SidebarV2() { const threads = useThreadShells(); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); - const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const defaultServerConfig = useDefaultServerConfig(); + const keybindings = defaultServerConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); @@ -1110,7 +1112,7 @@ export default function SidebarV2() { () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), [sidebarProjectSortOrder, threads, unsortedProjectGroups], ); - const serverProviders = useAtomValue(primaryServerProvidersAtom); + const serverProviders = defaultServerConfig?.providers ?? []; const providerEntryByInstanceId = useMemo( () => new Map( diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index f4f3413e204..803f553bb77 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -2,11 +2,9 @@ import { AlertTriangleIcon, ChevronDownIcon, ChevronRightIcon, - CloudIcon, CopyIcon, FolderOpenIcon, InfoIcon, - MonitorIcon, RefreshCwIcon, } from "lucide-react"; import { Link } from "@tanstack/react-router"; @@ -31,20 +29,10 @@ import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFo import { useEnvironmentQuery } from "../../state/query"; import { serverEnvironment } from "../../state/server"; import { shellEnvironment } from "../../state/shell"; -import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; -import { useActiveEnvironmentId } from "../../state/entities"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { useSettingsEnvironment } from "../../hooks/useSettingsEnvironment"; import { Button } from "../ui/button"; import { ScrollArea } from "../ui/scroll-area"; -import { - Select, - SelectGroup, - SelectGroupLabel, - SelectItem, - SelectPopup, - SelectTrigger, - SelectValue, -} from "../ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; import { @@ -58,9 +46,9 @@ import { diagnosticsConnectionNotice, pendingProcessSignalPids, removePendingProcessSignal, - resolveDiagnosticsEnvironmentId, type PendingProcessSignal, } from "./DiagnosticsSettings.logic"; +import { SettingsEnvironmentSelector } from "./SettingsEnvironmentSelector"; import { useAtomCommand } from "../../state/use-atom-command"; const NUMBER_FORMAT = new Intl.NumberFormat(); @@ -839,28 +827,15 @@ interface LogsDirectoryState { } export function DiagnosticsSettingsPanel() { - const { environments } = useEnvironments(); - const primaryEnvironment = usePrimaryEnvironment(); - const activeEnvironmentId = useActiveEnvironmentId(); - const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); - const environmentId = resolveDiagnosticsEnvironmentId({ - selectedEnvironmentId, - primaryEnvironmentId: primaryEnvironment?.environmentId ?? null, - activeEnvironmentId, - availableEnvironmentIds: environments.map((environment) => environment.environmentId), - }); - const diagnosticsEnvironment = - environments.find((environment) => environment.environmentId === environmentId) ?? null; + const { + environmentId, + environment: diagnosticsEnvironment, + environments, + primaryEnvironmentId, + selectEnvironment, + } = useSettingsEnvironment(); const observability = diagnosticsEnvironment?.serverConfig?.observability ?? null; const availableEditors = diagnosticsEnvironment?.serverConfig?.availableEditors ?? []; - const environmentItems = useMemo( - () => - environments.map((environment) => ({ - value: environment.environmentId, - label: environment.label, - })), - [environments], - ); const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { reportFailure: false, }); @@ -1082,37 +1057,12 @@ export function DiagnosticsSettingsPanel() { } control={ diagnosticsEnvironment ? ( - + ) : ( - } - /> - Add keybinding - - - - - - } + + - Open keybindings.json - -
- } - > - {!isElectron ? ( -
- -

- Some shortcuts may be claimed by the browser before T3 Code sees them. Use the desktop - app for better keybinding support. -

-
- ) : null} + ) : environmentsReady ? ( + + ) : null + } + /> +
- -
-
Command
-
Keybinding
-
When
-
Status
-
-
- {isAddingBinding ? ( - setIsAddingBinding(false)} - /> - ) : null} - {rows.map((row) => ( - + - ))} - {rows.length === 0 && !isAddingBinding ? ( -
- No keybindings match your search. -
- ) : null} -
-
- + + setIsAddingBinding(true)} + aria-label="Add keybinding" + > + + + } + /> + Add keybinding + + + + + + } + /> + Open keybindings.json + +
+ } + > + {!isElectron ? ( +
+ +

+ Some shortcuts may be claimed by the browser before T3 Code sees them. Use the + desktop app for better keybinding support. +

+
+ ) : null} + + +
+
Command
+
Keybinding
+
When
+
Status
+
+
+ {isAddingBinding ? ( + setIsAddingBinding(false)} + /> + ) : null} + {rows.map((row) => ( + + ))} + {rows.length === 0 && !isAddingBinding ? ( +
+ No keybindings match your search. +
+ ) : null} +
+
+ + ) : null} ); } diff --git a/apps/web/src/components/settings/ProviderEnvironmentSelector.tsx b/apps/web/src/components/settings/SettingsEnvironmentSelector.tsx similarity index 86% rename from apps/web/src/components/settings/ProviderEnvironmentSelector.tsx rename to apps/web/src/components/settings/SettingsEnvironmentSelector.tsx index 6a4ef1e87d4..c8acfa2c92a 100644 --- a/apps/web/src/components/settings/ProviderEnvironmentSelector.tsx +++ b/apps/web/src/components/settings/SettingsEnvironmentSelector.tsx @@ -13,19 +13,19 @@ import { SelectValue, } from "../ui/select"; -interface ProviderEnvironmentSelectorProps { +interface SettingsEnvironmentSelectorProps { readonly environmentId: EnvironmentId; readonly environments: ReadonlyArray; readonly primaryEnvironmentId: EnvironmentId | null; readonly onEnvironmentChange: (environmentId: EnvironmentId) => void; } -export function ProviderEnvironmentSelector({ +export function SettingsEnvironmentSelector({ environmentId, environments, primaryEnvironmentId, onEnvironmentChange, -}: ProviderEnvironmentSelectorProps) { +}: SettingsEnvironmentSelectorProps) { const selectedEnvironment = environments.find((environment) => environment.environmentId === environmentId) ?? null; const items = useMemo( @@ -43,11 +43,7 @@ export function ProviderEnvironmentSelector({ items={items} onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)} > - + {environmentId === primaryEnvironmentId ? ( ) : ( @@ -57,7 +53,7 @@ export function ProviderEnvironmentSelector({ - Configure providers on + Configure environment {environments.map((environment) => ( diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index f37a2d65d4e..1244942ced9 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -11,16 +11,16 @@ import { formatDiagnosticsDescription, isProjectGroupingEnabled, projectGroupingModeFromToggle, - resolveProviderSettingsEnvironmentId, + resolveSettingsEnvironmentId, } from "./SettingsPanels.logic"; const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("00000000-0000-4000-8000-000000000001"); const REMOTE_ENVIRONMENT_ID = EnvironmentId.make("00000000-0000-4000-8000-000000000002"); -describe("provider settings environment selection", () => { +describe("settings environment selection", () => { it("preserves an explicit selected environment", () => { expect( - resolveProviderSettingsEnvironmentId({ + resolveSettingsEnvironmentId({ availableEnvironmentIds: [LOCAL_ENVIRONMENT_ID, REMOTE_ENVIRONMENT_ID], selectedEnvironmentId: REMOTE_ENVIRONMENT_ID, primaryEnvironmentId: LOCAL_ENVIRONMENT_ID, @@ -31,7 +31,7 @@ describe("provider settings environment selection", () => { it("defaults to the primary environment in managed mode", () => { expect( - resolveProviderSettingsEnvironmentId({ + resolveSettingsEnvironmentId({ availableEnvironmentIds: [REMOTE_ENVIRONMENT_ID, LOCAL_ENVIRONMENT_ID], selectedEnvironmentId: null, primaryEnvironmentId: LOCAL_ENVIRONMENT_ID, @@ -42,7 +42,7 @@ describe("provider settings environment selection", () => { it("defaults to the active remote environment in client-only mode", () => { expect( - resolveProviderSettingsEnvironmentId({ + resolveSettingsEnvironmentId({ availableEnvironmentIds: [LOCAL_ENVIRONMENT_ID, REMOTE_ENVIRONMENT_ID], selectedEnvironmentId: null, primaryEnvironmentId: null, @@ -53,7 +53,7 @@ describe("provider settings environment selection", () => { it("falls back when the selected environment is no longer available", () => { expect( - resolveProviderSettingsEnvironmentId({ + resolveSettingsEnvironmentId({ availableEnvironmentIds: [LOCAL_ENVIRONMENT_ID], selectedEnvironmentId: REMOTE_ENVIRONMENT_ID, primaryEnvironmentId: LOCAL_ENVIRONMENT_ID, @@ -62,9 +62,20 @@ describe("provider settings environment selection", () => { ).toBe(LOCAL_ENVIRONMENT_ID); }); + it("uses the first saved environment when client-only mode has no active environment yet", () => { + expect( + resolveSettingsEnvironmentId({ + availableEnvironmentIds: [REMOTE_ENVIRONMENT_ID, LOCAL_ENVIRONMENT_ID], + selectedEnvironmentId: null, + primaryEnvironmentId: null, + activeEnvironmentId: null, + }), + ).toBe(REMOTE_ENVIRONMENT_ID); + }); + it("returns null when no environments are available", () => { expect( - resolveProviderSettingsEnvironmentId({ + resolveSettingsEnvironmentId({ availableEnvironmentIds: [], selectedEnvironmentId: null, primaryEnvironmentId: null, diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index d61cf894047..1678b8308da 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -9,7 +9,7 @@ import type { } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; -export function resolveProviderSettingsEnvironmentId(input: { +export function resolveSettingsEnvironmentId(input: { readonly availableEnvironmentIds: ReadonlyArray; readonly selectedEnvironmentId: EnvironmentId | null; readonly primaryEnvironmentId: EnvironmentId | null; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ece7e520ab2..99340b46cc9 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -15,6 +15,7 @@ import { type SidebarProjectGroupingMode, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { isAtomCommandInterrupted, @@ -44,12 +45,8 @@ import { TraitsPicker } from "../chat/TraitsPicker"; import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; import { useTheme } from "../../hooks/useTheme"; -import { - useEnvironmentSettings, - usePrimarySettings, - useUpdateEnvironmentSettings, - useUpdatePrimarySettings, -} from "../../hooks/useSettings"; +import { useSettingsEnvironment } from "../../hooks/useSettingsEnvironment"; +import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { @@ -62,17 +59,9 @@ import { sortProviderInstanceEntries, } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; -import { - primaryServerObservabilityAtom, - primaryServerProvidersAtom, - serverEnvironment, -} from "../../state/server"; -import { - type EnvironmentPresentation, - useEnvironments, - usePrimaryEnvironmentId, -} from "../../state/environments"; -import { useActiveEnvironmentId, useProjects } from "../../state/entities"; +import { serverEnvironment } from "../../state/server"; +import { type EnvironmentPresentation } from "../../state/environments"; +import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; import { Button } from "../ui/button"; @@ -90,7 +79,7 @@ import { type ProviderUpdateCandidate, } from "../ProviderUpdateLaunchNotification.logic"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; -import { ProviderEnvironmentSelector } from "./ProviderEnvironmentSelector"; +import { SettingsEnvironmentSelector } from "./SettingsEnvironmentSelector"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { buildProviderInstanceUpdatePatch, @@ -99,7 +88,6 @@ import { projectGroupingModeFromToggle, readLastEnabledProjectGroupingMode, rememberEnabledProjectGroupingMode, - resolveProviderSettingsEnvironmentId, } from "./SettingsPanels.logic"; import { SettingResetButton, @@ -401,8 +389,9 @@ function AboutVersionSection() { export function useSettingsRestore(onRestored?: () => void) { const { theme, setTheme } = useTheme(); - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); + const { environmentId } = useSettingsEnvironment(); + const settings = useEnvironmentSettings(environmentId); + const updateSettings = useUpdateEnvironmentSettings(environmentId); const isGitWritingModelDirty = !Equal.equals( settings.textGenerationModelSelection ?? null, @@ -520,13 +509,23 @@ export function useSettingsRestore(onRestored?: () => void) { export function GeneralSettingsPanel() { const { theme, setTheme } = useTheme(); - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); + const { + environmentId, + environment, + environments, + primaryEnvironmentId, + selectEnvironment, + isReady: environmentsReady, + } = useSettingsEnvironment(); + const settings = useEnvironmentSettings(environmentId); + const updateSettings = useUpdateEnvironmentSettings(environmentId); const lastEnabledProjectGroupingMode = useRef( readLastEnabledProjectGroupingMode(), ); - const observability = useAtomValue(primaryServerObservabilityAtom); - const serverProviders = useAtomValue(primaryServerProvidersAtom); + const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const observability = serverConfig?.observability ?? null; + const serverProviders = serverConfig?.providers ?? []; + const canConfigureServer = environment?.connection.phase === "connected"; const glassOpacityRatio = (settings.glassOpacity - MIN_GLASS_OPACITY) / (MAX_GLASS_OPACITY - MIN_GLASS_OPACITY); const glassOpacitySliderStyle = { @@ -566,6 +565,36 @@ export function GeneralSettingsPanel() { return ( + + + ) : environmentsReady ? ( + + ) : null + } + /> + + updateSettings({ enableAssistantStreaming: Boolean(checked) }) } @@ -820,6 +850,7 @@ export function GeneralSettingsPanel() { control={ updateSettings({ enableProviderUpdateChecks: Boolean(checked) }) } @@ -882,7 +913,11 @@ export function GeneralSettingsPanel() { } }} > - + {settings.defaultThreadEnvMode === "worktree" ? "New worktree" : "Local"} @@ -921,6 +956,7 @@ export function GeneralSettingsPanel() { control={ updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) }) } @@ -949,6 +985,7 @@ export function GeneralSettingsPanel() { control={ updateSettings({ addProjectBaseDirectory: next })} placeholder="~/" @@ -1027,9 +1064,13 @@ export function GeneralSettingsPanel() { ) : null } control={ -
+
-
+ } />
@@ -1109,20 +1150,14 @@ export function GeneralSettingsPanel() { } export function ProviderSettingsPanel() { - const { isReady, environments } = useEnvironments(); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const activeEnvironmentId = useActiveEnvironmentId(); - const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); - const environmentId = resolveProviderSettingsEnvironmentId({ - availableEnvironmentIds: environments.map((environment) => environment.environmentId), - selectedEnvironmentId, + const { + isReady, + environments, primaryEnvironmentId, - activeEnvironmentId, - }); - const environment = - environmentId === null - ? null - : (environments.find((candidate) => candidate.environmentId === environmentId) ?? null); + environmentId, + environment, + selectEnvironment, + } = useSettingsEnvironment(); if (environmentId === null || environment === null) { return ( @@ -1153,9 +1188,11 @@ export function ProviderSettingsPanel() { key={environmentId} environmentId={environmentId} environmentLabel={environment.label} + environmentStatus={connectionStatusText(environment.connection)} + isConnected={environment.connection.phase === "connected"} environments={environments} primaryEnvironmentId={primaryEnvironmentId} - onEnvironmentChange={setSelectedEnvironmentId} + onEnvironmentChange={selectEnvironment} /> ); } @@ -1163,12 +1200,16 @@ export function ProviderSettingsPanel() { function ProviderSettingsEnvironmentPanel({ environmentId, environmentLabel, + environmentStatus, + isConnected, environments, primaryEnvironmentId, onEnvironmentChange, }: { readonly environmentId: EnvironmentId; readonly environmentLabel: string; + readonly environmentStatus: string; + readonly isConnected: boolean; readonly environments: ReadonlyArray; readonly primaryEnvironmentId: EnvironmentId | null; readonly onEnvironmentChange: (environmentId: EnvironmentId) => void; @@ -1192,7 +1233,7 @@ function ProviderSettingsEnvironmentPanel({ const refreshingRef = useRef(false); const environmentSelector = ( -
diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index e0387f1973e..61e91c57cd8 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -2,7 +2,10 @@ import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react import * as Duration from "effect/Duration"; import * as Option from "effect/Option"; import { useState, type ReactNode } from "react"; +import { Link } from "@tanstack/react-router"; +import { connectionStatusText } from "@t3tools/client-runtime/connection"; import type { + EnvironmentId, SourceControlProviderKind, SourceControlDiscoveryResult, SourceControlProviderAuth, @@ -12,9 +15,9 @@ import type { } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; -import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; +import { useSettingsEnvironment } from "../../hooks/useSettingsEnvironment"; import { cn } from "../../lib/utils"; -import { usePrimaryEnvironment } from "../../state/environments"; import { useEnvironmentQuery } from "../../state/query"; import { sourceControlEnvironment } from "../../state/sourceControl"; import { Badge } from "../ui/badge"; @@ -48,7 +51,13 @@ import { type Icon, } from "../Icons"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; -import { SettingResetButton, SettingsPageContainer, SettingsSection } from "./settingsLayout"; +import { SettingsEnvironmentSelector } from "./SettingsEnvironmentSelector"; +import { + SettingResetButton, + SettingsPageContainer, + SettingsRow, + SettingsSection, +} from "./settingsLayout"; const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { versionControlSystems: [], @@ -290,11 +299,12 @@ function DiscoveryItemRow({ ); } -function GitFetchIntervalSettings() { - const automaticGitFetchInterval = usePrimarySettings( +function GitFetchIntervalSettings({ environmentId }: { environmentId: EnvironmentId }) { + const automaticGitFetchInterval = useEnvironmentSettings( + environmentId, (settings) => settings.automaticGitFetchInterval, ); - const updateSettings = useUpdatePrimarySettings(); + const updateSettings = useUpdateEnvironmentSettings(environmentId); const automaticGitFetchIntervalSeconds = durationToSeconds(automaticGitFetchInterval); const defaultAutomaticGitFetchIntervalSeconds = durationToSeconds( DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, @@ -440,9 +450,17 @@ function EmptySourceControlDiscovery({ } export function SourceControlSettingsPanel() { - const environmentId = usePrimaryEnvironment()?.environmentId ?? null; + const { + environmentId, + environment, + environments, + primaryEnvironmentId, + selectEnvironment, + isReady: environmentsReady, + } = useSettingsEnvironment(); + const isConnected = environment?.connection.phase === "connected"; const discovery = useEnvironmentQuery( - environmentId === null + environmentId === null || !isConnected ? null : sourceControlEnvironment.discovery({ environmentId, @@ -478,7 +496,37 @@ export function SourceControlSettingsPanel() { return ( - {isInitialScanPending ? ( + + + ) : environmentsReady ? ( + + ) : null + } + /> + + + {!isConnected || environmentId === null ? null : isInitialScanPending ? ( <> @@ -489,7 +537,9 @@ export function SourceControlSettingsPanel() { {result.versionControlSystems.map((item) => ( - {item.kind === "git" ? : undefined} + {item.kind === "git" ? ( + + ) : undefined} ))} diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 8c35278073e..6e8139cb7a8 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,11 +1,10 @@ -import { useAtomValue } from "@effect/atom-react"; import { SettingsIcon } from "lucide-react"; import { memo, useCallback } from "react"; import { Link, useNavigate } from "@tanstack/react-router"; import { APP_STAGE_LABEL } from "../../branding"; import { cn } from "../../lib/utils"; -import { primaryServerConfigAtom } from "../../state/server"; +import { useDefaultServerConfig } from "../../hooks/useDefaultServerConfig"; import { resolveSidebarStageBadgeLabel } from "../Sidebar.logic"; import { SidebarStageBackdrop, resolveSidebarStageBackdropVariant } from "../SidebarStageBackdrop"; import { @@ -72,8 +71,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { } function useSidebarStageLabel() { - const primaryServerVersion = - useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; + const primaryServerVersion = useDefaultServerConfig()?.environment.serverVersion ?? null; return resolveSidebarStageBadgeLabel({ primaryServerVersion, diff --git a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx index b28b967eefa..d99f4c3e1ce 100644 --- a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx @@ -1,10 +1,9 @@ import { useNavigate } from "@tanstack/react-router"; -import { useAtomValue } from "@effect/atom-react"; import type { ServerProvider } from "@t3tools/contracts"; import { CircleCheckIcon, DownloadIcon, LoaderIcon, TriangleAlertIcon, XIcon } from "lucide-react"; import { useCallback, useEffect, useState, type CSSProperties } from "react"; -import { primaryServerProvidersAtom } from "../../state/server"; +import { useDefaultServerConfig } from "../../hooks/useDefaultServerConfig"; import { getProviderUpdateSidebarPillView, type ProviderUpdateSidebarPillView, @@ -40,7 +39,7 @@ function latestProviderCheckedAt( export function SidebarProviderUpdatePill() { const navigate = useNavigate(); - const providers = useAtomValue(primaryServerProvidersAtom); + const providers = useDefaultServerConfig()?.providers ?? []; const [dismissedKeys, setDismissedKeys] = useState>(() => new Set()); const [renderedView, setRenderedView] = useState(null); const [pendingView, setPendingView] = useState(null); diff --git a/apps/web/src/hooks/useDefaultServerConfig.ts b/apps/web/src/hooks/useDefaultServerConfig.ts new file mode 100644 index 00000000000..1f7adf33313 --- /dev/null +++ b/apps/web/src/hooks/useDefaultServerConfig.ts @@ -0,0 +1,24 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { ServerConfig } from "@t3tools/contracts"; + +import { useActiveEnvironmentId } from "../state/entities"; +import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { serverEnvironment } from "../state/server"; + +/** + * Server configuration for global client surfaces. + * + * A managed client keeps using its primary backend. A client without a + * managed backend follows the active saved environment instead. + */ +export function useDefaultServerConfig(): ServerConfig | null { + const environmentId = useDefaultEnvironmentId(); + return useAtomValue(serverEnvironment.configValueAtom(environmentId)); +} + +export function useDefaultEnvironmentId() { + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const activeEnvironmentId = useActiveEnvironmentId(); + const { environments } = useEnvironments(); + return primaryEnvironmentId ?? activeEnvironmentId ?? environments[0]?.environmentId ?? null; +} diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 2479d6ba02f..2335df7819a 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -1,10 +1,13 @@ -import { useAtomValue } from "@effect/atom-react"; import { scopedProjectKey, scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; -import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts"; +import { + DEFAULT_RUNTIME_MODE, + DEFAULT_SERVER_SETTINGS, + type ScopedProjectRef, +} from "@t3tools/contracts"; import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { @@ -20,21 +23,15 @@ import { getProjectOrderKey, selectProjectGroupingSettings, } from "../logicalProject"; -import { readThreadShell, useProjects, useThread } from "../state/entities"; +import { readThreadShell, useProjects, useServerConfigs, useThread } from "../state/entities"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; -import { primaryServerSettingsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useClientSettings } from "./useSettings"; export function useNewThreadHandler() { const projects = useProjects(); - // New-thread defaults are a user preference, and the settings UI only ever - // edits the primary environment's settings.json. Reading the target - // environment's own settings here would silently reset remote projects to - // the decoded defaults ("local" mode, current branch), since nothing can - // set those values on a remote server. - const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); + const serverConfigs = useServerConfigs(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const router = useRouter(); const getCurrentRouteTarget = useCallback(() => { @@ -105,6 +102,8 @@ export function useNewThreadHandler() { candidate.id === projectRef.projectId && candidate.environmentId === projectRef.environmentId, ); + const targetServerSettings = + serverConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS; const logicalProjectKey = project ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) : scopedProjectKey(projectRef); @@ -146,7 +145,7 @@ export function useNewThreadHandler() { // preserved. When the draft is already open and no options were // passed, leave it alone entirely — the user may have just picked a // branch in the composer. - const defaultEnvMode = primaryServerSettings.defaultThreadEnvMode; + const defaultEnvMode = targetServerSettings.defaultThreadEnvMode; const workspaceContext = hasExplicitWorkspaceOption ? { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), @@ -162,7 +161,7 @@ export function useNewThreadHandler() { envMode: defaultEnvMode, startFromOrigin: resolveNewDraftStartFromOrigin({ envMode: defaultEnvMode, - newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, + newWorktreesStartFromOrigin: targetServerSettings.newWorktreesStartFromOrigin, }), }; if (workspaceContext) { @@ -245,7 +244,7 @@ export function useNewThreadHandler() { const draftId = newDraftId(); const threadId = newThreadId(); const createdAt = new Date().toISOString(); - const initialEnvMode = options?.envMode ?? primaryServerSettings.defaultThreadEnvMode; + const initialEnvMode = options?.envMode ?? targetServerSettings.defaultThreadEnvMode; return (async () => { setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { threadId, @@ -257,7 +256,7 @@ export function useNewThreadHandler() { options?.startFromOrigin ?? resolveNewDraftStartFromOrigin({ envMode: initialEnvMode, - newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, + newWorktreesStartFromOrigin: targetServerSettings.newWorktreesStartFromOrigin, }), runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), @@ -279,7 +278,7 @@ export function useNewThreadHandler() { }); })(); }, - [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, projects, router], + [getCurrentRouteTarget, projectGroupingSettings, projects, router, serverConfigs], ); } diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 514484d896a..c6a2fd9ee50 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -5,9 +5,9 @@ * `settings.json` on the server, fetched via `server.getConfig`) and * client-only settings (persisted in localStorage). * - * Live server settings always require an environment id. Primary-environment - * access is intentionally named as such so environment-sensitive consumers - * cannot silently read the wrong server's settings. + * Live server settings require an environment id. Callers without a selected + * environment receive schema defaults for server fields while client fields + * remain fully functional. */ import { useCallback, useMemo, useSyncExternalStore } from "react"; import { useAtomValue } from "@effect/atom-react"; @@ -220,10 +220,10 @@ export function useClientSettings( /** Read current settings for one environment, merged with client-local preferences. */ export function useEnvironmentSettings( - environmentId: EnvironmentId, + environmentId: EnvironmentId | null, selector?: (settings: UnifiedSettings) => T, ): T { - const serverSettings = useAtomValue(serverEnvironment.settingsValueAtom(environmentId)); + const serverSettings = useAtomValue(serverEnvironment.configValueAtom(environmentId))?.settings; return useMergedSettings(serverSettings ?? DEFAULT_SERVER_SETTINGS, selector); } @@ -271,7 +271,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { return updateSettings; } -export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) { +export function useUpdateEnvironmentSettings(environmentId: EnvironmentId | null) { return useUpdateSettingsTarget(environmentId); } diff --git a/apps/web/src/hooks/useSettingsEnvironment.ts b/apps/web/src/hooks/useSettingsEnvironment.ts new file mode 100644 index 00000000000..e1e22d3a4bc --- /dev/null +++ b/apps/web/src/hooks/useSettingsEnvironment.ts @@ -0,0 +1,56 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; + +import { resolveSettingsEnvironmentId } from "../components/settings/SettingsPanels.logic"; +import { useActiveEnvironmentId } from "../state/entities"; +import { + useEnvironments, + usePrimaryEnvironmentId, + type EnvironmentPresentation, +} from "../state/environments"; +import { + selectedSettingsEnvironmentIdAtom, + setSelectedSettingsEnvironmentId, +} from "../state/settingsEnvironment"; + +export interface SettingsEnvironmentTarget { + readonly isReady: boolean; + readonly environmentId: EnvironmentId | null; + readonly environment: EnvironmentPresentation | null; + readonly environments: ReadonlyArray; + readonly primaryEnvironmentId: EnvironmentId | null; + readonly selectEnvironment: (environmentId: EnvironmentId) => void; +} + +/** + * Resolve the server environment configured by a settings surface. + * + * Managed clients retain their historical primary-server behavior. Clients + * without a managed backend start with the currently active saved + * environment, then fall back to the first available environment. + */ +export function useSettingsEnvironment(): SettingsEnvironmentTarget { + const { isReady, environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const activeEnvironmentId = useActiveEnvironmentId(); + const selectedEnvironmentId = useAtomValue(selectedSettingsEnvironmentIdAtom); + const environmentId = resolveSettingsEnvironmentId({ + availableEnvironmentIds: environments.map((environment) => environment.environmentId), + selectedEnvironmentId, + primaryEnvironmentId, + activeEnvironmentId, + }); + const environment = + environmentId === null + ? null + : (environments.find((candidate) => candidate.environmentId === environmentId) ?? null); + + return { + isReady, + environmentId, + environment, + environments, + primaryEnvironmentId, + selectEnvironment: setSelectedSettingsEnvironmentId, + }; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index ed943e7e59c..fcddf54e9b7 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -28,6 +28,7 @@ import { } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { useClientSettings } from "../hooks/useSettings"; +import { useDefaultServerConfig } from "../hooks/useDefaultServerConfig"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKeyFromPath, @@ -138,7 +139,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} + {appShell} @@ -156,8 +157,7 @@ function GlassAppearanceSync() { } function DocumentTitleSync() { - const primaryServerVersion = - useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; + const primaryServerVersion = useDefaultServerConfig()?.environment.serverVersion ?? null; const title = resolveServerBackedAppDisplayName({ baseName: APP_BASE_NAME, fallbackDisplayName: APP_DISPLAY_NAME, diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index d38085bdbd8..31b0ee0d134 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,5 +1,4 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; -import { useAtomValue } from "@effect/atom-react"; import { useEffect, useMemo } from "react"; import { isCommandPaletteOpen } from "../commandPaletteBus"; @@ -20,14 +19,15 @@ import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; -import { primaryServerKeybindingsAtom } from "~/state/server"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { useDefaultServerConfig } from "~/hooks/useDefaultServerConfig"; function ChatRouteGlobalShortcuts() { const clearSelection = useThreadSelectionStore((state) => state.clearSelection); const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = useHandleNewThread(); - const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const keybindings = useDefaultServerConfig()?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projects = useProjects(); diff --git a/apps/web/src/state/settingsEnvironment.ts b/apps/web/src/state/settingsEnvironment.ts new file mode 100644 index 00000000000..ecbdd9278a7 --- /dev/null +++ b/apps/web/src/state/settingsEnvironment.ts @@ -0,0 +1,13 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; + +export const selectedSettingsEnvironmentIdAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("web-selected-settings-environment-id"), +); + +export function setSelectedSettingsEnvironmentId(environmentId: EnvironmentId): void { + appAtomRegistry.set(selectedSettingsEnvironmentIdAtom, environmentId); +}