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/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}. { + 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: [], + }), + ); + }); +}); + +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 new file mode 100644 index 00000000000..a5dc5bcd5f9 --- /dev/null +++ b/apps/web/src/components/settings/DiagnosticsSettings.logic.ts @@ -0,0 +1,104 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +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; +} + +/** + * 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 b1a54feb718..803f553bb77 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -7,13 +7,15 @@ import { InfoIcon, 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 +27,28 @@ 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 { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { useSettingsEnvironment } from "../../hooks/useSettingsEnvironment"; import { Button } from "../ui/button"; import { ScrollArea } from "../ui/scroll-area"; 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 { + addPendingProcessSignal, + diagnosticsConnectionNotice, + pendingProcessSignalPids, + removePendingProcessSignal, + type PendingProcessSignal, +} from "./DiagnosticsSettings.logic"; +import { SettingsEnvironmentSelector } from "./SettingsEnvironmentSelector"; import { useAtomCommand } from "../../state/use-atom-command"; const NUMBER_FORMAT = new Intl.NumberFormat(); @@ -394,12 +405,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; }) { @@ -508,7 +519,7 @@ function ProcessDiagnosticsTable({ @@ -779,10 +790,12 @@ function DiagnosticsLastChecked({ checkedAt }: { checkedAt: DateTime.Utc | null function DiagnosticsRefreshButton({ isPending, + isDisabled = false, label, onClick, }: { isPending: boolean; + isDisabled?: boolean; label: string; onClick: () => void; }) { @@ -794,7 +807,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} > @@ -807,11 +820,22 @@ function DiagnosticsRefreshButton({ ); } +interface LogsDirectoryState { + readonly environmentId: EnvironmentId | null; + readonly isOpening: boolean; + readonly error: string | null; +} + export function DiagnosticsSettingsPanel() { - const observability = useAtomValue(primaryServerObservabilityAtom); - const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); - const primaryEnvironment = usePrimaryEnvironment(); - const environmentId = primaryEnvironment?.environmentId ?? null; + const { + environmentId, + environment: diagnosticsEnvironment, + environments, + primaryEnvironmentId, + selectEnvironment, + } = useSettingsEnvironment(); + const observability = diagnosticsEnvironment?.serverConfig?.observability ?? null; + const availableEditors = diagnosticsEnvironment?.serverConfig?.availableEditors ?? []; const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { reportFailure: false, }); @@ -853,9 +877,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; @@ -863,16 +898,24 @@ 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); + const request: LogsDirectoryState = { environmentId, isOpening: true, error: null }; + setLogsDirectoryState(request); void (async () => { const result = await openInEditor({ environmentId, @@ -881,13 +924,21 @@ 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 failureMessage = + failure === null + ? null + : failure instanceof Error + ? failure.message + : "Unable to open logs folder."; + // 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 === request ? { environmentId, isOpening: false, error: failureMessage } : current, + ); })(); }, [availableEditors, environmentId, observability?.logsDirectoryPath, openInEditor]); @@ -905,13 +956,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); @@ -949,6 +1001,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; @@ -956,15 +1018,68 @@ 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 ? ( + + ) : ( + + ) + } + /> + + @@ -974,21 +1089,21 @@ export function DiagnosticsSettingsPanel() { {processDiagnosticsError || processError ? ( @@ -1009,12 +1124,13 @@ export function DiagnosticsSettingsPanel() { ) : null} @@ -1029,7 +1145,8 @@ export function DiagnosticsSettingsPanel() { /> @@ -1039,21 +1156,23 @@ export function DiagnosticsSettingsPanel() { {processResourceError || resourceError ? ( @@ -1076,9 +1195,10 @@ export function DiagnosticsSettingsPanel() { @@ -1095,7 +1215,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" > @@ -1106,7 +1228,8 @@ export function DiagnosticsSettingsPanel() { Open logs folder @@ -1114,15 +1237,15 @@ export function DiagnosticsSettingsPanel() { } > - + 0 ? "danger" : "default"} /> 0 ? "warning" : "default"} /> @@ -1192,7 +1315,12 @@ export function DiagnosticsSettingsPanel() { ))} ) : ( - + )} @@ -1221,7 +1349,10 @@ export function DiagnosticsSettingsPanel() { ) : ( )} @@ -1251,7 +1382,11 @@ export function DiagnosticsSettingsPanel() { ))} ) : ( - + )} @@ -1314,7 +1449,10 @@ export function DiagnosticsSettingsPanel() { ) : ( )} @@ -1347,7 +1485,11 @@ export function DiagnosticsSettingsPanel() { ))} ) : ( - + )} diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 67c33c9b942..4f3084b46c8 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -24,10 +24,14 @@ import { import { type KeybindingCommand, type KeybindingWhenNode, + type ServerConfig, type ServerRemoveKeybindingInput, type ServerUpsertKeybindingInput, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; +import { connectionStatusText } from "@t3tools/client-runtime/connection"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { Link } from "@tanstack/react-router"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -37,13 +41,8 @@ import { isElectron } from "../../env"; import { useOpenInPreferredEditor } from "../../editorPreferences"; import { formatShortcutLabel } from "../../keybindings"; import { cn } from "../../lib/utils"; -import { - primaryServerAvailableEditorsAtom, - primaryServerKeybindingsAtom, - primaryServerKeybindingsConfigPathAtom, - serverEnvironment, -} from "../../state/server"; -import { usePrimaryEnvironment } from "../../state/environments"; +import { serverEnvironment } from "../../state/server"; +import { useSettingsEnvironment } from "../../hooks/useSettingsEnvironment"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Kbd, KbdGroup } from "../ui/kbd"; @@ -69,10 +68,13 @@ import { unknownWhenVariables, whenAstToExpression, } from "./KeybindingsSettings.logic"; -import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; +import { SettingsEnvironmentSelector } from "./SettingsEnvironmentSelector"; +import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { useAtomCommand } from "../../state/use-atom-command"; +const EMPTY_AVAILABLE_EDITORS: ServerConfig["availableEditors"] = []; + function KeybindingPill({ value }: { value: string }) { const parts = value.split("+"); return ( @@ -1082,20 +1084,26 @@ function NewKeybindingTableRow({ } export function KeybindingsSettingsPanel() { - const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const keybindingsConfigPath = useAtomValue(primaryServerKeybindingsConfigPathAtom); - const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); - const primaryEnvironment = usePrimaryEnvironment(); + const { + environmentId, + environment, + environments, + primaryEnvironmentId, + selectEnvironment, + isReady: environmentsReady, + } = useSettingsEnvironment(); + const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const keybindings = serverConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; + const keybindingsConfigPath = serverConfig?.keybindingsConfigPath ?? null; + const availableEditors = serverConfig?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; + const isConnected = environment?.connection.phase === "connected"; const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { reportFailure: false, }); const removeKeybindingMutation = useAtomCommand(serverEnvironment.removeKeybinding, { reportFailure: false, }); - const openInPreferredEditor = useOpenInPreferredEditor( - primaryEnvironment?.environmentId ?? null, - availableEditors, - ); + const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors); const [query, setQuery] = useState(""); const [isSearchOpen, setIsSearchOpen] = useState(false); const searchInputRef = useRef(null); @@ -1149,7 +1157,7 @@ export function KeybindingsSettingsPanel() { const saveKeybinding = useCallback( (input: ServerUpsertKeybindingInput) => { - if (!primaryEnvironment) return; + if (environmentId === null || !isConnected) return; setSavingCommand(input.command); const payload: ServerUpsertKeybindingInput = { command: input.command, @@ -1159,7 +1167,7 @@ export function KeybindingsSettingsPanel() { }; void (async () => { const result = await upsertKeybinding({ - environmentId: primaryEnvironment.environmentId, + environmentId, input: payload, }); setSavingCommand(null); @@ -1177,16 +1185,16 @@ export function KeybindingsSettingsPanel() { } })(); }, - [primaryEnvironment, upsertKeybinding], + [environmentId, isConnected, upsertKeybinding], ); const removeKeybinding = useCallback( (row: KeybindingRow) => { - if (!primaryEnvironment) return; + if (environmentId === null || !isConnected) return; setSavingCommand(row.command); void (async () => { const result = await removeKeybindingMutation({ - environmentId: primaryEnvironment.environmentId, + environmentId, input: rowKeybindingTarget(row), }); setSavingCommand(null); @@ -1200,7 +1208,7 @@ export function KeybindingsSettingsPanel() { } })(); }, - [primaryEnvironment, removeKeybindingMutation], + [environmentId, isConnected, removeKeybindingMutation], ); const resetKeybinding = useCallback( @@ -1229,109 +1237,141 @@ export function KeybindingsSettingsPanel() { return ( - - - - 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} + ) : 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/SettingsEnvironmentSelector.tsx b/apps/web/src/components/settings/SettingsEnvironmentSelector.tsx new file mode 100644 index 00000000000..c8acfa2c92a --- /dev/null +++ b/apps/web/src/components/settings/SettingsEnvironmentSelector.tsx @@ -0,0 +1,73 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { CloudIcon, MonitorIcon } from "lucide-react"; +import { useMemo } from "react"; + +import type { EnvironmentPresentation } from "../../state/environments"; +import { + Select, + SelectGroup, + SelectGroupLabel, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "../ui/select"; + +interface SettingsEnvironmentSelectorProps { + readonly environmentId: EnvironmentId; + readonly environments: ReadonlyArray; + readonly primaryEnvironmentId: EnvironmentId | null; + readonly onEnvironmentChange: (environmentId: EnvironmentId) => void; +} + +export function SettingsEnvironmentSelector({ + environmentId, + environments, + primaryEnvironmentId, + onEnvironmentChange, +}: SettingsEnvironmentSelectorProps) { + 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..1244942ced9 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,80 @@ import { formatDiagnosticsDescription, isProjectGroupingEnabled, projectGroupingModeFromToggle, + 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("settings environment selection", () => { + it("preserves an explicit selected environment", () => { + expect( + resolveSettingsEnvironmentId({ + 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( + resolveSettingsEnvironmentId({ + 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( + resolveSettingsEnvironmentId({ + 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( + resolveSettingsEnvironmentId({ + availableEnvironmentIds: [LOCAL_ENVIRONMENT_ID], + selectedEnvironmentId: REMOTE_ENVIRONMENT_ID, + primaryEnvironmentId: LOCAL_ENVIRONMENT_ID, + activeEnvironmentId: REMOTE_ENVIRONMENT_ID, + }), + ).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( + resolveSettingsEnvironmentId({ + 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..1678b8308da 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 resolveSettingsEnvironmentId(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..99340b46cc9 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, @@ -14,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, @@ -43,7 +45,8 @@ 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 { useSettingsEnvironment } from "../../hooks/useSettingsEnvironment"; +import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { @@ -56,12 +59,8 @@ import { sortProviderInstanceEntries, } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; -import { - primaryServerObservabilityAtom, - primaryServerProvidersAtom, - serverEnvironment, -} from "../../state/server"; -import { usePrimaryEnvironment } from "../../state/environments"; +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"; @@ -80,6 +79,7 @@ import { type ProviderUpdateCandidate, } from "../ProviderUpdateLaunchNotification.logic"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; +import { SettingsEnvironmentSelector } from "./SettingsEnvironmentSelector"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { buildProviderInstanceUpdatePatch, @@ -389,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, @@ -508,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 = { @@ -554,6 +565,36 @@ export function GeneralSettingsPanel() { return ( + + + ) : environmentsReady ? ( + + ) : null + } + /> + + updateSettings({ enableAssistantStreaming: Boolean(checked) }) } @@ -808,6 +850,7 @@ export function GeneralSettingsPanel() { control={ updateSettings({ enableProviderUpdateChecks: Boolean(checked) }) } @@ -870,7 +913,11 @@ export function GeneralSettingsPanel() { } }} > - + {settings.defaultThreadEnvMode === "worktree" ? "New worktree" : "Local"} @@ -909,6 +956,7 @@ export function GeneralSettingsPanel() { control={ updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) }) } @@ -937,6 +985,7 @@ export function GeneralSettingsPanel() { control={ updateSettings({ addProjectBaseDirectory: next })} placeholder="~/" @@ -1015,9 +1064,13 @@ export function GeneralSettingsPanel() { ) : null } control={ -
+
-
+ } />
@@ -1097,10 +1150,74 @@ export function GeneralSettingsPanel() { } export function ProviderSettingsPanel() { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); - const serverProviders = useAtomValue(primaryServerProvidersAtom); - const primaryEnvironment = usePrimaryEnvironment(); + const { + isReady, + environments, + primaryEnvironmentId, + environmentId, + environment, + selectEnvironment, + } = useSettingsEnvironment(); + + if (environmentId === null || environment === null) { + return ( + + + } size="xs" variant="outline"> + Open connections + + ) : null + } + /> + + + ); + } + + return ( + + ); +} + +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; +}) { + 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 +1232,15 @@ export function ProviderSettingsPanel() { const [openInstanceDetails, setOpenInstanceDetails] = useState>({}); const refreshingRef = useRef(false); + const environmentSelector = ( + + ); + const providerUpdateCandidates = useMemo( () => collectProviderUpdateCandidates(serverProviders), [serverProviders], @@ -1145,14 +1271,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 +1281,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 +1305,7 @@ export function ProviderSettingsPanel() { } const result = await updateProvider({ - environmentId: primaryEnvironment.environmentId, + environmentId, input: { provider: candidate.driver, instanceId: candidate.instanceId, @@ -1213,9 +1333,22 @@ export function ProviderSettingsPanel() { return next; }); }, - [primaryEnvironment, updateProvider], + [environmentId, updateProvider], ); + if (serverSettings === null || !isConnected) { + return ( + + + + + + ); + } + interface InstanceRow { readonly instanceId: ProviderInstanceId; readonly instance: ProviderInstanceConfig; @@ -1393,6 +1526,7 @@ export function ProviderSettingsPanel() { title="Providers" headerAction={
+ {environmentSelector} {isAddInstanceDialogOpen ? ( - + ) : null} ); 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); +}